Files
VR-Mess/research/body-stage1-failure-brief.md
FoxyzandClaude Opus 5.5 cb518999e5 Body Stage 1 collapse root-caused + fixed: NaN in torso servo math (GetAngle = 2*acos(W) at W=1.0000001), not solver stacking
- 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>
2026-10-01 23:55:46 +03:00

12 KiB
Raw Permalink Blame History

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, then grabbed 'Anvil · 50 kg' (50 kg, left, two-handed) — and within ticks the log floods:
    ERROR: Condition "!v.is_finite()" is true.
       at: instance_set_transform (servers/rendering/renderer_scene_cull.cpp:1000)
    
    ×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).
  • 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

  1. Can a deterministic headless repro be built (M5)? If yes → does M1 alone stop the NaN?
  2. 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/.)
  3. 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?
  4. 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:

  1. The orientation servo computed qErr.GetAngle() = 2*acos(W). Godot C#'s Mathf.Acos is 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's wErr.Length() > max is false for NaN, and AngularVelocity += NaN goes straight into Box3D (b3Body_SetAngularVelocity only 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".
  2. 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.
  3. 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).