SpringBone sleep gate: wake anchors instead of last-frame snapshots - #431
SpringBone sleep gate: wake anchors instead of last-frame snapshots#431enitimeago wants to merge 4 commits into
Conversation
Three tests on the conformance capsule VRM (one chain resting pressed against the head capsule, asleep after ~33 frames on the async path): - collider jumps away in one frame: wakes, falls, sleeps again - collider drifts away at 1/4 wake-threshold per frame for 600 frames (150x the threshold in total): must wake - root drifts the same way with the collider still: must wake The two drift tests fail on 1.1.0. The wake check compares root and collider transforms against snapshots that are overwritten every frame, so it only ever asks "did it move more than the threshold since LAST frame" - motion slower than that never accumulates, and a sleeping chain holds its last local pose and rides along with its parent until something fast enough finally trips the gate. On a real avatar that is a hair chain that stays bent through a slow head turn and then snaps. Adds the internal `testWakeMotionThreshold` hook so the tests drift at a fraction of the EFFECTIVE threshold (sleepThreshold scaled by model size: 0.0005 on this fixture, not the nominal 0.001) instead of hardcoding it.
`previousRootPositionsForSleep` and the three `previous*CollidersForSleep` arrays were overwritten by `captureSleepSnapshots` every frame, so the wake check in `computeWakeMask` was a per-frame velocity test: anything slower than `sleepThreshold x modelScale` units per frame (about 9 cm/s at 90 Hz for a normal avatar) never woke a sleeping chain, no matter how far it eventually travelled. While asleep every kernel early-outs, so the chain kept its last local pose and moved rigidly with its parent. They are now wake ANCHORS. A root anchor is refreshed only while its chain is awake, or in the frame its motion tripped the gate. A collider anchor is refreshed when that collider tripped the gate, or while no chain is asleep. `computeWakeMask` records what it saw move (`WakeMotion`) and `captureSleepSnapshots` uses that; forced/global wakes refresh all. Slow motion therefore accumulates against the anchor until it crosses the threshold, and the chain wakes. No shader or buffer layout changes. The moved-collider helpers now return the moved indices alongside the group masks. Full suite: 2071 tests, 0 failures; the two drift tests from the previous commit now pass and the jump test still sees the chain sleep again after the collider stops.
|
Important Your team uses Gitar, but you don't have an assigned seat yet. Ask a team admin to add your seat so Gitar can review your code. Learn more |
… drift The review pass on the wake-anchor change spotted that the foreign (cross-avatar) and external (prop) collider snapshots are still overwritten every frame, so those two sources kept the exact per-frame velocity test the previous commits removed for authored colliders: a partner or prop drifting slower than the threshold never wakes a sleeping chain. Two tests on the same conformance capsule fixture, one per source since each keeps its own snapshot: a sphere appears well away from the chain (count 0 -> 1 must wake, static must let it sleep again), then drifts at 1/4 wake-threshold per frame for 600 frames (150x the threshold in total) and must wake the chain. Both fail on the previous commit.
…leep The foreign and external snapshots taken in captureSleepSnapshots now follow the same wake-anchor rule as roots and authored colliders: frozen while any chain sleeps, refreshed only when `foreignCollidersChanged` saw the set trip the threshold (WakeMotion.foreignMoved/externalMoved, one flag per source since these sets diff as whole snapshots, not per index) or while nothing sleeps. Sub-threshold drift of a partner avatar or prop collider therefore accumulates against the anchor and wakes the chain, instead of being re-zeroed every frame. The stored sets are still the CLAMPED ones writeForeignTail applied, so the wake check keeps diffing applied-vs-applied and over-budget colliders that were never written cannot cause a spurious wake. A count change (partner appears/leaves) trips the diff, wakes once, and refreshes the anchor, so it does not wake repeatedly. Also converts the moved-root membership test in the anchor refresh loop to a Set so it is O(1) per root instead of O(movedRoots). The two drift tests from the previous commit now pass; the SpringBone suites are green (263 tests, 0 failures).
CI failed: The Claude code review workflow failed because it lacked the required 'id-token: write' permission to fetch an OIDC token.Overview1 log failure encountered in the claude-review job due to missing workflow configuration permissions. FailuresMissing OIDC Token Permission (confidence: high)
Summary
Code Review ✅ Approved 2 resolved / 2 findingsFixes slow-motion wake detection for sleeping SpringBone chains by using wake anchors instead of per-frame snapshots. Chains now accumulate displacement while asleep and wake when motion crosses the threshold, rather than comparing only the most recent frame's motion. Foreign/external collider anchors and the O(chains × movedRoots) lookup have been addressed. All 2075 tests pass. ✅ 2 resolved✅ Edge Case: Foreign/external collider anchors still refresh every frame
✅ Performance: O(chains × movedRoots) contains() in per-frame anchor loop
Tip Comment Was this helpful? React with 👍 / 👎 | Gitar |
Fixes #430. Follow-up to #423/#424 and the open observation in my #87 device report.
What
A sleeping chain is woken when its root, or a collider in its group mask, has moved more than
sleepThreshold × modelScalesince the snapshot inprevious*ForSleep.captureSleepSnapshotsrefreshed that snapshot every frame, so wake detection effectively compared motion over the most recent snapshot interval rather than accumulating displacement while the chain slept.: motion below ~0.001 units/frame atmodelScale ≈ 1(≈ 9 cm/s at 90 Hz) therefore did not wake the chain, regardless of the total displacement accumulated over time. While asleep every kernel early-outs andwriteBonesToNodesskips the chain, so it kept its last local pose and rode rigidly with its parent — which explains the observed case where a braid remained bent against the hood during a slow head turn and only corrected after a later wake.previous*ForSleepare now wake anchors:WakeMotion.refreshAll).computeWakeMaskrecords what it saw cross the threshold (WakeMotion: root/sphere/capsule/plane indices) andcaptureSleepSnapshotsuses that at the end of the frame. Slow motion therefore accumulates against the pose the chain fell asleep in until it crosses the threshold, and a collider that moved once and then stopped cannot keep chains awake — its anchor was refreshed when it was seen to move.The
moved*Maskshelpers becomemoved*and return the moved indices alongside the group masks. No shader or buffer-layout changes;sleepThreshold/sleepDelayFramesdefaults and the 1.1.1 public knobs are untouched. The sync/offline path still never sleeps.Tests
SpringBoneSleepDriftWakeTestsonspringbone_collider_capsule_x0p02_r0p1.vrm— one 4-joint chain from the head, settled pressed against the head capsule (the harness asserts tail x − root x < −0.05 before each test), async path, asleep after ~33 frames:1.1.1testColliderJumpingAwayWakesSettledChainWhichThenFalls— capsule +0.5 x in one frametestColliderDriftingAwayWakesSettledChain— capsule drifts ¼ threshold/frame × 600testRootDriftingWakesSettledChain— root drifts the same, capsule stillThe drift tests step at a fraction of the effective threshold read through a new internal hook,
testWakeMotionThreshold(sleepThresholdscaled by model size — 0.0005 on this fixture, not the nominal 0.001), so they are not tied to the fixture's rest lengths.Verification
With only the test commit applied to
1.1.1: 3 tests, 2 failures (the two drift cases). Full suite on the fix commit over1.1.1: 2075 tests, exit 0 (swift test --parallel --disable-sandbox).Two commits: the tests first (they fail on
1.1.1), then the fix.