Skip to content

perf(simulator): don't stack depth-readback requests in flight - #370

Merged
dli7319 merged 2 commits into
google:mainfrom
salmanmkc:perf/simulator-depth-throttle
Jun 15, 2026
Merged

perf(simulator): don't stack depth-readback requests in flight#370
dli7319 merged 2 commits into
google:mainfrom
salmanmkc:perf/simulator-depth-throttle

Conversation

@salmanmkc

Copy link
Copy Markdown
Contributor

SimulatorDepth.update() was firing one render-to-target + one async readback per frame on the main thread. the readback uses WebGLRenderer.readRenderTargetPixelsAsync which polls a WebGL fence via setTimeout, and the fence-polling chain showed up dominating desktop perf traces of every depth-consuming demo (face_mirror, objects_3d, human_pose_detector). on a recent face_mirror trace this stack accounted for ~5 of every 12 main-thread seconds.

adds an inflight guard: don't fire a new readback while the previous promise is still resolving. without it simulatorUpdate stacks one per frame and the fence-polling chains overlap. when the in-flight readback resolves, the next simulatorUpdate runs a fresh pass normally.

initially also tried a motion gate (skip the pass when the depth camera hadn't moved), but two issues made it not worth it: scene-driven motion (animated object in SimulatorScene) wouldn't refresh depth until the camera moved, and slow tweens got skipped repeatedly then caught up in a burst. dropped it in the follow-up commit. inflight guard alone is the only behavior change.

real-headset path is untouched, SimulatorDepth only runs in the desktop simulator addon.

4 tests cover the guard: first-frame pass, no-double-fire while in flight, resume after readback resolves, steady-state per-frame.

context: surfaced while tracing face_mirror FPS for #366 / #367. independent of those, applies to every demo that consumes depth.

salmanmkc added a commit to salmanmkc/xrblocks that referenced this pull request Jun 15, 2026
BVH-building over the cloned depth-mesh geometry was running every
detection (computeBoundsTree on a few-thousand-triangle mesh, ~30 ms).
For a static desktop sim the depth source rarely changes between
detections so the same BVH is valid for many in a row.

getDepthMeshSnapshot now caches the cloned snapshot and its BVH and
reuses them while the source geometry's position attribute version is
unchanged. World transform (position / quaternion / scale) still
refreshes each call so the cached snapshot reflects whatever pose the
depth mesh is at right now.

When the source version bumps (three.js does this on needsUpdate =
true), we dispose the previous BVH and re-clone. Synergistic with the
inflight-only depth throttle (google#370): static scene = depth refreshes
seldom = BVH built once and held.
salmanmkc added a commit to salmanmkc/xrblocks that referenced this pull request Jun 15, 2026
BVH-building over the cloned depth-mesh geometry was running every
detection (computeBoundsTree on a few-thousand-triangle mesh, ~30 ms).
For a static desktop sim the depth source rarely changes between
detections so the same BVH is valid for many in a row.

getDepthMeshSnapshot now caches the cloned snapshot and its BVH and
reuses them while the source geometry's position attribute version is
unchanged. World transform (position / quaternion / scale) still
refreshes each call so the cached snapshot reflects whatever pose the
depth mesh is at right now.

When the source version bumps (three.js does this on needsUpdate =
true), we dispose the previous BVH and re-clone. Synergistic with the
inflight-only depth throttle (google#370): static scene = depth refreshes
seldom = BVH built once and held.
@salmanmkc salmanmkc changed the title simulator: don't stack depth-readback requests in flight perf(simulator): don't stack depth-readback requests in flight Jun 15, 2026
@dli7319
dli7319 force-pushed the perf/simulator-depth-throttle branch from d23443b to a64d3f3 Compare June 15, 2026 05:20
@dli7319

dli7319 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Oh didn't know you're still working on this. Can you push your change again?

SimulatorDepth.update() was firing one render-to-target + one async
readback per frame on the main thread. The readback uses
WebGLRenderer.readRenderTargetPixelsAsync which polls a WebGL fence
via setTimeout, and the fence-polling cost showed up dominating
desktop perf traces of every depth-consuming demo (face_mirror,
objects_3d, human_pose_detector). On a recent face_mirror trace
this stack accounted for ~5s of every 12s of main-thread time.

Two gates added in update():

1. Inflight guard: don't fire a new readback while the previous
   promise is still resolving. Without it simulatorUpdate stacks one
   per frame and the fence-polling chains overlap.

2. Motion gate: skip the entire pass (render-to-target + readback)
   when the depth camera hasn't moved or rotated beyond configurable
   epsilons since the last completed update. Defaults of 1 cm / ~0.5
   deg keep depth crisp during a drag without firing on JS-numerical
   noise. A static desktop sim therefore computes depth once on the
   first frame and again only when the user is actually moving.

Real-headset path is untouched (SimulatorDepth only runs in the
desktop simulator addon).

Adds 6 tests covering both gates: first-frame pass, no-motion skip,
translate-above-epsilon, rotate-above-epsilon, inflight skip,
resume-after-resolve. 295 tests total.
The motion gate skipped the depth pass when the depth camera hadn't
moved beyond an epsilon since the last completed update. Two issues
that aren't worth the saved frames:

- Scene-driven motion isn't detected. If a demo animates an object in
  SimulatorScene (the depth source), the depth buffer would go stale
  until the camera moved. The simulator scene is usually a static
  proxy for the physical environment but the assumption is fragile.

- Slow tweens get repeatedly skipped, then catch up in a burst when
  cumulative motion crosses the epsilon. Less smooth than just
  letting the pass run every frame.

Keeps the inflight guard, which is unambiguously safe (only skips
when an earlier readback is still polling its WebGL fence) and still
kills the stacked setTimeout-polling chain that was dominating perf
traces.
@salmanmkc
salmanmkc force-pushed the perf/simulator-depth-throttle branch from a64d3f3 to e5d22c9 Compare June 15, 2026 06:49
@salmanmkc

Copy link
Copy Markdown
Contributor Author

Oh didn't know you're still working on this. Can you push your change again?

sure done sorry was just dropping the motion detection part, since it's purely camera based and scene could change itself instead, your commit state I think was fine but pushed anyway

@dli7319
dli7319 merged commit a196170 into google:main Jun 15, 2026
8 checks passed
dli7319 pushed a commit to salmanmkc/xrblocks that referenced this pull request Jun 15, 2026
BVH-building over the cloned depth-mesh geometry was running every
detection (computeBoundsTree on a few-thousand-triangle mesh, ~30 ms).
For a static desktop sim the depth source rarely changes between
detections so the same BVH is valid for many in a row.

getDepthMeshSnapshot now caches the cloned snapshot and its BVH and
reuses them while the source geometry's position attribute version is
unchanged. World transform (position / quaternion / scale) still
refreshes each call so the cached snapshot reflects whatever pose the
depth mesh is at right now.

When the source version bumps (three.js does this on needsUpdate =
true), we dispose the previous BVH and re-clone. Synergistic with the
inflight-only depth throttle (google#370): static scene = depth refreshes
seldom = BVH built once and held.
dli7319 pushed a commit to salmanmkc/xrblocks that referenced this pull request Aug 7, 2026
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.
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