+
+ Performance
+
+ {stats ? `${stats.fps} fps` : '—'}
+
+
+
+ {stats ? (
+
+
+
+ {stats.frameMs > 0
+ ? `${stats.frameMs.toFixed(1)}ms cpu (max ${stats.frameMaxMs.toFixed(1)})`
+ : '—'}
+
+
+ {stats.encodeMs > 0
+ ? `${stats.encodeMs.toFixed(1)}ms (max ${stats.encodeMaxMs.toFixed(1)})`
+ : '—'}
+
+
+ {stats.gpuTracked
+ ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})`
+ : 'no timestamp-query'}
+
+
+ {stats.queueMs > 0
+ ? `${stats.queueMs.toFixed(1)}ms (max ${stats.queueMaxMs.toFixed(1)})`
+ : '—'}
+
+ {stats.drawCalls}
+ {`${(stats.triangles / 1000).toFixed(1)}k`}
+
+ {`${stats.geometries} geo ${stats.textures} tex ${mb(stats.gpuBytes)}`}
+
+ {stats.heapBytes > 0 ? mb(stats.heapBytes) : '—'}
+
+ {stats.dirty}
+ {stats.dirtyDetail ? ` (${stats.dirtyDetail})` : ''}
+
+
+ {`${stats.meshes} mesh ${stats.lines} line ${stats.lights} light`}
+
+
+
+ {stats.tracks.length > 0 && (
+
+ {stats.tracks.map((t) => (
+
+ {t.name}
+
+ {`${t.totalMs.toFixed(1)}ms (${t.count}×, max ${t.maxMs.toFixed(1)})`}
+
+
+ ))}
+
+ )}
+
+ ) : (
+
waiting for samples…
+ )}
+
,
+ document.body,
+ )
+}
+
+export default PerfPanel
diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx
index b4e83eaf76..e2c379cae8 100644
--- a/packages/viewer/src/components/viewer/post-processing.tsx
+++ b/packages/viewer/src/components/viewer/post-processing.tsx
@@ -24,13 +24,14 @@ import {
vec3,
vec4,
} from 'three/tsl'
-import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
+import { RenderPipeline, TimestampQuery, type WebGPURenderer } from 'three/webgpu'
import { backdropGradient, deepSkyColor, horizonHazeColor } from '../../lib/backdrop'
import { edgeColorFor, edgeOpacityScaleFor } from '../../lib/edge-style'
-import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
+import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf'
import { inkedEdges } from '../../lib/ink-edges'
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node'
+import { recordPerfSample, timeSpan } from '../../lib/perf-tracks'
import { getSceneTheme } from '../../lib/scene-themes'
import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat'
import useViewer from '../../store/use-viewer'
@@ -152,6 +153,36 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
objects.length = nextIndex
}
+// Two independent GPU readings per frame, both `?perf`-only:
+// - `gpu-render`: three's WebGPU timestamp queries — the summed GPU duration of
+// the frame's render passes, measured on the device. The only honest "GPU ms".
+// - `gpu-queue`: submit → `onSubmittedWorkDone()` wall time. That covers queue
+// backlog and CPU work that ran before the microtask got to resume, so it is
+// a backpressure signal, not GPU time.
+// `resolveTimestampsAsync` returns the previous resolve's value while one is in
+// flight, so calling it every frame is safe (and required — the query pool warns
+// once it fills).
+function recordFrameGpuTiming(renderer: any, submittedAt: number): void {
+ const queue = renderer.backend?.device?.queue as
+ | { onSubmittedWorkDone?: () => Promise