- BodyMath.RotationError: atan2-based shortest-arc error (no NaN, no GetAxis gain collapse inside 0.16 deg); used by PhysicsTorso orientation servo and PhysicsHands rotation lock - BodyMath.ResetIfNonFinite escape hatch on torso + hands - PhysicsHands.Drive extracted (static) so spikes run the real hand chase - CollapseSpike: deterministic repro (slow yaw drift across a parked chest, 7/40 NaN on old code) + solver-stacking scenarios (seated chest in table, two-hand-welded 50 kg anvil, real PropShop) — 49/49 clean after fix, x3; all other spikes PASS x3 - Failure brief RESOLUTION section; HANDOFF/AGENTS updated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
Body Stage 1 failure brief — chest collapse in-headset (2026-10-01)
STATUS: RESOLVED — root cause = NaN in the torso orientation servo math (not the solver). See the RESOLUTION section at the end. The hypotheses below are kept as the record.
SELF-CONTAINED brief for an external agent (zero project context assumed). Godot 4.7.2 mono,
C#, PCVR at 90 Hz (physics tick = 90), physics engine = Box3D (bearlikelion's
godot-box3d GDExtension — a full PhysicsServer3D replacement; locally patched so Godot's
ConeTwistJoint3D maps to Box3D's b3WeldJoint with rigid stiffness at bias 0). Everything
below is verified code or log evidence unless marked [H] (hypothesis).
What was just built (body-physics Stage 1)
The player's virtual chest became a physical body. PhysicsTorso.cs (Node) spawns:
- One RigidBody3D, box 0.22×0.5×0.13 m, Mass 30, GravityScale 0, CCD on, contact monitor, CollisionLayer = layer 3 (player body), CollisionMask = layers 1|3 (world+props | body).
- Every physics tick (90 Hz), target = head position (from the HMD camera node, kinematic) minus 6 cm along flattened camera forward, then offset by the torso-yaw basis (a yaw-only basis smoothed toward a blend of HMD yaw and hands-midpoint yaw) by (0, −0.42, +0.06), plus a small "reach offset" fed by the visual rig (≤ 0.25 m horizontal clamp).
- Linear drive — force-clamped PD, the same pattern the hand bodies use:
Vector3 desiredVelocity = (target - _body.GlobalPosition) / dt; // dt = 1/90 if (desiredVelocity.Length() > MaxSpeed) desiredVelocity = desiredVelocity.Normalized() * MaxSpeed; // 6 Vector3 dv = desiredVelocity - _body.LinearVelocity; Vector3 force = dv / dt * TorsoMass; // 30 kg if (force.Length() > MaxForce) force = force.Normalized() * MaxForce; // 2400 N _body.LinearVelocity += force / TorsoMass * dt; // DIRECT velocity write - Orientation drive — slewed angular velocity servo toward the yaw target:
Quaternion qErr = torsoBasisQ * _body.GlobalTransform.Basis.GetRotationQuaternion().Inverse(); if (qErr.W < 0f) qErr = -qErr; // shortest arc Vector3 spinDesired = axis(qErr) * min(angle(qErr) * OrientKp /*20*/, OrientMaxSpin /*10*/); Vector3 wErr = spinDesired - _body.AngularVelocity; if (wErr.Length() > OrientMaxAlpha * dt /*150/90*/) wErr = wErr.Normalized() * OrientMaxAlpha * dt; _body.AngularVelocity += wErr; // DIRECT angular velocity write - Snap safety: if body-to-target distance > 2 m → teleport body to target, zero velocities.
- Head/camera stays kinematic; the torso chases the head, never the reverse.
Context bodies:
- Hands: two RigidBody3D palms (0.4 kg, layer 4, mask 1|4), same velocity-write chase at 90 N, rotation hard-locked via angular velocity sets (cap 120 rad/s). They chase XR controllers. Stuck-snap and teleport-snap safeties are SUSPENDED for a hand while it grips.
- Grab: grabbing a prop creates a ConeTwistJoint3D (= b3WeldJoint, bias 0 = RIGID, locks relative pose) between hand body and prop. Two-handed grab = prop welded to BOTH hands rigidly. Props: layer 1, mask 1|3|4. Statics (floor/table): layer 1.
- No self-collision: hands (4) and chest (3) exclude each other both ways. Chest DOES collide with props (layer 1) and static world (layer 1) — deliberate, chest is the contact surface for hugs/leaning.
- Headless regression (body spike: track/yaw/5kg-impact/teleport/self-right) PASSES 3× — but the spike has NO static table and NO welded heavy prop touching the chest.
The failure (owner playtest, first session with the chest)
Owner report: "it quickly collapsed with my head detaching from the body". Screenshot shows
the prop table in chaos (props piled/thrown, panel knobs displaced). Log
(godot.log, ~45.7k lines, 22.8k of them the error below):
- Calibration line: head height 1.01 m → the player was SEATED/crouching (standing calibration would be ~1.6-1.7 m). Chest target therefore ≈ 0.59 m height; chest box spans ≈ 0.34-0.84 m; the prop table top is at 0.70-0.75 m → the seated chest overlaps the table geometry (chest mask includes statics). [H] constant penetration + 2400 N servo.
- Normal play: grabbed/released bowling ball, brick, apple — all fine.
- Then:
grabbed 'Anvil · 50 kg' (50 kg, left), released, thengrabbed 'Anvil · 50 kg' (50 kg, left, two-handed)— and within ticks the log floods:×22,829 — some node's transform went NaN/Inf (rendering rejects it). The visual result matches the chest body (and/or anvil) transforming to garbage → the torso mesh vanishes/ teleports → "head detached from the body" (head cube rides the camera, body went away).ERROR: Condition "!v.is_finite()" is true. at: instance_set_transform (servers/rendering/renderer_scene_cull.cpp:1000) - Owner kept grabbing/releasing the anvil afterwards (releases logged at 0.00-0.01 m/s — the anvil was braced/nearly immobile), then removed the headset (focus lost).
Hypotheses (ranked)
- H1 — solver blowup from stacked authorities [most likely]: the chest (velocity-write
servo, 2400 N) pressed against the STATIC table (seated overlap) while the 50 kg anvil
rigid-welded to both 90 N velocity-write hand bodies was grabbed/moved into that mess.
Three script-side velocity servos + rigid welds + persistent static contact in one
constraint island. Box3D's solver gets conflicting infinite-stiffness demands → energy
injection → NaN. This project has PRIOR ART for exactly this class: documented in
research/vr-two-handed-grab.md— "script velocity SETs + reaction forces = limit cycle" (grab v5.0-era instability). The hands survive the same pattern only because they're 0.4 kg, rarely braced, and have stuck-snap escape hatches. The chest is 30 kg, overlaps the table whenever the owner sits at it, and has no escape. - H2 — chest vs table penetration alone: even without the anvil, seated chest-in-table = constant deep penetration vs a static body; the velocity servo never yields (position error persists → max force every tick) → pressure + friction against statics pumps the solver. The anvil grab was just the straw.
- H3 — CCD tunneling/spike: 30 kg body with CCD in deep contact + welded 50 kg chain — CCD contact generation bug under stress. [H] least likely but cheap to test.
Candidate mitigations (evaluate, don't just pick one)
- M1 — ApplyForce instead of velocity writes for the chest (solver-owned integration; keeps the 2400 N clamp). The hands keep velocity writes (they have escape hatches). Boneworks' design rule "no infinite forces" supports solver-integrated forces.
- M2 — collision-aware target or compliance: if the chest is in persistent contact (contact monitor reports statics) with saturated force, soften (reduce effective budget) or stop pushing (target clamps out of penetration). Generic "yield when braced".
- M3 — seated reality: the chest target height derives purely from HMD (−0.42). When seated at a table the body legitimately overlaps world geometry. Options: chest collision disabled vs statics (mask 3 only — loses leaning on tables), or per-body contact disabling when penetration is self-inflicted (chase target inside a static), or pushing the TABLE (chest becomes heavy enough to matter? 30 kg vs static = never).
- M4 — saturation watchdog: like the hands' stuck-snap: if force saturated AND position error persistent AND in contact for > N ms → log + temporarily reduce budget / exclude the offending pair. Keeps M1's solver honesty while adding the escape hatch.
- M5 — reproduce headless first: extend the body spike with the exact cocktail — chest target set so the box overlaps a static table slab; a 50 kg box welded rigid to two 0.4 kg "hand" bodies (90 N chases) pressed into the chest; then sweep. A deterministic NaN repro beats every hypothesis debate. The full grab spike already emulates hands+welds headless (GrabSpike.cs pattern) — compose it with the chest.
Questions to answer
- Can a deterministic headless repro be built (M5)? If yes → does M1 alone stop the NaN?
- Is the velocity-write chase on a contact-rich 30 kg body categorically unsafe on Box3D, or only under simultaneous weld+static stacking? (Box3D source available in .vendor/.)
- What does the solver do with direct LinearVelocity writes on a body that is ALSO in an active contact manifold — are the writes applied pre- or post-solve, and can the contact impulse + next-tick write loop escalate?
- Seated-at-table is a first-class scenario for this product (scene viewer). What's the right long-term chest-vs-world story (M2/M3) that still allows leaning on tables?
Owner directive: painlessness bar = no multi-iteration fundamental sagas. Deliver the analysis, the repro, and the smallest honest fix — the owner playtests feel afterwards.
RESOLUTION (2026-10-01, same day) — root cause found, deterministic repro, fixed
The NaN was script math, not the solver. H1–H3 were all tested headless and did NOT
reproduce (scenes/collapse_spike.tscn, stacking family: chest 5/15 cm into the static
table, 50 kg anvil rigid-welded to two 90 N chase hands, the exact logged grab sequence,
the real PropShop with the chest plowing through props — all finite).
Root cause, verified step by step:
- The orientation servo computed
qErr.GetAngle()=2*acos(W). Godot C#'sMathf.Acosis UNCLAMPED (docs: input "must be" in [-1, 1]); when float rounding puts the error quaternion's W at 1.0000001, the angle is NaN.Mathf.Min(NaN, cap)passes NaN through, the slew clamp'swErr.Length() > maxis false for NaN, andAngularVelocity += NaNgoes straight into Box3D (b3Body_SetAngularVelocityonly asserts validity in debug builds). The chest transform goes NaN → renderer flood → arm visuals (derived from the chest pose) vanish with it → "head detached from body". - Why it needed a seated, still player:
Quaternion.GetAxis()returns the RAW un-normalized xyz once W > 1 − 1e-6 (angle < ~0.16°), so the servo's gain collapsed ~1000× inside that band — the body parked a fraction of a degree off target. The rounding hazard (error < ~3e-4 rad) is only reached when the yaw target then DRIFTS SLOWLY across that parked yaw — exactly a seated player holding still (the 50 kg anvil welded to both hands can't move: 2×90 N < 0.95×490 N friction, hence the logged 0.01 m/s releases). Normal motion crosses the band too fast. The hands and anvil stayed finite (they kept grabbing after the flood), consistent with a torso-only NaN. - Deterministic repro: head still at 40 yaws, target drifting 0.2°/s → 7/40 scenarios NaN the chest on the old code (W = 1.0000001 at the crossing tick, body w = 0).
Fix (scripts/BodyMath.cs): RotationError() = shortest-arc axis·angle via
2*atan2(|xyz|, W) with a properly normalized axis — exact near zero, can't leave its
domain. Used by the torso servo AND the hand rotation lock (hands were not observed to NaN
— their Basis-product path measured 0/200k — but shared the gain collapse). Escape hatch:
BodyMath.ResetIfNonFinite() on every driven body (torso, hands) — any future non-finite
state resets that body onto its target with a warning instead of poisoning the renderer.
After the fix: collapse spike 49/49 clean ×3, yaw error under slow drift 0.026° (was a
0.16° stall); physics/weld/grab/body spikes all PASS ×3.
Mitigations M1–M4 were NOT needed for the NaN. Side observations kept for the owner:
- The prop "table chaos" is real and separate: a seated chest sweeping the table plows props off at up to ~3.8 m/s (2400 N body) — honest physics, tune by feel if unwanted.
- Seated calibration: head height 1.01 m clamps arm length to the 0.50 m minimum, so
reaching saturates
ReachOffset(0.25 m) and leans the chest INTO the table. The chest then rests saturated on the table edge — stable in every repro, but a calibration that assumes standing height is the deeper issue (see HANDOFF known issues).