perf(simulator): don't stack depth-readback requests in flight - #370
Merged
Conversation
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.
dli7319
approved these changes
Jun 15, 2026
dli7319
force-pushed
the
perf/simulator-depth-throttle
branch
from
June 15, 2026 05:20
d23443b to
a64d3f3
Compare
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
force-pushed
the
perf/simulator-depth-throttle
branch
from
June 15, 2026 06:49
a64d3f3 to
e5d22c9
Compare
Contributor
Author
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
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.
This was referenced Aug 7, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SimulatorDepth.update()was firing one render-to-target + one async readback per frame on the main thread. the readback usesWebGLRenderer.readRenderTargetPixelsAsyncwhich 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
simulatorUpdatestacks one per frame and the fence-polling chains overlap. when the in-flight readback resolves, the nextsimulatorUpdateruns 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,
SimulatorDepthonly 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.