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:
co-authored by
Claude Opus 5
parent
a97ccca13c
commit
e97de9aafd
@@ -143,6 +143,14 @@ standing still changed. `pitch` exists at low ready only: down the sights the
|
|||||||
muzzle follows the camera, so there is nothing there to tune, and a slider that
|
muzzle follows the camera, so there is nothing there to tune, and a slider that
|
||||||
does nothing is worse than a missing one.
|
does nothing is worse than a missing one.
|
||||||
|
|
||||||
|
**The wrists turn the HAND, not the gun.** The weapon is a child of a
|
||||||
|
BoneAttachment3D on the trigger hand, so the two are welded by construction: a
|
||||||
|
wrist rotation swings the barrel off the aim line and takes every control that
|
||||||
|
could correct it along with it, which made the knob useless for aligning a hand
|
||||||
|
to a gun. `ShooterPoseModifier.wrist_comp_r` is the exact counter-rotation in
|
||||||
|
the hand's local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it.
|
||||||
|
To rotate the GUN inside the hand instead, use the grip rotation in ANCHORS.
|
||||||
|
|
||||||
Knobs that describe the WEAPON and the hands on it — where each hand sits along
|
Knobs that describe the WEAPON and the hands on it — where each hand sits along
|
||||||
it and off its barrel line, the finger curls, the weapon size — are shared,
|
it and off its barrel line, the finger curls, the weapon size — are shared,
|
||||||
because shouldering a gun does not move the hand along it. Both wrists take
|
because shouldering a gun does not move the hand along it. Both wrists take
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Two related traps:
|
|||||||
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
|
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
|
||||||
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
|
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
|
||||||
| `hold_pose_check.gd` | the lab shows only the selected pose's knobs; every wrist axis turns its hand | 0 failures |
|
| `hold_pose_check.gd` | the lab shows only the selected pose's knobs; every wrist axis turns its hand | 0 failures |
|
||||||
|
| `wrist_gun_check.gd` | the wrist turns the hand and NOT the gun welded to it | hand ~28°, gun < 1° |
|
||||||
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
||||||
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
||||||
| `ui_capture.gd` | one photo of every menu screen | — |
|
| `ui_capture.gd` | one photo of every menu screen | — |
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ var hold_tune: Dictionary = {}
|
|||||||
## Live anchor overrides — where the grip sits in the palm, and how the gun
|
## 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.
|
## rolls in the fingers. Same two sources as hold_tune. See RigAnchors.
|
||||||
var anchors: Dictionary = {}
|
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;
|
# Animation blending: locomotion plays full-body through a Transition node;
|
||||||
# gameplay one-shots (reload/throw/shoot/hit) play through an
|
# gameplay one-shots (reload/throw/shoot/hit) play through an
|
||||||
@@ -708,6 +711,7 @@ func _process(delta: float) -> void:
|
|||||||
_update_cloth_lod(delta)
|
_update_cloth_lod(delta)
|
||||||
if not _pose_mod:
|
if not _pose_mod:
|
||||||
return
|
return
|
||||||
|
_hold_weapon_still()
|
||||||
var t := 1.0 - exp(-POSE_SMOOTH * delta)
|
var t := 1.0 - exp(-POSE_SMOOTH * delta)
|
||||||
# The body lean gets its own, much slower rate, and is scaled by how fast the
|
# 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.
|
# character is ACTUALLY moving rather than by which key is held.
|
||||||
@@ -946,6 +950,29 @@ func set_anchors(a: Dictionary) -> void:
|
|||||||
_reseat_weapon()
|
_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.
|
## 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
|
## `_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:
|
if hold_tune.get("gun_stock", 0.0) > 0.0001:
|
||||||
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
|
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
|
||||||
_pose_mod.tune = hold_tune
|
_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 ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1312,6 +1344,18 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
## expressed in; without it, dragging left would mean something different at
|
## expressed in; without it, dragging left would mean something different at
|
||||||
## every pitch of the weapon.
|
## every pitch of the weapon.
|
||||||
var dbg_gun_basis: Basis = Basis.IDENTITY
|
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:
|
func _t(key: String, fallback: float) -> float:
|
||||||
return float(tune.get(key, fallback))
|
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
|
# 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
|
# hand's three freedoms, but nothing fixes how far the wrist is
|
||||||
# cocked or broken, and those were unreachable.
|
# 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.
|
# 6. Support hand: WRAP the handguard.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dydhylqo46x1n
|
||||||
@@ -49,6 +49,18 @@ extends Node3D
|
|||||||
## If a hand is not ON its marker, the IK could not reach — a different problem
|
## 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
|
## from the marker being in the wrong place, and dragging the marker further will
|
||||||
## not fix it.
|
## 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 := [
|
const POSES := [
|
||||||
["Low ready", "ground", 0.0, 0.0],
|
["Low ready", "ground", 0.0, 0.0],
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user