feat(rig lab): drag the anchors themselves, not just the gun under them
The lab could move the GUN and not the anchor points the hands are solved onto. `grip_offset` slides the weapon around inside the fist; `gun_fore` and `gun_stock` are distances ALONG the barrel, so the trigger and support hands could travel up and down the weapon's own axis and nowhere else. Nothing could take a hand off that axis, which is what a handguard below the bore, an angled foregrip, or a pistol whose grip is nowhere near its barrel line all need. Two things fix that. `grip_shift` and `fore_shift` give the two hand anchors real three-dimensional freedom, expressed in the GUN's own across/up/along frame so a sideways nudge stays sideways as the weapon pitches between low ready and ADS. Zero is exactly the old behaviour. Their z overlaps the along-axis distances, which is redundant and deliberate: keeping those separate is what lets the reach solver slide the support hand back down the handguard without undoing a considered sideways offset. And the markers are now draggable. They already showed the anchors; now they are handles. The one under the mouse swells and draws through the body — depth testing is right for judging whether a hand reached its target and wrong for a handle, because at any useful framing the hands occlude all three. Verified three ways, and each one had to be rebuilt once: anchor_shift_check first compared absolute positions and reported a 3.5 mm error that was the character BREATHING — there is a sin() on the muzzle pitch, so no anchor is ever in the same place twice. Measuring each anchor relative to the one it hangs off, rotated into the current gun basis, cancels the breathing, the ADS blend and the recoil exactly. 48 checks, six characters, both poses. anchor_drag_check asserts the drag writes the knob the MOUSE asked for, derived independently from the camera: 0.00-0.01 mm on all three. It does not assert the marker lands under the cursor, because it does not — the anchors hang off the shoulder and the arm chasing them moves the shoulder, so a drag settles at 0.77x-1.13x. Small enough to ignore interactively. That feedback first read as 1.5x-1.8x, because the cases were compounding on each other, and waiting LONGER for the pose to settle made it worse rather than better — which is the opposite of how a settling error behaves and is what gave it away. The buttstock case also failed for a while on a bug entirely in the test: it read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The lab has a note about that trap in `_reset`. It is just as easy to walk into from a test, and now has one there too. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
700d0925d7
commit
b1c8bab714
@@ -0,0 +1,140 @@
|
||||
extends SceneTree
|
||||
|
||||
## Do the hand anchors move where they are told, in the frame they are told in?
|
||||
##
|
||||
## `grip_shift` and `fore_shift` exist because the two hand anchors could only
|
||||
## ever slide along the barrel: `gun_stock` and `gun_fore` are distances along
|
||||
## the weapon's own axis, so the trigger and support hands travelled up and down
|
||||
## the gun and nowhere else. What could move freely was the GUN, under anchors
|
||||
## that stayed put.
|
||||
##
|
||||
## Two things have to hold, and only the first is obvious:
|
||||
##
|
||||
## 1. the anchor moves by the amount asked for;
|
||||
## 2. it moves in the GUN's frame, not the skeleton's. A sideways nudge has to
|
||||
## stay sideways relative to the weapon whether the muzzle is pitched down
|
||||
## at low ready or level at ADS — otherwise the same number means two
|
||||
## different places in the two poses, and a skeleton-space implementation
|
||||
## passes check 1 happily.
|
||||
##
|
||||
## MEASURED IN THE GUN'S FRAME, and it has to be. The hold BREATHES — there is a
|
||||
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
|
||||
## same world position twice, and the first version of this check compared
|
||||
## absolute positions and reported a 3.5 mm error that was just the character
|
||||
## inhaling. Taking each anchor relative to the one it hangs off and rotating
|
||||
## into the current gun basis cancels the breathing, the ADS blend and the
|
||||
## recoil kick exactly, because all three move the basis and the anchor together.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/anchor_shift_check.gd
|
||||
|
||||
## All three axes, deliberately asymmetric, so an axis swap or a sign flip
|
||||
## cannot pass.
|
||||
const SHIFT := Vector3(0.05, -0.03, 0.02)
|
||||
const TOLERANCE := 0.0015
|
||||
## Long enough for the ADS blend and the hold's take-up to settle. The gun-frame
|
||||
## measurement is invariant to both, but a half-blended pose is a bad place to
|
||||
## be reading anything.
|
||||
const SETTLE := 40
|
||||
|
||||
var _fails := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
await process_frame
|
||||
await process_frame
|
||||
var weapon := _first_weapon()
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(
|
||||
"res://assets/characters/skins/skins.json"))
|
||||
for entry in data["skins"]:
|
||||
await _check(entry["id"], entry.get("model", ""), weapon)
|
||||
print("\n=== ANCHOR SHIFTS ===\nFailures: %d" % _fails)
|
||||
quit(1 if _fails > 0 else 0)
|
||||
|
||||
|
||||
func _first_weapon() -> String:
|
||||
var db = root.get_node("LoadoutManager").weapon_db
|
||||
var ids: Array = db.keys()
|
||||
ids.sort()
|
||||
for id in ids:
|
||||
var s: String = db[id].get("script", "")
|
||||
if s != "" and ResourceLoader.exists(s):
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
## The two anchors in the gun's own across/up/along frame:
|
||||
## grip, relative to the buttstock == (0, 0, gun_stock) + grip_shift
|
||||
## fore, relative to the grip == (0, 0, fore_dist) + fore_shift
|
||||
func _local(pm) -> Array:
|
||||
var inv: Basis = pm.dbg_gun_basis.inverse()
|
||||
var grip: Vector3 = pm.dbg_grip
|
||||
var fore: Vector3 = pm.dbg_fore
|
||||
var stock: Vector3 = pm.dbg_stock
|
||||
return [inv * (grip - stock), inv * (fore - grip)]
|
||||
|
||||
|
||||
func _check(id: String, path: String, weapon: String) -> void:
|
||||
if path == "" or not ResourceLoader.exists(path):
|
||||
return
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.model_path = path
|
||||
model.skin_id = id
|
||||
root.add_child(model)
|
||||
for _i in 4:
|
||||
await process_frame
|
||||
model.set_weapon(weapon)
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
|
||||
# Both poses, because the gun's pitch differs between them and that is the
|
||||
# whole point of expressing the shift in the gun's frame.
|
||||
for pose in [["low ready", 0.0], ["ADS", 1.0]]:
|
||||
var pm = model._pose_mod
|
||||
if pm == null:
|
||||
_expect(false, "'%s' has a pose layer" % id)
|
||||
break
|
||||
model.set_hold_tuning({})
|
||||
model.update_state("ground", 0.0, false)
|
||||
model.set_locomotion(0.0, 0.0, pose[1])
|
||||
for _i in SETTLE:
|
||||
await process_frame
|
||||
var base: Array = _local(pm)
|
||||
|
||||
# Each anchor on its own. `fore` hangs off `grip`, so shifting the grip
|
||||
# legitimately carries the support hand with it — moving where the
|
||||
# trigger hand holds a rifle moves the whole rifle, handguard included.
|
||||
# Testing them together would just measure that, and the first version
|
||||
# of this check did, and reported the sum as a 2x error.
|
||||
for which in [["grip_shift", 0, "trigger"], ["fore_shift", 1, "support"]]:
|
||||
model.set_hold_tuning({which[0]: SHIFT})
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
var now: Array = _local(pm)
|
||||
var moved: Vector3 = now[which[1]] - base[which[1]]
|
||||
# x and y are across the barrel — the freedom that did not exist
|
||||
# before. z is along it, and for the support hand the reach solver
|
||||
# owns that, so it is not ours to predict.
|
||||
var across := Vector2(moved.x, moved.y)
|
||||
var want := Vector2(SHIFT.x, SHIFT.y)
|
||||
_expect(across.distance_to(want) <= TOLERANCE,
|
||||
"'%s' %s: %s anchor moved %.0f, %.0f mm across the barrel (wanted %.0f, %.0f)"
|
||||
% [id, pose[0], which[2], across.x * 1000.0, across.y * 1000.0,
|
||||
want.x * 1000.0, want.y * 1000.0])
|
||||
|
||||
model.set_hold_tuning({})
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
var back: Array = _local(pm)
|
||||
var residue: Vector3 = back[which[1]] - base[which[1]]
|
||||
_expect(Vector2(residue.x, residue.y).length() <= TOLERANCE,
|
||||
"'%s' %s: clearing %s restores the derived anchor"
|
||||
% [id, pose[0], which[0]])
|
||||
model.queue_free()
|
||||
|
||||
|
||||
func _expect(ok: bool, what: String) -> void:
|
||||
if ok:
|
||||
print(" OK: %s" % what)
|
||||
else:
|
||||
print(" FAIL: %s" % what)
|
||||
_fails += 1
|
||||
Reference in New Issue
Block a user