Skip to content

SpringBone sleep gate: wake anchors instead of last-frame snapshots - #431

Open
enitimeago wants to merge 4 commits into
arkavo-org:mainfrom
enitimeago:sleep-gate-wake-anchors
Open

SpringBone sleep gate: wake anchors instead of last-frame snapshots#431
enitimeago wants to merge 4 commits into
arkavo-org:mainfrom
enitimeago:sleep-gate-wake-anchors

Conversation

@enitimeago

Copy link
Copy Markdown
Contributor

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 × modelScale since the snapshot in previous*ForSleep. captureSleepSnapshots refreshed 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 at modelScale ≈ 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 and writeBonesToNodes skips 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*ForSleep are now wake anchors:

  • a root anchor is refreshed 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 at all;
  • forced/global wakes refresh everything (WakeMotion.refreshAll).

computeWakeMask records what it saw cross the threshold (WakeMotion: root/sphere/capsule/plane indices) and captureSleepSnapshots uses 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*Masks helpers become moved* and return the moved indices alongside the group masks. No shader or buffer-layout changes; sleepThreshold / sleepDelayFrames defaults and the 1.1.1 public knobs are untouched. The sync/offline path still never sleeps.

Tests

SpringBoneSleepDriftWakeTests on springbone_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:

test motion 1.1.1 this PR
testColliderJumpingAwayWakesSettledChainWhichThenFalls — capsule +0.5 x in one frame 1000× threshold pass pass; swings back toward vertical and sleeps again
testColliderDriftingAwayWakesSettledChain — capsule drifts ¼ threshold/frame × 600 0.075 total, 150× threshold fail — never wakes pass
testRootDriftingWakesSettledChain — root drifts the same, capsule still 0.075 total, 150× threshold fail — never wakes pass

The drift tests step at a fraction of the effective threshold read through a new internal hook, testWakeMotionThreshold (sleepThreshold scaled 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

swift test --filter 'SpringBoneSleepDriftWakeTests|Group4SpringBoneSleepTests' --disable-sandbox
# 15 tests, 0 failures — the 12 Group4 tests incl. #424's four new ones, plus the 3 above

With only the test commit applied to 1.1.1: 3 tests, 2 failures (the two drift cases). Full suite on the fix commit over 1.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.

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.
@gitar-bot

gitar-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

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

Comment thread Sources/VRMMetalKit/SpringBoneComputeSystem.swift
Comment thread Sources/VRMMetalKit/SpringBoneComputeSystem.swift
… 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).
@gitar-bot

gitar-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown
CI failed: The Claude code review workflow failed because it lacked the required 'id-token: write' permission to fetch an OIDC token.

Overview

1 log failure encountered in the claude-review job due to missing workflow configuration permissions.

Failures

Missing OIDC Token Permission (confidence: high)

  • Type: configuration
  • Affected jobs: 101508286195
  • Related to change: no
  • Root cause: The GitHub Actions workflow lacks the required 'id-token: write' permission, preventing the action from fetching an OIDC token.
  • Suggested fix: Add permissions: id-token: write to the workflow job configuration.

Summary

  • Change-related failures: 0
  • Infrastructure/flaky failures: 1 configuration failure in the review workflow
  • Recommended action: Update the workflow permissions for the claude-review job to include id-token: write.
Code Review ✅ Approved 2 resolved / 2 findings

Fixes 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

📄 Sources/VRMMetalKit/SpringBoneComputeSystem.swift:1347-1360 📄 Sources/VRMMetalKit/SpringBoneComputeSystem.swift:1201-1213
captureSleepSnapshots converts root and authored sphere/capsule/plane snapshots into slow-drift-accumulating wake anchors, but previousForeignForSleep/previousExternalForSleep are still overwritten unconditionally every frame (lines 1359-1360). computeWakeMask diffs against those via foreignCollidersChanged, so a foreign/external collider (e.g. a partner avatar's body during a slow head turn) that drifts below the per-frame threshold never accumulates displacement and never wakes a sleeping chain — the exact bug this PR fixes for authored colliders. The block comment at 1327-1334 ("every collider while any chain sleeps, keep the transform they had when the sleep began") is therefore inaccurate for foreign colliders. Track whether the foreign/external set tripped the threshold this frame (as with motion.spheres etc.) and only refresh those anchors when they moved or when no chain is asleep.

Performance: O(chains × movedRoots) contains() in per-frame anchor loop

📄 Sources/VRMMetalKit/SpringBoneComputeSystem.swift:1342-1345
The root-anchor refresh loop uses motion.roots.contains(i) inside a loop over all root indices (lines 1342-1343), making it O(chains × movedRoots) every frame while a chain is asleep. For avatars with many spring chains this runs each frame. Convert motion.roots to a Set<Int> (or iterate the moved indices directly) so the membership test is O(1).

Tip

Comment Gitar fix CI to trigger a fix.

Was this helpful? React with 👍 / 👎 | Gitar

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.

SpringBone sleep gate: motion slower than the threshold per frame never wakes a sleeping chain

1 participant