fix(springbone): segment per-substep host writes into per-substep slots (#396) - #398
Conversation
Closes #396. The substep loop rewrites globalParams, bindDirections and the collider buffers from the host on every substep, but only animatedRootPositions was segmented by substep index (the #278 fix). The other three were single regions every dispatch read at offset 0, which failed differently on each command-buffer path. On the self-committed path (synchronousSpringBone) each substep is committed immediately and nothing waits, so substep i+1's host writes landed while substep i was still reading them — the run-to-run divergence tracked as #394. On the shared-buffer path every substep is encoded before the caller commits, so no dispatch had executed by the time the last write landed and every substep read the final substep's values: deterministic, silent, and a different simulation. warmupPhysics already documents this exact race and pays a full GPU drain per step to dodge it; the runtime loop cannot afford that, so it segments instead. Each of the three buffers now carries maxSubstepsPerFrame slots at 256-byte aligned strides (the kernels bind them in the constant address space), written and bound at substepIndex * stride. Writers that are not per-substep — the load-time uploads and the once-per-frame foreign collider tail — broadcast to every slot so a substep never reads a slot the current frame did not populate. globalParams is segmented for uniformity but was not contributing to the divergence: every substep writes byte-identical params today, because `var params = globalParams` copies a stale `let` and neither windPhase nor settlingFrames is written back to it. That staleness is a separate latent bug, left alone here. Behaviour change: per-substep collider and bind-direction interpolation now actually reaches the GPU, so early substeps see the collider partway through the frame instead of fully advanced. SpringBoneRotationTests read bindDirections at offset 0 and now index the last substep's slot, which is where the frame's final direction lives. Verified: the #394 determinism test is green 3/3 over full parallel runs that failed 2/3 before.
The #396 fix makes per-substep collider and bind-direction interpolation actually reach the GPU, so early substeps now see geometry partway through the frame instead of fully advanced. The frozen trajectories shift accordingly. Drift scales with substeps per frame, which is the signature this change should have: ultra (120Hz, 2 substeps) 3.85 mm J_Sec_Hair4_04_end z high (90Hz) 2.13 mm J_Sec_Hair4_07_end z medium (60Hz, ~1 substep) 0.94 mm J_Sec_Hair4_04_end z low (30Hz, <=1 substep) unchanged Only ultra and high exceeded the 1 mm gate; medium is regenerated because it did move, and low produced no diff at all. Largest change is under 4 mm on a hair tip. Regenerated with VRM162_REGENERATE_BASELINE=1.
b9d1746 to
be399d0
Compare
The #396 segmentation moves this metric, so its calibration lands with it rather than leaving the PR red for a follow-up. Before segmentation the floor was 13/180; it is now 19/180. That is a real shift in this configuration, not a slipped guard: - The velocity correction still engages. Stubbing applyVelocityCorrection to an early return on post-#396 code gives 23/180 against 19/180 with it intact — a 4-frame benefit, versus 5 frames before. - This test runs augment:false, so no synthetic colliders and no swept CCD. It is the coarse-collider repro configuration, not the one #309/#313 shipped. On the augmented path the same change took re-entry from 24-27 frames to a flat 9, peak unchanged. - The pre-#396 collapse had every substep read the collider at its end-of-frame position, which during a fast swing put it ahead of its true mid-frame location and caught cloth early — an accidental, crude CCD. Correct per-substep interpolation removes that, exposing the ordinary discrete tunneling the synthetic colliders exist to fix. The doc block also carried three claims the measurements contradict: the 13/180 floor, the "flickers 13<->18" jitter, and "floor >=17 means the bleed stopped engaging". Segmenting removed the #394 race and the metric is now bit-identical run to run, so those are corrected and the evidence recorded in place. The min-over-3 is kept — it costs little and the determinism claim is measured serially. Verified: full parallel suite green 3/3 at --num-workers 14.
Review follow-up on #398 (gitar-bot). writeForeignTail broadcast the foreign collider tail into all 10 substep slots every frame, but the substep loop only ever dispatches [0, frameSubstepCount). Since the tail is rewritten every frame, slots above that are never read before being overwritten, so the extra writes were pure waste — up to 10x the per-frame tail memcpy in crowd and external-collider scenes. Bounded to max(1, frameSubstepCount). This adds no new assumption: the substep loop's own `t = (currentSubstepIndex + 1) / frameSubstepCount` already depends on the dispatched index staying below frameSubstepCount. Verified rather than assumed — a precondition asserting `currentSubstepIdx < frameSubstepCount` never tripped across a full parallel suite run. The load-time updateBindDirections / updateSphereColliders / updateCapsuleColliders broadcasts still fill every slot; those run once, not per frame. Verified: full parallel suite green 2/2 at --num-workers 14.
Code Review ✅ Approved 1 resolved / 1 findingsSegments springbone per-substep host writes into dedicated slots to eliminate race conditions, addressing the foreign tail broadcast inefficiency. No issues found. ✅ 1 resolved✅ Performance: Foreign tail broadcast to all 10 slots every frame is wasteful
Was this helpful? React with 👍 / 👎 | Gitar |
The baseline was captured on 1.1.0-beta.1, before #398 (per-substep host write segmentation) and #420 moved spring-bone trajectories on main. 1322 of 3059 samples differ, all of it main's motion, none of it this branch's. Verified rather than assumed: captured the same 90-frame armsCrossed sequence from a worktree at origin/main with the flag plumbing removed, and this branch's flag-off capture matches it bit for bit, 3059/3059. The invariant the oracle exists to protect — opt-in off changes nothing — now holds against 1.1.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
Closes #396.
The substep loop rewrites
globalParams,bindDirectionsand the collider buffers from the host on every substep, but onlyanimatedRootPositionswas segmented by substep index (the #278 fix). The other three were single regions every dispatch read at offset 0, which failed differently on each command-buffer path:synchronousSpringBone): each substep is committed immediately and nothing waits, so substep i+1's host writes landed while substep i was still reading them — the run-to-run divergence tracked as Flaky: SpringBoneRendererDeterminismTests fails ~1 run in 3 under parallel load — residual non-determinism after #283 #394.warmupPhysicsalready documents this exact race and pays a full GPU drain per step to dodge it; the runtime loop cannot afford that, so it segments instead.Each buffer now carries
maxSubstepsPerFrameslots at 256-byte aligned strides (the kernels bind them in theconstantaddress space), written and bound atsubstepIndex * stride. Writers that are not per-substep — the load-time uploads and the once-per-frame foreign collider tail — broadcast to every slot, so a substep never reads a slot the current frame did not populate.Verification
The #394 determinism test is green 3/3 over full parallel runs that failed 2/3 on the same machine before.
Both new tests were confirmed red-then-green by reverting the fix underneath them. Worth noting the first version of the content test was vacuous — it passed against a single-region build because a slot left holding a stale broadcast also merely differs from its neighbour. It now pins the actual interpolant per slot.
Baseline drift scales with substeps per frame, which is the signature this change should have and is independent evidence it is not perturbing something unrelated:
Only ultra and high exceeded the 1 mm gate. Regenerated in b9d1746. Sanity render of
AvatarSample_Uis unchanged.Behaviour change
Per-substep collider and bind-direction interpolation now actually reaches the GPU, so early substeps see geometry partway through the frame instead of fully advanced.
SpringBoneRotationTestsreadbindDirectionsat offset 0 and now index the last substep's slot, which is where the frame's final direction lives. Those failures were a stale helper, not a regression —(0.707, -0.707, 0)is exactly thet=0.5interpolant for a 90 degrees rotation with 2 substeps.Arm-swing guard re-baselined (#397)
testU_armSwing_velocityCorrection_reducesReentryFramesmoved from a 13/180 floor to 19/180, so its bound goes 16 -> 19 in this PR. Investigation on #397 established that this is a real shift in that configuration rather than a slipped guard:The velocity correction still engages. Stubbing
applyVelocityCorrectionto an early return on this branch gives 23/180 against 19/180 with it intact — a 4-frame benefit, versus 5 frames before.That guard runs
augment: false— no synthetic colliders, so no swept CCD. It is the coarse-collider repro configuration, not the one SpringBone collision: coarse collider shapes cause systemic clipping (hair→forehead, hair→arms, arm→skirt) — needs holistic fidelity analysis #309/SpringBone: motion-transient cloth clipping (sleeve→arm, skirt→leg) needs CCD/substep work #313 shipped. On the augmented path (testU_armSwing_armColliders_catapultStaysBounded) this change took re-entry from 24-27 frames to a flat 9, peak unchanged:Why coarse got worse: the old collapse had every substep read the collider at its end-of-frame position, which during a fast swing put it ahead of its true mid-frame location and caught cloth early — an accidental, crude CCD. Correct per-substep interpolation removes that, exposing the ordinary discrete tunneling the synthetic colliders exist to fix.
Both configurations are also now bit-deterministic run to run. The jitter that guard's docs attribute to "Metal compute reductions / fast-math" was substantially the #394 race.
Full parallel suite is green 3/3 at
--num-workers 14.Scope note
globalParamsis segmented for uniformity but was not contributing to the divergence: every substep writes byte-identical params today, becausevar params = globalParamscopies a staleletand neitherwindPhasenorsettlingFramesis written back to it. That staleness is a separate latent bug (wind phase never advances) and is left alone here.#380 rewrites much of this area and does not fix this — a grep across its full diff for the interpolation and wait symbols returns zero touched lines — so expect a conflict if it lands first.
🤖 Generated with Claude Code