fix(weapons): the wrist turns the hand, and the gun stays on the aim line

The weapon is a child of a BoneAttachment3D on the trigger hand, so the two were
welded by construction. Every degree `wrist_r` turned swung the barrel the same
degree off the aim line — and took with it every control that could have
corrected for it, because they are all expressed relative to that same hand.
There was no combination of sliders that aligned a hand to a gun, which is the
one thing the knob exists for.

Both outcomes are now computed where the hand's local pose is set: the rotation
the hand would take without the wrist offset, and the one it takes with it. The
hand gets the second; the difference between them is exactly the counter-rotation
the weapon mount needs, in the hand's own local frame, and
`SkinnedPlayerModel._hold_weapon_still` applies it to the mount each frame. The
gun ends up precisely where the solver put it.

That also gives the two controls a clean split, which is what makes them usable
together:

  TRIGGER / SUPPORT WRIST (hold)     turns the HAND, gun stays on the aim line
  Grip roll / pitch / yaw (anchors)  turns the GUN inside the hand

Identity when `wrist_r` is untuned, so a character nobody has tuned mounts its
weapon exactly as before.

Applied in `_process` rather than inside the modifier pass on purpose. The gun's
mount is not something the skeleton owns, and the compensated value only changes
when a slider moves or the ADS blend travels, so one frame of lag is a fraction
of a degree; reaching into the modifier to touch a scene node would be worse.

wrist_gun_check asserts both halves, because only asserting the first is how
this shipped broken: the hand must TURN, or the knob does nothing, and the gun
must NOT, or the knob cannot be used. Across both poses and all three axes the
hand turns 28.2-28.7 degrees for a 0.5 rad knob and the gun moves 0.1-0.6 —
against the ~28 it would move if it were still following the wrist. The residue
is the arm's own IK settling, since the hand's rotation feeds the chain that
places the shoulder.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 01:43:36 -04:00
co-authored by Claude Opus 5
parent a97ccca13c
commit e97de9aafd
6 changed files with 223 additions and 2 deletions
+60 -2
View File
@@ -106,6 +106,9 @@ var hold_tune: Dictionary = {}
## Live anchor overrides — where the grip sits in the palm, and how the gun
## rolls in the fingers. Same two sources as hold_tune. See RigAnchors.
var anchors: Dictionary = {}
## The weapon's local transform in the hand as `_measure_weapon` left it, before
## the wrist counter-rotation. See `ShooterPoseModifier.wrist_comp_r`.
var _weapon_seat: Transform3D = Transform3D.IDENTITY
# Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an
@@ -708,6 +711,7 @@ func _process(delta: float) -> void:
_update_cloth_lod(delta)
if not _pose_mod:
return
_hold_weapon_still()
var t := 1.0 - exp(-POSE_SMOOTH * delta)
# The body lean gets its own, much slower rate, and is scaled by how fast the
# character is ACTUALLY moving rather than by which key is held.
@@ -946,6 +950,29 @@ func set_anchors(a: Dictionary) -> void:
_reseat_weapon()
## Keep the weapon where the solver put it while the wrist turns under it.
##
## The two are welded by construction — the gun is a child of a BoneAttachment3D
## on the trigger hand — so a wrist rotation swings the barrel off the aim line
## and takes every control that could correct it along for the ride. The pose
## layer works out the exact counter-rotation in the hand's own local frame;
## this applies it.
##
## Deliberately in `_process` rather than inside the modifier pass. The gun's
## mount is not something the skeleton owns, and the value being compensated
## only changes when a slider moves or the ADS blend travels, so being one frame
## behind is a rotation of a fraction of a degree that nothing can see. Reaching
## into the modifier to touch a scene node would be worse.
func _hold_weapon_still() -> void:
if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0:
return
var w := _weapon_attachment.get_child(0) as Node3D
if w == null:
return
var comp: Quaternion = _pose_mod.wrist_comp_r
w.transform = Transform3D(Basis(comp), Vector3.ZERO) * _weapon_seat
## Re-apply the grip anchor and re-measure, after either table changed.
##
## `_measure_weapon` reads the weapon's transform to work out where its grip and
@@ -1173,6 +1200,11 @@ func _measure_weapon(w: Node3D) -> void:
if hold_tune.get("gun_stock", 0.0) > 0.0001:
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
_pose_mod.tune = hold_tune
# Where the weapon sits in the hand once everything derived and tuned has
# been applied. Kept because `_process` re-derives the mount every frame from
# it plus the wrist counter-rotation, and recomputing the seat instead would
# re-run this whole measurement sixty times a second.
_weapon_seat = w.transform
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1312,6 +1344,18 @@ class ShooterPoseModifier extends SkeletonModifier3D:
## expressed in; without it, dragging left would mean something different at
## every pitch of the weapon.
var dbg_gun_basis: Basis = Basis.IDENTITY
## What the weapon mount must be rotated by, in the trigger hand's own local
## frame, to undo `wrist_r` — so the hand turns and the GUN does not.
##
## The weapon is parented to a BoneAttachment3D on that hand, so without this
## the two are welded: rotating the wrist swings the barrel off the aim line,
## and there is no second control that could bring it back, because every
## control that moves the gun is expressed relative to the same hand. The
## wrist knob was therefore unusable for the one thing it exists for.
##
## Identity when `wrist_r` is untuned, so a character nobody has touched
## mounts its weapon exactly as before.
var wrist_comp_r: Quaternion = Quaternion.IDENTITY
func _t(key: String, fallback: float) -> float:
return float(tune.get(key, fallback))
@@ -1772,8 +1816,22 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# 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)
#
# The gun hangs off THIS bone, so rotating it carries the gun
# along and the barrel comes off the aim line — which made the
# knob useless for its actual purpose, since there was then no
# way to align the hand to a gun that had moved with it. Both
# outcomes are computed, the hand takes the rotated one, and the
# difference between them is published as the counter-rotation
# the weapon mount needs to stay exactly where the solver put it.
# See `wrist_comp_r`.
var cur := skel.get_bone_pose_rotation(hand)
var local_free := (g_fa_r.inverse() * arc).normalized()
var wrist_q := _wrist("wrist_r", side, gun_up, aim_dir)
var local_wrist := (g_fa_r.inverse() * (wrist_q * arc)).normalized()
var applied := cur.slerp(local_wrist, _hold_r)
wrist_comp_r = applied.inverse() * cur.slerp(local_free, _hold_r)
skel.set_bone_pose_rotation(hand, applied)
# 6. Support hand: WRAP the handguard.
#