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:
co-authored by
Claude Opus 5
parent
c4bcbc7fd1
commit
a13ae50f95
@@ -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
|
||||
Reference in New Issue
Block a user