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():
+13 -1
View File
@@ -169,7 +169,19 @@ static func all_knobs() -> Array:
##
## Across ALL poses, not just the one on screen: a reset or a save has to know
## what `pocket_ads` defaults to even while low ready is being adjusted.
static func default_for(key: String):
##
## WEAPON-AWARE, and it has to be. The hold profile for a weapon class supplies
## better defaults than the generic spec table — a knife's stock pocket is at the
## hip, a launcher's is above the shoulder — and the lab SAVES every knob it
## shows, not just the ones that were moved. Without the weapon here, opening the
## lab on the knife and pressing save would silently overwrite the blade profile
## with the rifle spec's values, and the character would go back to holding a
## knife like an AK with no indication that anything had happened.
static func default_for(key: String, weapon_id: String = ""):
if weapon_id != "":
var profile := WeaponHoldProfiles.knobs_for(weapon_id)
if profile.has(key):
return profile[key]
return TuningStore.default_for(all_knobs(), key)
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## One photograph of a character holding each weapon, from far enough away to
## see the SILHOUETTE.
##
## godot --path . --windowed --resolution 640x900 \
## -s res://debug/hold_capture.gd -- <out_dir> [skin]
##
## The rig lab's camera is a close-up on the hands, which is right for tuning a
## grip and useless for the question this feature exists to answer: can you tell
## what someone is carrying from across a map. That is a whole-body question, so
## this frames the whole body, side-on, where the difference between a shouldered
## tube and a low-ready rifle actually lives.
##
## Side-on and not three-quarter, deliberately. A profile is the harshest test of
## a hold — it shows exactly how far the weapon sits from the shoulder and how
## the head is tilted, with no foreshortening to hide behind.
const WEAPONS := ["m4", "mp7", "awp", "double_barrel_shotgun",
"rocket_launcher", "knife"]
var _out := "."
var _skin := "taila"
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
if args.size() > 1:
_skin = String(args[1])
_run()
func _run() -> void:
# `_initialize` runs BEFORE the autoloads' `_ready`, so SkinManager's table is
# still empty here and even its "default" fallback is missing — asking it for
# a skin at this point fails on the fallback rather than on the skin asked
# for, which reads as "no skin 'taila'" and sends you looking in the wrong
# place entirely.
await process_frame
var world := Node3D.new()
root.add_child(world)
var env := WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-38, 155, 0)
key.light_energy = 2.0
world.add_child(key)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-20, -40, 0)
fill.light_energy = 0.7
fill.light_color = Color(0.6, 0.7, 1.0)
world.add_child(fill)
var cam := Camera3D.new()
world.add_child(cam)
# Side-on, at chest height, far enough back that the whole figure and the
# whole weapon are in frame.
cam.position = Vector3(3.4, 1.05, 0.15)
cam.look_at(Vector3(0, 0.95, 0))
cam.fov = 42.0
var mgr = root.get_node_or_null("SkinManager")
if mgr == null:
print("hold_capture: no SkinManager")
quit(1)
return
var skin = mgr.get_skin(_skin)
if skin == null:
print("hold_capture: no skin '%s'" % _skin)
quit(1)
return
for weapon in WEAPONS:
var model := SkinnedPlayerModel.new()
model.skin_id = _skin
model.first_person_mode = false
world.add_child(model)
model.load_model(skin.model_path)
for _i in 40:
await process_frame
model.set_weapon("res://weapons/%s.gd" % weapon)
model.update_state("idle", 0.0, false)
# The hold blends in at ~8/s and the weapon measures itself on `ready`,
# so give it several time constants before believing the pose.
for _i in 100:
model.update_state("idle", 0.0, false)
await process_frame
await process_frame
var img := root.get_texture().get_image()
var path := "%s/hold_%s.png" % [_out, weapon]
img.save_png(path)
print("hold_capture: saved ", path)
model.queue_free()
for _i in 4:
await process_frame
quit(0)
+9 -2
View File
@@ -420,7 +420,8 @@ func _drag_to(mouse: Vector2) -> void:
var key := _knob_for(_drag_marker)
var spec := _spec_for("hold", key)
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
var cur: Vector3 = _knobs["hold"].get(key,
WeaponHoldTuning.default_for(key, _weapons[_weapon].id))
if not (cur is Vector3):
cur = Vector3.ZERO
var lo: float = spec[2]
@@ -490,9 +491,15 @@ func _hold_pose() -> String:
return WeaponHoldTuning.pose_for_ads(POSES[_pose][3])
## A knob's resting value when nothing has been saved for this character.
##
## The weapon is passed through, because the hold's defaults depend on what KIND
## of weapon it is — see WeaponHoldTuning.default_for. Without it the lab would
## show a knife the rifle spec's numbers and then save them over the blade
## profile the moment anyone pressed save.
func _default_for(group: String, key: String):
return RigAnchors.default_for(key) if group == "anchors" \
else WeaponHoldTuning.default_for(key)
else WeaponHoldTuning.default_for(key, _weapons[_weapon].id)
## Every knob of a group across BOTH poses. Reset works on the whole table, not
+250
View File
@@ -0,0 +1,250 @@
extends SceneTree
## Does the character actually hold each weapon DIFFERENTLY?
##
## godot --path . -s res://debug/weapon_hold_check.gd
##
## Every weapon used to be solved as a rifle: stock in the shoulder pocket,
## support hand out along the barrel, muzzle on the aim line. So a knife, an AWP
## and a rocket launcher produced the same pose, and the only thing telling a
## viewer what was being carried was the weapon mesh itself — which at the
## distance an enemy is usually seen is a few pixels.
##
## This asserts the consequence, not the plumbing. Setting `support_mode` and
## reading it back proves nothing; a hold system is easy to build so that the
## profile loads, the enum is stored and the JSON round-trips while the arms do
## not move. So it measures WHERE THE HANDS AND HEAD ACTUALLY ARE, per weapon,
## and requires the poses to be distinguishable from each other.
##
## ── Measured from inside the modifier pass ──────────────────────────────────
##
## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so
## reading `get_bone_global_pose` from a SceneTree script recomputes the globals
## from the ANIMATION alone — the shooter hold is not in what you measure, and
## every weapon would report an identical pose whether or not this feature
## exists. That is the single most expensive trap in this repo; see
## `references/verification.md`. The `PoseProbe` below is the fix.
##
## ── Measured in the SHOULDER's frame ────────────────────────────────────────
##
## Not in world space, and not even in skeleton space. The hold breathes — a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the
## whole torso, so no hand is ever at the same place twice and two samples of the
## SAME weapon would differ by more than two different weapons do. Taking each
## hand relative to the right shoulder joint, in the chest's own basis, cancels
## both, because they move the shoulder and the hand together.
const LAB := "res://debug/rig_lab.tscn"
## One weapon per style, plus the two rifles, so the table covers every branch
## and also shows that two weapons of the SAME style stay close together.
const CASES := [
["ak47", WeaponHoldProfiles.RIFLE],
["m4", WeaponHoldProfiles.RIFLE],
["mp7", WeaponHoldProfiles.SMG],
["awp", WeaponHoldProfiles.SNIPER],
["double_barrel_shotgun", WeaponHoldProfiles.SHOTGUN],
["rocket_launcher", WeaponHoldProfiles.LAUNCHER],
["knife", WeaponHoldProfiles.BLADE],
]
## Two weapons of DIFFERENT styles must place their hands at least this far
## apart, in metres, measured in the shoulder frame. Small — these are stylised
## characters with ~0.47 m arms — but far above the millimetre of noise the
## shoulder-frame measurement leaves behind.
const MIN_STYLE_SEPARATION := 0.045
## Two weapons of the SAME style should agree to within this.
const MAX_SAME_STYLE := 0.06
var _fails := 0
var _probe: PoseProbe = null
## Snapshot the pose from INSIDE the modifier pass. See the note above.
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
if lab._model == null or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
var skel: Skeleton3D = lab._model.skeleton
_probe = PoseProbe.new()
skel.add_child(_probe)
# AFTER the pose layer, so what it reads is what renders.
skel.move_child(_probe, skel.get_child_count() - 1)
var samples := {}
for case in CASES:
var weapon_id: String = case[0]
var want_style: String = case[1]
_expect(WeaponHoldProfiles.style_for(weapon_id) == want_style,
"%s is held as a %s" % [weapon_id, want_style])
# Whether this character has a SAVED hold for this weapon, which is
# allowed to disagree with the style profile. See `_compare`.
var tuned: bool = not WeaponHoldTuning.resolve(
WeaponHoldTuning.load_all(), lab._skins[lab._skin].id,
weapon_id).is_empty()
lab._model.hold_tune = {}
lab._model.set_weapon("res://weapons/%s.gd" % weapon_id)
# Long enough for HOLD_SMOOTH to arrive and for the weapon's `ready` to
# have measured it. The hold blends in at ~8/s, so ~60 frames is several
# time constants.
for _i in 90:
await process_frame
samples[weapon_id] = _sample(skel, lab._model)
if not samples[weapon_id].is_empty():
samples[weapon_id]["tuned"] = tuned
_report(samples)
_compare(samples)
_done()
## Both hands and the head, relative to the right shoulder, in the chest's basis.
func _sample(skel: Skeleton3D, model) -> Dictionary:
var mod = model._pose_mod
var sh_i: int = mod._idx.get("DEF-upper_arm.R", -1)
var chest_i: int = mod._idx.get("DEF-spine.003", -1)
if chest_i < 0:
chest_i = mod._idx.get("DEF-spine.002", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hand_l: int = mod._idx.get("DEF-hand.L", -1)
var head_i: int = mod._idx.get("DEF-head", -1)
if sh_i < 0 or hand_r < 0 or hand_l < 0:
return {}
var pose: Array = _probe.pose
if pose.size() <= maxi(maxi(sh_i, hand_r), hand_l):
return {}
var shoulder: Vector3 = pose[sh_i].origin
# The chest's rotation, so a torso lean does not read as a moved hand.
var frame: Basis = Basis.IDENTITY
if chest_i >= 0 and chest_i < pose.size():
frame = pose[chest_i].basis.orthonormalized()
var inv := frame.inverse()
var out := {
"r": inv * (pose[hand_r].origin - shoulder),
"l": inv * (pose[hand_l].origin - shoulder),
"style": model.hold_style,
"support": mod.support_mode,
"hold_l": mod._hold_l,
}
if head_i >= 0 and head_i < pose.size():
# The head's TILT, which is what a cheek weld is. Taken as the angle
# between the head's up axis and the chest's, signed about forward, so a
# weld (toward the weapon) and a lean-away come out opposite.
var head_up: Vector3 = pose[head_i].basis.orthonormalized().y
var local := inv * head_up
out["cheek_deg"] = rad_to_deg(atan2(local.x, local.y))
return out
func _report(samples: Dictionary) -> void:
print("\n=== HOLD POSE PER WEAPON (metres, in the shoulder's frame) ===")
for id in samples:
var s: Dictionary = samples[id]
if s.is_empty():
continue
print(" %-22s %-9s support=%-7s handR=(%.3f %.3f %.3f) handL=(%.3f %.3f %.3f) offhand=%.2f tilt=%+.1f deg"
% [id, s["style"], s["support"],
s["r"].x, s["r"].y, s["r"].z, s["l"].x, s["l"].y, s["l"].z,
s["hold_l"], s.get("cheek_deg", 0.0)])
func _compare(samples: Dictionary) -> void:
# Every pair of DIFFERENT styles must be distinguishable.
var ids: Array = samples.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var a: Dictionary = samples[ids[i]]
var b: Dictionary = samples[ids[j]]
if a.is_empty() or b.is_empty():
continue
# The blade releases its off hand to the animation, so comparing its
# left hand measures the idle clip, not the hold. Its trigger hand
# and its free offhand weight are what distinguish it.
var d: float = float(a["r"].distance_to(b["r"]))
if a["hold_l"] > 0.5 and b["hold_l"] > 0.5:
d = maxf(d, a["l"].distance_to(b["l"]))
if a["style"] == b["style"]:
# ...unless an artist has tuned one of them for THIS character.
# The profile is only a defaults layer; a saved hold is meant to
# be able to disagree with it, and aria's hand-tuned AK-47 sits
# 0.22 m from the untuned M4 for exactly that reason. Asserting
# they match would be asserting that the rig lab does nothing.
if a["tuned"] or b["tuned"]:
print(" -- %s and %s are both %s but %s is hand-tuned (%.3f m apart)"
% [ids[i], ids[j], a["style"],
ids[i] if a["tuned"] else ids[j], d])
continue
_expect(d <= MAX_SAME_STYLE,
"%s and %s are both %s and hold alike (%.3f m apart)"
% [ids[i], ids[j], a["style"], d])
else:
_expect(d >= MIN_STYLE_SEPARATION,
"%s (%s) and %s (%s) are held differently (%.3f m apart)"
% [ids[i], a["style"], ids[j], b["style"], d])
# The blade must actually let go of the off arm — that released arm is most
# of what makes a one-handed weapon read as one-handed.
if samples.has("knife"):
_expect(samples["knife"]["hold_l"] < 0.05,
"the knife releases the off arm to the animation (%.2f)"
% samples["knife"]["hold_l"])
for id in ["ak47", "awp", "rocket_launcher"]:
if samples.has(id):
_expect(samples[id]["hold_l"] > 0.9,
"%s keeps both hands on the weapon" % id)
# The cheek weld, and its inverse on a shouldered tube. These are the two
# poses that read at the greatest distance, so they get their own assertion
# rather than relying on the pairwise distance.
if samples.has("awp") and samples.has("ak47"):
var d: float = float(samples["awp"].get("cheek_deg", 0.0)) - float(samples["ak47"].get("cheek_deg", 0.0))
_expect(absf(d) > 1.5,
"the sniper welds its head to the stock (%+.1f deg vs the rifle)" % d)
if samples.has("rocket_launcher") and samples.has("ak47"):
var dl: float = float(samples["rocket_launcher"].get("cheek_deg", 0.0))
var dr: float = float(samples["ak47"].get("cheek_deg", 0.0))
var da: float = float(samples["awp"].get("cheek_deg", 0.0))
_expect((dl - dr) * (da - dr) < 0.0,
"the launcher leans the head AWAY, opposite the sniper (%+.1f vs %+.1f)"
% [dl - dr, da - dr])
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== WEAPON HOLD SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://f7600k001iqr
+292
View File
@@ -0,0 +1,292 @@
extends Object
class_name WeaponHoldProfiles
## How each weapon in the set is HELD — one archetype per class of weapon,
## rather than one rifle hold for all twelve.
##
## ── The problem this exists to fix ───────────────────────────────────────────
##
## `ShooterPoseModifier._apply_rifle_hold` did exactly what its name says, to
## everything. A knife, a rocket launcher and an AK were all solved as a rifle:
## stock in the shoulder pocket, support hand out along the barrel, muzzle on the
## aim line. In third person every character therefore stood in the same pose
## regardless of what they were carrying, and the ONLY thing distinguishing a
## sniper from a shotgun was the ~30 cm of gun mesh in their hands — which at the
## distance an enemy is usually seen is nothing.
##
## That matters beyond looking wrong. In a shooter the reason a character's pose
## is readable at range is that it is the fastest available answer to "what is
## about to happen to me". A shouldered tube means take cover; a blade held low
## means they have to close the distance; a rifle at low ready means they have not
## seen you yet. A single hold throws all of that away.
##
## It is also the specific thing HoYoverse's team say they chase in Zenless Zone
## Zero: characters read by SILHOUETTE first, and their designers deliberately
## refuse to settle on one construction method because a single method limits how
## distinguishable the results can be. The same argument applies one level down,
## to how a character holds a thing.
##
## ── What a profile controls ─────────────────────────────────────────────────
##
## A style is not a bundle of slider values. Three of the differences below
## cannot be expressed as a number on the existing rifle solve at all, and those
## are the ones that make the silhouette:
##
## support where the off hand goes, and how it is ORIENTED there —
## wrapped round a handguard, cupped under a pistol grip, hooked
## under a tube, or released entirely so the animation owns it
## 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 (a cheek weld) or
## leans away to clear a tube
##
## Everything else — the pocket offsets, the muzzle pitch at low ready, the elbow
## poles, the finger curls — is an ordinary knob, and the profile just supplies a
## better DEFAULT for that weapon than one global constant could.
##
## ── Layering ────────────────────────────────────────────────────────────────
##
## This is a defaults layer, underneath everything an artist has tuned:
##
## code constants the rifle solve's own fallbacks
## THIS FILE per weapon: what kind of thing it is
## weapon_holds.json defaults -> skins.<skin>._all -> skins.<skin>.<weapon>
##
## So a character with tuning saved from the rig lab is completely unaffected —
## aria's hand-tuned AK-47 hold still wins on every knob it sets — and a weapon
## nobody has tuned stops being held like an AK.
# ── The styles ───────────────────────────────────────────────────────────────
const RIFLE := "rifle"
const SMG := "smg"
const SNIPER := "sniper"
const SHOTGUN := "shotgun"
const LAUNCHER := "launcher"
const PISTOL := "pistol"
const BLADE := "blade"
# ── Where the support hand goes, and how it is turned ────────────────────────
## Wrapped round a handguard, fingers closing ACROSS the barrel. The rifle case.
const SUPPORT_BARREL := "barrel"
## Cupped under and around the firing fist. Two hands together, no shoulder
## contact — a pistol, or any weapon light enough to be held out in front.
const SUPPORT_CUPPED := "cupped"
## Hooked UNDER a tube from below, palm up, well forward of the shoulder. The
## launcher case, where there is no handguard to wrap and the weight is carried
## rather than aimed.
const SUPPORT_TUBE := "tube"
## No support hand at all. The animation keeps the arm, which is what a character
## carrying a blade should look like — the off hand swings with the run cycle.
const SUPPORT_FREE := "free"
## Style -> the structural rules for it.
##
## `pocket_hip` / `pocket_ads` are where the weapon's REAR sits, relative to the
## right shoulder joint, in skeleton space (x across, y up, z forward). They are
## the single most important number here: it is what decides whether a weapon
## reads as shouldered, carried, or held out.
##
## `cheek` is how far the head comes down and across to meet the stock, 0..1,
## applied only as the character shoulders the weapon. Negative leans the head
## AWAY, which is what a tube over the shoulder requires.
##
## `pitch_hip` is the muzzle's droop at low ready, in radians. A launcher's tube
## rides nose-UP because that is how you carry something you do not want pointed
## at your own feet; a blade points forward and in.
const STYLES := {
RIFLE: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.03, -0.07, 0.06),
"pocket_ads": Vector3(0.05, 0.01, 0.07),
"pitch_hip": 0.16,
"cheek": 0.18,
"pole_r_hip": Vector3(-0.55, -0.85, -0.20),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"curl_wrap": 1.0,
"curl_trigger": 1.0,
},
# Compact: the whole weapon is carried closer in, the support hand cannot go
# far because there is not much gun in front of the grip, and the firing elbow
# tucks rather than flares. This is what stops an MP7 from being posed as a
# short rifle with the support arm reaching for a handguard that ended.
SMG: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.04, -0.10, 0.03),
"pocket_ads": Vector3(0.05, 0.00, 0.05),
"pitch_hip": 0.24,
"cheek": 0.10,
"pole_r_hip": Vector3(-0.40, -0.95, -0.15),
"pole_l_hip": Vector3(0.35, -0.95, -0.05),
"gun_fore": 0.17,
"curl_wrap": 1.05,
"curl_trigger": 1.0,
},
# The cheek weld IS the sniper silhouette. The head comes down onto the stock,
# the pocket sits high and tight so the optic lands at eye height, and the
# support hand goes far out because a long barrel gives it somewhere to go.
SNIPER: {
"support": SUPPORT_BARREL,
# Rides HIGH. A scoped rifle is held so the optic meets the eye, not so
# the butt meets the pocket, and raising the whole weapon is what makes
# that read — pushing the support hand further out does NOT, because the
# reach solver slides it back down the handguard until the arm can get
# there, so a longer `gun_fore` on these stylised arms lands the off hand
# in exactly the same place as a rifle's. Measured: 0.388 vs 0.387 m.
"pocket_hip": Vector3(0.015, 0.005, 0.045),
"pocket_ads": Vector3(0.040, 0.075, 0.055),
"pitch_hip": 0.12,
"cheek": 0.85,
"pole_r_hip": Vector3(-0.72, -0.55, -0.22),
"pole_l_hip": Vector3(0.22, -1.05, -0.02),
"gun_fore": 0.34,
"curl_wrap": 1.0,
"curl_trigger": 0.85,
},
# Held lower and squarer than a rifle, with the support hand back on the
# forend rather than out at the muzzle, and a hard wrap — a shotgun is gripped,
# not balanced.
SHOTGUN: {
"support": SUPPORT_BARREL,
# Carried low and FORWARD, the opposite of the sniper's high tuck. A
# shotgun is pointed rather than aimed and the elbows go wide, because
# the recoil comes back through them.
#
# Low and INBOARD does not work, however tempting the symmetry: +x is
# toward the character's centreline, so dropping the pocket and pushing
# it across at the same time swings the barrel through the chest. It is
# visible immediately in debug/hold_capture.gd and in no assertion —
# the hands were still exactly where they had been asked to go.
"pocket_hip": Vector3(0.030, -0.130, 0.080),
"pocket_ads": Vector3(0.045, -0.040, 0.075),
"pitch_hip": 0.30,
"cheek": 0.22,
"pole_r_hip": Vector3(-0.62, -0.80, -0.22),
"pole_l_hip": Vector3(0.58, -0.72, -0.16),
"gun_fore": 0.22,
"curl_wrap": 1.2,
"curl_trigger": 1.0,
},
# The tube goes ON the shoulder, not into it, and the head leans AWAY to clear
# it. The support hand hooks under from below, well forward, because there is
# nothing to wrap and the job of that arm is to carry weight.
LAUNCHER: {
"support": SUPPORT_TUBE,
"pocket_hip": Vector3(0.055, 0.06, 0.01),
"pocket_ads": Vector3(0.065, 0.105, 0.03),
"pitch_hip": -0.16,
"cheek": -0.30,
"pole_r_hip": Vector3(-0.85, -0.45, -0.10),
"pole_l_hip": Vector3(0.25, -0.85, 0.10),
"gun_stock": 0.30,
"gun_fore": 0.30,
"curl_wrap": 1.0,
"curl_trigger": 0.9,
},
# No shoulder contact at all. The pocket is pushed forward and outboard of the
# joint, which puts both arms out in front of the chest, and the support hand
# cups the firing fist instead of reaching for a barrel.
PISTOL: {
"support": SUPPORT_CUPPED,
"pocket_hip": Vector3(0.02, -0.16, 0.16),
"pocket_ads": Vector3(0.00, -0.04, 0.30),
"pitch_hip": 0.30,
"cheek": 0.05,
"pole_r_hip": Vector3(-0.45, -0.95, -0.05),
"pole_l_hip": Vector3(0.45, -0.95, -0.05),
"gun_stock": 0.03,
"gun_fore": 0.05,
"weapon_scale": 1.0,
"curl_wrap": 1.15,
"curl_trigger": 0.9,
},
# One hand. The off arm is RELEASED back to the animation, so it swings with
# the run cycle instead of gripping a handguard that does not exist — which is
# most of what makes a knife read as a knife at any distance.
BLADE: {
"support": SUPPORT_FREE,
"pocket_hip": Vector3(0.09, -0.20, 0.10),
"pocket_ads": Vector3(0.05, -0.06, 0.22),
"pitch_hip": 0.55,
"cheek": 0.0,
"pole_r_hip": Vector3(-0.35, -1.00, -0.10),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"gun_stock": 0.02,
"gun_fore": 0.0,
"weapon_scale": 1.0,
# A blade is held in a full fist — there is no trigger to keep a finger
# straight along, and an extended index on a knife handle looks like a
# mistake rather than like discipline.
"curl_wrap": 1.25,
"curl_trigger": 1.25,
},
}
## Weapon id (the script's basename) -> style.
##
## Keyed on the basename rather than on the class, so this table does not have to
## load twelve weapon scripts to be read, and so a weapon that has not been
## written yet can be listed here the moment its file exists.
const WEAPON_STYLES := {
"ak47": RIFLE,
"m4": RIFLE,
"plasma_gun": RIFLE,
"mp7": SMG,
"nail_gun": SMG,
"dmr": SNIPER,
"awp": SNIPER,
"double_barrel_shotgun": SHOTGUN,
"rocket_launcher": LAUNCHER,
"rocket_swarm": LAUNCHER,
"mortar": LAUNCHER,
"knife": BLADE,
}
## What an unlisted weapon is held as. A rifle is the safe assumption: it is the
## only style that reaches for a handguard, and a weapon with no handguard held
## as a rifle looks odd, where a rifle held as anything else looks broken.
const FALLBACK := RIFLE
## The style name for a weapon id or script path.
static func style_for(weapon: String) -> String:
var id := _id_of(weapon)
return WEAPON_STYLES.get(id, FALLBACK)
## The default knob table for a weapon — the style's values, ready to be merged
## under whatever the JSON tuning says.
##
## Returns a COPY, because the caller merges the artist's values into it and a
## shared dictionary would accumulate one character's tuning into every other's.
static func knobs_for(weapon: String) -> Dictionary:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
var out := style.duplicate(true)
# `support` and `cheek` are structural, not knobs — they are read straight
# off the style by the pose layer and must not end up in the tuning table,
# where the rig lab would offer sliders for them.
out.erase("support")
out.erase("cheek")
return out
## How the off hand is used for this weapon. See the SUPPORT_* constants.
static func support_for(weapon: String) -> String:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return style.get("support", SUPPORT_BARREL)
## How far the head comes to the stock, 0..1, negative to lean away.
static func cheek_for(weapon: String) -> float:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return float(style.get("cheek", 0.0))
## A weapon id from either an id or a `res://weapons/<id>.gd` path.
static func _id_of(weapon: String) -> String:
if weapon.ends_with(".gd"):
return weapon.get_file().get_basename()
return weapon
+1
View File
@@ -0,0 +1 @@
uid://bhfyjgkpsvb2x