Reduce viewer rendering cost with lighter materials

This commit is contained in:
sudhir
2026-05-13 22:47:10 +05:30
10 changed files with 303 additions and 83 deletions
@@ -1,12 +1,12 @@
import { import {
useInteractive,
useRegistry,
useScene,
type AnimationEffect, type AnimationEffect,
type AnyNodeId, type AnyNodeId,
type Interactive, type Interactive,
type ItemNode, type ItemNode,
type LightEffect, type LightEffect,
useInteractive,
useRegistry,
useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useAnimations } from '@react-three/drei' import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone' 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 { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => { const getMaterialForOriginal = (original: Material): Material => {
if (original.name.toLowerCase() === 'glass') { if (original.name.toLowerCase() === 'glass') {
return glassMaterial return glassMaterial
} }
@@ -1,6 +1,6 @@
'use client' '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 { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer' import { ColumnRenderer } from './column/column-renderer'
@@ -10,14 +10,14 @@ import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer' import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer' import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer' import { LevelRenderer } from './level/level-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { ScanRenderer } from './scan/scan-renderer' import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer' import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer' import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer' import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer' import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { WallRenderer } from './wall/wall-renderer' import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer' import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-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 { 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 { useNodeEvents } from '../../../hooks/use-node-events'
import { unionPolygons } from '../../../lib/polygon-union' import { unionPolygons } from '../../../lib/polygon-union'
import useViewer from '../../../store/use-viewer' 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 */} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && ( {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]} /> <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} color={bgColor}
depthWrite={true} depthWrite={true}
polygonOffset={true} polygonOffset={true}
polygonOffsetFactor={1} polygonOffsetFactor={1}
polygonOffsetUnits={1} polygonOffsetUnits={1}
/> /> */}
</mesh> </mesh>
)} )}
@@ -22,10 +22,8 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
return ( return (
<mesh <mesh
castShadow
material={material} material={material}
position={node.position} position={node.position}
receiveShadow
ref={ref} ref={ref}
rotation={node.rotation} rotation={node.rotation}
visible={node.visible} 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 { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-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 { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system' import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-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 { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemSystem } from '../../systems/item/item-system'
import { LevelSystem } from '../../systems/level/level-system' import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system' import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system' import { ScanSystem } from '../../systems/scan/scan-system'
@@ -160,11 +161,16 @@ const Viewer: React.FC<ViewerProps> = ({
useBvh = true, useBvh = true,
}) => { }) => {
const theme = useViewer((state) => state.theme) 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 ( return (
<Canvas <Canvas
camera={{ position: [50, 50, 50], fov: 50 }} camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`} className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]} dpr={[1, maxDpr]}
frameloop="never" frameloop="never"
gl={ gl={
((props: { canvas?: HTMLCanvasElement }) => { ((props: { canvas?: HTMLCanvasElement }) => {
@@ -242,7 +248,7 @@ const Viewer: React.FC<ViewerProps> = ({
<ItemLightSystem /> <ItemLightSystem />
{selectionManager === 'default' && <SelectionManager />} {selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />} {(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children} {children}
</ErrorBoundary> </ErrorBoundary>
</Canvas> </Canvas>
@@ -251,7 +257,16 @@ const Viewer: React.FC<ViewerProps> = ({
const DebugRenderer = () => { const DebugRenderer = () => {
useFrame(({ gl, scene, camera }) => { useFrame(({ gl, scene, camera }) => {
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
gl.render(scene, camera) 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 return null
} }
@@ -4,6 +4,17 @@ import type { AmbientLight, DirectionalLight, OrthographicCamera } from 'three/w
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer' 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() { export function Lights() {
const theme = useViewer((state) => state.theme) const theme = useViewer((state) => state.theme)
const isDark = theme === 'dark' const isDark = theme === 'dark'
@@ -109,24 +120,26 @@ export function Lights() {
return ( return (
<> <>
<directionalLight <directionalLight
castShadow castShadow={!SHADOWS_DISABLED}
position={[10, 10, 10]} position={[10, 10, 10]}
ref={light1Ref} ref={light1Ref}
shadow-bias={-0.002} shadow-bias={-0.002}
shadow-mapSize={[1024, 1024]} shadow-mapSize={[1024, 1024]}
shadow-normalBias={0.3} shadow-normalBias={0.3}
shadow-radius={3} shadow-radius={2}
> >
<orthographicCamera {SHADOWS_DISABLED ? null : (
attach="shadow-camera" <orthographicCamera
bottom={-shadowCameraSize} attach="shadow-camera"
far={100} bottom={-shadowCameraSize}
left={-shadowCameraSize} far={100}
near={1} left={-shadowCameraSize}
ref={shadowCamera} near={1}
right={shadowCameraSize} ref={shadowCamera}
top={shadowCameraSize} right={shadowCameraSize}
/> top={shadowCameraSize}
/>
)}
</directionalLight> </directionalLight>
<directionalLight position={[-10, 10, -10]} ref={light2Ref} /> <directionalLight position={[-10, 10, -10]} ref={light2Ref} />
@@ -1,17 +1,50 @@
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { drainGpuSamples } from '../../lib/gpu-perf'
const SAMPLE_INTERVAL = 0.5 // seconds between display updates const SAMPLE_INTERVAL = 0.5 // seconds between display updates
export const PerfMonitor = () => { 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 frameCount = useRef(0)
const elapsed = useRef(0) const elapsed = useRef(0)
const lastMs = 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++ frameCount.current++
const now = clock.elapsedTime const now = clock.elapsedTime
const dt = now - elapsed.current const dt = now - elapsed.current
@@ -20,11 +53,57 @@ export const PerfMonitor = () => {
const fps = Math.round(frameCount.current / dt) const fps = Math.round(frameCount.current / dt)
const frameMs = lastMs.current const frameMs = lastMs.current
const info = gl.info const info = gl.info
const drawCalls = info.render?.calls ?? 0 // calls/triangles have been accumulating since the last reset (start of
const triangles = info.render?.triangles ?? 0 // 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 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 frameCount.current = 0
elapsed.current = now elapsed.current = now
} }
@@ -50,10 +129,15 @@ export const PerfMonitor = () => {
whiteSpace: 'pre', whiteSpace: 'pre',
}} }}
> >
{`FPS ${stats.fps} {`FPS ${stats.fps}
DRAW ${stats.drawCalls} GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'}
TRI ${(stats.triangles / 1000).toFixed(1)}k DRAW ${stats.drawCalls}
DIRTY ${stats.dirty}`} TRI ${(stats.triangles / 1000).toFixed(1)}k
DIRTY ${stats.dirty}
MESH ${stats.meshes}
LINE ${stats.lines}
SPRITE ${stats.sprites}
LIGHT ${stats.lights}`}
</div> </div>
</Html> </Html>
) )
@@ -21,6 +21,7 @@ import {
vec4, vec4,
} from 'three/tsl' } from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' 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 { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node' import { mergedOutline } from '../../lib/merged-outline-node'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
@@ -41,6 +42,43 @@ export const SSGI_PARAMS = {
useTemporalFiltering: false, 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 MAX_PIPELINE_RETRIES = 3
const RETRY_DELAY_MS = 500 const RETRY_DELAY_MS = 500
@@ -178,9 +216,17 @@ const PostProcessingPasses = ({
return 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', { console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion, version: pipelineVersion,
ssgi: SSGI_PARAMS.enabled, ssgi: ssgiEnabled,
denoise: denoiseEnabled,
outline: outlineEnabled,
perfDisable,
hoverHighlightMode, hoverHighlightMode,
projectId, projectId,
rendererCtor: (renderer as any).constructor?.name, rendererCtor: (renderer as any).constructor?.name,
@@ -227,7 +273,7 @@ const PostProcessingPasses = ({
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4> 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) // MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
scenePass.setMRT( scenePass.setMRT(
mrt({ mrt({
@@ -265,15 +311,21 @@ const PostProcessingPasses = ({
const giTexture = (giPass as any).getTextureNode() 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 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 // Composite: scene * AO + diffuse * GI
sceneColor = vec4( sceneColor = vec4(
@@ -284,36 +336,39 @@ const PostProcessingPasses = ({
// Single merged outline node: one shared depth pass for both selected + hovered groups. // Single merged outline node: one shared depth pass for both selected + hovered groups.
const outliner = useViewer.getState().outliner const outliner = useViewer.getState().outliner
const outlineNode = mergedOutline(scene, camera, { let compositeWithOutlines = sceneColor
primaryObjects: outliner.selectedObjects, if (outlineEnabled) {
secondaryObjects: outliner.hoveredObjects, const outlineNode = mergedOutline(scene, camera, {
primaryEdgeThickness: uniform(1), primaryObjects: outliner.selectedObjects,
secondaryEdgeThickness: uniform(1.5), secondaryObjects: outliner.hoveredObjects,
}) primaryEdgeThickness: uniform(1),
secondaryEdgeThickness: uniform(1.5),
})
// Selected: white visible, yellow hidden // Selected: white visible, yellow hidden
const selectedVisibleColor = uniform(new Color(0xff_ff_ff)) const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
const selectedHiddenColor = uniform(new Color(0xf3_ff_47)) const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
const selectedStrength = uniform(3) const selectedStrength = uniform(3)
const selectedOutline = outlineNode.primaryVisibleEdge const selectedOutline = outlineNode.primaryVisibleEdge
.mul(selectedVisibleColor) .mul(selectedVisibleColor)
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor)) .add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
.mul(selectedStrength) .mul(selectedStrength)
// Hovered: blue visible, yellow hidden, pulsing // Hovered: blue visible, yellow hidden, pulsing
const pulsePeriod = uniform(3) const pulsePeriod = uniform(3)
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
const osc = mix(oscillating, float(1), hoverPulseMix) const osc = mix(oscillating, float(1), hoverPulseMix)
const hoverOutline = outlineNode.secondaryVisibleEdge const hoverOutline = outlineNode.secondaryVisibleEdge
.mul(hoverVisibleColor) .mul(hoverVisibleColor)
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
.mul(hoverStrength) .mul(hoverStrength)
.mul(osc) .mul(osc)
const compositeWithOutlines = vec4( compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutline.add(hoverOutline)), add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a, sceneColor.a,
) )
}
const finalOutput = vec4( const finalOutput = vec4(
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha), mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
@@ -371,12 +426,21 @@ const PostProcessingPasses = ({
sanitizeOutlineObjects(outliner.selectedObjects) sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects) sanitizeOutlineObjects(outliner.hoveredObjects)
if (hasPipelineErrorRef.current || !renderPipelineRef.current) { if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(1) ;(renderer as any).setClearAlpha(1)
} }
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
;(renderer as any).render(scene, camera) ;(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) { } catch (fallbackError) {
console.error('[viewer/post-processing] Fallback render failed.', 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, // 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). // making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node).
;(renderer as any).setClearAlpha(0) ;(renderer as any).setClearAlpha(0)
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
renderPipelineRef.current.render() 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) { } catch (error) {
hasPipelineErrorRef.current = true hasPipelineErrorRef.current = true
console.error('[viewer/post-processing] Render pass failed.', { console.error('[viewer/post-processing] Render pass failed.', {
+26
View File
@@ -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
}
+3 -5
View File
@@ -1,13 +1,13 @@
import { import {
getMaterialPresetByRef, getMaterialPresetByRef,
resolveMaterial,
type MaterialMapProperties, type MaterialMapProperties,
type MaterialPresetPayload, type MaterialPresetPayload,
type MaterialProperties, type MaterialProperties,
type MaterialSchema, type MaterialSchema,
resolveMaterial,
} from '@pascal-app/core' } from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
export const baseMaterial = new MeshStandardNodeMaterial({ export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed', color: '#f2f0ed',
@@ -15,10 +15,8 @@ export const baseMaterial = new MeshStandardNodeMaterial({
metalness: 0.0, metalness: 0.0,
}) })
export const glassMaterial = new MeshStandardNodeMaterial({ export const glassMaterial = new MeshLambertNodeMaterial({
color: '#e0f2fe', color: '#e0f2fe',
roughness: 0.05,
metalness: 0.0,
transparent: true, transparent: true,
opacity: 0.35, opacity: 0.35,
side: THREE.DoubleSide, side: THREE.DoubleSide,