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
+1
View File
@@ -0,0 +1 @@
uid://dydhylqo46x1n
+12
View File
@@ -49,6 +49,18 @@ extends Node3D
## If a hand is not ON its marker, the IK could not reach — a different problem
## from the marker being in the wrong place, and dragging the marker further will
## not fix it.
##
## HAND versus GUN. These rotate different things, and the split is the point:
##
## TRIGGER/SUPPORT WRIST (hold) turns the HAND. The gun stays exactly where
## the solver put it, on the aim line.
## Grip roll/pitch/yaw (anchors) turns the GUN inside the hand.
##
## The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
## used to be welded — every degree the wrist turned swung the barrel the same
## degree, and took with it every control that could have corrected for it. The
## pose layer now publishes the counter-rotation and the mount applies it, so
## the wrist sliders align the hand TO the gun rather than dragging it around.
const POSES := [
["Low ready", "ground", 0.0, 0.0],
+141
View File
@@ -0,0 +1,141 @@
extends SceneTree
## Does rotating the trigger wrist turn the HAND without taking the GUN with it?
##
## The two are welded by construction: the weapon is a child of a
## BoneAttachment3D on the trigger hand, so every degree the wrist turns swings
## the barrel the same degree off the aim line — and takes with it every control
## that could have corrected for it, because they are all expressed relative to
## that same hand. The wrist knob was therefore unusable for the one thing it
## exists for, which is aligning a hand to a gun.
##
## Both halves have to be asserted, and the second is the one that was broken:
##
## the HAND turns or the knob does nothing
## the GUN does not or the knob cannot be used
##
## Measured through a PoseProbe for the hand, because Godot restores every
## bone's local pose after the modifier pass and reading it from here would
## report the animation rather than the hold. The gun is a scene node, so its
## own global transform is the truth and no probe is needed.
##
## godot --path . -s res://debug/wrist_gun_check.gd
const LAB := "res://debug/rig_lab.tscn"
## Large on purpose. A small twist could hide behind the tolerance of a gun that
## really was following the hand.
const TWIST := 0.5
## Degrees the barrel may wander. Not zero: the hand's rotation feeds the IK
## chain that positions the whole arm, so the shoulder and the aim line settle
## very slightly differently — but a gun still welded to the wrist would move by
## something on the order of TWIST, which is 28 degrees.
const GUN_TOLERANCE := 3.0
const HAND_MIN := 5.0
var _fails := 0
var _probe: PoseProbe = null
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
for _i in 200:
await process_frame
var model = lab._model
if model == null or model._pose_mod == null or model.skeleton == null:
_expect(false, "the lab built a character holding a weapon")
_done()
return
var gun := _gun(model)
if gun == null:
_expect(false, "the character mounted a weapon")
_done()
return
_expect(true, "the lab built a character holding a weapon")
_probe = PoseProbe.new()
_probe.name = "WristGunProbe"
model.skeleton.add_child(_probe)
for _i in 10:
await process_frame
var hand: int = _role_bone(model, "hand.R")
for pose in ["hip", "ads"]:
lab._pose = 0 if pose == "hip" else 1
for axis in 3:
var v := Vector3.ZERO
v[axis] = TWIST
model.set_hold_tuning({})
await _settle(lab, pose)
var hand0 := _probe_rot(hand)
var gun0: Basis = gun.global_transform.basis.orthonormalized()
model.set_hold_tuning({"wrist_r_%s" % pose: v})
await _settle(lab, pose)
var hand1 := _probe_rot(hand)
var gun1: Basis = gun.global_transform.basis.orthonormalized()
var hand_moved := rad_to_deg(hand0.angle_to(hand1))
var gun_moved := rad_to_deg(
gun0.get_rotation_quaternion().angle_to(gun1.get_rotation_quaternion()))
_expect(hand_moved >= HAND_MIN,
"%s axis %d: the hand turned %.1f deg" % [pose, axis, hand_moved])
_expect(gun_moved <= GUN_TOLERANCE,
"%s axis %d: the gun stayed put (%.1f deg)" % [pose, axis, gun_moved])
model.set_hold_tuning({})
_done()
func _settle(lab: Node, pose: String) -> void:
lab._model.update_state("ground", 0.0, false)
lab._model.set_locomotion(0.0, 0.0, 1.0 if pose == "ads" else 0.0)
for _i in 60:
await process_frame
func _gun(model) -> Node3D:
if model.skeleton == null:
return null
var attach: Node = model.skeleton.get_node_or_null("WeaponAttachment")
if attach == null or attach.get_child_count() == 0:
return null
return attach.get_child(0) as Node3D
func _probe_rot(bone: int) -> Quaternion:
if _probe == null or bone < 0 or bone >= _probe.pose.size():
return Quaternion.IDENTITY
var t: Transform3D = _probe.pose[bone]
return t.basis.orthonormalized().get_rotation_quaternion()
func _role_bone(model, role: String) -> int:
var name: String = model._rig_info.get("roles", {}).get(role, "")
return model.skeleton.find_bone(name) if name != "" else -1
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
func _done() -> void:
print("\n=== WRIST vs GUN ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)