fix(springbone): close issue #138 physics audit (8 bugs + verification harness)#143
Merged
arkavo-com merged 12 commits intomainfrom May 9, 2026
Merged
fix(springbone): close issue #138 physics audit (8 bugs + verification harness)#143arkavo-com merged 12 commits intomainfrom
arkavo-com merged 12 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>
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>
…lVelocity 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>
This was referenced May 4, 2026
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>
…er 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>
This was referenced May 5, 2026
…ectoryTests 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>
This was referenced May 9, 2026
Closed
Closed
arkavo-com
added a commit
that referenced
this pull request
May 10, 2026
…ON arrays Two latent loader bugs that surfaced in the analysis of PR #148. 1) **VRM 0.x \`_BlendMode\` was parsed but never mapped to \`alphaMode\`.** AliciaSolid's bangs are authored as \`_BlendMode=3\` (TransparentWithZWrite), but \`alphaMode\` stayed at the glTF default \`"OPAQUE"\`. Pipeline selection then routed the material through the opaque pass and the bangs rendered without alpha blending. Fix: switch on \`blendMode\` after parsing it and set the corresponding \`alphaMode\` (0→OPAQUE, 1→MASK, 2/3→BLEND). 2) **\`as? [Double]\` failed on \`Int\`-typed JSON arrays.** AnyCodable decodes \`[1, 1, 1]\` from JSON as \`[Int]\`, not \`[Double]\`. Several MToon factor parsers (\`shadeColorFactor\`, \`matcapFactor\`, \`parametricRimColorFactor\`, \`outlineColorFactor\`) used \`as? [Double]\` and silently dropped the value when the JSON happened to use integer literals. Adds a \`floatArray(from:count:)\` helper that accepts \`Int\`, \`Double\`, and \`Float\` element types and routes the four call sites through it. Salvaged from PR #148 (which was forked from a pre-PR-#143 commit and cannot be cleanly rebased). 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
Closes the SpringBone physics audit from #138. Fixes all 8 live bugs (#1-7 and #11), adds the test infrastructure that proves each fix, and leaves Bugs #8/#9/#10 as won't-fix (dead
SpringBoneSkinningSystem).The work was originally developed as five stacked PRs (#139-142 plus an unpushed trajectory branch) for incremental review. Combined here per request — one comprehensive PR is easier to review than a stack.
Audit status
writeBonesToNodesindex desync on nil jointtestNilNodeDoesNotDesyncSubsequentJointMappingdistance.metalusesbindDirections[id]instead of[parentIndex]testDistanceConstraintRecoversCollapsedBoneUsingParentDirectiontestRootBonePositionUnchangedAfterCollisionbonePosCurras previous-position historytestRootBonePrevPositionTracksPreviousAnimatedFrameNotCurrentSubsteppopulateSpringBoneDatabind-direction count mismatch on nil jointtestBindDirectionsBufferInitializedDespiteNilNodeSpringBonePredict.metaltestInertiaCompensationBlockIsLiveCodeNotCommentedOut+testAliciaIdleHairChainDoesNotFlutterPostSettlegroupIndex >= 32triggers bit-shift UBtestBoneInGroup0NotAffectedByColliderWithGroupIndexAbove31SpringBoneSkinningSystemexternalVelocityfield exists but is dead codetestCharacterVelocityProducesInertialHairDriftWhat's in this PR
Verification infrastructure (new)
BoneTrajectoryDumperinSources/VRMMetalKit/— public API. CSV-file or in-memory sink, regex bone-name filter. Each row carries the bone's world position, its parent's world position, and the rigid-follow expectation (computed by walking up to the first non-spring ancestor, then composing bind-pose local matrices back down).--dump-bones <path.csv>and--dump-bones-filter <regex>CLI flags onVRMVideoRenderer. The.movkeeps rendering normally; the CSV is a side artifact.Tests/VRMMetalKitTests/SpringBoneTrajectory.swift:assertLagDuringFastMotion(samples:bone:window:minLagDegrees:)assertNoFlutter(samples:bone:settledWindow:maxRMS:minDecayRatio:)assertSpringChainsStable(samples:bones:maxAbsoluteCoordinate:maxLinkLength:)XCTExpectFailurecases for each negative path.Production fixes
Bug #1 —
SpringBoneComputeSystem.swift:1043-1056Move
globalBoneIndex += 1outside theguard let nodeso a missing joint advances the GPU index instead of leaving downstream joints reading the wrong slot.Bug #2 —
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.Bug #3 —
SpringBoneCollision.metal:147-190Add
if (parentIndex == 0xFFFFFFFFu) return;to all threecollide*entry kernels (sphere/capsule/plane). Roots are kinematic and must not be displaced by colliders.Bug #4 —
SpringBoneComputeSystem.swift+SpringBoneKinematic.metalAdded
animatedRootPositionsPrevBuffer— a mirror ofanimatedRootPositionsBuffer, copied at the start of each frame BEFORE this frame's animated pose overwrites the live buffer. The kinematic kernel now readspreviousPosfrom this dedicated buffer (binding index 12), not frombonePosCurr.bonePosPrev[root]is now independent of any downstream writer touchingbonePosCurrbetween substeps.Bug #5 —
SpringBoneComputeSystem.swift:672-694Nil-node branch in the second-pass world-space conversion was
continue-ing without appending, causing a count mismatch that silently rejected the entire bind-direction update. Now appends a fallback socount == numBones.Bug #6 —
SpringBonePredict.metalRemoved the
/* INERTIA COMPENSATION DISABLED FOR DEBUGGING */block comment and re-enabled lines 121-141. Compensation only engages above ~2 mm/frame parent Y motion (smoothstep gate), so idle micro-twitches are unaffected. Pairs with the Bug #4 fix so compensation reads clean velocity history.Bug #7 —
SpringBoneComputeSystem.swift:511, 864, 1360(three places)Clamp
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 low bits of the bone mask and the filter leaks. Three call sites needed the clamp —populateSpringBoneData,updateAnimatedPositions, andcaptureTargetColliderTransforms.Bug #11 —
VRMRenderer.swift+VRMRenderer+MTKViewDelegate.swiftAdded
VRMRenderer.characterVelocity: SIMD3<Float>(default.zero).updateSpringBoneForces(which already runs per draw before the spring-bone compute pass) copies it intomodel.springBoneGlobalParams.externalVelocitywhere the predict kernel reads it. The shader was always correct; only the Swift glue was missing.Misc fixes uncovered along the way
VRMVideoRendererwas rendering withenableSpringBone = false(its default), so spring physics never ran on the .mov pipeline. Now opts in afterloadModel.BoneTrajectoryDumper's rigid-follow math originally usedparent.worldMatrix * initialTranslation, which equals the actual position by construction for any spring joint (sinceparent.worldMatrixalready contains the physics-driven rotation). Corrected to walk up to the first non-spring ancestor.Test inventory
SpringBoneBugAuditTDDTests(Bugs #1, #2, #3, #5)SpringBoneBugAuditTDDBatch2Tests(Bugs #4, #6, #7)SpringBoneTrajectoryHelperTests(synthetic)HairFlutterTrajectoryTests(real-asset trajectory)SpringBone*suites (regression check)Notable decisions documented in commit messages
minDecayRatiois 2.0, not 1.0 — Idle.vrma loops, producing a small dynamic equilibrium rather than monotonic damping. Pre-audit decay was ~2.1× in the test window (clear pumping); post-audit ~1.2× (small-amplitude noise). Threshold of 2.0 cleanly catches the pre-fix regression.MUSE_RESOURCES_PATHandVRMA_*_PACKenv vars with hard-coded local fallbacks; CI without the assets getsXCTSkip, not failure.Test plan
swift test --filter SpringBoneBugAuditTDD— all 7 audit tests GREEN.swift test --filter "SpringBone|HairFlutter"— full suite, 109 tests, 0 failures.swift buildandmake shaders— both clean.🤖 Generated with Claude Code