big
This commit is contained in:
+82
-142
@@ -1,162 +1,102 @@
|
||||
extends SceneTree
|
||||
|
||||
## Dev tool: do the LEGS point where the character is going, and does the CHEST
|
||||
## stay pointed where the player is aiming?
|
||||
## Regression for authored directional locomotion.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/travel_dir_check.gd -- [skin_glb]
|
||||
##
|
||||
## The clip library has one forward locomotion cycle and no strafe or backpedal
|
||||
## clips, so nothing in the animation itself says which way the character is
|
||||
## travelling. SkinnedPlayerModel._update_travel says it by yawing the hips onto
|
||||
## the travel direction and unwinding that yaw up the spine.
|
||||
##
|
||||
## The number that matters is LEGS OFF BY: how far the stride points from the
|
||||
## direction the character is actually travelling. A backpedal is achieved by
|
||||
## running the cycle BACKWARDS with the hips near square, so it is scored on
|
||||
## where the feet go, not on where the hips face.
|
||||
##
|
||||
## CHEST is reported for information only, and is not a pass/fail. Most of it is
|
||||
## the clip's own torso twist — a run cycle counter-rotates the chest against the
|
||||
## hips by tens of degrees twice a stride — and averaging over a stride only
|
||||
## partly removes it. It is also not what keeps the gun on target: the rifle hold
|
||||
## places the weapon from the SHOULDER and the aim direction and then solves the
|
||||
## arms onto it, so the aim survives whatever the torso is doing. Read this
|
||||
## column as "does the upper body still look square-ish", nothing stronger.
|
||||
## The old test expected one forward run to be procedurally yawed or reversed.
|
||||
## The runtime now blends separate forward/back/left/right source cycles. This
|
||||
## gate verifies that physical direction enters that authored BlendSpace, stride
|
||||
## speed stays calibrated, and the feet actually move in every cardinal case.
|
||||
|
||||
## label, strafe, forward, the direction of travel this should read as (degrees
|
||||
## about the character's up axis, 0 = straight ahead)
|
||||
## The real controller ground speed, not a convenient library-preview speed.
|
||||
## This is the failure mode the test exists to catch: the old lateral cycle was
|
||||
## technically "matched" at 5 m/s but exploded to 6.1x playback in gameplay.
|
||||
const SPEED := 11.0
|
||||
const SETTLE_FRAMES := 18
|
||||
const SAMPLE_FRAMES := 24
|
||||
const CASES := [
|
||||
["forward", 0.0, 1.0, 0.0],
|
||||
["strafe right", 1.0, 0.0, -90.0],
|
||||
["strafe left", -1.0, 0.0, 90.0],
|
||||
["fwd-right", 0.7, 0.7, -45.0],
|
||||
["fwd-left", -0.7, 0.7, 45.0],
|
||||
["backpedal", 0.0, -1.0, 180.0],
|
||||
["back-right", 0.7, -0.7, -135.0],
|
||||
["forward", 0.0, 1.0, "Sprint"],
|
||||
["backward", 0.0, -1.0, "RunBackward"],
|
||||
["left", -1.0, 0.0, "RunLeft"],
|
||||
["right", 1.0, 0.0, "RunRight"],
|
||||
["forward-right", 1.0, 1.0, ""],
|
||||
["back-left", -1.0, -1.0, ""],
|
||||
]
|
||||
const SETTLE := 70
|
||||
## Frames averaged at the end of each case — comfortably more than one stride.
|
||||
const MEASURE_OVER := 40
|
||||
|
||||
var _frames := 0
|
||||
var _model: SkinnedPlayerModel = null
|
||||
var _probe = null
|
||||
var _case := 0
|
||||
var _rows: Array = []
|
||||
var _hips_sum := 0.0
|
||||
var _chest_sum := 0.0
|
||||
var _n := 0.0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var path: String = args[0] if args.size() > 0 \
|
||||
var path: String = args[0] if not args.is_empty() \
|
||||
else "res://assets/characters/skins/taila.glb"
|
||||
|
||||
var scene := Node3D.new()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
_model = SkinnedPlayerModel.new()
|
||||
_model.model_path = path
|
||||
scene.add_child(_model)
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.model_path = path
|
||||
scene.add_child(model)
|
||||
|
||||
for _frame in 60:
|
||||
await process_frame
|
||||
if not model.loaded or model.skeleton == null:
|
||||
_expect(false, "the authored character loads")
|
||||
_done()
|
||||
return
|
||||
|
||||
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
|
||||
_expect(foot >= 0, "a foot bone resolves")
|
||||
|
||||
for test_case in CASES:
|
||||
var direction := Vector2(float(test_case[1]), float(test_case[2])).normalized()
|
||||
for _frame in SETTLE_FRAMES:
|
||||
model.set_locomotion(direction.x, direction.y, 0.0)
|
||||
model.update_state("ground", SPEED, false)
|
||||
await process_frame
|
||||
|
||||
var blend := model.locomotion_blend_debug()
|
||||
_expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999,
|
||||
"%s drives the authored BlendSpace in the physical direction"
|
||||
% String(test_case[0]))
|
||||
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.02,
|
||||
"%s authored stride matches %.1f m/s"
|
||||
% [String(test_case[0]), SPEED])
|
||||
var expected_clip := String(test_case[3])
|
||||
if not expected_clip.is_empty():
|
||||
_expect(model.current_clip_debug() == expected_clip,
|
||||
"%s selects %s" % [String(test_case[0]), expected_clip])
|
||||
|
||||
if foot >= 0:
|
||||
var start := model.skeleton.get_bone_pose_rotation(foot)
|
||||
var greatest := 0.0
|
||||
for _frame in SAMPLE_FRAMES:
|
||||
model.set_locomotion(direction.x, direction.y, 0.0)
|
||||
model.update_state("ground", SPEED, false)
|
||||
await process_frame
|
||||
greatest = maxf(greatest, start.angle_to(
|
||||
model.skeleton.get_bone_pose_rotation(foot)))
|
||||
_expect(greatest > 0.03,
|
||||
"%s uses a moving authored foot cycle (%.3f rad)"
|
||||
% [String(test_case[0]), greatest])
|
||||
|
||||
model.queue_free()
|
||||
_done()
|
||||
|
||||
|
||||
## Bone poses read from INSIDE the modifier pass — Godot restores them after it,
|
||||
## so anything read later is the animation alone with the pose layer missing.
|
||||
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 _process(_delta: float) -> bool:
|
||||
_frames += 1
|
||||
if _frames < 8 or not _model.loaded:
|
||||
return false
|
||||
var skel: Skeleton3D = _model.skeleton
|
||||
if skel == null:
|
||||
return true
|
||||
if _probe == null:
|
||||
_probe = PoseProbe.new()
|
||||
_probe.name = "TravelProbe"
|
||||
skel.add_child(_probe)
|
||||
return false
|
||||
|
||||
var c: Array = CASES[_case]
|
||||
# Run fast enough that the lean, which scales with speed, is fully in.
|
||||
_model.update_state("ground", 9.0, false)
|
||||
_model.set_locomotion(c[1], c[2], 0.0)
|
||||
# AVERAGED over the tail of each case, not sampled at one instant.
|
||||
#
|
||||
# A run cycle twists the torso against the hips by tens of degrees twice per
|
||||
# stride, all of it authored and correct. Reading a single frame therefore
|
||||
# measures wherever the clip happened to be, and the numbers swing far wider
|
||||
# than anything the pose layer does — a backpedal read as 50 degrees of chest
|
||||
# yaw with the hips almost square, which is the clip, not the layer.
|
||||
var phase: int = (_frames - 9) % SETTLE
|
||||
if phase >= SETTLE - MEASURE_OVER and _probe.pose.size() == skel.get_bone_count():
|
||||
_hips_sum += _yaw_of(skel, "hips")
|
||||
_chest_sum += _yaw_of(skel, "spine_top")
|
||||
_n += 1.0
|
||||
if phase != SETTLE - 1:
|
||||
return false
|
||||
|
||||
if _probe.pose.size() == skel.get_bone_count():
|
||||
_rows.append([c[0], _hips_sum / _n, _chest_sum / _n,
|
||||
float(c[3]), _model.stride_reversed_debug()])
|
||||
_hips_sum = 0.0
|
||||
_chest_sum = 0.0
|
||||
_n = 0.0
|
||||
_case += 1
|
||||
if _case >= CASES.size():
|
||||
_report()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Yaw of a bone away from its own rest orientation, in degrees, about the
|
||||
## character's up axis. Measured against REST rather than against the parent, so
|
||||
## it reads as "how far round from square" whatever the rig's bone axes are.
|
||||
func _yaw_of(skel: Skeleton3D, which: String) -> float:
|
||||
var roles: Dictionary = _model._rig_info.get("roles", {})
|
||||
var name := ""
|
||||
if which == "hips":
|
||||
name = String(roles.get("hips", "DEF-hips"))
|
||||
func _expect(ok: bool, description: String) -> void:
|
||||
if ok:
|
||||
print(" OK: ", description)
|
||||
else:
|
||||
var spine: Array = roles.get("spine", [])
|
||||
# The chest: the last spine bone that is not the neck or the head.
|
||||
var neck := String(roles.get("neck", ""))
|
||||
var head := String(roles.get("head", ""))
|
||||
for n in spine:
|
||||
if String(n) != neck and String(n) != head:
|
||||
name = String(n)
|
||||
var b := skel.find_bone(name)
|
||||
if b < 0:
|
||||
return 0.0
|
||||
var now: Basis = (_probe.pose[b] as Transform3D).basis
|
||||
var rest: Basis = skel.get_bone_global_rest(b).basis
|
||||
var delta := rest.inverse() * now
|
||||
# The character faces +Z; how far that axis has swung in the ground plane.
|
||||
var f: Vector3 = rest * (delta * (rest.inverse() * Vector3(0, 0, 1)))
|
||||
return rad_to_deg(atan2(f.x, f.z))
|
||||
printerr(" FAIL: ", description)
|
||||
_failures += 1
|
||||
|
||||
|
||||
func _report() -> void:
|
||||
print("\n=== does the body say which way it is going? ===")
|
||||
print(" %-13s %9s %9s %9s %s" % [
|
||||
"input", "hips", "chest", "want legs", "stride"])
|
||||
for r in _rows:
|
||||
# A reversed stride points the feet the opposite way to the hips.
|
||||
var legs: float = r[1] + (180.0 if r[4] else 0.0)
|
||||
var err: float = absf(wrapf(legs - r[3], -180.0, 180.0))
|
||||
print(" %-13s %+8.1f° %+8.1f° %+8.1f° %-8s legs off by %5.1f°%s" % [
|
||||
r[0], r[1], r[2], r[3], "reversed" if r[4] else "forward", err,
|
||||
" CHEST OFF AIM" if absf(r[2]) > 25.0 else ""])
|
||||
print("\n 'legs off by' is how far the stride points from the travel")
|
||||
print(" direction. The hips are capped (see MAX_TRAVEL_YAW), so a pure")
|
||||
print(" sidestep is expected to fall short — a shooter keeps its aim.\n")
|
||||
func _done() -> void:
|
||||
print("\n=== AUTHORED DIRECTION SUMMARY ===")
|
||||
print("Failures: %d" % _failures)
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
Reference in New Issue
Block a user