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
@@ -136,6 +136,31 @@ Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
|
|||||||
to isolate it — that is how the classifier gets checked: click `hair` and
|
to isolate it — that is how the classifier gets checked: click `hair` and
|
||||||
anything else still standing was misclassified.
|
anything else still standing was misclassified.
|
||||||
|
|
||||||
|
**Drag the coloured markers.** They ARE the anchor points the hands are solved
|
||||||
|
onto — red the trigger grip, green the support hand, blue the buttstock — and
|
||||||
|
the one under the mouse swells and draws through the body so it can be grabbed
|
||||||
|
where the hands would otherwise hide it.
|
||||||
|
|
||||||
|
Dragging an anchor is not the same as any slider:
|
||||||
|
|
||||||
|
| | moves |
|
||||||
|
|---|---|
|
||||||
|
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
|
||||||
|
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
|
||||||
|
| dragging a marker | the anchor itself, in three dimensions |
|
||||||
|
|
||||||
|
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
|
||||||
|
barrel, so the two hand anchors could slide up and down the gun and nowhere
|
||||||
|
else — no use for a handguard below the bore, an angled foregrip, or a pistol
|
||||||
|
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
|
||||||
|
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
|
||||||
|
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
|
||||||
|
for whichever pose is showing.
|
||||||
|
|
||||||
|
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
|
||||||
|
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
|
||||||
|
when it looks right — and measured, not assumed.
|
||||||
|
|
||||||
## Read before you touch anything
|
## Read before you touch anything
|
||||||
|
|
||||||
Load the reference that matches what you are doing. They are short and each one
|
Load the reference that matches what you are doing. They are short and each one
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ Two related traps:
|
|||||||
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
|
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
|
||||||
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
|
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
|
||||||
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
|
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
|
||||||
|
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
|
||||||
|
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
|
||||||
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
||||||
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
||||||
| `ui_capture.gd` | one photo of every menu screen | — |
|
| `ui_capture.gd` | one photo of every menu screen | — |
|
||||||
@@ -78,6 +80,14 @@ Three of these exist because the obvious check passes on a broken system.
|
|||||||
nobody consumed. It measures in the attachment's frame, not the world's:
|
nobody consumed. It measures in the attachment's frame, not the world's:
|
||||||
the attachment tracks a bone on an animating skeleton, so a world-space delta
|
the attachment tracks a bone on an animating skeleton, so a world-space delta
|
||||||
is mostly the idle animation.
|
is mostly the idle animation.
|
||||||
|
- `anchor_shift_check` and `anchor_drag_check` both measure in the GUN's frame
|
||||||
|
rather than the world's, and have to. The hold BREATHES — a
|
||||||
|
`sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
|
||||||
|
same world position twice, and comparing absolute positions reported a 3.5 mm
|
||||||
|
error that was 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.
|
||||||
- `surface_class_check` FAILS on a surface that falls through to the heuristic
|
- `surface_class_check` FAILS on a surface that falls through to the heuristic
|
||||||
instead of resolving from the table. A model whose names stopped matching still
|
instead of resolving from the table. A model whose names stopped matching still
|
||||||
renders — the fallback catches it — and quietly loses its per-class art
|
renders — the fallback catches it — and quietly loses its per-class art
|
||||||
|
|||||||
@@ -1307,6 +1307,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var dbg_grip: Vector3 = Vector3.ZERO
|
var dbg_grip: Vector3 = Vector3.ZERO
|
||||||
var dbg_fore: Vector3 = Vector3.ZERO
|
var dbg_fore: Vector3 = Vector3.ZERO
|
||||||
var dbg_stock: Vector3 = Vector3.ZERO
|
var dbg_stock: Vector3 = Vector3.ZERO
|
||||||
|
## The gun's frame this frame — across, up, along the barrel. The lab needs
|
||||||
|
## it to turn a mouse drag on an anchor marker back into the axes its knob is
|
||||||
|
## expressed in; without it, dragging left would mean something different at
|
||||||
|
## every pitch of the weapon.
|
||||||
|
var dbg_gun_basis: Basis = Basis.IDENTITY
|
||||||
|
|
||||||
func _t(key: String, fallback: float) -> float:
|
func _t(key: String, fallback: float) -> float:
|
||||||
return float(tune.get(key, fallback))
|
return float(tune.get(key, fallback))
|
||||||
@@ -1654,7 +1659,14 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var shoulder := skel.get_bone_global_pose(ua_r).origin
|
var shoulder := skel.get_bone_global_pose(ua_r).origin
|
||||||
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
|
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
|
||||||
var stock_pos := shoulder + pocket
|
var stock_pos := shoulder + pocket
|
||||||
var grip_pos := stock_pos + aim_dir * gun_stock
|
# The gun's own frame: across, up, along the barrel. The hand anchors are
|
||||||
|
# nudged in THIS rather than in skeleton space so a sideways offset stays
|
||||||
|
# sideways relative to the weapon as it pitches from low ready to ADS,
|
||||||
|
# instead of sliding around the gun as it tips.
|
||||||
|
var gun_basis := Basis(side, gun_up, aim_dir)
|
||||||
|
dbg_gun_basis = gun_basis
|
||||||
|
var grip_pos := stock_pos + aim_dir * gun_stock \
|
||||||
|
+ gun_basis * _tv("grip_shift", Vector3.ZERO)
|
||||||
# The support hand rides as far out the handguard as it can actually
|
# The support hand rides as far out the handguard as it can actually
|
||||||
# REACH. Without this a long rifle puts the foregrip past the left
|
# REACH. Without this a long rifle puts the foregrip past the left
|
||||||
# arm's limit and the IK yanks the whole arm out straight.
|
# arm's limit and the IK yanks the whole arm out straight.
|
||||||
@@ -1677,7 +1689,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
fore_dist = maxf(fore_dist * 0.8, floor_fore)
|
fore_dist = maxf(fore_dist * 0.8, floor_fore)
|
||||||
if fore_dist <= floor_fore:
|
if fore_dist <= floor_fore:
|
||||||
break
|
break
|
||||||
var fore_pos := grip_pos + aim_dir * fore_dist
|
var fore_pos := grip_pos + aim_dir * fore_dist \
|
||||||
|
+ gun_basis * _tv("fore_shift", Vector3.ZERO)
|
||||||
dbg_grip = grip_pos
|
dbg_grip = grip_pos
|
||||||
dbg_fore = fore_pos
|
dbg_fore = fore_pos
|
||||||
dbg_stock = stock_pos
|
dbg_stock = stock_pos
|
||||||
|
|||||||
@@ -52,6 +52,25 @@ const KNOBS := [
|
|||||||
Vector3(0.03, -0.07, 0.06)],
|
Vector3(0.03, -0.07, 0.06)],
|
||||||
["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true,
|
["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true,
|
||||||
Vector3(0.05, 0.01, 0.07)],
|
Vector3(0.05, 0.01, 0.07)],
|
||||||
|
# The two hand anchors, off the barrel line.
|
||||||
|
#
|
||||||
|
# `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a
|
||||||
|
# long time that was the only freedom either anchor had: the trigger hand
|
||||||
|
# could slide up and down the gun's own axis and nowhere else, and so could
|
||||||
|
# the support hand. That is fine for where along a handguard to hold, and
|
||||||
|
# useless for a handguard that sits below the bore, an angled foregrip, or a
|
||||||
|
# pistol whose grip is nowhere near its barrel line.
|
||||||
|
#
|
||||||
|
# These are in the GUN's frame — x across, y up, z along the barrel — so they
|
||||||
|
# stay meaningful as the weapon pitches between low ready and ADS. Zero is
|
||||||
|
# exactly the old behaviour. Their z overlaps `gun_stock`/`gun_fore`, which
|
||||||
|
# is redundant but harmless, and keeping the along-axis distances separate is
|
||||||
|
# what lets the reach solver slide the support hand back down the handguard
|
||||||
|
# without also undoing a deliberate sideways nudge.
|
||||||
|
["grip_shift", "Trigger-hand anchor, off the barrel line", -0.15, 0.15, true,
|
||||||
|
Vector3.ZERO],
|
||||||
|
["fore_shift", "Support-hand anchor, off the barrel line", -0.15, 0.15, true,
|
||||||
|
Vector3.ZERO],
|
||||||
# Zero means "use the code's own hip/ADS blend" for these two — see _tv in
|
# Zero means "use the code's own hip/ADS blend" for these two — see _tv in
|
||||||
# ShooterPoseModifier, which treats a zero-length vector as unset.
|
# ShooterPoseModifier, which treats a zero-length vector as unset.
|
||||||
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Does dragging an anchor marker in the rig lab move that anchor where the
|
||||||
|
## mouse went?
|
||||||
|
##
|
||||||
|
## The maths behind a viewport drag has four frames in it — screen, world,
|
||||||
|
## skeleton, gun — and every one is a chance to transpose an inverse or lose a
|
||||||
|
## handedness. All of those mistakes still MOVE the marker, so "the number
|
||||||
|
## changed" proves nothing. What is asserted here is that the number changed by
|
||||||
|
## the RIGHT AMOUNT, in the frame that knob is written in, derived independently
|
||||||
|
## from the camera.
|
||||||
|
##
|
||||||
|
## Deliberately NOT asserted: that the marker lands exactly under the mouse. It
|
||||||
|
## does not, and the reason is a real property of the hold rather than a bug in
|
||||||
|
## the drag — see THE FEEDBACK below. The screen-space check kept here is a
|
||||||
|
## direction-and-order-of-magnitude one, which is the band the transform
|
||||||
|
## mistakes above actually live in: a swapped axis or a lost handedness sends
|
||||||
|
## the marker the wrong way entirely.
|
||||||
|
##
|
||||||
|
## THE FEEDBACK. Every anchor hangs off the shoulder — `stock_pos = shoulder +
|
||||||
|
## pocket`, and the grip and fore anchors are measured out from there. The
|
||||||
|
## shoulder is driven by the arm, and the arm is chasing the anchor. So moving
|
||||||
|
## an anchor moves the shoulder, which moves the anchor again. Measured from a
|
||||||
|
## clean slate it settles at 0.77x-1.13x of the drag depending on which anchor,
|
||||||
|
## which is small enough to be invisible interactively — you stop dragging when
|
||||||
|
## it looks right.
|
||||||
|
##
|
||||||
|
## It is worth the paragraph because of how it first showed up. Without the
|
||||||
|
## `_reset` between cases below, each drag started on top of the last one still
|
||||||
|
## working its way through the arm, and the ratio read 1.5x-1.8x; 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 the compounding
|
||||||
|
## away.
|
||||||
|
##
|
||||||
|
## godot --path . -s res://debug/anchor_drag_check.gd
|
||||||
|
|
||||||
|
const LAB := "res://debug/rig_lab.tscn"
|
||||||
|
const DRAG := Vector2(60, 0)
|
||||||
|
## Metres. What the knob is checked to — this part is exact maths, so it can be.
|
||||||
|
const KNOB_TOLERANCE := 0.0005
|
||||||
|
## The screen-space check is direction and order of magnitude only. See above.
|
||||||
|
const MIN_TRAVEL := 0.5
|
||||||
|
const MAX_TRAVEL := 2.2
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
await process_frame
|
||||||
|
var lab: Node = load(LAB).instantiate()
|
||||||
|
root.add_child(lab)
|
||||||
|
# The pose layer chases its targets exponentially at a rate times DELTA, and
|
||||||
|
# a headless run is uncapped, so each frame advances the blend by almost
|
||||||
|
# nothing and the hold takes hundreds of frames to stop moving on its own.
|
||||||
|
for _i in 300:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if lab._model == null or not lab._model.loaded or lab._model._pose_mod == null:
|
||||||
|
_expect(false, "the lab built a character with a pose layer")
|
||||||
|
_done()
|
||||||
|
return
|
||||||
|
_expect(true, "the lab built a character with a pose layer")
|
||||||
|
|
||||||
|
# Frame the hands, so a pixel is a small distance in the world — a drag
|
||||||
|
# measured at arm's length is mostly noise.
|
||||||
|
lab._pivot = lab._model.skeleton.global_transform * lab._model._pose_mod.dbg_grip
|
||||||
|
lab._dist = 0.6
|
||||||
|
lab._update_camera()
|
||||||
|
for _i in 20:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
for case in [[0, "trigger hand", "grip_shift"], [1, "support hand", "fore_shift"],
|
||||||
|
[2, "buttstock", "pocket_hip"]]:
|
||||||
|
await _drag_case(lab, case[0], case[1], case[2])
|
||||||
|
_done()
|
||||||
|
|
||||||
|
|
||||||
|
func _drag_case(lab: Node, marker: int, label: String, key: String) -> void:
|
||||||
|
# From a clean slate each time, or the second case measures the first's
|
||||||
|
# shift still working its way through the arm.
|
||||||
|
lab._reset("hold")
|
||||||
|
for _i in 120:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var m: Node3D = lab._markers[marker]
|
||||||
|
if not m.visible:
|
||||||
|
_expect(false, "the %s marker is visible" % label)
|
||||||
|
return
|
||||||
|
var before: Vector2 = lab._cam.unproject_position(m.global_position)
|
||||||
|
_expect(lab._marker_under(before) == marker,
|
||||||
|
"the %s marker is grabbable where it is drawn" % label)
|
||||||
|
|
||||||
|
# What the drag SHOULD write, worked out from the camera here rather than
|
||||||
|
# from the lab's own code, so the two have to agree independently.
|
||||||
|
var want: Vector3 = _expected(lab, marker, m.global_position, before, before + DRAG)
|
||||||
|
var was: Vector3 = _knob(lab, key)
|
||||||
|
|
||||||
|
lab._begin_drag(marker, before)
|
||||||
|
# In steps, as a real drag arrives — a single jump would hide an error that
|
||||||
|
# accumulates per motion event.
|
||||||
|
for step in 6:
|
||||||
|
lab._drag_to(before + DRAG * (float(step + 1) / 6.0))
|
||||||
|
await process_frame
|
||||||
|
lab._drag_marker = -1
|
||||||
|
|
||||||
|
var wrote: Vector3 = _knob(lab, key) - was
|
||||||
|
var err: float = (wrote - want).length()
|
||||||
|
_expect(err <= KNOB_TOLERANCE,
|
||||||
|
"the %s drag wrote %s into '%s' (wanted %s, off by %.2f mm)"
|
||||||
|
% [label, _mm(wrote), key, _mm(want), err * 1000.0])
|
||||||
|
|
||||||
|
# ...and the marker really did go that way on screen.
|
||||||
|
for _i in 150:
|
||||||
|
await process_frame
|
||||||
|
var now: Vector2 = lab._cam.unproject_position(m.global_position)
|
||||||
|
var moved: Vector2 = now - before
|
||||||
|
var along: float = moved.dot(DRAG.normalized()) / DRAG.length()
|
||||||
|
_expect(along >= MIN_TRAVEL and along <= MAX_TRAVEL,
|
||||||
|
"the %s marker followed the drag (%.2fx of it; the shoulder feedback puts this over 1)"
|
||||||
|
% [label, along])
|
||||||
|
|
||||||
|
|
||||||
|
## The knob delta a drag from `a` to `b` ought to produce, in that knob's frame.
|
||||||
|
func _expected(lab: Node, marker: int, at: Vector3, a: Vector2, b: Vector2) -> Vector3:
|
||||||
|
var world := _plane(lab._cam, at, b) - _plane(lab._cam, at, a)
|
||||||
|
var v: Vector3 = lab._model.skeleton.global_transform.basis.inverse() * world
|
||||||
|
if lab.MARKER_KNOB[marker][1] == "gun":
|
||||||
|
v = lab._model._pose_mod.dbg_gun_basis.inverse() * v
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
func _plane(cam: Camera3D, at: Vector3, mouse: Vector2) -> Vector3:
|
||||||
|
var origin := cam.project_ray_origin(mouse)
|
||||||
|
var dir := cam.project_ray_normal(mouse)
|
||||||
|
var n := -cam.global_transform.basis.z
|
||||||
|
return origin + dir * (((at - origin).dot(n)) / dir.dot(n))
|
||||||
|
|
||||||
|
|
||||||
|
## An absent knob reads as its DEFAULT, not as zero.
|
||||||
|
##
|
||||||
|
## Those are the same thing for `grip_shift` and `fore_shift` and not for
|
||||||
|
## `pocket_hip`, whose default is (30, -70, 60) mm. Reading it as zero made a
|
||||||
|
## perfectly correct 60 px drag look like a 97 mm error — the difference was
|
||||||
|
## exactly the default. The lab has a note about this trap in `_reset`; it is
|
||||||
|
## just as easy to walk into from a test.
|
||||||
|
func _knob(lab: Node, key: String) -> Vector3:
|
||||||
|
var v = lab._knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
|
||||||
|
return v if v is Vector3 else Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _mm(v: Vector3) -> String:
|
||||||
|
return "(%.0f, %.0f, %.0f) mm" % [v.x * 1000.0, v.y * 1000.0, v.z * 1000.0]
|
||||||
|
|
||||||
|
|
||||||
|
func _expect(ok: bool, what: String) -> void:
|
||||||
|
if ok:
|
||||||
|
print(" OK: %s" % what)
|
||||||
|
else:
|
||||||
|
print(" FAIL: %s" % what)
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
|
||||||
|
func _done() -> void:
|
||||||
|
print("\n=== ANCHOR DRAG ===\nFailures: %d" % _fails)
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cmh6vowe1f6d5
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://colk7su4vk56m
|
||||||
+190
-9
@@ -28,14 +28,27 @@ extends Node3D
|
|||||||
## for the game to run correctly.
|
## for the game to run correctly.
|
||||||
##
|
##
|
||||||
## CONTROLS
|
## CONTROLS
|
||||||
## left drag orbit wheel zoom
|
## drag a MARKER move that anchor left drag orbit
|
||||||
## middle drag pan F frame the hands
|
## middle drag pan wheel zoom
|
||||||
## R reset knobs S save C copy JSON to clipboard
|
## F frame the hands
|
||||||
|
## R reset knobs S save C copy JSON to clipboard
|
||||||
##
|
##
|
||||||
## The three coloured markers are the points being solved for — red is the
|
## The three coloured markers ARE the anchors — red is the trigger grip, green
|
||||||
## trigger grip, green the support hand on the handguard, blue the buttstock. If
|
## the support hand on the handguard, blue the buttstock — and they can be
|
||||||
## a hand is not on its marker the IK could not reach, which is a different
|
## dragged. The marker under the mouse swells; drag it and the hand follows.
|
||||||
## problem from the marker being in the wrong place.
|
##
|
||||||
|
## Dragging an anchor is not the same as any slider above it. The sliders move
|
||||||
|
## the GUN: `grip_offset` slides the weapon around inside the fist, and
|
||||||
|
## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither
|
||||||
|
## can take a hand OFF that axis, which is what a handguard below the bore, an
|
||||||
|
## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or
|
||||||
|
## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways
|
||||||
|
## stays sideways as the weapon pitches; the buttstock marker writes the shoulder
|
||||||
|
## pocket for whichever pose is showing.
|
||||||
|
##
|
||||||
|
## If a hand is not ON its marker, the IK could not reach — a different problem
|
||||||
|
## from the marker being in the wrong place, and dragging the marker further will
|
||||||
|
## not fix it.
|
||||||
|
|
||||||
const POSES := [
|
const POSES := [
|
||||||
["Low ready", "ground", 0.0, 0.0],
|
["Low ready", "ground", 0.0, 0.0],
|
||||||
@@ -162,7 +175,8 @@ func _build_world() -> void:
|
|||||||
# Depth-tested on purpose. Drawn through the body they look like they are
|
# Depth-tested on purpose. Drawn through the body they look like they are
|
||||||
# floating in front of the chest when they are in fact behind an arm,
|
# floating in front of the chest when they are in fact behind an arm,
|
||||||
# which is exactly the wrong impression for judging whether a hand is on
|
# which is exactly the wrong impression for judging whether a hand is on
|
||||||
# its target.
|
# its target. `_process` lifts this for the marker under the mouse, so
|
||||||
|
# the one you are about to grab is the one you can see.
|
||||||
mat.no_depth_test = false
|
mat.no_depth_test = false
|
||||||
m.material_override = mat
|
m.material_override = mat
|
||||||
add_child(m)
|
add_child(m)
|
||||||
@@ -269,6 +283,155 @@ func _process(_delta: float) -> void:
|
|||||||
for i in _markers.size():
|
for i in _markers.size():
|
||||||
_markers[i].global_position = to_world * pts[i]
|
_markers[i].global_position = to_world * pts[i]
|
||||||
_markers[i].visible = pts[i] != Vector3.ZERO
|
_markers[i].visible = pts[i] != Vector3.ZERO
|
||||||
|
# The one being dragged, or the one the mouse is over, swells AND
|
||||||
|
# draws through the body.
|
||||||
|
#
|
||||||
|
# Depth testing is right for the other two: drawn through the mesh
|
||||||
|
# they look like they are floating in front of the chest when they
|
||||||
|
# are in fact behind an arm, which is the wrong impression for
|
||||||
|
# judging whether a hand reached its target. But it is wrong for the
|
||||||
|
# one being grabbed — at any useful framing the hands occlude all
|
||||||
|
# three markers, and a handle you cannot see is a handle you cannot
|
||||||
|
# find. `_marker_under` never cared about occlusion; this makes that
|
||||||
|
# visible rather than a secret.
|
||||||
|
var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker)
|
||||||
|
_markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0)
|
||||||
|
var mat := _markers[i].material_override as StandardMaterial3D
|
||||||
|
if mat:
|
||||||
|
mat.no_depth_test = hot
|
||||||
|
|
||||||
|
|
||||||
|
# ── dragging the anchors ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The markers are the anchor POINTS the hands are solved onto. Until now they
|
||||||
|
# could only be moved through the sliders, and two of the three could only move
|
||||||
|
# along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own
|
||||||
|
# axis, so the trigger and support hands slid up and down the weapon and nowhere
|
||||||
|
# else. What the sliders DID move freely was the gun itself, under anchors that
|
||||||
|
# stayed put.
|
||||||
|
#
|
||||||
|
# So: grab a marker and drag it. Each one writes to the knob that expresses that
|
||||||
|
# motion, in the frame that knob is written in — which is why the pose layer now
|
||||||
|
# publishes the gun's basis. A drag left has to mean "left across the weapon"
|
||||||
|
# whether the muzzle is down at low ready or level at ADS.
|
||||||
|
|
||||||
|
## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along
|
||||||
|
## basis; "skeleton" is the model's, which is where the shoulder pocket lives.
|
||||||
|
const MARKER_KNOB := [
|
||||||
|
["grip_shift", "gun"],
|
||||||
|
["fore_shift", "gun"],
|
||||||
|
["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose
|
||||||
|
]
|
||||||
|
## How close, in pixels, the mouse has to be to a marker to take hold of it.
|
||||||
|
const GRAB_RADIUS := 26.0
|
||||||
|
|
||||||
|
var _drag_marker := -1
|
||||||
|
var _hover_marker := -1
|
||||||
|
## The plane a drag is measured on: fixed at grab time so it does not drift
|
||||||
|
## toward the camera as the marker follows the mouse.
|
||||||
|
var _drag_plane_at := Vector3.ZERO
|
||||||
|
var _drag_last := Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
## The stock anchor is a blend of two knobs; a drag edits whichever one this
|
||||||
|
## pose is actually showing. Dragging at low ready must not silently rewrite the
|
||||||
|
## aiming pocket.
|
||||||
|
func _stock_knob() -> String:
|
||||||
|
return "pocket_ads" if POSES[_pose][3] > 0.5 else "pocket_hip"
|
||||||
|
|
||||||
|
|
||||||
|
func _knob_for(marker: int) -> String:
|
||||||
|
var key: String = MARKER_KNOB[marker][0]
|
||||||
|
return _stock_knob() if key == "pocket" else key
|
||||||
|
|
||||||
|
|
||||||
|
func _marker_under(mouse: Vector2) -> int:
|
||||||
|
var best := -1
|
||||||
|
var best_d := GRAB_RADIUS
|
||||||
|
for i in _markers.size():
|
||||||
|
if not _markers[i].visible:
|
||||||
|
continue
|
||||||
|
var p: Vector3 = _markers[i].global_position
|
||||||
|
# Behind the camera projects to a nonsense point that can still land
|
||||||
|
# within the grab radius.
|
||||||
|
if _cam.is_position_behind(p):
|
||||||
|
continue
|
||||||
|
var d := _cam.unproject_position(p).distance_to(mouse)
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
best = i
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
## Where the mouse ray meets the drag plane — through the grab point, facing the
|
||||||
|
## camera.
|
||||||
|
func _plane_hit(mouse: Vector2) -> Vector3:
|
||||||
|
var origin := _cam.project_ray_origin(mouse)
|
||||||
|
var dir := _cam.project_ray_normal(mouse)
|
||||||
|
var n := -_cam.global_transform.basis.z
|
||||||
|
var denom := dir.dot(n)
|
||||||
|
if absf(denom) < 1e-5:
|
||||||
|
return _drag_last
|
||||||
|
return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom)
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_drag(marker: int, mouse: Vector2) -> void:
|
||||||
|
_drag_marker = marker
|
||||||
|
_drag_plane_at = _markers[marker].global_position
|
||||||
|
_drag_last = _plane_hit(mouse)
|
||||||
|
_status.text = "Dragging the %s anchor (%s)" % [
|
||||||
|
["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)]
|
||||||
|
|
||||||
|
|
||||||
|
func _drag_to(mouse: Vector2) -> void:
|
||||||
|
if _model == null or _model._pose_mod == null or _model.skeleton == null:
|
||||||
|
return
|
||||||
|
var now := _plane_hit(mouse)
|
||||||
|
var world_delta := now - _drag_last
|
||||||
|
_drag_last = now
|
||||||
|
|
||||||
|
# World -> skeleton, because that is the space the pose layer works in.
|
||||||
|
var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta
|
||||||
|
if MARKER_KNOB[_drag_marker][1] == "gun":
|
||||||
|
# ...and on into the gun's frame for the two hand anchors. Orthonormal,
|
||||||
|
# so the inverse is the transpose, but say what is meant.
|
||||||
|
delta = _model._pose_mod.dbg_gun_basis.inverse() * delta
|
||||||
|
|
||||||
|
var key := _knob_for(_drag_marker)
|
||||||
|
var spec := _spec_for("hold", key)
|
||||||
|
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
|
||||||
|
if not (cur is Vector3):
|
||||||
|
cur = Vector3.ZERO
|
||||||
|
var lo: float = spec[2]
|
||||||
|
var hi: float = spec[3]
|
||||||
|
_set_knob("hold", key, Vector3(
|
||||||
|
clampf(cur.x + delta.x, lo, hi),
|
||||||
|
clampf(cur.y + delta.y, lo, hi),
|
||||||
|
clampf(cur.z + delta.z, lo, hi)))
|
||||||
|
|
||||||
|
|
||||||
|
func _spec_for(group: String, key: String) -> Array:
|
||||||
|
for spec in _specs(group):
|
||||||
|
if spec[0] == key:
|
||||||
|
return spec
|
||||||
|
return ["", "", -1.0, 1.0, true, Vector3.ZERO]
|
||||||
|
|
||||||
|
|
||||||
|
## Write a knob from somewhere other than its own slider, and keep the slider in
|
||||||
|
## step. Without the write-back the sliders would silently disagree with the
|
||||||
|
## model the moment anything was dragged, and Save would store the sliders.
|
||||||
|
func _set_knob(group: String, key: String, value) -> void:
|
||||||
|
_knobs[group][key] = value
|
||||||
|
var entry = _sliders[group].get(key)
|
||||||
|
if entry != null:
|
||||||
|
if entry.spec[4]:
|
||||||
|
entry.x.set_value_no_signal(value.x)
|
||||||
|
entry.y.set_value_no_signal(value.y)
|
||||||
|
entry.z.set_value_no_signal(value.z)
|
||||||
|
else:
|
||||||
|
entry.x.set_value_no_signal(float(value))
|
||||||
|
_refresh_label(group, key)
|
||||||
|
_push(group)
|
||||||
|
|
||||||
|
|
||||||
# ── knobs ─────────────────────────────────────────────────────────────────────
|
# ── knobs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -406,7 +569,7 @@ func _build_ui() -> void:
|
|||||||
_status = Label.new()
|
_status = Label.new()
|
||||||
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
_status.custom_minimum_size = Vector2(400, 40)
|
_status.custom_minimum_size = Vector2(400, 40)
|
||||||
_status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands"
|
_status.text = "DRAG A MARKER to move that anchor · left drag orbit · wheel zoom · middle drag pan · F frame hands"
|
||||||
box.add_child(_status)
|
box.add_child(_status)
|
||||||
# Reflect whatever the shot arguments or the defaults selected, or the
|
# Reflect whatever the shot arguments or the defaults selected, or the
|
||||||
# dropdown says one thing while the scene shows another.
|
# dropdown says one thing while the scene shows another.
|
||||||
@@ -581,6 +744,14 @@ func _update_camera() -> void:
|
|||||||
|
|
||||||
func _unhandled_input(e: InputEvent) -> void:
|
func _unhandled_input(e: InputEvent) -> void:
|
||||||
if e is InputEventMouseMotion:
|
if e is InputEventMouseMotion:
|
||||||
|
# An anchor drag OWNS the left button — otherwise grabbing a marker
|
||||||
|
# would also orbit the camera, and the anchor would appear to move
|
||||||
|
# because the view did.
|
||||||
|
if _drag_marker >= 0:
|
||||||
|
_drag_to(e.position)
|
||||||
|
return
|
||||||
|
_hover_marker = _marker_under(e.position) \
|
||||||
|
if e.button_mask == 0 else _hover_marker
|
||||||
if e.button_mask & MOUSE_BUTTON_MASK_LEFT:
|
if e.button_mask & MOUSE_BUTTON_MASK_LEFT:
|
||||||
_yaw -= e.relative.x * 0.006
|
_yaw -= e.relative.x * 0.006
|
||||||
_pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4)
|
_pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4)
|
||||||
@@ -589,6 +760,16 @@ func _unhandled_input(e: InputEvent) -> void:
|
|||||||
var b := _cam.global_transform.basis
|
var b := _cam.global_transform.basis
|
||||||
_pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015
|
_pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015
|
||||||
_update_camera()
|
_update_camera()
|
||||||
|
elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if e.pressed:
|
||||||
|
var hit := _marker_under(e.position)
|
||||||
|
if hit >= 0:
|
||||||
|
_begin_drag(hit, e.position)
|
||||||
|
elif _drag_marker >= 0:
|
||||||
|
_status.text = "%s is now %s — Save to keep it" % [
|
||||||
|
_knob_for(_drag_marker),
|
||||||
|
_knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)]
|
||||||
|
_drag_marker = -1
|
||||||
elif e is InputEventMouseButton and e.pressed:
|
elif e is InputEventMouseButton and e.pressed:
|
||||||
if e.button_index == MOUSE_BUTTON_WHEEL_UP:
|
if e.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||||
_dist = maxf(_dist * 0.9, 0.15)
|
_dist = maxf(_dist * 0.9, 0.15)
|
||||||
|
|||||||
Reference in New Issue
Block a user