Feat/fidelity pass 2 #23
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://f7600k001iqr
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhfyjgkpsvb2x
|
||||
Reference in New Issue
Block a user