Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
8 changed files with 793 additions and 16 deletions
Showing only changes of commit a13ae50f95 - Show all commits
+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