feat(tools): a weapon hold lab for tuning each character's grip in 3D

I have been guessing at numbers that want an eye on them. This is the
environment to set them instead.

  godot --path . res://debug/weapon_lab.tscn

Pick a character, pick a weapon, pick a pose, drag sliders, press Save.
Thirteen knobs — weapon size, support-hand distance along the barrel, grip
to buttstock, muzzle pitch at low ready, both wrist rolls, three finger
curl amounts, the stock pocket for low-ready and for aiming, and both elbow
poles. Three markers show the points being solved for: red trigger grip,
green support hand, blue buttstock. If a hand is not ON its marker the IK
could not reach it, which is a different problem from the marker being in
the wrong place, and the two used to be indistinguishable.

Results land in assets/characters/weapon_holds.json, resolved in layers so
a number can be set once and contradicted where it matters:

  defaults              every character, every weapon
  skins.<skin>._all     this character, every weapon
  skins.<skin>.<weapon> this character, this weapon

An empty file means "use what the code derives", so the game runs exactly
as before until something is actually tuned. The knobs that CAN be derived
from the skeleton still are — mount rotation, wrist frame, weapon size —
and their sliders read "auto" at zero rather than silently overriding.

Two things this had to get right to be honest rather than merely present.
Slider defaults come from the same table as the code defaults, because a
slider parked at 0 beside a code default of 1.0 means the first touch of
that slider silently switches finger curl off. And the markers are
depth-tested: drawn through the body they look like they are floating in
front of the chest when they are really behind an arm, which is the exact
wrong impression for judging whether a hand is on its target.

`-- shot <path> [skin] [weapon]` renders one framed close-up and quits, so
the lab can be checked without a human at the controls — which is how both
of those bugs were caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-26 21:15:53 -04:00
co-authored by Claude Opus 5
parent 2a6b321984
commit 33d07b3717
6 changed files with 628 additions and 9 deletions
+78 -9
View File
@@ -94,6 +94,12 @@ var _joint_helpers: Array = []
var _rig_info: Dictionary = {}
var _spring_mod: SpringBones
var is_holding_weapon: bool = false
## Which skin this is, so per-character hold tuning can be looked up. Set by
## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = ""
## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by
## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {}
# Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an
@@ -863,6 +869,22 @@ func get_lean_debug() -> float:
return _cur_fwd
## Push a new hold tuning table in and re-seat the weapon with it.
##
## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on
## the next frame, but weapon SIZE and the grip offset are baked into the
## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t
if _pose_mod:
_pose_mod.tune = t
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = Transform3D.IDENTITY
_measure_weapon(w)
## Whether the locomotion cycle is running backwards, which points the stride
## the opposite way to the hips. For debug/travel_dir_check.gd.
func stride_reversed_debug() -> bool:
@@ -893,6 +915,12 @@ func set_weapon(script_path: String) -> void:
is_holding_weapon = script_path != ""
if script_path == "" or not skeleton:
return
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
# the normal case and means "use what the code derives".
if hold_tune.is_empty():
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
var script = load(script_path)
if not script:
@@ -1039,15 +1067,25 @@ func _measure_weapon(w: Node3D) -> void:
# Size the gun to the arm that has to hold it, then seat the grip in the fist.
var k := _weapon_scale(stock_u + fore_u)
# A tuned size wins over the derived one. The derivation keeps the handguard
# inside the support arm's reach, which is a floor on believability, not a
# statement about how big the gun should LOOK.
if hold_tune.get("weapon_scale", 0.0) > 0.01:
k = float(hold_tune["weapon_scale"])
w.scale = Vector3.ONE * k
w.position -= w.transform.basis * grip
_pose_mod.gun_fore = fore_u * k
if hold_tune.get("gun_fore", 0.0) > 0.0001:
_pose_mod.gun_fore = float(hold_tune["gun_fore"])
# Stock: not authored anywhere, so derive it from the barrel. Half the
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
# pocket for every gun in the set.
_pose_mod.gun_stock = stock_u * k
if hold_tune.get("gun_stock", 0.0) > 0.0001:
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
_pose_mod.tune = hold_tune
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1172,6 +1210,25 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var roles: Dictionary = {}
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new
## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
## picks it up from the same table.
var tune: Dictionary = {}
## Where the hold put the weapon this frame, in skeleton space. The lab draws
## these so the points being tuned are visible rather than inferred.
var dbg_grip: Vector3 = Vector3.ZERO
var dbg_fore: Vector3 = Vector3.ZERO
var dbg_stock: Vector3 = Vector3.ZERO
func _t(key: String, fallback: float) -> float:
return float(tune.get(key, fallback))
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.
return v if (v is Vector3 and v.length() > 0.0001) else fallback
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.
@@ -1488,12 +1545,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.
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe
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()
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
@@ -1506,7 +1565,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 = POCKET_HIP.lerp(POCKET_ADS, ads)
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
var stock_pos := shoulder + pocket
var grip_pos := stock_pos + aim_dir * gun_stock
# The support hand rides as far out the handguard as it can actually
@@ -1532,6 +1591,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if fore_dist <= floor_fore:
break
var fore_pos := grip_pos + aim_dir * fore_dist
dbg_grip = grip_pos
dbg_fore = fore_pos
dbg_stock = stock_pos
# 3. Support hand goes to the mag well during a reload (under the
# receiver — the correct side), otherwise to the handguard.
@@ -1550,8 +1612,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 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized()
var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized()
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 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",
@@ -1580,7 +1642,8 @@ 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 + R_HAND_TWIST) * arc
arc = Quaternion(aim_dir, roll + _t("trigger_roll",
R_HAND_TWIST)) * arc
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
# 6. Support hand: WRAP the handguard.
@@ -1605,7 +1668,11 @@ 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.
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
# `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,
-aim_dir)
var rest: Basis = _hand_frame["L"]
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
@@ -1646,14 +1713,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if bones.is_empty():
continue
# The right index rides the trigger; everything else wraps.
var amount: Array = CURL_TRIGGER if (side == "R" and digit == "index") else CURL_WRAP
var trigger: bool = side == "R" and digit == "index"
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
for i in bones.size():
var a: float = amount[i] if i < amount.size() else amount[-1]
_add_space(skel, bones[i], Quaternion(axis, a * w))
_add_space(skel, bones[i], Quaternion(axis, a * scale * w))
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
for i in thumb.size():
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
_add_space(skel, thumb[i], Quaternion(axis, a * w))
_add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w))
## Straight-arm length of an arm chain, from the rest pose.
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,