Spike: decoupled hands chase the controller (issue #42) #43

Merged
merith-tk merged 4 commits from muse-lantern/da-game:hand-ik-rework into main 2026-11-13 02:28:15 +00:00
Member

Spike: decoupled hands chase the controller

This is a spike to feel, not a decision. It tries the direction from #42 (comments 918, 920, 960): decouple the hands from the controllers, tracking hands to the controllers the same way grabbed objects are tracked, so when a grabbed gun drifts because it is caught on physics, the hand sticks with the gun. The H6 pivot split waits on what this shows — it is NOT built here.

What it tries

  • Hands become world-space followers. New HandFollower (modules/deadair/src/player/hand_follower.gd, Node3D, child of PlayerRig — NOT under the controller) chases its grip controller's pose with an exponential spring/damper in _physics_process (priority 10, like Grabbable._follow): position alpha = 1 - exp(-stiffness * delta), rotation Basis.slerp. Tunable: stiffness 20.0/s, angular_stiffness 20.0/s (~50 ms time constant), stick_radius 0.05 m.
  • The follower IS the hand. PlayerRig._ready reparents each HandView onto its follower (follower pre-placed at the grip transform, so the glove does not jump). HandInteractor.grip_node() returns the follower (fallback to the grip controller when there is none); HandCollider follows the follower; the hand_collider meta is set on both the follower and the grip (the flat controller still grabs with the grip directly, dev-only path). The laser pointer stays on the controller; the grip rings in sandbox_gizmos.gd stay controller-anchored, so drift is visible.
  • Leash (the "hand sticks with the gun" demonstration). When the hand holds an item whose Grabbable.follow_blocked is true, the follower's desired position clamps to within stick_radius of the held item's grip point. A clamp, not a park — pulling the controller back moves the clamped target back too, so the pair unblocks. Rotation is never leashed.
  • Teleport snap. A target jump over 0.5 m in one physics frame (Origin teleport on sandbox entry / level load / respawn) snaps the hand to the controller instead of chasing across the scene — a hand cannot move that far in 1/60 s. Found while testing: without it, enter_sandbox teleports the grips 5.5 m and the followers chase for a second while the held gun's snap t is driven by the chase.
  • Generic curl only. The follower copies the target controller's da_trigger_primary_analog / da_grip_primary_analog each frame; HandView reads them from the follower. No per-grip poses, no per-finger API, no H5 calibration change.

What it measures

  • grab_state gains a hand_follow section per hand from HandFollower.follow_snapshot(): hand + controller transforms, drift vector + drift_m, angular_drift_deg, leashed, and the tuning values.
  • New bridge/MCP tool set_hand_follow (side both/L/R; optional stiffness, angular_stiffness, stick_radius) for live tuning without rebuilding.
  • New suite tests/hand_follow_check.gd (29 checks, in CI): structure (followers exist, HandViews reparented, grip_node() is the follower, collider follows the follower), lag + convergence (position and rotation — the hand does not teleport, drift < 0.005 after settling), leash (pistol held, wall in front, grip driven past it: gun blocked, follower leashed, hand stays 0.050 m from the stuck gun's grip point, hand-to-controller drift 0.332 m, gun does not pass the wall), release (converges again, leash clears).

What it does NOT decide

  • Does NOT fix H5 (glove curl calibration) — that is its own rework.
  • Does NOT split the H6 pivot (rifles pitch/yaw-only, pistols snap the secondary hand) — that waits on what the spike shows.
  • Does NOT decide the decoupled model is final. It does not change any item JSON, the per-finger API, or the pivot logic (the gun still aligns to the palm-to-palm line in the two-hand branch; the spike demonstrates the opposite direction — hands to the gun — only through the leash).

Test-drive adaptation (same behaviour, new hand model)

tests/mag_rail_check.gd drove the grip in 5 cm/frame steps, which a rigid hand absorbed 1:1. With a follower, that drive leaves the hand far behind the grip: when the snap fires and the grip stops, the follower's residual travel along the 5 cm rail drives t to 1 and the mag clicks in during the aim. The aim now drives at 0.3 m/s (time-based, frame-rate independent; ~1.5 cm follower lag) with the aim budgets at 3.0 s, and the point-rail mag spawns beside/below the gun so the approach comes up into the magwell instead of crossing the scene into the gun's side face (where the leashed hand sticks with the blocked mag). Assertions unchanged. Measured on main for comparison: the old drive only passed phase 8 because the rigid hand dragged the mag along the gun's face, popped it off the corner, and flung it 2 m up before it fell back onto the magwell — the follower does not do that, and should not.

Verification (lantern-vm, Godot 4.6.3 pin)

  • Double --import after a .godot wipe: 0 ERROR both passes.
  • python3 tools/check.py: 6/6.
  • Full headless suites: 35/35 PASS, 1,661 ok (34 on main + the new hand_follow_check, 29 ok; mag_rail_check 63 ok).
  • Raw output grepped for SCRIPT ERROR: exactly the 3 known pre-existing lines (2x add_child on null — one each in firearm_check and mag_filo_check — plus 1x nonexistent is_trigger_down in firearm_check; same three as on main).
  • Bridge e2e (test_e2e.py under xvfb): PASS, 140 ok.

How to feel-test it

In-headset, sandbox, grab the generic pistol in R (and a mag in L):

  • Lag: flick the R controller fast and watch the glove — at stiffness 20/s it trails ~50 ms, then catches up. Does it feel springy, or too loose? Try set_hand_follow with stiffness 40 (tighter) or 10 (looser) and compare.
  • Drift: hold the controller still — the glove should sit on it (drift ~0). Watch the grip rings (still controller-anchored) vs the glove: the gap between them is the drift, readable live in grab_state's hand_follow.drift_m.
  • Oscillation: shake the controller — does the glove overshoot or ring, or settle cleanly? (The spring has no explicit damping term beyond the exponential approach; overshoot would show here.)
  • Leash: grab the pistol, push it against a wall/floor so it catches, then pull the controller back — the glove should stay with the gun (leashed), not follow the controller through the wall, and the pair should unblock as you pull back. hand_follow.leashed reads true while it holds.
  • Two-handed: grab the pistol with both hands — the gun still aligns to the palm-to-palm line (pivot split NOT built); watch whether the decoupled hands make that feel different, better or worse.

Commits: c90942b (spike) + 1a7e304 (mag_rail drive adaptation). Base: main a583800 (PR #41 merge).

## Spike: decoupled hands chase the controller **This is a spike to feel, not a decision.** It tries the direction from #42 (comments 918, 920, 960): decouple the hands from the controllers, tracking hands to the controllers the same way grabbed objects are tracked, so when a grabbed gun drifts because it is caught on physics, the hand sticks with the gun. The H6 pivot split waits on what this shows — it is NOT built here. ### What it tries - **Hands become world-space followers.** New `HandFollower` (`modules/deadair/src/player/hand_follower.gd`, Node3D, child of `PlayerRig` — NOT under the controller) chases its grip controller's pose with an exponential spring/damper in `_physics_process` (priority 10, like `Grabbable._follow`): position `alpha = 1 - exp(-stiffness * delta)`, rotation `Basis.slerp`. Tunable: `stiffness` 20.0/s, `angular_stiffness` 20.0/s (~50 ms time constant), `stick_radius` 0.05 m. - **The follower IS the hand.** `PlayerRig._ready` reparents each `HandView` onto its follower (follower pre-placed at the grip transform, so the glove does not jump). `HandInteractor.grip_node()` returns the follower (fallback to the grip controller when there is none); `HandCollider` follows the follower; the `hand_collider` meta is set on both the follower and the grip (the flat controller still grabs with the grip directly, dev-only path). The laser pointer stays on the controller; the grip rings in `sandbox_gizmos.gd` stay controller-anchored, so drift is visible. - **Leash (the "hand sticks with the gun" demonstration).** When the hand holds an item whose `Grabbable.follow_blocked` is true, the follower's desired position clamps to within `stick_radius` of the held item's grip point. A clamp, not a park — pulling the controller back moves the clamped target back too, so the pair unblocks. Rotation is never leashed. - **Teleport snap.** A target jump over 0.5 m in one physics frame (Origin teleport on sandbox entry / level load / respawn) snaps the hand to the controller instead of chasing across the scene — a hand cannot move that far in 1/60 s. Found while testing: without it, `enter_sandbox` teleports the grips 5.5 m and the followers chase for a second while the held gun's snap `t` is driven by the chase. - **Generic curl only.** The follower copies the target controller's `da_trigger_primary_analog` / `da_grip_primary_analog` each frame; `HandView` reads them from the follower. No per-grip poses, no per-finger API, no H5 calibration change. ### What it measures - `grab_state` gains a `hand_follow` section per hand from `HandFollower.follow_snapshot()`: hand + controller transforms, drift vector + `drift_m`, `angular_drift_deg`, `leashed`, and the tuning values. - New bridge/MCP tool `set_hand_follow` (side `both`/`L`/`R`; optional `stiffness`, `angular_stiffness`, `stick_radius`) for live tuning without rebuilding. - New suite `tests/hand_follow_check.gd` (29 checks, in CI): structure (followers exist, HandViews reparented, `grip_node()` is the follower, collider follows the follower), lag + convergence (position and rotation — the hand does not teleport, drift < 0.005 after settling), leash (pistol held, wall in front, grip driven past it: gun blocked, follower leashed, hand stays 0.050 m from the stuck gun's grip point, hand-to-controller drift 0.332 m, gun does not pass the wall), release (converges again, leash clears). ### What it does NOT decide - Does NOT fix H5 (glove curl calibration) — that is its own rework. - Does NOT split the H6 pivot (rifles pitch/yaw-only, pistols snap the secondary hand) — that waits on what the spike shows. - Does NOT decide the decoupled model is final. It does not change any item JSON, the per-finger API, or the pivot logic (the gun still aligns to the palm-to-palm line in the two-hand branch; the spike demonstrates the opposite direction — hands to the gun — only through the leash). ### Test-drive adaptation (same behaviour, new hand model) `tests/mag_rail_check.gd` drove the grip in 5 cm/frame steps, which a rigid hand absorbed 1:1. With a follower, that drive leaves the hand far behind the grip: when the snap fires and the grip stops, the follower's residual travel along the 5 cm rail drives `t` to 1 and the mag clicks in during the aim. The aim now drives at 0.3 m/s (time-based, frame-rate independent; ~1.5 cm follower lag) with the aim budgets at 3.0 s, and the point-rail mag spawns beside/below the gun so the approach comes up into the magwell instead of crossing the scene into the gun's side face (where the leashed hand sticks with the blocked mag). Assertions unchanged. Measured on main for comparison: the old drive only passed phase 8 because the rigid hand dragged the mag along the gun's face, popped it off the corner, and flung it 2 m up before it fell back onto the magwell — the follower does not do that, and should not. ### Verification (lantern-vm, Godot 4.6.3 pin) - Double `--import` after a `.godot` wipe: 0 ERROR both passes. - `python3 tools/check.py`: 6/6. - Full headless suites: **35/35 PASS, 1,661 ok** (34 on main + the new `hand_follow_check`, 29 ok; `mag_rail_check` 63 ok). - Raw output grepped for SCRIPT ERROR: exactly the 3 known pre-existing lines (2x `add_child` on null — one each in `firearm_check` and `mag_filo_check` — plus 1x nonexistent `is_trigger_down` in `firearm_check`; same three as on main). - Bridge e2e (`test_e2e.py` under xvfb): **PASS, 140 ok**. ### How to feel-test it In-headset, sandbox, grab the generic pistol in R (and a mag in L): - **Lag:** flick the R controller fast and watch the glove — at stiffness 20/s it trails ~50 ms, then catches up. Does it feel springy, or too loose? Try `set_hand_follow` with stiffness 40 (tighter) or 10 (looser) and compare. - **Drift:** hold the controller still — the glove should sit on it (drift ~0). Watch the grip rings (still controller-anchored) vs the glove: the gap between them is the drift, readable live in `grab_state`'s `hand_follow.drift_m`. - **Oscillation:** shake the controller — does the glove overshoot or ring, or settle cleanly? (The spring has no explicit damping term beyond the exponential approach; overshoot would show here.) - **Leash:** grab the pistol, push it against a wall/floor so it catches, then pull the controller back — the glove should stay with the gun (leashed), not follow the controller through the wall, and the pair should unblock as you pull back. `hand_follow.leashed` reads true while it holds. - **Two-handed:** grab the pistol with both hands — the gun still aligns to the palm-to-palm line (pivot split NOT built); watch whether the decoupled hands make that feel different, better or worse. Commits: `c90942b` (spike) + `1a7e304` (mag_rail drive adaptation). Base: main `a583800` (PR #41 merge).
Hands become world-space followers instead of riding the XRController3D grip pose rigidly: a new HandFollower (Node3D, child of PlayerRig) chases its grip with an exponential spring/damper (stiffness / angular_stiffness, 20/s defaults, ~50 ms tau), and the HandView, HandInteractor hand, and HandCollider all hang off it. The grip node is only the pose it chases.

Leash: when the held item's follow is blocked (the gun is caught on physics), the follower's desired position clamps to within stick_radius (0.05 m) of the held item's grip point, so the hand sticks with the gun; pulling the controller back moves the clamped target back too, so the pair unblocks. Rotation is never leashed.

Teleport snap: a target jump over 0.5 m in one physics frame (Origin teleport on sandbox entry / level load / respawn) snaps the hand to the controller instead of chasing across the scene — a hand cannot move that far in 1/60 s.

Curl stays generic: the follower copies the target controller's trigger/grip analogs each frame and HandView reads them from the follower. No per-grip poses, no per-finger API, no pivot-split change, no item JSON change.

Measurement: grab_state gains a hand_follow section (drift vector + drift_m, angular_drift_deg, leashed, tuning) from HandFollower.follow_snapshot(); new bridge/MCP tool set_hand_follow (side both/L/R; stiffness, angular_stiffness, stick_radius) for live tuning. New suite tests/hand_follow_check.gd (29 checks: structure, lag + convergence position and rotation, leash against a wall, release) runs in CI.

This is a spike to feel, not a decision: it does not fix H5 (curl calibration), does not split the H6 pivot, and does not settle the decoupled model.
Spike: adapt mag_rail_check drive to the decoupled hand (issue #42)
All checks were successful
tests / test (pull_request) Successful in 2m28s
1a7e304aa1
The suite's aim drove the grip in 5 cm/frame steps, which a rigid hand absorbed 1:1. With a follower, that drive leaves the hand far behind the grip: when the snap fires and the grip stops, the follower's residual travel along the 5 cm rail drives t to 1 and the mag clicks in during the aim. The aim now drives the grip at 0.3 m/s (time-based, frame-rate independent), so the follower tracks with ~1.5 cm lag, and the aim budgets go 2.0 s -> 3.0 s.

The point-rail spawn also crossed the whole scene into the gun's side face, where the leashed hand sticks with the blocked mag and the grip runs away from it. The fresh mag now spawns beside/below the gun so the approach comes up into the magwell. Assertions unchanged — only the drive and the spawn.
muse-lantern force-pushed hand-ik-rework from 1a7e304aa1
All checks were successful
tests / test (pull_request) Successful in 2m28s
to d47c6be6c8
Some checks failed
tests / test (pull_request) Failing after 1m48s
2026-11-12 03:01:58 +00:00
Compare
HandFollower: rigid tracking, leash only when forced (issue #42)
Some checks failed
tests / test (pull_request) Failing after 1m47s
249793b992
The spring is gone. Unleashed, the hand IS the controller transform
exactly — no spring, no lag, no float. Decoupling happens only when
the held item's follow is blocked: the desired position clamps
within stick_radius of the item's grip point and the hand sits at
that clamp. Rotation is never leashed.

Leash-engage transition is a snap, by design: when the leash
engages the hand is at the controller and the clamp may be
centimetres away, and the hand jumps to it because it is forced to
be with the gun. Smoothing that jump would reintroduce lag into
the normal path, which is exactly what the rigid law removes.
Release snaps back the same way.

Teleport snap now detects the TARGET jumping, not hand-to-target
distance: a leashed hand legitimately sits far from its
controller, so distance no longer distinguishes a teleport from a
leash stretch — the old check would fight the leash once drift
passed SNAP_DIST. The grip node only jumps that far in one physics
frame when the Origin teleports.

stiffness / angular_stiffness are removed entirely (follower,
snapshot, set_hand_follow tool + schema) so nothing pretends to
tune a follow rate that no longer exists. stick_radius is the
remaining live tunable. hand_follow_check now asserts exact
tracking (drift 0.0000 unleashed) instead of lag/convergence.
Grabbable: follow after the HandFollower so held items track rigidly (issue #42)
Some checks failed
tests / test (pull_request) Failing after 1m52s
d77e288e3c
The held item trailed the hand like a leash: Grabbable._follow and
HandFollower both ran at physics priority 10, and _follow ran first,
so the item targeted the follower's previous-frame pose — exactly
one physics step behind. Measured headlessly (probe): 1 m/s drive,
max item drift 0.0207 m; a 0.5 m step arrived one frame late
(drift 0.5069 the frame the hand arrived); a 90 deg rotation step
arrived one frame late (90.00 deg). The sweep itself always
completed in one frame — the lag was purely the ordering.

Fix: Grabbable runs at priority 20, after the follower (10). After
the fix the same probe reads drift 0.0000 m at 1 m/s and same-frame
arrival on the step and rotation. Blocked behaviour is unchanged
(the sweep still stops at walls and sets follow_blocked); recoil
pose_offset is untouched (measured peak 2.53 deg of designed kick
on the test gun, the gun tracks the recoil target exactly); the
mag rail follow_override is exact (mag-vs-override 0.0000 m over
22 samples) — its t is one step stale (max 0.0025 m at 0.15 m/s
slide speed) because _update_snap runs at priority 0, reported
not fixed: moving it is a broader interactor timing change.

New suite tests/item_follow_check.gd (20 checks): 1 m/s drive,
0.5 m step, 90 deg rotation step, rail slide. Negative control:
with priority 10 it fails (drive 0.0207 m, step 0.5138 m,
rotation 90.00 deg). Listed in .forgejo/workflows/tests.yml.

volume_container_check drove the hand by pinning grip_node() —
the follower — which the follower's per-step copy of the
controller undoes before _follow reads it. The pin only worked
because _follow read the follower before the copy ran (the lag).
It now pins the controller (right_grip) instead, through the real
path: controller -> follower -> item. The suite's remaining
failures are the same four wall checks as pure main 8d466cf (the
grip-pose fallout fixed on PR #44), not new storage failures.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine!43
No description provided.