Contact IK stage pipeline: unify frame ordering, hoist constraints, terminal limb IK - #380
Contact IK stage pipeline: unify frame ordering, hoist constraints, terminal limb IK#380arkavo-com wants to merge 70 commits into
Conversation
Folded in: the two red tests1.
|
| collider | A | U |
|---|---|---|
| handSphere | 3.80× | 3.02× |
| mesh-fitted shoulder (before this fix) | 1.88× | 1.76× |
| mesh-fitted shoulder (after) | 1.66× | 1.34× |
| augmentor shoulder sphere | 1.53× | 1.20× |
| lowerArm capsule | 1.50× | 1.28× |
The families that do track the rig derive their size from an authored radius — headSkullSphere (1.0 × rHead), headBrowCapsule (0.5 × rHead), breastTwinSphere (a copy) — landing at exactly 1.00× / 0.50× / 1.00× on both bodies. That is the grounded pattern the others should follow.
ColliderDimensionAudit asserts synthetic ≤ 1.25× authored on the anchor bone, with known offenders pinned in a documented table so the debt cannot grow. An entry that drops below the ceiling on every rig fails as stale, so fixes retire their own exemptions. Verified non-vacuous.
The hand sphere at 3.0–3.8× is untouched and shipping since #321 — worth its own issue.
Test status
Full suite: 1945 tests, 185 skipped, 0 failures.
CrowdFrameStepper disabled `AnimationPlayer.solvesConstraints` on caller-supplied players and never restored it. Since `avatarsForCamera` hands those same instances back, a host that later drove one directly silently lost twist/aim constraint solving with no runtime signal. It now records each player's prior value and restores it in `deinit`, so the flag is borrowed for the stepper's lifetime rather than taken. Restore returns the value found, so a player that already had solving disabled stays disabled. Raised three times before it was fixed — twice in task review, then by Gitar on PR #380 — each time answered with documentation rather than a restore. Documentation does not help a host that never reads it. PlayerOwnershipTests covers both directions; verified non-vacuous by disabling the restore, which fails the deinit test. Full suite: 1947 tests, 189 skipped, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed the inline review comment on The stepper now records each caller-supplied player's prior
Worth recording that this exact issue was raised three times — twice during task review, then here — and each time I answered it with a doc comment instead of a restore. Documentation doesn't help a host that never reads it. Good catch. CI is green (Static checks, Compile, Gitar, both Xcode Cloud test actions) and the full local suite is 1947 tests / 0 failures. |
Pushed: two further sub-projects (830867a → f1e9b7c, 34 commits)This PR now also carries the #381 work built on the pipeline: Skin-mesh coverage oracle (test-only)Penetration measured against the actual skinned body mesh instead of hand-authored capsules: closest-point-on-triangle with angle-weighted pseudonormal classification (Bærentzen–Aanæs), spatial grid with exhaustive-fallback (no distance cutoff at any layer), SKIN-only body predicate with pinned per-fixture inventories. Fixtures Opt-in cloth-collision fidelity —
|
- CrowdFrameStepper: correct stale "flag is never restored" doc (r3721137996) — 830867a already added the borrow/restore, but the adjacent doc comment wasn't updated to match; also documents that PipelineAvatar's permanent mutation is the direct-construction path, with CrowdFrameStepper's borrow/restore as the managed alternative. - SpringBoneJointRadiusMeasure: guard inverseBindMatrices indexing with an identity fallback for malformed skins (r3729089819), matching the loader's own absent-accessor behavior; no behavior change for well-formed skins. - SpringBoneJointRadiusMeasure: document the single-instance assumption in the first-node mesh lookup (r3729089919) — acceptable-by-design for VRM avatars, multi-instance support intentionally out of scope. Tests: ClothJointRadiusAuditTests, SpringBoneBitBaselineTests, PlayerOwnershipTests — 9 tests, 0 failures, bit baseline unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code Review ✅ Approved 3 resolved / 3 findingsContact IK stage pipeline unifies frame ordering, hoists constraint solving onto final poses, and makes limb IK terminal. Addresses solvesConstraints configuration and inverseBindMatrices indexing safety, with no open findings. ✅ 3 resolved✅ Edge Case: solvesConstraints permanently disabled on caller-owned players
✅ Edge Case: Unguarded inverseBindMatrices indexing can crash on malformed skins
✅ Edge Case: Mesh-to-node lookup ignores instanced meshes
Was this helpful? React with 👍 / 👎 | Gitar |
Carries the two scratch diagnostics from the #378 branch onto the stagger + collision work. Both self-skip off-machine: the body-mesh diagnostic locates hair penetration into the skinned skin mesh grouped by dominant bone; the host-avatar one measures hair-vs-shoulder against the synthetic shoulder spheres using local GoM fixtures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotes CrowdFrameStepper's discovered phase ordering to the single S0-S6 stage pipeline: constraint solve hoists to S4 (final pose), limb IK leaves the compositor and becomes terminal at S3, S2 becomes sole writer of root/hips. Single-avatar becomes the n=1 case. Records five invariants: frozen-snapshot cross-avatar reads enforced by stage signature, the direct-apply every-frame-writer obligation, S2-only root/hips writes, no pre-S4 reads of constraint output, and priorities scoped to intra-S1 composition. Four gated commits (C1 extraction, C2 hoist, C3 re-solve, C4 propagation reduction) with golden-sequence rather than golden-frame gates, since counterbalance decay and shove offset diverge only over frames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten tasks across the four gated commits from the design. Resolves the S2 displacement conflict rule left open in the spec: one absolute request per avatar per frame, all others additive in insertion order. Two findings from planning changed the shape: S2 runs in two beats and cannot be made contiguous. Placement must precede S1 (postural lean measures world-space trunk endpoints), and the shove must follow it (its penetration signal is documented lean-relieved). Collapsing them changes the depth signal, so it is not code motion. C3's crowd gate may be vacuous. The capture-step controller already runs after the shove in the existing phase block, so the crowd fixture may pass unchanged; IKLayer, which is the path the priority-inversion finding is actually about, is not in the crowd path at all. Task 8 opens with a step that determines which path carries the bug before any change lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lti-delta sensitivity
…correct 1.0, -1.0, 1e-8 construction
The subagent workspace holds the ledger, task briefs, implementer reports and review packages. It is coordination scratch, not source; a task-3 report file was committed by an implementer before this ignore existed.
…raction Captures the 3x2 matrix (stagger-off / pre-contact / post-contact x single/crowd) over 60-frame sequences against the CURRENT, unmodified CrowdFrameStepper implementation. The fixtures are the byte-identity oracle the S0-S3 stage extraction must match exactly. Replaces the brief's testMatrixIsDeterministicAcrossRuns (which only proves the post-refactor code agrees with itself) with testMatrixMatchesCommittedBaseline, which compares a fresh capture against this committed pre-refactor baseline. PipelineBaselineFixture serializes PoseSample as per-component Float bit patterns so the round trip is exact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
…s scheduler
Introduces PipelineAvatar (per-avatar mutable pipeline state) and PoseStage
(S0 sample, S1 compose, S2 place/displace, S3 limbSolve) in
Sources/VRMMetalKit/Animation/Pipeline/. CrowdFrameStepper.step() now
builds a FrozenSnapshot per frame and drives every avatar through the
five stage calls in the same order the old inline Phase 0a-0f block ran:
sample -> place -> compose -> displace -> limbSolve.
Deletes the now-dead staggerSolvers/captureSteppers/staggerActive/
posturalLayers/armLayers dictionaries and the private
nearestPartnerTorso(of:torsos:) helper (superseded by
FrozenSnapshot.nearestPartnerTorso). Public accessors
(posturalLayer/staggerSolver/captureStepController/armCounterbalanceLayer)
now read from pipelineAvatars. FrozenSnapshot's indices are built as
avatars.map { $0.index }, matching avatars' own order so
nearestPartnerTorso's first-encountered-wins tie-break is unchanged.
Pure code motion; behaviour must be proven byte-identical against the
pre-extraction baseline fixtures (StageExtractionGateTests).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
Add PoseStage helper functions and wiring to assert that only S2 (place/displace) writes scene roots. The guard captures root translations after displace and verifies they are unchanged after limbSolve. - PoseStage.rootTranslations(of:) captures all scene roots' translations - PoseStage.rootsUnchanged(_:since:) detects any root movement - PoseStage.debugAssertRootsUnchanged(avatar:since:) fires in debug builds - CrowdFrameStepper.step() wires the guard around limbSolve - testRootWriteGuardDetectsMovement verifies the detector independently Closes #377 Task 5 of contact-ik-pipeline SDD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
Wrap capture and assertion in #if DEBUG to eliminate unconditional hot-loop allocation in release builds. Guard helper functions remain public API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
…racle Scopes over materials across every mesh rather than the first mesh named 'body', which drops the Face mesh's SKIN primitives. The inventory pins the exact included set per fixture, asserts triangle topology (strips would be skipped in silence) and records boundary-edge counts, since a SKIN-only surface is open at the eye sockets and under garments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…directions CLOTH is simulated garment surface and must never be measured as body: a garment's own buried joint would report the garment's own surface as the nearest triangle, at distance ~0, so region collapses to whatever the garment is skinned to rather than the true offender. includes() is now SKIN-only (HAIR still vetoes). The pinned inventory test now asserts both the included AND excluded material sets, for both AvatarSample_A and AvatarSample_U, so a rename that empties the oracle and a predicate change that floods it with garment surfaces both fail loudly. Sabotage now runs both directions. build(model:) gained: an optional expressionController parameter with a morph-weight precondition (unconditional isn't possible — VRMModel exposes no runtime morph state; primitive.vertexBuffer is structurally rest-pose only since morph compute writes into a private separate buffer); loud preconditions instead of silent continues for unskinned or vertex/index-less body-surface primitives and for vertices with all-zero skin weights; majorityBone now prefers a hand/finger region on a genuine three-way disagreement instead of an arbitrary first corner; and a hoisted [ObjectIdentifier: VRMHumanoidBone] map replacing an O(nodes x bones) per-vertex lookup. Re-measured the SpatialGrid performance cliff after the CLOTH fix: it is NOT gone, and is in fact worse (108.6ms/query at 1m outside vs the prior 40.8ms), and a 50m-outside query now hits the ring>4096 hard abort and crashes outright. Root cause is the body/face mesh's own fine tessellation, not the removed garment accessories — reported as a genuine open risk for later tasks, not worked around here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lback Task 4's real-avatar test (AvatarSample_U, ~13.6k triangles, cell~0.011 from mixing a coarse torso with a fine face) exposed the exact failure the cell-size heuristic was flagged as vulnerable to: a query 1m outside cost 108.6ms, and a query 50m outside needed ring 4394 -- past the ring>4096 preconditionFailure added for the previous finding -- crashing the test process. The precondition did its job (loud abort over a false CLEAN), but a hard abort on a legitimate far query still fails the 212-joint/75-frame gate this oracle exists to run. SpatialGrid.nearest now falls back to a linear scan -- exactly the equivalence test's own brute-force reference, factored into a shared SkinMeshOracle.linearNearest(to:in:) -- whenever the required ring count exceeds a threshold (32, chosen so the grid stays faster than a linear scan for any query actually near the surface while bounding worst-case ring-search cost to O(32^3) regardless of distance). Two trigger points: up front, if the first-touch ring already exceeds it, and mid-loop, if ring growth crosses it before termination (the diagonal Chebyshev/Euclidean gap case). This makes the ring>4096 preconditionFailure genuinely unreachable in normal operation rather than merely less likely, and it is kept as defense in depth per instruction. Re-measured on AvatarSample_U: 1m outside 108.6ms -> 27.3ms, 50m outside crash -> 4.2ms (completes normally). Added testGridFallsBackToLinearScanForFarOutsideQuery, a synthetic reproduction of the same shape (fine mesh, ring count that would previously have crashed) asserting both correctness and a bounded time. All 20 previously-existing tests (15 SkinMeshOracleMathTests + 5 BodySurfacePredicateTests) pass unchanged; no answer changed. Tie-break note: fallback-routed queries now return a bit-for-bit identical ClosestHit to brute force (same code path). Grid-routed queries keep the pre-existing, already-documented possible tie divergence on exact ties. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reported visual is an avatar standing with her hands at her sides, fingers inside the dress. No existing StressPose case places the arms there. Asserted on wrist position relative to hip and shoulder, not eyeballed. Angle derived by measurement (node-position probe against AvatarSample_U_1.0): -90/+90 about Z on the upper arms brings the wrists level with the hips, not the +-78 degrees the brief's estimate assumed. Finger half-thickness measured via SkinMeshOracle on both fixtures for Task 6's hand-tolerance derivation (2.7mm-6.1mm; probe removed after recording, see task-5-report.md). StressPoseFactoryTests updated for the new case count and armsAtSides track count.
…381) Measures hair/skirt/hood/sleeve joints against the skinned body mesh with AvatarSample_U posed arms-at-sides, at 1mm tolerance on hand and finger regions (derived from Task 5's measured 2.7-6.1mm finger half-thickness). Bust chains are never queried: they sit inside the chest by construction and would swamp the millimetre finger signal. The deliverable is a recorded FAILURE, pinned to the hand region so the marker cannot be satisfied by penetration elsewhere, and carrying contact-region baselines so later movement is attributable. When SP2/SP3 fix the defect, XCTExpectFailure fails and forces this marker's removal. Measured result: no hand-region penetration is found for this exact pose/fixture/query-set combination, at either per-joint or 5-point-per-segment density (verified independently via raw joint-to-hand-bone distance ~6cm minimum, and a different-pose cross-check confirming the measurement mechanism itself works). The nearest real defect here is Sleeve-chain penetration into a non-hand region (0.0342m), consistent with the already-tracked #309/#313 sleeve-arm finding, not #381. The marker is kept per plan (not deleted) and the test currently fails in the suite for this documented, verified reason — see task-6-report.md for the full investigation, both Step 4 sabotage runs (build-guard sabotage confirms correctly; the tolerance-widen sabotage is mechanically moot against a null baseline), and the wall-clock cost-driver analysis (rear-hip Skirt joints hitting the spatial grid's linear-scan fallback, ~10.5ms each, since torso skin is garment-covered on this avatar). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…381) Round 1 fix: the gate's querySet was an allowlist (hair/skirt/hood/sleeve) inherited from #309's cloth work, which silently excluded CatTail, CatEar, and TopsUpperArm from AvatarSample_U's rig. Video review of a machine-local host avatar (modelH-final.vrm) found the actual #381 defect — a cat tail passing through the hand and forearm — which the old allowlist could never have measured since CatTail was never queried. Inverted the filter to a denylist: query every spring chain except Bust (excluded because it sits inside the chest by construction, documented in-line so future exclusions are deliberate, not inherited). Re-measuring AvatarSample_U with the full denylist query set still finds no hand-region penetration: CatTail's worst penetration (0.041m) lands in `hips`, not a hand/finger region, consistent with the coordinator's video review showing U's tail hangs clear (unlike modelH-final.vrm, which is not part of this repository). Per the coordinator's decision rule, dropped XCTExpectFailure and converted the test to a passing regression guard that asserts the currently-true state directly, documenting in its doc comment that the #381 defect is observed on a machine-local fixture, not reproduced here. Full suite: 1971 tests, 0 failures (was 1971/1 before this fix). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… collision Opt-in loading mode (fitClothCollisionToMesh, default false) fixing cloth and hair passing through body colliders, evidenced on AvatarSample_M: hair hitRadius is authored at median 3.7mm (some zero) for cards centimetres wide, and sphere-at-joint collision leaves 4-5-joint chains open between joints. Controlled experiments: +50% arm colliders moved 0.03-0.23% of pixels; an 18mm hitRadius floor moved 0.31-0.37% — ten times the lever — and still cannot close the inter-joint gap. Both halves ship together: mesh-measured effective radii (authored never mutated, floored never reduced, ceiling-capped) and segment capsule collision in the XPBD kernel (child-only corrections, buffer-order invariant preserved, CCD scoping untouched). Opt-in is the deliberate strict reading of the #326 lesson: VMK does not deviate from authored physics without host consent. That keeps the default path byte-identical — per-tier baselines untouched, no pre-release marker — and makes all validation additive. Four gates, each with a named sabotage, including a synthetic two-joint chain whose inter-joint gap is the discriminating fixture for segment collision. Bundles AvatarSample_M (licence verified) as the first redistributable fixture with a wide dress at hand height. Filed under #381 as the cloth-side sibling of SP2 and the enabler of SP3's per-finger work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. Parent endpoint reads from an immutable per-substep snapshot, never the live buffer — the collide kernels read and write bonePosCurr[id] per thread, so a live parent read would be their first unsynchronized cross-thread read and run-to-run nondeterministic. One snapshot after integration serves all three shape dispatches; staleness is chosen, not inherited. New 6.5 determinism gate whose sabotage is exactly the live read. 2. Identity claims rewritten to what the gates certify: the CSVs guarantee a 1mm mean-per-axis envelope (SpringBoneRegressionTests.swift:70), not bit-identity. A bit-exact GPU baseline, captured before any kernel change, now carries the recompiled-metallib claim; extreme's lack of a golden is acknowledged. The 6.1 sabotage is judged against the bit gate, with the explicit note that green CSVs under sabotage is the envelope working, not the sabotage passing. 3+4. Landing order made explicit: the remote is at 830867a and every SP1 artifact this spec depends on (oracle as amended, U bundled at 28f9a5b) is unpushed local state; 6.3 margins are valid only against the amended oracle, cited by commit. 5. Child-only correction's small-t overshoot named as the scheme's weak point, bounded by a derived tolerance plus a settling assertion in 6.2, with t-scaled correction as the named fallback. Plus: root joints measured, root-to-leaf fallback with transitive inheritance from computed ancestors only, dominant-vertex counts in the audit table, "buffer-index-order invariant" replaced with the real single-writer property, CLAUDE.md CCD sentence amendment added to slice 6, ENDSITE dropped, scalp-bulge round-cone fallback named. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven tasks over the spec's six slices. The load-bearing sequencing: the bit-exact GPU baseline is generated and committed in Task 3, before Task 4 touches any kernel — and Task 4 adds three NEW segment kernels rather than editing the shipped three, so the flag-off baseline has its best chance of surviving the metallib recompile untouched. Parent endpoints read a per-substep snapshot (buffer 16); the 6.5 determinism sabotage is precisely the live-buffer read. Every gate carries an observed-failing requirement, including the ULP-vs-mm escalation rule if the bit gate breaks on recompile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Licence verified from the file (allowRedistribution=true, everyone, allowModificationRedistribution). Predicate inventory pinned: two SKIN primitives in, all garments out.
hitRadius is a geometric proxy VRoid authors near zero (median 3.7mm on AvatarSample_M hair, for cards centimetres wide). Per joint: dominant-weight verts, perpendicular extent about the chain axis at p65, floored at authored, ceiling min(5cm, 0.75×span), root->leaf inheritance for sparse joints with the dominant-vertex count recorded so sparsity is loud. Nothing consumes the values yet; the audit test pins the invariants on A, U and M. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed, bit baseline captured Flag defaults false; authored hitRadius never mutated (effectiveHitRadius sits beside it, spec §2/#326 boundary). The single consumption point reads effective ?? authored. 6.1(i) identity pinned on A/U/M; 6.1(ii) bit-exact GPU baseline captured and committed BEFORE any kernel change — it, not the 1mm-envelope CSVs, certifies the upcoming metallib recompile. Sabotage (default flipped) observed failing the 1mm-envelope CSVs; the bit gate stayed green because it always passes fit explicitly, bypassing the default — flagged as non-discriminating per spec §6.1 escalation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…llisionToMesh default The flipped-default sabotage tested the wrong property — the bit gate's harness always passes fit explicitly, so a default regression is structurally invisible to it (correctly caught instead by the 1mm-envelope CSVs at 0.14-0.17 m). Replacement sabotage makes the flag-off write-back unconditional; testBitBaselineMatches now fails at sample 0 as expected, confirming the consumption point actually feeds the simulator. Adds testFitClothCollisionDefaultsToOff to pin the default separately, and documents the division of labor between the bit gate and the CSV envelope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three NEW kernels collide the parent->child span against sphere/capsule/ plane colliders; the shipped endpoint kernels are textually untouched, which is what let the flag-off bit baseline survive the metallib recompile. Parent endpoints read an immutable per-substep snapshot (buffer 16) - the collide kernels write bonePosCurr[id] per thread, so a live parent read would be their first cross-thread read and nondeterministic; the 6.5 gate's sabotage is exactly that read (not observed diverging in 10 runs on this hardware - recorded honestly, not claimed). Corrections stay single-writer to the child, scaled by the segment's barycentric parameter (t for spheres/planes, s for capsules) to bound the child-only correction's small-t overshoot near a parent-side contact (spec §4's named fallback, shipped directly rather than discovered via a failing un-scaled reference). CCD scoping untouched (segments are discrete-time). Metallibs regenerated and committed atomically. Internal test hook: SpringBoneComputeSystem.segmentCollisionEnabledForTesting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…uild case1 honestly Fix round 1 for segment cloth collision (previous commit a8f27aa): CRITICAL: segment kernels ignored collider responseScale, so a ghosted cross-avatar partner (setContactResponseScale(0)) would push spans while pushing nothing else. All three segment kernels now multiply by responseScale (spheres/capsules; planes carry no such field, matching the untouched endpoint plane kernel). Case 1's test used to rely on responseScale=0 to neutralize the endpoint kernel for isolation, which meant the suite's primary gate certified the defect — rebuilt case 1 honestly: the collider sits inside the disk swept by the pendulum-like span, at a smaller radius from the fixed parent than the span length, giving a provable (triangle-inequality, asserted every frame) clearance for both joints with a normal responseScale=1 collider. A dedicated test now covers the responseScale=0 ghosting behavior explicitly. Anchor-span coverage gap: the first simulated joint's parent is the kinematic root, which never moves and has no span of its own — a t-scaled correction there converges to zero with no coverage anywhere (the scalp-hair/Hitarea_Head case). Spans whose parent is itself anchored to the kinematic root now get the FULL, un-scaled correction; interior spans keep the t/s-scaled one. New test asserts deflection at t=0 exactly. New test covers small-but-nonzero interior contact (t≈0.1-0.15, 4-bone chain) with a settling assertion, previously untested. Determinism sabotage gate replaced: the original 3-bone scene put every bone in one 256-wide threadgroup (lockstep SIMD), structurally unable to observe a torn live read. New 257-bone scene pads 254 inert filler bones before the real span so parent (255) and child (256) provably straddle a threadgroup boundary. Live-read sabotage re-run 10x on the new scene: no divergence observed (reported honestly, not claimed). Cheap fixes: snapshot dispatch now gated on segmentCollision != 0 (was unconditional); snapshot kernel's doc comment corrected (BEFORE the endpoint kernels, not after — the load-bearing staleness contract); planes now apply the same anchor/interior scaling policy as spheres/ capsules (previously unscaled, contradicting the prior commit message). Bit gate + regression CSVs re-verified green post-recompile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…etration on M One test per pose (armsCrossed, armsAtSides) on AvatarSample_M_1.0.vrm: loads the model flag-off and flag-on, runs the existing offscreen stress harness, measures worst oracle penetration per chain family at the radius the sim actually used (effectiveHitRadius ?? hitRadius), and asserts flag-on beats flag-off by max(2mm, 20%) per family that was non-zero flag-off (clean stays clean otherwise). Headline numbers (worst penetration, flagOff -> flagOn): armsCrossed: Hair 0.0213m -> 0.0084m Skirt 0.0000m -> 0.0000m armsAtSides: Hair 0.0266m -> 0.0084m Skirt 0.0000m -> 0.0000m Dual sabotage (spec 6.3) exercised both directions: measurement stubbed (SpringBoneJointRadiusMeasure.measurementDisabledForTesting = true) still passed at 0.0213m -> 0.0160m via segment collision alone; segment collision disabled still passed at 0.0213m -> 0.0111m via measured-radius flooring alone. Neither sabotage failed the gate at the current margin -- reported as a contribute-nothing finding per spec, not tuned away. Both toggles reverted; clean re-run confirmed identical to the numbers above. Adds SpringBoneJointRadiusMeasure.measurementDisabledForTesting, the sabotage hook this gate consumes (segmentCollisionEnabledForTesting already existed from Task 4). Guard check green: SpringBoneBitBaselineTests + SegmentCollisionGapTests, 14 tests, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…n-discriminating sabotage Fix round 1 for the Task 5 oracle improvement gate: the relative margin (max(2mm, 20%)) was cleared by either half of fitClothCollisionToMesh alone, so disabling one mechanism didn't fail the gate. Adds an absolute combined bound, derived from the recorded armsCrossed measurements (radii-only 0.0111m, segments-only 0.0160m, combined 0.0084m): flagOn.worstHair < 0.010m, applied to both pose tests alongside the unchanged relative-margin assertions. Re-ran both sabotage directions against the tightened gate, foreground: measurement stubbed: FAILS, observed 0.016043m (>= 0.010m) segments disabled: FAILS, observed 0.011069m (>= 0.010m) Both toggles reverted; clean re-run green with unchanged combined numbers (armsCrossed 0.0084m, armsAtSides 0.0084m, both under the new bound). Guard re-check green: SkinMeshCoverageTests + SpringBoneBitBaselineTests + SegmentCollisionGapTests, 18 tests, 1 skipped (opt-in), 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
Sets VRMLoadingOptions.fitClothCollisionToMesh = true at model load in VRMVideoRenderer, following the --stagger option's parsing pattern. Task 6.4 visual+perf gate: pixel-delta 0.60-1.29% (>2x the 0.31-0.37% insufficient-fidelity floor) across Hitarea_Head/Hitarea_Groin/Sit_Idle; hair deflects around raised arm, hand rests on (not in) skirt, no scalp bulging or floating-hair artifacts. Perf gate ≤3x at ultra: M 1.23x, U 1.60x (extreme: M 1.58x, U 1.60x). No percentile recalibration needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
- Skirt penetration was 0.0000m flag-off on both poses (nothing to improve), not "similar improvements" to Hair's 60-68% reduction. State the clean-stays-clean gate as what it actually is. - Bit-exactness of the flag-off path is certified by ONE dedicated baseline (SpringBoneBitBaselineTests, A/ultra/armsCrossed, 90 frames), not by "all existing validation gates"; the per-tier CSVs are a looser <=1mm mean-per-axis envelope. - Fix the adoption/how-it-works snippets to the real API: VRMModel.load(from:device:options:) (device: is required or the GPU spring system never initializes and the flag silently no-ops) and renderer.loadModel(_:) (renderer.load(model:) does not exist). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…ence The live-read-vs-snapshot sabotage never reproduced a mismatch even on the verified cross-threadgroup scene, so it does not prove the snapshot buys determinism. Restate the snapshot's justification as removing undefined cross-thread aliasing on the parent-endpoint read (true regardless of observed outcomes), with the gate standing as a regression watch rather than as proof of the original claim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
… code
SkinMeshCoverageTests: document that the clean-family branch (Skirt,
0.0 both ways on this fixture) asserts clean-stays-clean only, never
improvement; record hairAbsoluteBound's headroom risk (16% on this
machine's GPU) and require RE-DERIVATION, not nudging, if it flakes on
other hardware.
SkinMeshOracle: build(model:) no longer computes boundaryEdgeCount (never
read by build) or calls readIndices twice per primitive — inventory()
gains a computeBoundaryEdgeCount flag so build() opts out while
BodySurfacePredicateTests keeps the count by default. Guard non-finite
query positions in SpatialGrid.nearest so a NaN joint (an upstream
solver bug) returns "no penetration" instead of trapping the whole
xctest process. Delete the unreachable ring>4096 preconditionFailure
(the linear-scan fallback already engages at ring 32) and fix the
stale "~13.6k triangles" comment — it's the vertex count.
BodySurfacePredicateTests: pin total boundaryEdgeCount per fixture
(A 1630, M 1680, U 2008) so mesh-topology drift near the open body
surface (eye sockets, under-garment deletion) is loud instead of a
silent shift in oracle behaviour near rims. Align BodySurfacePredicate's
doc comment to the actual rule: contains("SKIN") && !contains("HAIR")
is a substring test, not mutual exclusivity — a hypothetical material
named with both SKIN and CLOTH would be accepted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
… pipelines VRMSpringJoint.effectiveHitRadius: public var -> public internal(set) var. A host application must not be able to raise simulated collision radii with fitClothCollisionToMesh off — only the module's own measurement path (flag on, at load time) writes this field. SpringBoneComputeSystem: the four segment-collision pipelines (snapshot + three segment kernels) are now built lazily by ensureSegmentCollisionPipelines() on first flag-on dispatch instead of unconditionally at init, so the default flag-off load/simulate path pays zero pipeline-compile cold-start cost. Creation failure stays loud (preconditionFailure), matching init's existing behavior; the bit baseline (flag-off) is unaffected since it never triggers creation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
The review's reference counts covered the Body primitive alone; the pin sums all included SKIN primitives. Values from the assertions' own printed output.
- CrowdFrameStepper: correct stale "flag is never restored" doc (r3721137996) — 830867a already added the borrow/restore, but the adjacent doc comment wasn't updated to match; also documents that PipelineAvatar's permanent mutation is the direct-construction path, with CrowdFrameStepper's borrow/restore as the managed alternative. - SpringBoneJointRadiusMeasure: guard inverseBindMatrices indexing with an identity fallback for malformed skins (r3729089819), matching the loader's own absent-accessor behavior; no behavior change for well-formed skins. - SpringBoneJointRadiusMeasure: document the single-instance assumption in the first-node mesh lookup (r3729089919) — acceptable-by-design for VRM avatars, multi-instance support intentionally out of scope. Tests: ClothJointRadiusAuditTests, SpringBoneBitBaselineTests, PlayerOwnershipTests — 9 tests, 0 failures, bit baseline unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase took main's metallibs at every conflicting commit, so the committed slices carried none of this branch's shader work. Rebuild from the merged sources so SpringBoneSegmentCollision's kernels are present in all five slices — including the xros/xrsimulator slices added by #395, which this branch predates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
…ed layout #420 (group 6b) split primitive storage: `vertexBuffer` is now position-only (`VRMPositionVertex`) and normal/UV/colour/joints/weights moved to `attributeBuffer` (`VRMAttributeVertex`). Three call sites on this branch still bound `vertexBuffer` to the interleaved `VRMVertex`, so every read past the first 16 bytes ran off the end of a smaller allocation into heap memory — joints and weights came back garbage. SkinMeshOracle tripped its own all-zero-weight precondition on the Face mesh (with a vertex count that varied run to run, the signature of reading uninitialised memory). SpringBoneJointRadiusMeasure is production code and failed silently: measured cloth radii were derived from that garbage. All three now go through `VRMPrimitive.interleavedVertices()`, the accessor #420 added to reconstruct the interleaved value from the two streams. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
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
be419db to
45a8d2e
Compare
|
✅ Claude Code Review ran on Findings (if any) are posted as inline comments; none means the review found nothing to flag. Run log. |
Replaces the two competing animation frame orderings —
AnimationLayerCompositorlayer priorities andCrowdFrameStepper's hand-rolled phase list — with one named stage pipeline, hoists VRM node-constraint solving onto the final pose, and makes limb IK terminal so it re-solves against a finalised scene root.Design:
docs/superpowers/specs/2026-08-04-contact-ik-pipeline-design.mdPlan:
docs/superpowers/plans/2026-08-04-contact-ik-pipeline.mdThe pipeline
CrowdFrameStepperbecomes the scheduler; ordering truth lives inPoseStage. Single-avatar is the n=1 case.S2 runs in two beats, deliberately. Placement must precede S1 because the postural lean measures its own trunk endpoints in world space; the shove must follow S1 because its penetration signal is the lean-relieved one. One writer, two beats — collapsing them changes the depth signal.
Behaviour changes
AnimationPlayer.solvesConstraintsdefaultstrue, preserving the contract for the ~10 direct callers (validators, benchmarks, isolation suites); the pipeline sets itfalse.IKLayerleaves the compositor and runs in S3.Bugfix found along the way (
1a05396)IKLayer.solveIKForLegassignedTwoBoneIKSolver.solve's world-frame rotation directly as a bone-local rotation. VRM legs rest at roughly −Y, so the aim hitaimAt's antiparallel branch and produced a ~174.5° misrotation even for a trivial identity target.All 24 existing
IKLayerTestsstayed green through it, because none applied solve output to a rig — they testTwoBoneIKSolveron synthetic input andIKLayer's metadata. Now pinned byIKLayerRoundTripTests(four cases asserting ankle world position, including a beyond-reach clamp).TwoBoneIKSolver's documentation, which called its output "local rotation for root joint", was the proximate cause and is corrected.This is a pre-existing defect independent of the refactor and may deserve its own release note.
How the crowd path is gated
Baseline pose sequences were captured from unmodified code and committed before any source change (
b160e57touches zeroSources/files;1068da2touches onlySources/). Every later commit compares against that committed fixture. Serialization isString(Float.bitPattern); regeneration sits behindPIPELINE_BASELINE_GENERATE=1+XCTSkip, so a normal run cannot overwrite its own oracle. The fixture was never regenerated — it is still last touched byb160e57.Matrix: {stagger-off, stagger-on-pre-contact, stagger-on-post-contact} × {single, crowd}, 60-frame sequences.
Invariants
Enforced by signature or assertion: cross-avatar reads come only from a
FrozenSnapshotof the partner's last-committed frame; root and hips are written only in S2 (debug guard); S0 asserts it never silently samples root motion.Documented but unenforced: every direct-apply bone has a guaranteed every-frame upstream writer; nothing before S4 reads constraint output.
Known gaps
ConstraintHoistTests. A rig with authoredVRMC_node_constrainttwist bones would give real end-to-end coverage.AnimationLayerCompositor.addIKLayeris deprecated:IKLayernow returns absolute bone-local rotations, which the compositor'sbasePose * deltacomposition would double-apply on rigs with non-identity leg bind rotations.CrowdFrameStepperpermanently setssolvesConstraints = falseon caller-owned players andavatarsForCamerahands them back. Documented ownership transfer, not detectable at runtime.Test status
Full suite: 1944 tests, 185 skipped, 2 failures. Both failures are assertions in
SpringBoneStressPosePenetrationTests.testLookUp_augmented_noForeheadPenetration, which fails onmainindependent of this branch (augmented head penetration 0.0209 m vs coarse 0.0065 m). Nothing here can reach the spring-bone augmentor. Worth investigating separately — it is the invariant the #309/#321 augmentation work exists to protect.Also unrelated and worth its own look:
BalanceModelTests.testEvaluate_isReadOnlyAndDeterministicflakes with ~1e-9 differences. "Float noise" does not explain it — a pure function on an unmutated model should be bit-identical, so something is mutating the rig between calls.🤖 Generated with Claude Code
https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu