skip the simulator depth readback when nothing has changed - #505
Conversation
SimulatorDepth renders the depth scene and reads it back every frame. google#370 stopped those readbacks stacking up but not how often they run. The target is 160x160 single-channel float, about 100 KB, so the cost is not bandwidth. Reading it back stalls the pipeline waiting on the GPU. In a stationary view every one of those returns a buffer identical to the last. Skips the render and readback unless something that affects depth changed. The camera transform is checked, and so is a hash over the world transform and visibility of every node the depth pass draws, so an object moving under a still camera still refreshes. A staleness floor bounds the worst case, since a transform hash cannot see vertex-level animation such as skinning. Measured on demos/portals with a resting camera, five interleaved runs per side, 8 second samples, mean and standard deviation, on an RTX 4060: getBufferSubData 244.5 +/- 31.9 ms -> 0.0 +/- 0.0 getParameter 314.9 +/- 33.8 ms -> 0.0 +/- 0.0 clientWaitSync 22.2 +/- 3.7 ms -> 0.0 +/- 0.0 main-thread idle 67.2 +/- 1.3 % -> 77.7 +/- 1.9 % frame rate 156.0 +/- 4.5 -> 154.6 +/- 4.1 A second set of five runs in a different thermal state reproduced the same shape: getBufferSubData 375.9 to 1.9, idle 59.8% to 71.8%, and frame rate 151.4 versus 150.6. Frame rate does not change. The gain is main-thread headroom for application logic, AI and physics. On integrated graphics the readback costs considerably more. A single run there showed getBufferSubData falling from 7705 ms to 1721 ms over 10 seconds, which matters because XR devices use mobile-class GPUs, but that is one sample and is not re-measurable on this machine. A resting camera is the best case. Counting actual readbacks over 16 seconds of moving through the scene by hand, 73 would have run before and 33 ran after, so 55% fewer during real use. The depth buffer itself is byte for byte identical either way.
maxDepthAgeMs reads like a hedge against animation the transform hash cannot see, and it is, but it is also load bearing for cache invalidation. The detectors cache a cloned depth mesh and its BVH keyed on the depth mesh position attribute version, so the floor is what keeps that version advancing while the scene sits still. Removing it would freeze the version in a stationary scene, leave those caches pointing at a stale clone, and bring back the bug where the face wireframe only appeared while the camera was moving. Verified against demos/face_mirror: over four idle seconds the downsampled position version advances 20 to 28, which is the twice a second the floor guarantees, and the geometry carries real depth rather than a flat plane.
04ebe55 to
a78e234
Compare
|
I filed mrdoob/three.js#34182. If this gets addressed within threejs, readRenderTargetPixelsAsync is actually super fast: |
How can I get precise measurements like this? |
|
It's a CDP script, nothing clever. Drives Chrome over the DevTools protocol, starts a trace, and counts the events straight out of it. The ± comes from interleaving. Running A five times then B five times gives you thermal drift dressed up as a result, so it alternates A/B/A/B and reports mean and sd across the pairs. I had a single-run pair that looked like +14% fps and it was just noise, run to run spread is about ±4.5. Worth checking what actually rendered, too. Chrome can quietly fall back to SwiftShader, which is software rasterisation on the CPU, and then you're timing the fallback instead of the GPU. The script reads the unmasked renderer string and warns if it sees it. I ran with Happy to send it as a PR if that's useful, about 300 lines and no dependencies. |
that's awesome, and nice pr you made there too! hope it gets merged soon |



follow-up to #373. pattern 1 there was the simulator depth readback stalling the main thread, and #370 fixed the part where the readback promises stacked up. it didn't change how often they run, and that turns out to be most of the desktop profile still.
SimulatorDepth.update()renders the depth scene and starts a readback every frame. the target is 160x160 single channel float, about 100 KB, so it isn't bandwidth, it's the stall: reading the target back waits on the GPU. measures at roughly 98 ms per readback on an intel UHD.and in a stationary view every one of those comes back identical to the last. a probe over 120 consecutive frames showed the simulator camera doesn't move at all in automation mode, 0 frames changed.
so this skips the render + readback unless something that actually affects depth changed. camera transform, plus a hash over the world transform and visibility of every node the depth pass draws, so an object moving under a still camera still refreshes.
maxDepthAgeMsbounds the worst case since a transform hash can't see skinning or vertex shaders.numbers from
demos/portalswith a resting camera, five interleaved runs per side so both see the same thermal/background drift, 8s samples, mean ± sd, RTX 4060:getBufferSubDatagetParameterclientWaitSyncrepeated the whole thing a second time at a different time/thermal state, same shape:
getBufferSubData375.9 ± 91.0 -> 1.9 ± 4.3, idle 59.8 ± 4.5 -> 71.8 ± 2.2, fps 151.4 ± 4.7 vs 150.6 ± 6.4.fps doesn't change. the gain is main-thread headroom, ~580 ms of readback and fence-polling per 8s window handed back to app logic / AI / physics. i originally read a single-run pair as +14% fps and that was just noise, run to run spread is ±4.5.
on integrated graphics the readback is much more expensive (a single 10s run showed
getBufferSubData7705 ms -> 1721 ms and idle 16% -> 59%), which matters given XR devices are mobile-class GPUs. These are more indicative, since I didn't run these again over averages on the iGPU.control:
samples/ui, which has no depth, shows nogetBufferSubDataat all, so the cost really is the readback and not general scene load.a resting camera is the best case. counting actual readbacks over 16s of moving around portals by hand: 73 would have run before, 33 ran after, so 55% fewer during real use rather than ~87% sitting still. the depth buffer itself comes out byte for byte identical either way, same checksum over all 25600 samples.
the hash itself is cheap, ~14 ms of a 9965 ms window.
10 unit tests cover camera move, object moving under a still camera, visibility toggle, object added, staleness refresh, and the existing in-flight guard.