Skip to content

skip the simulator depth readback when nothing has changed - #505

Merged
dli7319 merged 2 commits into
google:mainfrom
salmanmkc:perf-sim-depth-skip
Aug 7, 2026
Merged

skip the simulator depth readback when nothing has changed#505
dli7319 merged 2 commits into
google:mainfrom
salmanmkc:perf-sim-depth-skip

Conversation

@salmanmkc

@salmanmkc salmanmkc commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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. maxDepthAgeMs bounds the worst case since a transform hash can't see skinning or vertex shaders.

numbers from demos/portals with a resting camera, five interleaved runs per side so both see the same thermal/background drift, 8s samples, mean ± sd, RTX 4060:

metric before after
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 %
fps 156.0 ± 4.5 154.6 ± 4.1

repeated the whole thing a second time at a different time/thermal state, same shape: getBufferSubData 375.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 getBufferSubData 7705 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 no getBufferSubData at 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.

@salmanmkc
salmanmkc marked this pull request as draft August 7, 2026 07:27
@salmanmkc
salmanmkc marked this pull request as ready for review August 7, 2026 07:33
@dli7319

dli7319 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks.
It's interesting that getParameter takes the longest time.
That seems to just be a sanity check and not doing anything meaningful.

Screenshot 2026-08-07 at 3 05 13 PM

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.
@dli7319
dli7319 force-pushed the perf-sim-depth-skip branch from 04ebe55 to a78e234 Compare August 7, 2026 22:08
@dli7319
dli7319 merged commit 46c0753 into google:main Aug 7, 2026
10 checks passed
@dli7319

dli7319 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

I filed mrdoob/three.js#34182.

If this gets addressed within threejs, readRenderTargetPixelsAsync is actually super fast:
Screenshot 2026-08-07 at 8 46 27 PM

@dli7319

dli7319 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

| metric | before | after |
|---|---|---|
| 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 % |
| fps | 156.0 ± 4.5 | 154.6 ± 4.1 |

How can I get precise measurements like this?

@salmanmkc

Copy link
Copy Markdown
Contributor Author

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 --force_high_performance_gpu so it picks the discrete card rather than the integrated one, and ?xrAutomation=1 so the simulator starts without a click.

Happy to send it as a PR if that's useful, about 300 lines and no dependencies.

@salmanmkc

Copy link
Copy Markdown
Contributor Author

I filed mrdoob/three.js#34182.

If this gets addressed within threejs, readRenderTargetPixelsAsync is actually super fast: Screenshot 2026-08-07 at 8 46 27 PM

that's awesome, and nice pr you made there too! hope it gets merged soon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants