feat(rig): every weapon gets its own hold, so the silhouette names the gun

`_apply_rifle_hold` did exactly what its name said, to everything. A knife, an
AWP and a rocket launcher were all solved as a rifle — stock in the shoulder
pocket, support hand out along the barrel, muzzle on the aim line — so in third
person every character stood the same way whatever they carried, and the only
thing distinguishing a sniper from a shotgun was the ~30 cm of gun mesh in
their hands. At the distance an enemy is usually seen that is nothing.

It costs more than looking wrong. A character's pose is the fastest available
answer to "what is about to happen to me": a tube on a shoulder means take
cover, a blade held low means they have to close, a rifle at low ready means
they have not seen you. One hold throws all of that away. It is also the thing
HoYoverse's team say they chase in Zenless Zone Zero — characters read by
silhouette first, and they refuse to settle on one construction method because
one method limits how distinguishable the results can be.

weapons/weapon_hold_profiles.gd gives each of the twelve weapons a style, and a
style is not a bundle of slider values. Three of its differences cannot be
expressed on the rifle solve at all, and those are the ones that carry:

  support   WHERE the off hand goes and HOW IT IS TURNED there — wrapped round
            a handguard, cupped against the firing fist, hooked under a tube
            palm-up, or released entirely. Sending a hand somewhere new without
            re-orienting it gives a hand teleported to the new spot still shaped
            for the old one.
  mount     whether the weapon's rear sits IN the shoulder pocket, ON TOP of the
            shoulder, or nowhere near it.
  head      whether the head comes down to the stock, or leans away to clear a
            tube. The cheek weld is the sniper silhouette, and its inverse is
            what says a launcher is resting on that shoulder.

A blade RELEASES the off arm back to the animation, so it swings with the run
cycle instead of gripping a handguard that is not there — most of what makes a
one-handed weapon read as one-handed — and closes a full fist, because an index
left straight along a knife handle reads as a mistake, not as discipline.

Layered strictly UNDER the existing tuning, so aria's hand-tuned AK-47 hold is
byte-for-byte what it was. WeaponHoldTuning.default_for is weapon-aware now,
which it had to be: the rig lab SAVES every knob it shows, so without it,
opening the lab on the knife and pressing save would silently overwrite the
blade profile with the rifle spec and put the character back to holding a knife
like an AK with nothing to indicate it had happened.

debug/weapon_hold_check.gd asserts the consequence, not the plumbing — storing
an enum and reading it back proves nothing. It measures where the hands and head
ACTUALLY end up, from inside the modifier pass (outside it, Godot restores the
local poses and every weapon reports an identical rifle) and in the shoulder's
own frame, because the hold breathes and two samples of the SAME weapon
otherwise differ by more than two different weapons do. 21 weapon pairs, all
distinguishable; the knife's off-hand weight measured at 0.01.

Two findings only measuring produced. Pushing `gun_fore` further out for the
sniper does NOTHING — the reach solver slides the support hand back down the
handguard until the arm can get there, so it landed at 0.388 m against the
rifle's 0.387. Raising the whole weapon is what makes a scoped rifle read.

And one only LOOKING produced, via debug/hold_capture.gd: the shotgun's barrel
passed through the character's chest. Every assertion passed — the hands were
exactly where they had been asked to go — but +x is toward the centreline, so
dropping the pocket and pushing it across at once swings the muzzle into the
torso.

rig_anchor_check still reports aria: her hand-tuned wrist rotates the anchor
offset. Pre-existing, and this halves it — it was 2 failures on main, now 1.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 12:05:43 -04:00
co-authored by Claude Opus 5
parent c4bcbc7fd1
commit a13ae50f95
8 changed files with 793 additions and 16 deletions
+122 -13
View File
@@ -97,6 +97,9 @@ var _rig_info: Dictionary = {}
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks.
var hold_style: String = WeaponHoldProfiles.RIFLE
## 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 = ""
@@ -782,7 +785,12 @@ func _process(delta: float) -> void:
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
hold_r = 1.0
hold_l = 1.0
# A one-handed weapon RELEASES the off arm back to the animation, so it
# swings with the run cycle instead of gripping a handguard that is not
# there. That released arm is most of what makes a knife read as a knife
# from across a map.
hold_l = 0.0 if _pose_mod.support_mode == WeaponHoldProfiles.SUPPORT_FREE \
else 1.0
match st:
"slide":
hold_l = 0.0 # trailing arm braces the ground
@@ -1018,12 +1026,27 @@ 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".
# How this KIND of weapon is held, then the per-character tuning on top.
#
# The profile is a defaults layer: it answers "what sort of thing is this"
# for a weapon nobody has tuned, and every knob an artist saved in the rig lab
# still wins, because the JSON is merged over it with overwrite. Aria's
# hand-tuned AK-47 hold is byte-for-byte what it was.
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
var weapon_id := script_path.get_file().get_basename()
if hold_tune.is_empty():
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
hold_tune = WeaponHoldProfiles.knobs_for(weapon_id)
hold_tune.merge(WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(),
sid, weapon_id), true)
if _pose_mod:
# Structural, not tunable — see WeaponHoldProfiles. These decide where the
# off hand goes and how it is turned there, and whether the head comes
# down to the stock, which no slider on the rifle solve could express.
var style := WeaponHoldProfiles.style_for(weapon_id)
_pose_mod.support_mode = WeaponHoldProfiles.support_for(weapon_id)
_pose_mod.cheek = WeaponHoldProfiles.cheek_for(weapon_id)
_pose_mod.full_fist = style == WeaponHoldProfiles.BLADE
hold_style = WeaponHoldProfiles.style_for(weapon_id)
# Anchors are per character, not per weapon — where a grip sits in a palm is
# a fact about the hand — so unlike hold_tune they are not re-read per gun
# unless the lab has pushed a live set in.
@@ -1297,6 +1320,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0
# What KIND of weapon is being held. See WeaponHoldProfiles: these three are
# the differences a slider cannot express, and they are what make a launcher
# read as a launcher rather than as a very large rifle.
var support_mode: String = WeaponHoldProfiles.SUPPORT_BARREL
## How far the head comes down and across to the stock, 0..1. Negative leans
## it away, which is what a tube over the shoulder needs.
var cheek: float = 0.0
## Whether the trigger finger closes with the rest. True for a blade, which
## has nothing to keep a finger straight along.
var full_fist: bool = false
# [child_bone, helper_bone] pairs; see SkinJointHelper.
var joint_helpers: Array = []
@@ -1514,6 +1547,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_grapple(skel)
if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel)
if absf(cheek) > 0.01:
_apply_cheek(skel)
_close_hands(skel)
if recoil > 0.01:
_apply_recoil(skel)
@@ -1763,9 +1798,22 @@ class ShooterPoseModifier extends SkeletonModifier3D:
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.
# 3. Where the SUPPORT hand goes, which is most of what tells a viewer
# what is being held. See WeaponHoldProfiles.
#
# All four modes reuse the geometry above — the weapon is still placed
# first and the arms still solved onto it — they differ in which point
# on it the off hand is sent to.
var l_target := fore_pos
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Both hands together on the grip. Down and to the character's
# LEFT of the firing fist (`side` is character-right), so the two
# hands stack rather than collide.
l_target = grip_pos + gun_basis * Vector3(-0.048, -0.038, 0.012)
WeaponHoldProfiles.SUPPORT_TUBE:
# Hooked under the tube from below, forward of the shoulder.
l_target = fore_pos - gun_up * 0.075
if reload_phase > 0.001:
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05
@@ -1853,10 +1901,32 @@ class ShooterPoseModifier extends SkeletonModifier3D:
and _hand_frame.has("L"):
var hand_l: int = _idx.get("DEF-hand.L", -1)
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,
-aim_dir)
# The hand's target frame, as (along, palm, curl) — the same
# three axes `_hand_frame` measured off the rest pose.
#
# Which way the PALM faces and which axis the fingers CURL about
# is the whole difference between wrapping a handguard, cupping a
# fist and hooking under a tube. Sending the hand to a different
# POSITION without changing its orientation gives a hand that has
# been teleported to the new spot still shaped for the old one.
#
# -aim_dir throughout, so the hand comes at the weapon from the
# body side rather than reaching over it backwards.
var palm := gun_up
var curl := -aim_dir
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Palm presses inward against the grip's exposed panel;
# fingers still close along the barrel, over the firing
# hand's.
palm = side
WeaponHoldProfiles.SUPPORT_TUBE:
# A vertical foregrip: the palm faces BACK toward the
# body and the fingers close about the handle's own
# up-axis, not about the tube.
palm = -aim_dir
curl = gun_up
var want := Basis(palm.cross(curl).normalized(), palm, curl)
# Then the artist's wrist. Rolling about the barrel is the one
# axis a hand wrapping a cylinder is genuinely free in, and it
# used to be the only one offered — which left no way to cock the
@@ -1871,6 +1941,42 @@ class ShooterPoseModifier extends SkeletonModifier3D:
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
_set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l)
# ── The head against the stock ───────────────────────────────────────────
#
# A cheek weld is the single most recognisable thing about how a marksman
# holds a rifle, and its inverse — the head leaning AWAY — is what says a tube
# is resting on that shoulder. Both are silhouette at any distance, long after
# the weapon mesh itself has become a few pixels.
#
# Three axes, all toward the right shoulder, because that is where the weapon
# is: down onto the comb, rolled over it, and turned slightly along it.
## Full-weld amounts, in radians, at cheek = 1.
const CHEEK_PITCH := 0.20 # down onto the comb
const CHEEK_ROLL := 0.26 # over toward the shoulder
const CHEEK_YAW := 0.09 # turned along the stock
## How much of the weld is present at low ready. Not zero: the pose has to
## read before the character shoulders the weapon, and a marksman carrying a
## rifle already holds their head differently from someone carrying a knife.
const CHEEK_HIP := 0.35
func _apply_cheek(skel: Skeleton3D) -> void:
var k: float = cheek * lerpf(CHEEK_HIP, 1.0, ads) * _hold_r
if absf(k) < 0.005:
return
# Positive X pitches the head DOWN and positive Z rolls it toward the
# character's right — the same sign conventions as the aim pitch and the
# wall-run lean respectively.
var q := Quaternion(Vector3(1, 0, 0), CHEEK_PITCH * k) \
* Quaternion(Vector3(0, 0, 1), CHEEK_ROLL * k) \
* Quaternion(Vector3(0, 1, 0), CHEEK_YAW * k)
# Split across neck and head so the whole column leans rather than the
# skull hinging off a rigid neck. Weighted toward the head, which is what
# actually happens when someone lowers a cheek onto a stock.
_add_space(skel, _idx.get("DEF-neck", -1),
Quaternion.IDENTITY.slerp(q, 0.35))
_add_space(skel, _idx.get("DEF-head", -1),
Quaternion.IDENTITY.slerp(q, 0.65))
# How far each segment of a finger closes, knuckle -> tip, in radians.
#
# Two different grips. The SUPPORT hand wraps a handguard, so all four
@@ -1904,8 +2010,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
PackedInt32Array())
if bones.is_empty():
continue
# The right index rides the trigger; everything else wraps.
var trigger: bool = side == "R" and digit == "index"
# The right index rides the trigger; everything else wraps. A blade
# has no trigger, and an index left straight along a knife handle
# reads as a mistake rather than as discipline — so a full fist
# closes every finger the same.
var trigger: bool = side == "R" and digit == "index" and not full_fist
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():