perf improvements
This commit is contained in:
@@ -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'
|
||||||
@@ -9,14 +9,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 useViewer from '../../../store/use-viewer'
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
import { Canvas, extend, useFrame, useThree, type ThreeToJSXElements } 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'
|
||||||
import { DoorSystem } from '../../systems/door/door-system'
|
import { DoorSystem } from '../../systems/door/door-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'
|
||||||
@@ -158,11 +159,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 }) => {
|
||||||
@@ -249,7 +255,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>
|
||||||
@@ -258,7 +264,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,14 +120,15 @@ 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}
|
||||||
>
|
>
|
||||||
|
{SHADOWS_DISABLED ? null : (
|
||||||
<orthographicCamera
|
<orthographicCamera
|
||||||
attach="shadow-camera"
|
attach="shadow-camera"
|
||||||
bottom={-shadowCameraSize}
|
bottom={-shadowCameraSize}
|
||||||
@@ -127,6 +139,7 @@ export function Lights() {
|
|||||||
right={shadowCameraSize}
|
right={shadowCameraSize}
|
||||||
top={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
|
||||||
}
|
}
|
||||||
@@ -51,9 +130,14 @@ export const PerfMonitor = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`FPS ${stats.fps}
|
{`FPS ${stats.fps}
|
||||||
|
GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'}
|
||||||
DRAW ${stats.drawCalls}
|
DRAW ${stats.drawCalls}
|
||||||
TRI ${(stats.triangles / 1000).toFixed(1)}k
|
TRI ${(stats.triangles / 1000).toFixed(1)}k
|
||||||
DIRTY ${stats.dirty}`}
|
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
|
||||||
|
|
||||||
@@ -175,9 +213,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,
|
||||||
@@ -209,7 +255,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({
|
||||||
@@ -247,15 +293,21 @@ const PostProcessingPasses = ({
|
|||||||
|
|
||||||
const giTexture = (giPass as any).getTextureNode()
|
const giTexture = (giPass as any).getTextureNode()
|
||||||
|
|
||||||
|
const gi = giPass.rgb
|
||||||
|
let ao: any
|
||||||
|
if (denoiseEnabled) {
|
||||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
// 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 aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
||||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||||
denoisePass.index.value = 0
|
denoisePass.index.value = 0
|
||||||
denoisePass.radius.value = 4
|
denoisePass.radius.value = 4
|
||||||
|
ao = (denoisePass as any).r
|
||||||
const gi = giPass.rgb
|
} else {
|
||||||
const ao = (denoisePass as any).r
|
// 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(
|
||||||
@@ -266,6 +318,8 @@ 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
|
||||||
|
let compositeWithOutlines = sceneColor
|
||||||
|
if (outlineEnabled) {
|
||||||
const outlineNode = mergedOutline(scene, camera, {
|
const outlineNode = mergedOutline(scene, camera, {
|
||||||
primaryObjects: outliner.selectedObjects,
|
primaryObjects: outliner.selectedObjects,
|
||||||
secondaryObjects: outliner.hoveredObjects,
|
secondaryObjects: outliner.hoveredObjects,
|
||||||
@@ -292,10 +346,11 @@ const PostProcessingPasses = ({
|
|||||||
.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),
|
||||||
@@ -353,12 +408,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)
|
||||||
}
|
}
|
||||||
@@ -369,7 +433,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.', {
|
||||||
|
|||||||
@@ -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 {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user