Files
VR-Mess/research/ragdoll-player-step2.md
T

25 KiB
Raw Blame History

Body step 2 — the muscle-driven ragdoll player (2026-10-02)

Implementation record for Nimso's Project TX player rebuilt on Box3D (design source: nimso-project-tx.md §2; joint mapping: godot-box3d-6dof.md). Code: scripts/PlayerRagdoll.cs (input-agnostic body), scripts/PlayerRig.cs (XR driver + stick locomotion), regression scenes/ragdoll_spike.tscn ("RAGDOLL: overall PASS", 14 phases). It replaces PhysicsHands / PhysicsTorso / PhysicsArms / BodyRig in main.tscn. Those scripts stay for their own spikes.

What was built

Part Implementation Why
Bodies 9 boxes, each with its origin at its centre of mass: pelvis (pelvis + abdomen + both legs' Dempster mass, 36 kg at 60 kg total), chest 13 kg, head + neck 4.9 kg, upper arm 1.7, forearm 1.0, hand 0.36 Legs aren't physical yet (Nimso's leg collision is off by default too), but their mass belongs to the body the ground carries
Spine, neck Generic6DOFJoint3D BALL (cone/twist, twist axis = up) + muscle motor Local muscles, Nimso: "each bone drives itself"
Shoulder Generic6DOFJoint3D DRIVE chest→upper arm. The linear velocity motor (2000 N) holds the humeral head on the chest point + the True Body girdle offset; the angular velocity motor (45 N·m) is the shoulder muscle One joint carries both the moving girdle (shrug/protraction = real reach) and the muscle. See "light links" below
Elbow HingeJoint3D 0..150° with a muscle motor (45 N·m) on the flexion angle A real hinge can't flip or hyper-extend
Wrist BALL, cone 80°, twist ±90°, no motor The hand guide orients the hand
Hand guide DRIVE chest→hand. Linear velocity motor 40 N with controller-velocity feed-forward, angular 25 N·m "Tracking is a guide". The reaction goes into the chest, so pushing a wall pushes you
Balance DRIVE from a shapeless world anchor to the pelvis, angular motor only (400 N·m grounded, 30% airborne) Upright/yaw hold. See "explicit torques" below
Ground control Ray down from the pelvis. If ground is within hip height + 0.3 m (normal.y > 0.6): critically damped 4 Hz PD on the pelvis toward its pose height above the actual ground, with gravity compensation for the whole body mass. Support is clamped to [0, 2 × weight], horizontal force to friction × support Nimso's "leg strength" scales with mass and only pushes. No ground → falls
Muscle law Motor target velocity = feed-forward (the target's own relative spin) + Kp·RotationError(target, current) in joint frame A, capped at 15 rad/s; force/torque cap = strength Proven in sixdof_spike. Feed-forward keeps moving poses from lagging by speed/Kp
Targets From TrueBodySolver: trunk rebuilt top-down (upper chest → lumbar → hips, hips take 50% of the crouch lean). Head = camera orientation tilted so the body's eye lands on the camera. Arms = ArmSolver shoulder/elbow/wrist; hinge axis = Pole × arm direction A pose the physical chain can actually reach
Camera / play space Camera stays 1:1. Each tick the driver applies OriginCorrection: a 3 cm sphere swept from the head body's eye to the camera. If geometry is in the way, the view is put back at the free point (walls stop both stick and real walking). If the path is free, the camera may lead the body by up to 0.2 m (leaning over a table whose edge holds your hips), beyond that it follows. Grounded: horizontal only, the floor eases toward the ground under the pelvis. Airborne: 3D (falls carry the view) Owner: Bonelab push-back, no fade
Discrete moves Origin jumps > 0.5 m or > 0.1 rad in one tick (spawn, teleport markers) and snap turns carry the body with TeleportBy + TrueBodySolver.Shift No body flung across the gap
Locomotion Left stick head-relative 2.2 m/s (8 m/s² accel, no air control); right stick snap 30° (PlayerRig.SmoothTurn → 2.5 rad/s). A stick whose hand points at the menu scrolls it instead Owner scope: Boneworks-style stick only, no jump

Measured findings (RagdollSpike, Box3D fork, 90 Hz, 4 substeps)

  1. Light links between two strong motors oscillate. The first build had a 0.6 kg neck link (two neck joints) and a 0.8 kg clavicle body per arm (chest→clavicle drive + clavicle→upper-arm ball). The neck pitch oscillated with growing amplitude (±11 rad/s head spin while standing still). Raising the shoulder to 200 N·m made the arms worse (0.94 m hand lag vs 0.47 m). Box3D runs one solver iteration per substep, and a light body squeezed between two motors with large caps doesn't converge. Fix: no light intermediate bodies. The neck merged into the head (single joint at the neck base); the clavicle was replaced by one shoulder DRIVE carrying both the girdle translation and the muscle. Result: standing error ~0, swing lag 0.13 m, random-motion eye error 2.3 cm. Rule for the legs and anything later: keep every body that sits between two motors ≳ 1 kg, or merge it.
  2. Explicit world torques on the pelvis vs an implicit motor. The explicit 3 Hz PD torque balance looked fine until the arms got strong. With the light links gone, the implicit balance motor is stable and holds the pelvis rigidly against arm reaction torques.
  3. Hand-guide strength is the "cheat budget". With a 120 N guide the arms looked perfect, but 12 kg at arm's length sagged only 2 cm (Nimso: it must not be holdable). At 40 N guide + 45 N·m shoulder/elbow (≈ human): 12 kg held close sags 1.3 cm, at arm's length 25 cm. Fast swings still track (0.13 m peak lag at 1.5 Hz ±0.25 m).
  4. Test poses must be anatomically possible. Controllers pointing forward with the arms out sideways need a 90° wrist bend. The old strong guide hid that; with honest strength the wrist limit wins. RagdollSpike's reach poses now aim the controllers along the arm.
  5. Unreachable targets stop at the arm's end. That is correct, not a bug: a push target 0.91 m from a 0.57 m arm leaves the hands at full reach.
  6. Hinge sign: positive HingeJoint3D motor velocity rotates the child about the joint's +Z (spike phase 0 measures it; PlayerRagdoll.HingeSign).

Not done yet / known gaps

  • Legs: no physical or visual legs; the pelvis floats on ground control. Step 3 = procedural legs (Nimso: "feet don't hold the player up" — visuals + optional collision).
  • Shoulder has no cone limit (Box3D's motor joint has none). A strong external force can bend the arm into the chest; muscles return it. If it shows, add a BALL limit via a second, collision-only constraint, or a clavicle heavy enough (≥ 1 kg) to be stable.
  • Climbing: grabs are props only (PropGrabber); welding to static geometry is not wired. The physics would allow it: the hand guide reacts into the chest.
  • Strength values are guesses at human scale (Nimso's are unpublished). One global strength scalar (Nimso's "Crysis mode") is not exposed yet.
  • 144 Hz: not switched. Physics runs at the headset rate. Decoupling it needs physics interpolation, and today AnchorMoveMode/Locomotion move the XR origin in _Process, which would jitter under interpolation.
  • Anchor-grab / teleport legacy locomotion still drive the origin. Teleports carry the body. Grab-flying upward now fights gravity (the body falls and the view follows it), so it effectively only pulls you along the ground.

Step 2.1 — owner playtest fixes + sandbox (2026-10-02)

Owner playtest of step 2: "feels amazing for the most part". It found three problems:

  • You could walk onto the table: the body detached for a second and wobbled before settling.
  • You couldn't climb onto the table by pushing yourself up.
  • You couldn't crawl under the table.

Changes and the measured reasons:

Change Why (RagdollSpike evidence)
Step rule: ground more than MaxStepHeight 0.35 m above the feet isn't stood on unless the hips got up there: airborne/landing, legs tucked, or hips > 60% of hip height above it, and a second ray 12 cm behind the hips also finds it The single pelvis ray treated a 0.725 m table under the hips as a "step" → walking onto the table
Lower-body collider (Legs, 6 kg box from the hips down to a step above the feet, DRIVE from the pelvis). It slides up when crouching, and its shape is disabled (not just layer-cleared) and tucked into the pelvis when crawling or when the arms lift the body (mantle) Knee-high obstacles and tables now block; steps ≤ 0.35 m pass under it. Box3D kept the old contact when only the collision layer changed, which jammed a crawl against a tunnel roof
Structural hand strength: guide cap = lerp(40 N, 400 N, axial share of the hand error), with a 3 cm deadband Force along the arm (pressing down on a ledge, hanging, carrying at your side) is bone-strong; across the arm it stays weak. Measured: two-hand mantle onto 0.9 m ✓, one hand ✗ (5 cm). Pull-up on a grabbed bar: two hands lift the pelvis 0.69 m, one hand 0.24 m. 12 kg at arm's length still sags (6.6 cm vs 0.9 cm close)
Arm aim from the physical shoulder, horizontal only: the arm reaches for the real hand when the body is held away horizontally (ledge, lean-over), but keeps its True Body pose vertically Purely camera-relative arms left the hands 26 cm short when the body was held back at a ledge. Purely physical arms fold to wherever a sagging chest is and stop being pillars, so the crawl collapsed (eye 0.14 m vs 0.42). The split fixes both
Ground feed-forward from the GOAL (pose + ground offset) When the floor follows up onto a ledge, pose and offset move opposite; differentiating the pose alone launched the body 1 m up
Crawl pose (TrueBodySolver): when the hips would go below 0.15 m, the trunk tips toward ~83°, hips kick back; ragdoll hips take the full lean in a crawl Crouch lean capped at 43° put the hips through the floor, so the head could never get low
Crawl carry 300 N + torso friction 0.3 With the torso resting on the floor, μ·weight exceeded the support-scaled carry, so stick crawling stalled (Nimso has no stick crawl at all; ours is a comfort concession)
Braced air control 150 N, AirSlack 0.05 m While mantling/hanging, the hips drift after the head; airborne, the view rides the body tightly
Climbing grabs: PropGrabber welds the hand to any StaticBody3D under the palm (5 cm sphere) except group no_grab (the floor) Pull-ups, ledges, climbing-wall holds, monkey bars
Procedural legs (ProceduralLegs.cs): visual only. Planted feet, alternate stepping on 20 cm / 34° error, stride leads velocity by 0.25 s, 0.2–0.32 s steps; airborne they dangle, crawling they trail Nimso: feet don't hold the body up
144 Hz physics + physics interpolation. XROrigin3D, TrueBody and PlayerRig (ghosts) are excluded; the ragdoll is explicitly interpolated. Legacy chase-body spikes pin 90 Hz Owner's earlier choice; the old origin-moving code runs per frame, so its subtree must not interpolate
Strength slider (0.25–3×, scales every muscle + hand guides), Spectator toggle (no body, the old grab-drag flight), Turning snap/smooth toggle Owner: drag movement "doesn't fit this anymore", keep it as a spectator mode
Playground (Playground.cs): mantling ledges 0.3–1.5 m, a 1 m platform with stairs, an 18° ramp and a 40° ramp, a climbing wall, a pull-up bar, monkey bars, a plank bridge, loose planks, a seesaw, a hanging swing board, a crate pyramid, a 25 kg box, barrels, a balance beam, a 0.6 m crawl tunnel, a duck bar, a doorway. "Reset props" resets it Owner: a Nimso-sandbox-like level

Ground steeper than MaxSlopeDegrees 35° gives no support, so the 40° ramp can't be walked up; the 18° one can. Still open: the shoulder cone limit (needs a fork addition). tools/playground_shots.tscn renders overview PNGs for layout checks.

Step 2.2 — the 2.1 playtest was unstable; root causes (2026-10-02)

The owner reported everything "all over the place":

  • Mantling swung the chest out of control and the hands "wobbled more and more".
  • Crouching made the body slide around and spin.
  • Nothing static was grabbable.
  • The steep ramp slipped the body out from under them.

RagdollSpike passed all of it, because its input was IDEAL: smooth poses every 144 Hz tick, no jitter. Fixes:

  1. The spike now runs on REAL input by default: tracking sample-and-hold at 90 Hz under 144 Hz physics, plus 0.5 mm / 0.1° jitter per sample (-- ideal / nonoise / nohold isolate the factors). Lesson: a body-physics test with perfect input proves nothing about the headset.
  2. PoseFilter + RagdollDriver (scripts/RagdollDriver.cs). Tracked poses are timestamped per new sample and extrapolated between samples in play-space-local coordinates. One driver runs the tick pipeline for both PlayerRig and the spike, so the test runs the game's code path.
  3. Low-pass every feed-forward: muscles/elbow τ 0.05 s, ground control τ 0.12 s. Ground control's velocity term multiplies by the whole body mass, so 1.5 mm of jitter became hundreds of newtons of buzz (pelvis 0.27 → 0.04 m/s at rest).
  4. Arm muscles stay LOCAL to the chest's target pose. 2.1 aimed them in the physical chest frame, which closes a rotation feedback loop: the chest turns, the world-fixed arm targets push back on the chest, and the chest turns further. Jitter excited it into a slow twist (eye drifting 15 cm): the mantle "chest slipping". Only a horizontal translation of the reach is kept (body held back from the camera).
  5. Structural hand strength only when the hand pushes on or holds something. A hand reaching past its arm's length into free air pulled the whole body down by up to 400 N (crouch: eye 11 cm off).
  6. No free-path view chasing while grounded. Any eye/camera mismatch (crouch poses can't match exactly) moved the view, the pose target followed, the body followed, and so on: an 8 m slide while holding a crouch. The view is now only pushed back when geometry blocks the head. Instead, the stick stops pushing in the direction the body is held back (PlayerRagdoll.HeldBack, PlayerRig.HoldBack).
  7. Explicit inertia on the Legs body. Disabling its only collision shape (tuck) left it with no rotational inertia; its 400 N·m drive then tumbled the pelvis forward, which flipped the "yaw". This was the crouch spin. Rule: a body whose shapes can all be disabled needs explicit Inertia.
  8. World grabs: the Box3D wrapper returns a null collider from IntersectShape, only collider_id. Resolve it with GodotObject.InstanceFromId. The grab sphere is now 7 cm, checked at the palm and then the hand centre. The spike asserts the game's own PropGrabber.WorldUnderPalm finds the bar.

New spike phases: hold a crouch (eyes 1.0 m) and a deep crouch (0.7 m) without drift (< 5 cm) or spin (< 5°), eye on camera (< 5 cm). 29 phases total, all on real input.

Step 2.3 — view alignment, mantling, climbing (2026-10-02)

Owner, after 2.2:

  • "my head leaving the character's head … losing the alignment when climbing" (screenshots: the camera behind and below the character's own head while on the monkey bars).
  • Mantling: "legs trying to get off the ground and instantly come back".
  • Climbing near impossible.
  • Grabbing world objects and ducking now worked.
Change Why
The view rides the body. Deadzone 6 cm (2 cm climbing), smoothed follow τ 0.12 s (0.03 s climbing), and a hard limit MaxViewLead 8 cm. Standing, height belongs to the ground follow; airborne or climbing, the follow is 3D. The blocked eye→camera sweep stays 2.2's "never follow while standing" fixed the drift loop by letting the view leave the body. The drift actually needed a body that can match its pose; with crouch error ~5 mm, a follow with a deadzone doesn't drift (new phase: six held poses, 0.000 m drift, eye within 9 mm)
Hold-back measured at the HIPS, plus capped leg push (BlockedPush 150 N) while held, and balance 800 N·m Measured at the head it was fooled: the body tipped over the knee-high box, so the head wasn't behind the camera. Full leg force bulldozed the body over the box or steep slope
Sticky legs tuck. It tucks while the hands brace and lift (or hold static), and untucks only after 0.3 s unneeded AND a free-space check The flip-flop re-enabled the collider inside the ledge face every few ticks, shoving the body off: the reported loop
Strength by direction (Nimso "asymmetric"): shoulder raise 35 N·m vs pull-down 100 N·m; elbow extension 45 vs flexion 70; caps chosen per tick from the commanded direction. Structural hand-guide strength is PUSH-only (bone in compression) Pull-ups and climbing are muscle work. 12 kg at arm's length sags 49 cm (close: 1.4 cm). Pull-up: two hands 0.74 m, one hand 0.16 m. Mantle: two ✓, one ✗. Wall climb hand over hand: 0.98 m
Limb friction 0.25 / torso 0.3, set BEFORE AddChild Box3D wrapper quirk: friction is read only when shapes are created. A later PhysicsMaterialOverride silently does nothing, so 2.1's torso friction never applied. Hands brushing a wall were pinned by friction and their own arm's pressure
New phases: held poses without drift, hanging keeps the view on the body, the human-motion mantle (crouch, hands on the edge, stand and press), hand-over-hand wall climb. The pull-up grabs when the hands actually reach the bar Tests now act like a person, on 90 Hz input with jitter

Rules added:

  • The view must stay on the body; prevent drift by making the body able to match its pose, not by detaching the view.
  • Measure "blocked" where the body is blocked.
  • Hysteresis plus a free-space check before re-enabling any collider.
  • In the Box3D wrapper, physics materials must be set before AddChild.

Step 2.4 — playtest polish (2026-10-02)

Owner, after 2.3: "Climbing actually feels very solid now. Mantling is almost effortless … There wasn't anything I wasn't able to climb." Fixed from the same session:

  • Landing sink. Support capped at 2× body weight leaves 1 g of braking, so a 1 m drop (4.4 m/s) took ~0.4 m to stop and the body sank out from under the view. LegStrength is now 4.5× (real legs brake landings at 3–5 g). The vertical support is now a rate-limited velocity servo, MaxRiseSpeed 1.2 m/s: full strength to brake, but the legs raise the body no faster than that. Legs can push but not pull, so a hard push toward a higher stance (just mantled onto a ledge) overshot into a 0.67 m jump. Spike: landing keeps the pelvis at standing height, eye within 12 cm.
  • Body weight on movable ground. The feet aren't physical, so standing on a seesaw/plank/crate never pushed it. Ground control now applies the opposite of its support + carry force to a RigidBody3D under the ground ray, at the hit point. Box3D ray results also carry a null collider, so it uses collider_id. Spike: a seesaw tips 17°.
  • Playground hinges. Joint3D.NodeA/NodeB resolve relative to the JOINT node. GetPathTo from the parent pointed at nonexistent children, so the seesaw and swing board were never hinged. Absolute paths now.
  • Big props weren't grabbable. Prop grabbing measured distance to the prop ORIGIN, far from the hand on a 2–3 m plank. PropGrabber.BodyUnderPalm queries the surface (a prop first, else static world).
  • Reset props removed every "prop". PropShop.Respawn now frees only its own children; the playground resets in place.
  • The climbing spike now climbs like a person: hand moves are sequential (never release the second hand before the first re-grips), and pulls are relative to the body/room.

Step 2.5 — the camera always rides the body (2026-10-02)

Owner: "why doesn't the camera just follow the character when landing? … And not just when landing, but always. And I'd prefer to exclude the moments where the camera clips out of the head as much as possible." Correct, and it is what Boneworks/BIMOS does (the rig rides the body; the head is driven to the HMD).

  • One rule, always, 3D: the camera rides the character's eye. Deadzone 2 cm, follow τ 0.04 s, hard MaxViewLead 4 cm (the head box reaches 2 cm in front of the eye). The blocked eye→camera sweep stays. 2.3's separate standing-height "floor follow" is gone.
  • Why there is no feedback loop (the fear behind 2.3's split): the True Body reads crouch as LOCAL head height (camera − play-space floor), ground control re-bases its goal onto the real ground (footY − floorY), and so does the new head guide. Moving the play space changes no target. 2.3's mantle "fight" came from two controllers editing the same height.
  • Head guide (BIMOS drives the head to the HMD): a 250 N, 5 Hz PD pulls the character's eye onto the camera while on your feet (off while airborne, climbing or bracing on the hands). Without it, any transient body lag (mid-squat) was converted by the tight view follow into a permanent play-space offset: 7 cm per deep squat. With it, six held poses drift 1 cm.
  • Slow floor re-centering (τ 1 s, grounded): eases the play-space floor onto the real ground. It moves the camera toward the eye, the same direction as the follow, so it can't fight it. Without it, a small height offset picked up while crawling held the head against a tunnel roof for good.
  • Braced excludes the floor (group no_grab): hands on the floor while crawling aren't "pushing yourself up".
  • Anatomical head box: centred 0.5 cm below the eyes (crown ~10 cm above, chin ~11 cm below; was 12.5 / 8.5). The old box poked 2 cm into a 0.6 m tunnel's roof at a 0.42 m eye height.

Results (real-input spike): landing keeps the pelvis at standing height with eye-to-camera ≤ 6.8 cm (during the fall); held poses drift 1 cm with the eye within 7 mm; crawl, mantle, climb, pull-up and hang all pass.

Step 2.6 — landing dip, jump, stick crouch, look-down flip, smooth view (2026-10-02)

Change Why / measured
Body facing = BodyMath.HeadFacing (visor projected flat, reversed when it disagrees with the crown; near vertical the crown alone). Stick direction uses it too; the crouch lean's head pitch is atan2 against the facing (asin peaked at 90°) Looking down past vertical (crouching, things on the ground) flipped the visor's flat projection 180° and the whole body turned around. Spike: look down to 125° crouched → pelvis/chest yaw change 0.0°
Landing dip from physics: airborne legs touch down only within LandingReach (5 cm) of the pose's hip height; standing contact keeps GroundProbeExtra (steps down) Support used to start 30 cm early and braked the fall in the air — no dip (owner: no landing feedback). Now 4.5 g legs stop the body below the stance: 1 m → 0.23 m dip, 5 m → 0.88 m (legs fully folded), view follows (eye vs camera ≤ 5 cm after impact)
Rise eased to what gravity can stop: min(MaxRiseSpeed, √(2·0.7g·below)) Legs push, never pull: rising at 1.2 m/s into the stance overshot 4 cm on gravity alone
Play-space corrections shift ground control's / head guide's feed-forward history horizontally only The vertical goals are floor-relative (− floorY) and don't move with the play space; shifting them turned the view riding the dip into a +1 m/s feed-forward → 15 cm bounce
Legs collider drive speed-limited (LegsMaxSpeed 1.5 m/s) Untucking after a landing slammed the 6 kg box down; the reaction kicked the pelvis up
Jump (right A): state Dip (support goal drops to ≥ JumpDip + extension of stroke) → Push (JumpForce 2.4 × weight to full extension, max stroke 0.5 m) → Flight (no ground while rising) → touchdown. Buffer 0.15 s, coyote 0.12 s, head guide off Takeoff speed is physics, v² = 2(F/m − g)·stroke (+ the dip's rebound): standing hips rise 0.45 m, from a 0.45 m crouch 0.72 m
Stick crouch (right stick Y) = RagdollDriver.StickCrouch, a virtual floor this far above the real one, fed to the solver, ground control and the view re-centering The pose crouches, the play space settles that far below the ground, the camera rides down. Spike: 0.6 m → eye 1.045, origin −0.601, eye vs camera 5 mm; back up to 1.650
XR origin drawn interpolated (PlayerRig: physics owns _physOrigin; _Process (priority −100) shows prev.InterpolateWith(cur, Engine.GetPhysicsInterpolationFraction()), _PhysicsProcess (priority −100) restores it; anyone else moving the origin is adopted; jumps/snap turns don't interpolate) The bodies are drawn interpolated a tick behind while the origin stepped raw at 144 Hz under a 90 Hz display — the body looked like it was in two places when moving (owner). Not verifiable headless; needs the headset
Menu summon on left X only Right A is jump

Shoulder range (2026-10-02): the fork's DRIVE now honours angular limits (Box3D motor joint + swing ellipse/twist, research/godot-box3d-6dof.md); PlayerRagdoll.ShoulderRange (Y −90..+100° mirrored, Z −85..+155°, twist ±80°, frame X = humerus at build). Ragdoll spike unchanged with it on — the limits only engage when something forces the arm.