Files
VR-Mess/research/body-stage1-failure-brief.md
T

8.7 KiB
Raw Blame History

Body Stage 1 failure brief — chest collapse in-headset (2026-10-01)

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.