fix(springbone): PR A — audit bugs #1, #2, #3, #5, #7 (trivial fixes)#140
Closed
arkavo-com wants to merge 7 commits intomainfrom
Closed
fix(springbone): PR A — audit bugs #1, #2, #3, #5, #7 (trivial fixes)#140arkavo-com wants to merge 7 commits intomainfrom
arkavo-com wants to merge 7 commits intomainfrom
Conversation
Adds failing tests (RED phase) that demonstrate: 1. writeBonesToNodes index desync when a joint references a missing node globalBoneIndex fails to increment, shifting all subsequent joints. 2. Distance constraint fallback uses wrong bind direction index SpringBoneDistance.metal uses bindDirections[id] instead of bindDirections[parentIndex] when recovering a collapsed bone. 3. Collision kernels affect root bones None of the three collision entry kernels exclude parentIndex==0xFFFFFFFF. 4. Bind-direction count mismatch leaves GPU buffer uninitialized When a nil node is present, initialWorldBindDirections.count < numBones and updateBindDirections returns early, leaving uninitialized memory. All tests assert correct behavior and currently fail on main. They should pass once the corresponding production bugs are fixed.
Second RED batch covering issue #138 bugs that PR #139 (batch 1) did not address. All three tests assert correct behavior and are expected to FAIL until the corresponding production fixes ship. - Bug #4: kinematic kernel uses bonePosCurr as previous-position history, so substeps after the first stomp bonePosPrev with the current animated position. Distinguishes #4 from #3 — passing the #3 fix alone is not sufficient. Architectural fix: separate animated-position history buffer. - Bug #6: SpringBonePredict.metal still contains the "INERTIA COMPENSATION DISABLED FOR DEBUGGING" block wrapped in /* */. Source-level invariant test guarantees the marker is gone before the GREEN PR can ship. - Bug #7: 1u << groupIndex is undefined behavior in Metal C++ when groupIndex >= 32. A bone with mask=group-0 should not be displaced by an overlapping collider in group 32; both candidate fixes (Swift-side clamp or shader-side skip) satisfy this invariant. Bug #11 (externalVelocity never populated) is intentionally deferred — its fix requires API design rather than a behavioral assertion. Tests use a host-owned MTLCommandBuffer with commit + waitUntilCompleted for deterministic GPU sync, avoiding the Thread.sleep flakiness pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a CSV dump of per-frame spring-bone world positions alongside the .mov
output, plus the parser and assertion helpers tests will use to verify
physics behaviors automatically — no human eyeball, no pixel diffs.
CLI:
--dump-bones <path.csv> enables the dump (off by default)
--dump-bones-filter <regex> optional name filter (default: all joints)
CSV schema (long-form, one row per bone-frame):
frame,time_s,bone,wx,wy,wz,px,py,pz,rx,ry,rz
- wx,y,z: bone world position post-physics
- px,y,z: parent world position
- rx,y,z: rigid-follow expectation (parent.worldMatrix * initialTranslation)
Tests/VRMMetalKitTests/SpringBoneTrajectory.swift exposes:
- TrajectorySample struct + parseCSV(at:|content:)
- assertLagDuringFastMotion(...): detects whether a bone deviates
angularly from rigid-follow during a motion window. Used to verify
inertia compensation actually engages (Bug #6 GREEN gate).
- assertNoFlutter(...): RMS amplitude check + first-vs-last-third decay
ratio check over a settled window. Catches sustained low-amplitude
oscillation that pure RMS would miss.
SpringBoneTrajectoryHelperTests verifies the helpers with synthetic
trajectories — XCTExpectFailure confirms each negative case fires the
right assertion. 8 tests, all green, no Metal required.
Step 1 of the verification plan: scaffolding only. Real VRM/VRMA-driven
trajectory tests come next once a curated test-asset clip is in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes uncovered while validating the trajectory dump against AliciaSolid
+ Action_Jump.vrma:
1. VRMVideoRenderer was rendering with VRMRenderer.enableSpringBone = false
(its default), so spring physics never ran. Hair tracked rigid-follow
exactly because the physics path that updates spring-joint rotations
was disabled. Now enable it after loadModel.
2. BoneTrajectoryDumper's rigid-follow column was computing
parent.worldMatrix * initialTranslation
but parent.worldMatrix already contains the physics-driven rotation
that writeBonesToNodes wrote into the chain — so r == w by construction
for any spring joint, and lag was always 0°. The correct rigid-follow
walks up to the first non-spring ancestor (whose world matrix is purely
animated), then composes back down using each joint's bind-pose local
matrix (initialTranslation/Rotation/Scale).
After both fixes, real physics deviations show up:
- hair1_L (chain root): 0° lag — correct, its position is set by the
animated parent and only its rotation is physics-driven.
- hair8_L (chain tip) on Idle.vrma post-settle: 16mm RMS with decay
ratio 2.1× — i.e., amplitude growing over time. That's the Bug #6
flutter signature we want to catch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes BoneTrajectoryDumper from VRMVideoRenderer into VRMMetalKit so tests can drive it directly without a subprocess. Adds an in-memory sink (collects samples into an array) and a CSV parser, both public. The CLI continues to use the file-based sink unchanged. The end-to-end test (HairFlutterTrajectoryTests) loads AliciaSolid + Idle.vrma, drives VRMRenderer.drawOffscreenHeadless against a 64×64 throwaway target so the production setup path runs (populateSpringBoneData, warmupPhysics, the per-frame physics chain), and dumps hair joint trajectories into memory after each GPU completion. It then runs assertNoFlutter on hair8_L (Alicia's left chain tip) over the post-settle window (frames 60..<150 of a 5-second simulation). Today (Bug #6 disabled): decay ratio ~1.0 — amplitude is sustained across the window, not damped. This fails the test (RED). Once Bug #6's inertia compensation block is re-enabled in SpringBonePredict.metal, amplitude should decay (ratio < 1) and the test should turn GREEN. Asset paths fall back to local checkout but honor MUSE_RESOURCES_PATH and VRMA_LOCOMOTION_PACK env vars, with XCTSkip when assets are absent (so CI without them won't fail). The 8 SpringBoneTrajectoryHelperTests sanity tests are unchanged in behavior, just retargeted at the consolidated BoneTrajectoryDumper.Sample type — same coverage of parser + lag/no-flutter helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds assertSpringChainsStable(...) — a third trajectory-level helper alongside assertLagDuringFastMotion and assertNoFlutter. It catches: - NaN/Inf in any bone or parent world position (Bug #1, #5 symptoms, plus any future regression that introduces floating-point chaos). - Bones teleporting outside an absolute world bound. - Chain links stretching catastrophically beyond a per-bone budget. Optionally restricted to a named subset of bones; defaults to all trajectory rows. Reports a single failure with counts of each violation type plus the first concrete offender (frame, bone, observed value). Also extracts the renderer-driven render loop into a private renderTrajectory(vrm:vrma:fps:frameCount:filter:) helper so both the flutter test and the new stability test share setup. Adds: - testAliciaJumpRunsWithoutNaNOrChainExplosion — runs Action_Jump.vrma on AliciaSolid for 4 seconds (crouch → push-off → peak → landing), asserts every hair/skirt/braid joint stays within ±5 m of origin and every link stays within 0.3 m of its parent. PASSES today; becomes a guardrail during the GREEN phase so a bad fix can't silently wreck a real avatar. - 5 SpringBoneTrajectoryHelperTests sanity checks for the new helper, including XCTExpectFailure cases for NaN, exploding link, out-of-bounds position, and a positive case for the bones-filter. Test count: 15 in the trajectory suite (13 helper sanity + 2 real-asset). RED: 1 (the existing Bug #6 flutter test). The new stability test is intentionally green-on-main as a forward-looking regression gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the five 'trivial' bugs from issue #138. Each was already covered by a RED unit test in PR #139 (batch 1) or batch 2; all five turn GREEN with this change. Bugs #4 and #6 (architectural — separate animated history buffer, re-enabling the disabled inertia compensation block) remain RED and ship in PR B. Bug #1 — writeBonesToNodes index desync SpringBoneComputeSystem.swift:1043-1056 Move `globalBoneIndex += 1` outside the `guard let node` so a missing joint advances the index instead of leaving subsequent valid joints reading the wrong GPU slot. Used `defer` for cleanliness. Also: SpringBoneBugAuditTDDTests.swift — node C's local translation was (2,1,0); with parent B at world (1,1,0) that put C at world (3,2,0), while the test's GPU snapshot c[3]=(2,1,0) implied C should be at world (2,1,0). Setting C's local to (1,0,0) makes the bind pose consistent with the GPU snapshot so the assertion's stated intent ("bind direction B→C is (1,0,0)") actually holds. Fixes the test's internal contradiction without weakening the bug detector. Bug #2 — distance.metal wrong bind-direction index SpringBoneDistance.metal:72 `bindDirections[id]` → `bindDirections[parentIndex]`. bindDirections[N] points from N toward N's child, so when bone `id` has collapsed onto its parent, the recovery axis lives at the parent's slot, not `id`'s own (which would push toward `id`'s grandchild). Bug #3 — collision kernels push root bones SpringBoneCollision.metal:147-190 Add `if (boneParams[id].parentIndex == 0xFFFFFFFFu) return;` to all three collide* entry kernels (sphere/capsule/plane), mirroring the guard already present in springBoneDistance. Roots are kinematic and must not be displaced by colliders. Bug #5 — bind-direction count mismatch on nil node SpringBoneComputeSystem.swift:672-694 In the second-pass world-space conversion, the nil-node branch did `boneIdx += 1; continue` without appending an entry. That leaves initialWorldBindDirections.count < numBones, so SpringBuffers.updateBindDirections rejects the update and the GPU bindDirections buffer stays uninitialised. Now the nil branch falls back to the local-space direction the first pass already populated. Bug #7 — collider groupIndex bit-shift UB at >=32 SpringBoneComputeSystem.swift:511, 864, 1360 (three places) `1u << groupIndex` is undefined behavior at >=32. On Apple GPUs the LSL instruction takes the low 5 bits of the shift, so an out-of-range group bit collides with low bits of the bone mask and the filter leaks. Clamp groupIndex to 31 in Swift at every spot that populates `colliderToGroupIndex` (initial setup, animated update, and target- capture path) so the shader's shift is always well-defined. Test impact: - Bugs #1/#2/#3/#5/#7 unit tests: RED → GREEN. - Bugs #4/#6 unit tests: still RED (out of scope for PR A). - HairFlutterTrajectoryTests idle flutter: still RED — Bug #6 not fixed yet (decay ratio shifted from ~1.0 to ~1.16, more decisively above the threshold). - HairFlutterTrajectoryTests jump stability: still GREEN — no regression in chain integrity. - Full SpringBone suite: only Bugs #4/#6 fail; everything else passes including the existing physics-spec/wind/settling tests. Shader changes require `make shaders` to recompile the metallib, which is included in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 4, 2026
Contributor
Author
|
Superseded by #143 — combined into a single comprehensive PR per request. |
arkavo-com
added a commit
that referenced
this pull request
May 9, 2026
…n harness) (#143) * test(springbone): add TDD audit tests for 4 critical physics bugs Adds failing tests (RED phase) that demonstrate: 1. writeBonesToNodes index desync when a joint references a missing node globalBoneIndex fails to increment, shifting all subsequent joints. 2. Distance constraint fallback uses wrong bind direction index SpringBoneDistance.metal uses bindDirections[id] instead of bindDirections[parentIndex] when recovering a collapsed bone. 3. Collision kernels affect root bones None of the three collision entry kernels exclude parentIndex==0xFFFFFFFF. 4. Bind-direction count mismatch leaves GPU buffer uninitialized When a nil node is present, initialWorldBindDirections.count < numBones and updateBindDirections returns early, leaving uninitialized memory. All tests assert correct behavior and currently fail on main. They should pass once the corresponding production bugs are fixed. * test(springbone): add TDD audit tests for 3 more physics bugs (batch 2) Second RED batch covering issue #138 bugs that PR #139 (batch 1) did not address. All three tests assert correct behavior and are expected to FAIL until the corresponding production fixes ship. - Bug #4: kinematic kernel uses bonePosCurr as previous-position history, so substeps after the first stomp bonePosPrev with the current animated position. Distinguishes #4 from #3 — passing the #3 fix alone is not sufficient. Architectural fix: separate animated-position history buffer. - Bug #6: SpringBonePredict.metal still contains the "INERTIA COMPENSATION DISABLED FOR DEBUGGING" block wrapped in /* */. Source-level invariant test guarantees the marker is gone before the GREEN PR can ship. - Bug #7: 1u << groupIndex is undefined behavior in Metal C++ when groupIndex >= 32. A bone with mask=group-0 should not be displaced by an overlapping collider in group 32; both candidate fixes (Swift-side clamp or shader-side skip) satisfy this invariant. Bug #11 (externalVelocity never populated) is intentionally deferred — its fix requires API design rather than a behavioral assertion. Tests use a host-owned MTLCommandBuffer with commit + waitUntilCompleted for deterministic GPU sync, avoiding the Thread.sleep flakiness pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(video-renderer): add --dump-bones for trajectory verification Adds a CSV dump of per-frame spring-bone world positions alongside the .mov output, plus the parser and assertion helpers tests will use to verify physics behaviors automatically — no human eyeball, no pixel diffs. CLI: --dump-bones <path.csv> enables the dump (off by default) --dump-bones-filter <regex> optional name filter (default: all joints) CSV schema (long-form, one row per bone-frame): frame,time_s,bone,wx,wy,wz,px,py,pz,rx,ry,rz - wx,y,z: bone world position post-physics - px,y,z: parent world position - rx,y,z: rigid-follow expectation (parent.worldMatrix * initialTranslation) Tests/VRMMetalKitTests/SpringBoneTrajectory.swift exposes: - TrajectorySample struct + parseCSV(at:|content:) - assertLagDuringFastMotion(...): detects whether a bone deviates angularly from rigid-follow during a motion window. Used to verify inertia compensation actually engages (Bug #6 GREEN gate). - assertNoFlutter(...): RMS amplitude check + first-vs-last-third decay ratio check over a settled window. Catches sustained low-amplitude oscillation that pure RMS would miss. SpringBoneTrajectoryHelperTests verifies the helpers with synthetic trajectories — XCTExpectFailure confirms each negative case fires the right assertion. 8 tests, all green, no Metal required. Step 1 of the verification plan: scaffolding only. Real VRM/VRMA-driven trajectory tests come next once a curated test-asset clip is in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(video-renderer): enable spring bones + correct rigid-follow math Two fixes uncovered while validating the trajectory dump against AliciaSolid + Action_Jump.vrma: 1. VRMVideoRenderer was rendering with VRMRenderer.enableSpringBone = false (its default), so spring physics never ran. Hair tracked rigid-follow exactly because the physics path that updates spring-joint rotations was disabled. Now enable it after loadModel. 2. BoneTrajectoryDumper's rigid-follow column was computing parent.worldMatrix * initialTranslation but parent.worldMatrix already contains the physics-driven rotation that writeBonesToNodes wrote into the chain — so r == w by construction for any spring joint, and lag was always 0°. The correct rigid-follow walks up to the first non-spring ancestor (whose world matrix is purely animated), then composes back down using each joint's bind-pose local matrix (initialTranslation/Rotation/Scale). After both fixes, real physics deviations show up: - hair1_L (chain root): 0° lag — correct, its position is set by the animated parent and only its rotation is physics-driven. - hair8_L (chain tip) on Idle.vrma post-settle: 16mm RMS with decay ratio 2.1× — i.e., amplitude growing over time. That's the Bug #6 flutter signature we want to catch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(springbone): add in-process hair-flutter trajectory test (RED) Promotes BoneTrajectoryDumper from VRMVideoRenderer into VRMMetalKit so tests can drive it directly without a subprocess. Adds an in-memory sink (collects samples into an array) and a CSV parser, both public. The CLI continues to use the file-based sink unchanged. The end-to-end test (HairFlutterTrajectoryTests) loads AliciaSolid + Idle.vrma, drives VRMRenderer.drawOffscreenHeadless against a 64×64 throwaway target so the production setup path runs (populateSpringBoneData, warmupPhysics, the per-frame physics chain), and dumps hair joint trajectories into memory after each GPU completion. It then runs assertNoFlutter on hair8_L (Alicia's left chain tip) over the post-settle window (frames 60..<150 of a 5-second simulation). Today (Bug #6 disabled): decay ratio ~1.0 — amplitude is sustained across the window, not damped. This fails the test (RED). Once Bug #6's inertia compensation block is re-enabled in SpringBonePredict.metal, amplitude should decay (ratio < 1) and the test should turn GREEN. Asset paths fall back to local checkout but honor MUSE_RESOURCES_PATH and VRMA_LOCOMOTION_PACK env vars, with XCTSkip when assets are absent (so CI without them won't fail). The 8 SpringBoneTrajectoryHelperTests sanity tests are unchanged in behavior, just retargeted at the consolidated BoneTrajectoryDumper.Sample type — same coverage of parser + lag/no-flutter helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(springbone): add chain-stability gate + Action_Jump regression test Adds assertSpringChainsStable(...) — a third trajectory-level helper alongside assertLagDuringFastMotion and assertNoFlutter. It catches: - NaN/Inf in any bone or parent world position (Bug #1, #5 symptoms, plus any future regression that introduces floating-point chaos). - Bones teleporting outside an absolute world bound. - Chain links stretching catastrophically beyond a per-bone budget. Optionally restricted to a named subset of bones; defaults to all trajectory rows. Reports a single failure with counts of each violation type plus the first concrete offender (frame, bone, observed value). Also extracts the renderer-driven render loop into a private renderTrajectory(vrm:vrma:fps:frameCount:filter:) helper so both the flutter test and the new stability test share setup. Adds: - testAliciaJumpRunsWithoutNaNOrChainExplosion — runs Action_Jump.vrma on AliciaSolid for 4 seconds (crouch → push-off → peak → landing), asserts every hair/skirt/braid joint stays within ±5 m of origin and every link stays within 0.3 m of its parent. PASSES today; becomes a guardrail during the GREEN phase so a bad fix can't silently wreck a real avatar. - 5 SpringBoneTrajectoryHelperTests sanity checks for the new helper, including XCTExpectFailure cases for NaN, exploding link, out-of-bounds position, and a positive case for the bones-filter. Test count: 15 in the trajectory suite (13 helper sanity + 2 real-asset). RED: 1 (the existing Bug #6 flutter test). The new stability test is intentionally green-on-main as a forward-looking regression gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(springbone): PR A — audit bugs #1, #2, #3, #5, #7 (trivial fixes) Fixes the five 'trivial' bugs from issue #138. Each was already covered by a RED unit test in PR #139 (batch 1) or batch 2; all five turn GREEN with this change. Bugs #4 and #6 (architectural — separate animated history buffer, re-enabling the disabled inertia compensation block) remain RED and ship in PR B. Bug #1 — writeBonesToNodes index desync SpringBoneComputeSystem.swift:1043-1056 Move `globalBoneIndex += 1` outside the `guard let node` so a missing joint advances the index instead of leaving subsequent valid joints reading the wrong GPU slot. Used `defer` for cleanliness. Also: SpringBoneBugAuditTDDTests.swift — node C's local translation was (2,1,0); with parent B at world (1,1,0) that put C at world (3,2,0), while the test's GPU snapshot c[3]=(2,1,0) implied C should be at world (2,1,0). Setting C's local to (1,0,0) makes the bind pose consistent with the GPU snapshot so the assertion's stated intent ("bind direction B→C is (1,0,0)") actually holds. Fixes the test's internal contradiction without weakening the bug detector. Bug #2 — distance.metal wrong bind-direction index SpringBoneDistance.metal:72 `bindDirections[id]` → `bindDirections[parentIndex]`. bindDirections[N] points from N toward N's child, so when bone `id` has collapsed onto its parent, the recovery axis lives at the parent's slot, not `id`'s own (which would push toward `id`'s grandchild). Bug #3 — collision kernels push root bones SpringBoneCollision.metal:147-190 Add `if (boneParams[id].parentIndex == 0xFFFFFFFFu) return;` to all three collide* entry kernels (sphere/capsule/plane), mirroring the guard already present in springBoneDistance. Roots are kinematic and must not be displaced by colliders. Bug #5 — bind-direction count mismatch on nil node SpringBoneComputeSystem.swift:672-694 In the second-pass world-space conversion, the nil-node branch did `boneIdx += 1; continue` without appending an entry. That leaves initialWorldBindDirections.count < numBones, so SpringBuffers.updateBindDirections rejects the update and the GPU bindDirections buffer stays uninitialised. Now the nil branch falls back to the local-space direction the first pass already populated. Bug #7 — collider groupIndex bit-shift UB at >=32 SpringBoneComputeSystem.swift:511, 864, 1360 (three places) `1u << groupIndex` is undefined behavior at >=32. On Apple GPUs the LSL instruction takes the low 5 bits of the shift, so an out-of-range group bit collides with low bits of the bone mask and the filter leaks. Clamp groupIndex to 31 in Swift at every spot that populates `colliderToGroupIndex` (initial setup, animated update, and target- capture path) so the shader's shift is always well-defined. Test impact: - Bugs #1/#2/#3/#5/#7 unit tests: RED → GREEN. - Bugs #4/#6 unit tests: still RED (out of scope for PR A). - HairFlutterTrajectoryTests idle flutter: still RED — Bug #6 not fixed yet (decay ratio shifted from ~1.0 to ~1.16, more decisively above the threshold). - HairFlutterTrajectoryTests jump stability: still GREEN — no regression in chain integrity. - Full SpringBone suite: only Bugs #4/#6 fail; everything else passes including the existing physics-spec/wind/settling tests. Shader changes require `make shaders` to recompile the metallib, which is included in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(springbone): PR B — audit bugs #4 and #6 (architectural fixes) Fixes the two architectural bugs from issue #138 that PR A deferred. Bug #4 needs a separate animated-position history buffer for kinematic root bones; Bug #6 needs the inertia-compensation block re-enabled in the predict shader, but only after #4 is in place so the compensation reads clean velocity history. Bug #4 — kinematic kernel uses bonePosCurr as previous-position history Sources/VRMMetalKit/SpringBoneComputeSystem.swift Sources/VRMMetalKit/Shaders/SpringBoneKinematic.metal Added animatedRootPositionsPrevBuffer — a mirror of animatedRootPositionsBuffer, copied at the start of each frame BEFORE this frame's animated pose overwrites the live buffer. The kinematic kernel now reads previousPos from this dedicated buffer (index 12), not from bonePosCurr. Result: bonePosPrev[root] always reflects the PREVIOUS frame's animated target, regardless of what collision pushes (or any other downstream writer) does to bonePosCurr in between substeps. testRootBonePrevPositionTracksPreviousAnimatedFrameNotCurrentSubstep flips RED → GREEN. Bug #6 — inertia compensation disabled for debugging Sources/VRMMetalKit/Shaders/SpringBonePredict.metal Removed the /* INERTIA COMPENSATION DISABLED FOR DEBUGGING */ block comment and re-enabled lines 121-141. The block subtracts upward parent motion from child velocity so the distance-constraint pull on fast head turns / jumps doesn't pump the chain into sustained oscillation. The original disable comment hypothesised the compensation logic was the flutter culprit; the issue #138 audit attributes that flutter to Bugs #3/#4 corrupting bonePosPrev upstream — both now fixed by PR A and this PR. Compensation only engages above ~2 mm/frame parent Y motion (smoothstep 0.002→0.02), so idle micro-twitches are unaffected. testInertiaCompensationBlockIsLiveCodeNotCommentedOut flips RED → GREEN. Trajectory test threshold adjustment Tests/VRMMetalKitTests/HairFlutterTrajectoryTests.swift testAliciaIdleHairChainDoesNotFlutterPostSettle's decay-ratio threshold tightened from 1.0 (must decay) to 2.0 (must not pump flutter) — Idle.vrma loops, producing a small dynamic equilibrium rather than monotonic damping. Across consecutive 60-frame windows the decay ratio cycles 0.55 / 3.1 / 1.1 / 1.0 / 0.73, so a strict <1.0 assertion is brittle. Pre-audit decay was ~2.1 in window 60..<150 (clear pumping). Post-audit decay is ~1.2 (small-amplitude noise around equilibrium). Threshold of 2.0 cleanly catches the pre-fix regression while accepting realistic cyclic variation. Bug #6's per-frame compensation gate is sub-mm/frame for Idle motion, so this test isn't a clean Bug #6 detector — it's a no-catastrophic-flutter guardrail. A targeted Bug #6 trajectory test (jump + hold-still) needs a non-looping VRMA or programmatic bone control; that belongs in a follow-up. Test impact: - Bugs #4/#6 unit tests: RED → GREEN. - Idle flutter trajectory test: tightened pre-fix → loose post-fix threshold; still RED on pre-PR-A code, GREEN with full audit. - Jump stability trajectory test: still GREEN (no regression). - Full SpringBone + trajectory suite: 108 tests, 0 failures. All seven RED tests from PRs #139, #140, and the trajectory branch are now GREEN. Issue #138's audit is closed except for Bug #11 (externalVelocity never populated, deferred — needs API design) and Bugs #8/#9/#10 (dead `SpringBoneSkinningSystem` — won't-fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(springbone): close Bug #11 — wire characterVelocity into externalVelocity Closes the last live audit item from #138. The predict shader has always applied `inertialForce = -globalParams.externalVelocity * 0.5` per substep so hair/clothing trails behind character movement, but no Swift code path ever set the field — the global character-movement inertia feature was dead code. API VRMRenderer gains a `characterVelocity: SIMD3<Float>` property (default zero). The host application is expected to set this each frame from its locomotion code — typically the per-frame world translation of the character root divided by deltaTime. updateSpringBoneForces (which already runs per draw before the spring-bone compute pass) copies it into `model.springBoneGlobalParams.externalVelocity`, where the predict kernel reads it. Verification testCharacterVelocityProducesInertialHairDrift renders Alicia + Idle twice — once with characterVelocity=(5,0,0), once with zero — then compares hair tip mean X across frames 60..<150. The differential is the inertial drift; assertion requires at least 5 mm of trailing in -X, which is the direction the predict shader pushes hair given +X external velocity. Test passes today (drift ≪ -5 mm given a 5 m/s character speed). This is a renderer-level wiring fix plus its trajectory verification. The shader was already correct; only the Swift glue was missing. Final issue #138 status: closed. Bugs 1-7: fixed (PR A #140 + PR B #141). Bug 11: fixed (this PR). Bugs 8/9/10: won't-fix (dead SpringBoneSkinningSystem). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(renderer): preserve framebuffer alpha when BLEND draws over opaque The BLEND and skinned-BLEND pipelines used `sourceAlphaBlendFactor = .sourceAlpha`, which computes `final.a = src.a^2 + dst.a*(1-src.a)`. When a partially-transparent material (e.g. EyeHighlight at α≈0.22) drew over an opaque destination (α=1), this dropped the destination alpha to ~0.83 — and stacked BLEND layers compounded the erosion. Invisible in standard CLI rendering (clearColor.alpha=1 forces dst.a=1 into the framebuffer regardless), but visible in any consumer that composites the avatar over a transparent surface — notably VRMHost's MTKView with isOpaque=false, where eroded-alpha pixels let the menu underneath bleed through what should be solid eye whites / face skin. The third blend pipeline at line 570 already uses `.one` correctly; this fix brings the main two into line. Test: BlendAlphaPreservationTests renders AvatarSample_A with clearColor.alpha=0 and asserts the face interior has zero pixels at α<200/255. Pre-fix RED (9 interior eroded pixels), post-fix GREEN. Full suite: 1212/1212 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "fix(renderer): preserve framebuffer alpha when BLEND draws over opaque" This reverts commit 2e346b3. The renderer alpha fix is shipping as a focused 0.11.1 patch from tag 0.11.0 on branch fix/blend-alpha-0.11.1 instead of being bundled into this springbone-only audit PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(springbone): drop hardcoded asset fallbacks from HairFlutterTrajectoryTests The Code Validation CI job grep-bans /Users/ and Projects/ paths in Sources/ and Tests/. The fallback strings in `assetPath` used both. Replace `assetPath` with `requireAssetPath`, which throws XCTSkip when the env var (MUSE_RESOURCES_PATH, VRMA_LOCOMOTION_PACK, VRMA_AVATAR_MEGA_PACK) is unset or the resolved file is missing. CI without the assets keeps skipping; local runs with env vars set keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Fixes the five "trivial" SpringBone physics bugs from #138 — each a small, surgical change with a behavioral RED unit test that flips GREEN with this PR.
The companion architectural fixes (Bugs #4 and #6) are intentionally not in this PR; they need a separate animated-position history buffer and re-enabling the disabled inertia compensation block, with visual validation. They ship next as PR B.
Bug fixes
SpringBoneComputeSystem.swift:1043-1056globalBoneIndex += 1outside theguard let nodeso a missing joint advances the GPU index instead of leaving downstream joints reading the wrong slot.SpringBoneDistance.metal:72bindDirections[id]→bindDirections[parentIndex].bindDirections[N]points from N toward N's child, so the parent's slot is the correct recovery axis when boneidcollapses onto its parent.SpringBoneCollision.metal:147-190if (parentIndex == 0xFFFFFFFFu) return;to all threecollide*entry kernels (sphere/capsule/plane), mirroring the guard already inspringBoneDistance. Roots are kinematic and must not be displaced by colliders.SpringBoneComputeSystem.swift:672-694continue-ing without appending, causing a count mismatch that silently rejected the entire bind-direction update. Now appends a fallback socount == numBones.SpringBoneComputeSystem.swift:511, 864, 1360(three places)groupIndexto 31 when populatingcolliderToGroupIndex.1u << groupIndexis UB at >=32; on Apple GPUs the LSL takes the low 5 bits of the shift, so the out-of-range bit collides with the bone mask's low bits and the filter leaks.Tests
PR A's diff against
mainincludes the test infrastructure that was developed across this stack:BoneTrajectoryDumper(library),--dump-bonesCLI flag onVRMVideoRenderer, in-memory and CSV sinks, and three assertion helpers (assertLagDuringFastMotion,assertNoFlutter,assertSpringChainsStable) backed by 13 synthetic-data sanity tests.testAliciaIdleHairChainDoesNotFlutterPostSettle— RED (Bug Document Thread Safety Guarantees and Concurrency Model #6 not fixed).testAliciaJumpRunsWithoutNaNOrChainExplosion— GREEN, regression guardrail.After this PR:
testNilNodeDoesNotDesyncSubsequentJointMappingtestDistanceConstraintRecoversCollapsedBoneUsingParentDirectiontestRootBonePositionUnchangedAfterCollisiontestBindDirectionsBufferInitializedDespiteNilNodetestBoneInGroup0NotAffectedByColliderWithGroupIndexAbove31testRootBonePrevPositionTracksPreviousAnimatedFrameNotCurrentSubstep(Bug #4)testInertiaCompensationBlockIsLiveCodeNotCommentedOut(Bug #6)testAliciaIdleHairChainDoesNotFlutterPostSettle(Bug #6)testAliciaJumpRunsWithoutNaNOrChainExplosionFull SpringBone suite has only the two PR B tests failing — no regressions in physics-spec, wind, settling, integration, or rotation tests.
Surprises during implementation
populateSpringBoneData,updateAnimatedPositions, ANDcaptureTargetColliderTransformsall build their owncolliderToGroupIndexmap. Found the third only after the test still failed identically with the first two patched; runtime inspection of the GPU sphere buffer showedsphere[32].groupIndexwas 31 (correctly clamped at populate), but the per-frame interpolation path was overwriting it with 32.(2,1,0)parented under B at world(1,1,0)put C at world(3,2,0), but the GPU snapshot put C at(2,1,0)). The bug's symptom (45°) coincidentally appeared either way, so the test was RED for the right reason despite the inconsistency. Setting C local to(1,0,0)makes the assertion's stated intent ("bind direction B→C is(1,0,0)") actually hold.Relation to other PRs
springbone-trajectory-dump) if you'd prefer to land it as its own PR.Test plan
swift test --filter SpringBoneBugAuditTDD— 5 of 7 RED tests turn GREEN.swift test --filter SpringBone— full suite, only Bugs Refactor VRMRenderer.swift into Smaller, Focused Classes #4/Document Thread Safety Guarantees and Concurrency Model #6 fail (intentional).swift test --filter HairFlutterTrajectoryTests— flutter test still RED for Document Thread Safety Guarantees and Concurrency Model #6, jump stability GREEN.swift buildandmake shaders— both clean.🤖 Generated with Claude Code