feat(rig lab): wrists in three axes, and sliders that belong to the pose on screen

Three things the lab could not do.

WRISTS. Each hand had one scalar, a twist about the barrel. That is the only
axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is
solved — which is true of the support hand, was never true of the trigger hand,
and in neither case left a way to cock a wrist forward or break it inward. Both
now take pitch, yaw and roll, applied in the GUN's frame so the three sliders
mean the same thing whether the muzzle is down at low ready or level down the
sights. Zero is exactly the old behaviour, since the roll term defaulted to zero
too.

HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at
which each hand sits, and they were labelled by what they measure rather than by
whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel
shifts for the same two hands, so the four controls that place a hand read as
four controls that place a hand.

POSES. The hold's knobs are now per pose, and the lab shows one pose's at a
time. Half of them mean something different at low ready than down the sights;
showing both sets at once meant every slider on screen was for one of two poses
with nothing saying which. Selecting a pose rebuilds the panel.

Two poses, not four, and deliberately: the runtime blends between exactly two
holds on `ads`. Running and Crouched are locomotion states that still use the
low-ready hold, so they edit the same numbers — and the heading says so, rather
than letting someone tune "Running" and wonder why standing still changed.
Offering four independent tunings would be inventing a capability the code does
not have, and the fourth would silently do nothing.

`pitch` is the case that forced the design: down the sights the muzzle follows
the CAMERA, so there is nothing there to tune. It exists at low ready and
nowhere else, and a spec table where a knob names the poses it applies to is
what lets that be said instead of shipping a control that does nothing.

hold_pose_check asserts both halves — that no pose shows another's knobs, that
aiming offers no muzzle pitch, that the heading names the hold being edited, and
that all twelve wrist axes turn the hand they name.

Its first version reported every wrist axis as moving the hand by 0.0 degrees,
which is precisely the answer it would have given if the wrists had never been
implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot
restores every bone's local pose after the modifier pass. The repo has a
reference section about exactly this and it still cost a cycle. Measured through
a PoseProbe, every axis turns its hand ~20 degrees.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 01:31:08 -04:00
co-authored by Claude Opus 5
parent b1c8bab714
commit a97ccca13c
8 changed files with 477 additions and 61 deletions
+48 -14
View File
@@ -1318,9 +1318,32 @@ class ShooterPoseModifier extends SkeletonModifier3D:
func _tv(key: String, fallback: Vector3) -> Vector3:
var v = tune.get(key)
# A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It
# is how the elbow poles keep their hip/ADS blend unless overridden.
# A zero-length vector means "not set" — see WeaponHoldTuning. It is how
# the elbow poles keep their hip/ADS blend unless overridden.
return v if (v is Vector3 and v.length() > 0.0001) else fallback
## A per-pose scalar knob, blended by `ads` the same way the hold itself is.
##
## Stored as `<stem>_hip` and `<stem>_ads` — the convention `pocket_hip` and
## `pocket_ads` already used, now that every knob which ought to differ
## between the two holds can.
func _tp(stem: String, d_hip: float, d_ads: float) -> float:
return lerpf(_t(stem + "_hip", d_hip), _t(stem + "_ads", d_ads), ads)
func _tvp(stem: String, d_hip: Vector3, d_ads: Vector3) -> Vector3:
return _tv(stem + "_hip", d_hip).lerp(_tv(stem + "_ads", d_ads), ads)
## The wrist offset for one hand, as a rotation in the GUN's frame.
##
## Pitch about the weapon's across-axis, yaw about its up, roll about the
## barrel — so the three sliders mean the same thing whether the muzzle is
## down at low ready or level down the sights. Identity when untuned, which
## is exactly what the hold did before there was anything but a roll.
func _wrist(stem: String, side: Vector3, up: Vector3, fwd: Vector3) -> Quaternion:
var w := _tvp(stem, Vector3.ZERO, Vector3.ZERO)
if w == Vector3.ZERO:
return Quaternion.IDENTITY
return Quaternion(side, w.x) * Quaternion(up, w.y) * Quaternion(fwd, w.z)
var _fing: Dictionary = {} # same, resolved to bone indices
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
@@ -1637,13 +1660,14 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if ua_r < 0:
return
var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
var t_pocket_hip := _tv("pocket_hip", POCKET_HIP)
var t_pocket_ads := _tv("pocket_ads", POCKET_ADS)
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
var kick := recoil * 0.12
# 1. The gun's line: pitched down at low-ready, on the camera line at
# ADS, kicked up by recoil.
#
# `pitch` is a low-ready knob only, and deliberately: down the sights
# the muzzle follows the CAMERA, so there is nothing there to tune.
var gun_pitch := lerpf(_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick + breathe
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
* Vector3(0, 0, 1)).normalized()
@@ -1657,7 +1681,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# 2. Anchor the stock at the shoulder, then walk out along the barrel.
var shoulder := skel.get_bone_global_pose(ua_r).origin
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
var pocket: Vector3 = _tvp("pocket", POCKET_HIP, POCKET_ADS)
var stock_pos := shoulder + pocket
# The gun's own frame: across, up, along the barrel. The hand anchors are
# nudged in THIS rather than in skeleton space so a sideways offset stays
@@ -1712,8 +1736,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
# 4. Solve both arms onto those points.
var pole_r: Vector3 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized()
var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, ads)).normalized()
var pole_r: Vector3 = _tvp("pole_r", POLE_R_HIP, POLE_R_ADS).normalized()
var pole_l: Vector3 = _tvp("pole_l", POLE_L_HIP, POLE_L_ADS).normalized()
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
"DEF-hand.R", grip_pos, pole_r, _hold_r)
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
@@ -1742,8 +1766,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if up_flat.length_squared() > 0.0001:
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
arc = Quaternion(aim_dir, roll + _t("trigger_roll",
R_HAND_TWIST)) * arc
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
# The artist's wrist, on top of the solved one. Three axes in the
# gun's frame rather than the single twist this used to take —
# the barrel has to lie on the aim line, which fixes two of the
# hand's three freedoms, but nothing fixes how far the wrist is
# cocked or broken, and those were unreachable.
arc = _wrist("wrist_r", side, gun_up, aim_dir) * arc
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
# 6. Support hand: WRAP the handguard.
@@ -1768,12 +1797,17 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if hand_l >= 0:
# -aim_dir so the hand comes at the handguard from the body side
# rather than reaching over it backwards.
# `support_roll` rolls the whole target frame about the barrel,
# which is the one axis a hand wrapping a cylinder is free in.
var roll_q := Quaternion(aim_dir, _t("support_roll", 0.0))
var up_r := roll_q * gun_up
var want := Basis(up_r.cross(-aim_dir).normalized(), up_r,
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
-aim_dir)
# Then the artist's wrist. Rolling about the barrel is the one
# axis a hand wrapping a cylinder is genuinely free in, and it
# used to be the only one offered — which left no way to cock the
# wrist forward or break it inward, and those are most of what
# separates a convincing support hand from a mannequin's.
# Rotating the whole frame is equivalent to the old roll for the
# roll component, since -aim_dir is unchanged by a rotation
# about aim_dir.
want = Basis(_wrist("wrist_l", side, gun_up, aim_dir)) * want
var rest: Basis = _hand_frame["L"]
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()