diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md index 2a4e8c1..fd93799 100644 --- a/.claude/skills/character-pipeline/SKILL.md +++ b/.claude/skills/character-pipeline/SKILL.md @@ -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 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 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 diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md index 042f879..8da02ae 100644 --- a/.claude/skills/character-pipeline/references/verification.md +++ b/.claude/skills/character-pipeline/references/verification.md @@ -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_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 | +| `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 | — | | `roster_capture.gd` | one photo of every character, from the picker | — | | `ui_capture.gd` | one photo of every menu screen | — | diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index a9f00c2..df527f4 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -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. # diff --git a/debug/hold_pose_check.gd.uid b/debug/hold_pose_check.gd.uid new file mode 100644 index 0000000..d48de7d --- /dev/null +++ b/debug/hold_pose_check.gd.uid @@ -0,0 +1 @@ +uid://dydhylqo46x1n diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd index 1c1414d..290e702 100644 --- a/debug/rig_lab.gd +++ b/debug/rig_lab.gd @@ -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], diff --git a/debug/wrist_gun_check.gd b/debug/wrist_gun_check.gd new file mode 100644 index 0000000..5749341 --- /dev/null +++ b/debug/wrist_gun_check.gd @@ -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)