Reduce viewer rendering cost with lighter materials
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
useInteractive,
|
||||
useRegistry,
|
||||
useScene,
|
||||
type AnimationEffect,
|
||||
type AnyNodeId,
|
||||
type Interactive,
|
||||
type ItemNode,
|
||||
type LightEffect,
|
||||
useInteractive,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
@@ -24,7 +24,7 @@ import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||
import { ErrorBoundary } from '../../error-boundary'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
|
||||
const getMaterialForOriginal = (original: Material): Material => {
|
||||
if (original.name.toLowerCase() === 'glass') {
|
||||
return glassMaterial
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||
import { useScene, type AnyNode } from '@pascal-app/core'
|
||||
import { BuildingRenderer } from './building/building-renderer'
|
||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||
import { ColumnRenderer } from './column/column-renderer'
|
||||
@@ -10,14 +10,14 @@ import { FenceRenderer } from './fence/fence-renderer'
|
||||
import { GuideRenderer } from './guide/guide-renderer'
|
||||
import { ItemRenderer } from './item/item-renderer'
|
||||
import { LevelRenderer } from './level/level-renderer'
|
||||
import { RoofRenderer } from './roof/roof-renderer'
|
||||
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
|
||||
import { RoofRenderer } from './roof/roof-renderer'
|
||||
import { ScanRenderer } from './scan/scan-renderer'
|
||||
import { SiteRenderer } from './site/site-renderer'
|
||||
import { SlabRenderer } from './slab/slab-renderer'
|
||||
import { SpawnRenderer } from './spawn/spawn-renderer'
|
||||
import { StairRenderer } from './stair/stair-renderer'
|
||||
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
|
||||
import { StairRenderer } from './stair/stair-renderer'
|
||||
import { WallRenderer } from './wall/wall-renderer'
|
||||
import { WindowRenderer } from './window/window-renderer'
|
||||
import { ZoneRenderer } from './zone/zone-renderer'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { unionPolygons } from '../../../lib/polygon-union'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
@@ -126,15 +126,23 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
<meshStandardMaterial
|
||||
{/* PERF TEST: basic material — no PBR / shadows / lighting calc.
|
||||
Ground color = canvas background, so lighting is invisible work. */}
|
||||
<meshBasicMaterial
|
||||
color={bgColor}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
{/* <meshStandardMaterial
|
||||
color={bgColor}
|
||||
depthWrite={true}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
/> */}
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,10 +22,8 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
material={material}
|
||||
position={node.position}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
rotation={node.rotation}
|
||||
visible={node.visible}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
|
||||
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
||||
@@ -11,8 +12,8 @@ import { DoorSystem } from '../../systems/door/door-system'
|
||||
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
|
||||
import { FenceSystem } from '../../systems/fence/fence-system'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { ItemSystem } from '../../systems/item/item-system'
|
||||
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||
import { ItemSystem } from '../../systems/item/item-system'
|
||||
import { LevelSystem } from '../../systems/level/level-system'
|
||||
import { RoofSystem } from '../../systems/roof/roof-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
@@ -160,11 +161,16 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
useBvh = true,
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
// Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep
|
||||
// fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
|
||||
// Desktops (fine pointer) keep the original 1.5 cap.
|
||||
const maxDpr =
|
||||
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||
dpr={[1, 1.5]}
|
||||
dpr={[1, maxDpr]}
|
||||
frameloop="never"
|
||||
gl={
|
||||
((props: { canvas?: HTMLCanvasElement }) => {
|
||||
@@ -242,7 +248,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
|
||||
<ItemLightSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Canvas>
|
||||
@@ -251,7 +257,16 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
|
||||
const DebugRenderer = () => {
|
||||
useFrame(({ gl, scene, camera }) => {
|
||||
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
|
||||
gl.render(scene, camera)
|
||||
if (PERF_OVERLAY_ENABLED) {
|
||||
const queue = (gl as any).backend?.device?.queue as
|
||||
| { onSubmittedWorkDone?: () => Promise<void> }
|
||||
| undefined
|
||||
queue?.onSubmittedWorkDone?.().then(() => {
|
||||
pushGpuSample(performance.now() - submittedAt)
|
||||
})
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,17 @@ import type { AmbientLight, DirectionalLight, OrthographicCamera } from 'three/w
|
||||
import * as THREE from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
// Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass
|
||||
// (which doubles draw calls for every shadow-casting mesh) so you can
|
||||
// isolate how much of the baseline GPU cost is shadows vs. raw geometry.
|
||||
const SHADOWS_DISABLED =
|
||||
typeof window !== 'undefined' &&
|
||||
new Set(
|
||||
(new URLSearchParams(window.location.search).get('disable') ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim()),
|
||||
).has('shadows')
|
||||
|
||||
export function Lights() {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const isDark = theme === 'dark'
|
||||
@@ -109,24 +120,26 @@ export function Lights() {
|
||||
return (
|
||||
<>
|
||||
<directionalLight
|
||||
castShadow
|
||||
castShadow={!SHADOWS_DISABLED}
|
||||
position={[10, 10, 10]}
|
||||
ref={light1Ref}
|
||||
shadow-bias={-0.002}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-normalBias={0.3}
|
||||
shadow-radius={3}
|
||||
shadow-radius={2}
|
||||
>
|
||||
<orthographicCamera
|
||||
attach="shadow-camera"
|
||||
bottom={-shadowCameraSize}
|
||||
far={100}
|
||||
left={-shadowCameraSize}
|
||||
near={1}
|
||||
ref={shadowCamera}
|
||||
right={shadowCameraSize}
|
||||
top={shadowCameraSize}
|
||||
/>
|
||||
{SHADOWS_DISABLED ? null : (
|
||||
<orthographicCamera
|
||||
attach="shadow-camera"
|
||||
bottom={-shadowCameraSize}
|
||||
far={100}
|
||||
left={-shadowCameraSize}
|
||||
near={1}
|
||||
ref={shadowCamera}
|
||||
right={shadowCameraSize}
|
||||
top={shadowCameraSize}
|
||||
/>
|
||||
)}
|
||||
</directionalLight>
|
||||
|
||||
<directionalLight position={[-10, 10, -10]} ref={light2Ref} />
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { drainGpuSamples } from '../../lib/gpu-perf'
|
||||
|
||||
const SAMPLE_INTERVAL = 0.5 // seconds between display updates
|
||||
|
||||
export const PerfMonitor = () => {
|
||||
const [stats, setStats] = useState({ fps: 0, frameMs: 0, drawCalls: 0, triangles: 0, dirty: 0 })
|
||||
const [stats, setStats] = useState({
|
||||
fps: 0,
|
||||
frameMs: 0,
|
||||
gpuMs: 0,
|
||||
gpuMaxMs: 0,
|
||||
drawCalls: 0,
|
||||
triangles: 0,
|
||||
dirty: 0,
|
||||
meshes: 0,
|
||||
lines: 0,
|
||||
sprites: 0,
|
||||
lights: 0,
|
||||
})
|
||||
const frameCount = useRef(0)
|
||||
const elapsed = useRef(0)
|
||||
const lastMs = useRef(0)
|
||||
// Carry the previous tick's reading forward when no fresh samples arrive,
|
||||
// so the display doesn't flicker to "—" on slow resolve windows.
|
||||
const lastGpuMs = useRef(0)
|
||||
const lastGpuMaxMs = useRef(0)
|
||||
|
||||
useFrame(({ gl, clock }) => {
|
||||
// Take ownership of info reset. The custom RenderPipeline.render() path
|
||||
// we use in post-processing doesn't trigger three.js's automatic per-frame
|
||||
// info reset, so calls/triangles accumulate across frames and the display
|
||||
// shows lifetime totals. Disabling autoReset and explicitly resetting at
|
||||
// each window gives true per-frame averages.
|
||||
const gl = useThree((s) => s.gl)
|
||||
useEffect(() => {
|
||||
if (!gl?.info) return
|
||||
const previousAutoReset = gl.info.autoReset
|
||||
gl.info.autoReset = false
|
||||
gl.info.reset()
|
||||
return () => {
|
||||
gl.info.autoReset = previousAutoReset
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
useFrame(({ gl, scene, clock }) => {
|
||||
frameCount.current++
|
||||
const now = clock.elapsedTime
|
||||
const dt = now - elapsed.current
|
||||
@@ -20,11 +53,57 @@ export const PerfMonitor = () => {
|
||||
const fps = Math.round(frameCount.current / dt)
|
||||
const frameMs = lastMs.current
|
||||
const info = gl.info
|
||||
const drawCalls = info.render?.calls ?? 0
|
||||
const triangles = info.render?.triangles ?? 0
|
||||
// calls/triangles have been accumulating since the last reset (start of
|
||||
// window). Divide by frameCount to get a per-frame average.
|
||||
const totalCalls = info.render?.calls ?? 0
|
||||
const totalTriangles = info.render?.triangles ?? 0
|
||||
const drawCalls = Math.round(totalCalls / Math.max(1, frameCount.current))
|
||||
const triangles = totalTriangles / Math.max(1, frameCount.current)
|
||||
info.reset()
|
||||
const dirty = useScene.getState().dirtyNodes.size
|
||||
|
||||
setStats({ fps, frameMs, drawCalls, triangles, dirty })
|
||||
// Count visible drawables by type so we can match scene contents
|
||||
// against the renderer's draw count and find hidden contributors.
|
||||
let meshes = 0
|
||||
let lines = 0
|
||||
let sprites = 0
|
||||
let lights = 0
|
||||
scene.traverse((obj: any) => {
|
||||
if (!obj.visible) return
|
||||
if (obj.isMesh) meshes++
|
||||
else if (obj.isLine || obj.isLineSegments || obj.isLineLoop) lines++
|
||||
else if (obj.isSprite) sprites++
|
||||
else if (obj.isLight) lights++
|
||||
})
|
||||
|
||||
// GPU samples are pushed by post-processing.tsx after each pipeline
|
||||
// render via device.queue.onSubmittedWorkDone(). We drain whatever
|
||||
// has accumulated since the last tick.
|
||||
const samples = drainGpuSamples()
|
||||
if (samples.length > 0) {
|
||||
let sum = 0
|
||||
let max = 0
|
||||
for (const s of samples) {
|
||||
sum += s
|
||||
if (s > max) max = s
|
||||
}
|
||||
lastGpuMs.current = sum / samples.length
|
||||
lastGpuMaxMs.current = max
|
||||
}
|
||||
|
||||
setStats({
|
||||
fps,
|
||||
frameMs,
|
||||
gpuMs: lastGpuMs.current,
|
||||
gpuMaxMs: lastGpuMaxMs.current,
|
||||
drawCalls,
|
||||
triangles,
|
||||
dirty,
|
||||
meshes,
|
||||
lines,
|
||||
sprites,
|
||||
lights,
|
||||
})
|
||||
frameCount.current = 0
|
||||
elapsed.current = now
|
||||
}
|
||||
@@ -50,10 +129,15 @@ export const PerfMonitor = () => {
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{`FPS ${stats.fps}
|
||||
DRAW ${stats.drawCalls}
|
||||
TRI ${(stats.triangles / 1000).toFixed(1)}k
|
||||
DIRTY ${stats.dirty}`}
|
||||
{`FPS ${stats.fps}
|
||||
GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'}
|
||||
DRAW ${stats.drawCalls}
|
||||
TRI ${(stats.triangles / 1000).toFixed(1)}k
|
||||
DIRTY ${stats.dirty}
|
||||
MESH ${stats.meshes}
|
||||
LINE ${stats.lines}
|
||||
SPRITE ${stats.sprites}
|
||||
LIGHT ${stats.lights}`}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
vec4,
|
||||
} from 'three/tsl'
|
||||
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
|
||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
||||
import { mergedOutline } from '../../lib/merged-outline-node'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
@@ -41,6 +42,43 @@ export const SSGI_PARAMS = {
|
||||
useTemporalFiltering: false,
|
||||
}
|
||||
|
||||
// Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx`
|
||||
// to the URL (any subset) and reload to skip those passes. Each flag prevents
|
||||
// allocation + per-frame work for that stage, so device temperature deltas
|
||||
// across combos isolate which pass is the actual culprit. Picked up once at
|
||||
// pipeline build; reload after changing the URL.
|
||||
// - ao: skip SSGI entirely (and denoise, since denoise has nothing to denoise)
|
||||
// - denoise: keep SSGI but feed its raw noisy AO straight to the composite
|
||||
// - outline: skip the merged-outline node and its 14 internal RTs
|
||||
// - postFx: bypass the whole RenderPipeline and use renderer.render(scene, camera)
|
||||
// directly — isolates raw scene-render cost from any post-FX overhead
|
||||
function readPerfDisableFlags() {
|
||||
if (typeof window === 'undefined') {
|
||||
return { ao: false, denoise: false, outline: false, postFx: false }
|
||||
}
|
||||
const raw = new URLSearchParams(window.location.search).get('disable') ?? ''
|
||||
const set = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
return {
|
||||
ao: set.has('ao'),
|
||||
denoise: set.has('denoise'),
|
||||
outline: set.has('outline'),
|
||||
postFx: set.has('postFx'),
|
||||
}
|
||||
}
|
||||
|
||||
const PERF_POST_FX_DISABLED =
|
||||
typeof window !== 'undefined' &&
|
||||
new Set(
|
||||
(new URLSearchParams(window.location.search).get('disable') ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim()),
|
||||
).has('postFx')
|
||||
|
||||
const MAX_PIPELINE_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 500
|
||||
|
||||
@@ -178,9 +216,17 @@ const PostProcessingPasses = ({
|
||||
return
|
||||
}
|
||||
|
||||
const perfDisable = readPerfDisableFlags()
|
||||
const ssgiEnabled = SSGI_PARAMS.enabled && !perfDisable.ao
|
||||
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
|
||||
const outlineEnabled = !perfDisable.outline
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
ssgi: ssgiEnabled,
|
||||
denoise: denoiseEnabled,
|
||||
outline: outlineEnabled,
|
||||
perfDisable,
|
||||
hoverHighlightMode,
|
||||
projectId,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
@@ -227,7 +273,7 @@ const PostProcessingPasses = ({
|
||||
|
||||
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
|
||||
|
||||
if (SSGI_PARAMS.enabled) {
|
||||
if (ssgiEnabled) {
|
||||
// MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
|
||||
scenePass.setMRT(
|
||||
mrt({
|
||||
@@ -265,15 +311,21 @@ const PostProcessingPasses = ({
|
||||
|
||||
const giTexture = (giPass as any).getTextureNode()
|
||||
|
||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||
denoisePass.index.value = 0
|
||||
denoisePass.radius.value = 4
|
||||
|
||||
const gi = giPass.rgb
|
||||
const ao = (denoisePass as any).r
|
||||
let ao: any
|
||||
if (denoiseEnabled) {
|
||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||
denoisePass.index.value = 0
|
||||
denoisePass.radius.value = 4
|
||||
ao = (denoisePass as any).r
|
||||
} else {
|
||||
// Diagnostic path: feed raw noisy SSGI AO straight through. Will
|
||||
// look grainy — that's the point, it isolates denoise cost.
|
||||
ao = giTexture.a
|
||||
}
|
||||
|
||||
// Composite: scene * AO + diffuse * GI
|
||||
sceneColor = vec4(
|
||||
@@ -284,36 +336,39 @@ const PostProcessingPasses = ({
|
||||
|
||||
// Single merged outline node: one shared depth pass for both selected + hovered groups.
|
||||
const outliner = useViewer.getState().outliner
|
||||
const outlineNode = mergedOutline(scene, camera, {
|
||||
primaryObjects: outliner.selectedObjects,
|
||||
secondaryObjects: outliner.hoveredObjects,
|
||||
primaryEdgeThickness: uniform(1),
|
||||
secondaryEdgeThickness: uniform(1.5),
|
||||
})
|
||||
let compositeWithOutlines = sceneColor
|
||||
if (outlineEnabled) {
|
||||
const outlineNode = mergedOutline(scene, camera, {
|
||||
primaryObjects: outliner.selectedObjects,
|
||||
secondaryObjects: outliner.hoveredObjects,
|
||||
primaryEdgeThickness: uniform(1),
|
||||
secondaryEdgeThickness: uniform(1.5),
|
||||
})
|
||||
|
||||
// Selected: white visible, yellow hidden
|
||||
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
|
||||
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
|
||||
const selectedStrength = uniform(3)
|
||||
const selectedOutline = outlineNode.primaryVisibleEdge
|
||||
.mul(selectedVisibleColor)
|
||||
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
|
||||
.mul(selectedStrength)
|
||||
// Selected: white visible, yellow hidden
|
||||
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
|
||||
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
|
||||
const selectedStrength = uniform(3)
|
||||
const selectedOutline = outlineNode.primaryVisibleEdge
|
||||
.mul(selectedVisibleColor)
|
||||
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
|
||||
.mul(selectedStrength)
|
||||
|
||||
// Hovered: blue visible, yellow hidden, pulsing
|
||||
const pulsePeriod = uniform(3)
|
||||
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||
.mul(hoverVisibleColor)
|
||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||
.mul(hoverStrength)
|
||||
.mul(osc)
|
||||
// Hovered: blue visible, yellow hidden, pulsing
|
||||
const pulsePeriod = uniform(3)
|
||||
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||
.mul(hoverVisibleColor)
|
||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||
.mul(hoverStrength)
|
||||
.mul(osc)
|
||||
|
||||
const compositeWithOutlines = vec4(
|
||||
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
|
||||
sceneColor.a,
|
||||
)
|
||||
compositeWithOutlines = vec4(
|
||||
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
|
||||
sceneColor.a,
|
||||
)
|
||||
}
|
||||
|
||||
const finalOutput = vec4(
|
||||
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
|
||||
@@ -371,12 +426,21 @@ const PostProcessingPasses = ({
|
||||
sanitizeOutlineObjects(outliner.selectedObjects)
|
||||
sanitizeOutlineObjects(outliner.hoveredObjects)
|
||||
|
||||
if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
|
||||
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
|
||||
try {
|
||||
if ((renderer as any).setClearAlpha) {
|
||||
;(renderer as any).setClearAlpha(1)
|
||||
}
|
||||
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
|
||||
;(renderer as any).render(scene, camera)
|
||||
if (PERF_OVERLAY_ENABLED) {
|
||||
const queue = (renderer as any).backend?.device?.queue as
|
||||
| { onSubmittedWorkDone?: () => Promise<void> }
|
||||
| undefined
|
||||
queue?.onSubmittedWorkDone?.().then(() => {
|
||||
pushGpuSample(performance.now() - submittedAt)
|
||||
})
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||
}
|
||||
@@ -387,7 +451,21 @@ const PostProcessingPasses = ({
|
||||
// Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0,
|
||||
// making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node).
|
||||
;(renderer as any).setClearAlpha(0)
|
||||
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
|
||||
renderPipelineRef.current.render()
|
||||
if (PERF_OVERLAY_ENABLED) {
|
||||
// device.queue.onSubmittedWorkDone() resolves once the GPU has
|
||||
// finished the work we just submitted — the delta from our submit
|
||||
// timestamp is a clean per-frame GPU duration. Doesn't block CPU
|
||||
// (no await) and works for the custom RenderPipeline path that
|
||||
// bypasses three.js's timestamp-query infrastructure.
|
||||
const queue = (renderer as any).backend?.device?.queue as
|
||||
| { onSubmittedWorkDone?: () => Promise<void> }
|
||||
| undefined
|
||||
queue?.onSubmittedWorkDone?.().then(() => {
|
||||
pushGpuSample(performance.now() - submittedAt)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error('[viewer/post-processing] Render pass failed.', {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// GPU work-time measurement, gated by `?perf` in the URL.
|
||||
//
|
||||
// We can't use WebGPU timestamp queries here because the editor renders via
|
||||
// a custom `RenderPipeline.render()` path that bypasses three.js's built-in
|
||||
// timestamp infrastructure. Instead we use `device.queue.onSubmittedWorkDone()`,
|
||||
// which resolves when the GPU finishes all submitted work — measuring the
|
||||
// CPU→GPU-done delta gives a clean approximation of per-frame GPU duration
|
||||
// regardless of which render path produced it.
|
||||
|
||||
export const PERF_OVERLAY_ENABLED =
|
||||
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('perf')
|
||||
|
||||
const MAX_SAMPLES = 256
|
||||
const samples: number[] = []
|
||||
|
||||
export function pushGpuSample(ms: number): void {
|
||||
samples.push(ms)
|
||||
if (samples.length > MAX_SAMPLES) samples.shift()
|
||||
}
|
||||
|
||||
export function drainGpuSamples(): number[] {
|
||||
if (samples.length === 0) return []
|
||||
const out = samples.slice()
|
||||
samples.length = 0
|
||||
return out
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
type MaterialMapProperties,
|
||||
type MaterialPresetPayload,
|
||||
type MaterialProperties,
|
||||
type MaterialSchema,
|
||||
resolveMaterial,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
|
||||
export const baseMaterial = new MeshStandardNodeMaterial({
|
||||
color: '#f2f0ed',
|
||||
@@ -15,10 +15,8 @@ export const baseMaterial = new MeshStandardNodeMaterial({
|
||||
metalness: 0.0,
|
||||
})
|
||||
|
||||
export const glassMaterial = new MeshStandardNodeMaterial({
|
||||
export const glassMaterial = new MeshLambertNodeMaterial({
|
||||
color: '#e0f2fe',
|
||||
roughness: 0.05,
|
||||
metalness: 0.0,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: THREE.DoubleSide,
|
||||
|
||||
Reference in New Issue
Block a user