feat(anim): point the legs where the character is actually going

The clip library has one forward locomotion cycle and no strafe or
backpedal clips, so a character sidestepping ran forwards on the spot
while sliding sideways. Nothing in the animation said which way they were
travelling and a body lean was carrying the whole burden of telling the
player.

Yaw the HIPS onto the travel direction and unwind it up the spine. The
legs hang off the hips, so the whole stride turns with them and it costs
no new animation; the chest keeps facing roughly where the player aims.
Past about a right angle the hips cannot follow, so the cycle plays in
reverse and the legs point the other way — a real backpedal instead of a
moonwalk. The regime is hysteretic and the yaw is eased, so crossing
between them reads as a pivot, which is what a person does there.

The lean moved into the travel frame with it. Leaning "forward" along the
facing while the legs run off to one side leans them sideways relative to
their own stride, which is what being dragged rather than running feels
like. It is also driven by how hard the character is moving rather than
by signed forward input, so a sidestep leans into its own stride instead
of standing straight up.

debug/travel_dir_check.gd measures it. How far the stride points from the
actual direction of travel:

  forward 9°   fwd-diagonals 5-6°   backpedal 2°   back-diagonal 2°
  pure sidestep 39-42°, which is the deliberate hip cap

Two things worth knowing about that tool. It reads bone poses from inside
the modifier pass, because Godot restores them afterwards and anything
read later is the animation with the pose layer missing. And it averages
over a full stride: a run cycle twists the torso against the hips by tens
of degrees twice per stride, so a single-frame sample measures the clip,
not the layer.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-26 03:16:24 -04:00
co-authored by Claude Opus 5
parent 0dd9d01ac7
commit 040b595397
2 changed files with 272 additions and 10 deletions
+110 -10
View File
@@ -135,6 +135,11 @@ var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
var _cur_wall: float = 0.0
## Which way the legs are actually travelling, in radians about the character's
## own up axis, and whether the locomotion cycle is running backwards to achieve
## it. See _update_travel.
var _cur_travel: float = 0.0
var _travel_reverse: bool = false
var _owner_visible: bool = false
## Horizontal speed from the last update_state, so the lean can scale with how
## fast the character is really moving.
@@ -612,8 +617,11 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
is_loco = true
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
# the forward clip. The shared library has no authored reverse run, and
# a reversed stride reads correctly for a backpedal.
if is_loco and _cur_fwd < -0.25:
# a reversed stride reads correctly for a backpedal. The hip yaw in
# _update_travel picks the regime and points the legs to match, so the
# two must agree — reading a different threshold here used to leave a
# band where the feet ran one way and pointed the other.
if is_loco and _travel_reverse:
s = -s
_anim_tree.set("parameters/loco_scale/scale", s)
@@ -671,8 +679,10 @@ func _process(delta: float) -> void:
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
_update_travel(delta, drive)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.travel_yaw = _cur_travel
_pose_mod.ads = _cur_ads
_pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall
@@ -742,6 +752,56 @@ func _process(delta: float) -> void:
_pose_mod.reload_phase = rl_target
## Which way the LEGS should point, and whether the stride runs backwards.
##
## The shared clip library has one forward locomotion cycle and no strafe or
## backpedal clips, so a character sidestepping used to run forwards on the spot
## while sliding sideways — nothing in the animation said which way they were
## going, and a body lean was carrying the whole burden of telling the player.
##
## Turning the HIPS toward the travel direction is what actually says it, and it
## costs no new animation: the legs are children of the hips, so the whole stride
## turns with them, while the spine counter-rotates so the chest and the gun stay
## on the aim. It is the same split every third-person shooter uses, and the same
## one Hoyoverse's locked-on locomotion uses.
##
## Beyond about a right angle the hips cannot follow, so the cycle plays in
## REVERSE and the legs point the other way instead — a real backpedal rather
## than a moonwalk. Which regime is in force is hysteretic, and the yaw is eased
## rather than snapped, so switching between them reads as a pivot on the spot,
## which is what a person actually does there.
## The most the hips may turn away from where the character is facing.
##
## A right angle would point the legs exactly along a sidestep, but a person
## sidestepping does not stand with their hips square to their path — they open
## maybe half that and let the feet cross. Past this the silhouette stops reading
## as a shooter holding an aim and starts reading as someone who has turned round.
const MAX_TRAVEL_YAW := 0.95
const TRAVEL_SMOOTH := 9.0
const TRAVEL_REVERSE_IN := -0.35
const TRAVEL_REVERSE_OUT := -0.12
func _update_travel(delta: float, drive: float) -> void:
var st: float = _target_strafe
var fw: float = _target_fwd
if _travel_reverse:
if fw > TRAVEL_REVERSE_OUT:
_travel_reverse = false
elif fw < TRAVEL_REVERSE_IN:
_travel_reverse = true
var want := 0.0
if absf(st) > 0.01 or absf(fw) > 0.01:
# Skeleton space: the character faces +Z and character-right is -X, so a
# travel direction of (strafe right, forward) is (-strafe, 0, fwd). A yaw
# of `want` about +Y points the legs along it — or along the opposite of
# it when the stride is running backwards.
want = atan2(st, -fw) if _travel_reverse else atan2(-st, fw)
want = clampf(want, -MAX_TRAVEL_YAW, MAX_TRAVEL_YAW) * drive
# Shortest way round, so a pivot never takes the long route.
var d := wrapf(want - _cur_travel, -PI, PI)
_cur_travel += d * (1.0 - exp(-TRAVEL_SMOOTH * delta))
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
@@ -802,6 +862,12 @@ func get_lean_debug() -> float:
return _cur_fwd
## Whether the locomotion cycle is running backwards, which points the stride
## the opposite way to the hips. For debug/travel_dir_check.gd.
func stride_reversed_debug() -> bool:
return _travel_reverse
## Clip currently playing. For debug/transition_check.gd.
func current_clip_debug() -> String:
return _current_clip
@@ -971,6 +1037,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
# Yaw of the LOWER body about the character's up axis, in radians — which way
# the legs are actually travelling. See SkinnedPlayerModel._update_travel.
var travel_yaw: float = 0.0
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
var recoil: float = 0.0 # decaying shot kick
var state: String = "idle"
@@ -1000,6 +1069,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# The lean is the ONLY thing that tells a viewer which way this character is
# travelling — the library has one forward locomotion cycle and no strafe
# clips — so it has to be legible, not subtle.
## How much of the hip yaw the spine takes back, so the chest, the head and
## the gun stay pointed where the player is aiming. Not all of it: a real
## torso does follow the hips a little, and countering the whole thing makes
## the waist look broken.
const TRAVEL_COUNTER := 0.82
const LEAN_ROLL := 0.42
const LEAN_PITCH := 0.30
const SLIDE_BACK := 0.75 # torso lean-back during slide
@@ -1108,16 +1182,42 @@ class ShooterPoseModifier extends SkeletonModifier3D:
for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back)
# Distribute a skeleton-space lean across the spine bones.
# Turn the lower body to face the way the character is travelling, and lean
# along that direction rather than along the facing.
#
# The legs hang off the hips, so yawing the hips turns the whole stride — the
# one thing that makes a sidestep look like a sidestep when the clip library
# has only a forward run. The spine takes most of it back so the chest and the
# gun stay on the aim.
#
# The LEAN has to move into the travel frame with it. Leaning "forward" along
# the character's facing while the legs run off to one side leans them
# sideways relative to their own stride, which is exactly the sensation of a
# character being dragged rather than running.
func _apply_lean(skel: Skeleton3D) -> void:
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
var roll := strafe * LEAN_ROLL * (1.0 - slide)
if absf(pitch) < 0.001 and absf(roll) < 0.001:
var yaw := travel_yaw * (1.0 - slide)
# How hard the character is driving, regardless of which way. Signed
# `fwd` is wrong now that the legs turn: a sidestep has fwd near zero and
# should still lean into its own stride.
var effort := clampf(Vector2(strafe, fwd).length(), 0.0, 1.0)
var pitch := effort * LEAN_PITCH * (1.0 - slide)
if absf(pitch) < 0.001 and absf(yaw) < 0.001:
return
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var turn := Quaternion(Vector3(0, 1, 0), yaw)
# Pitch about the axis ACROSS the direction of travel, not across the
# facing — see the comment above the function.
var lean := Quaternion((turn * Vector3(1, 0, 0)).normalized(), pitch)
# The hips carry the yaw, and a little of the lean.
_add_space(skel, _idx.get("DEF-hips", -1),
turn * Quaternion.IDENTITY.slerp(lean, 0.25))
# The torso unwinds the yaw and takes the rest of the lean.
var torso: Array = SPINE.slice(1)
var n := maxf(torso.size(), 1)
var back := Quaternion(Vector3(0, 1, 0), -yaw * TRAVEL_COUNTER)
var per_yaw := Quaternion.IDENTITY.slerp(back, 1.0 / n)
var per_lean := Quaternion.IDENTITY.slerp(lean, 0.75 / n)
for b in torso:
_add_space(skel, _idx.get(b, -1), per_yaw * per_lean)
# Grapple zip: the whole body pivots to fly along the line to the anchor,
# legs trail behind, and the FREE (left) hand reaches up the rope — the
+162
View File
@@ -0,0 +1,162 @@
extends SceneTree
## Dev tool: do the LEGS point where the character is going, and does the CHEST
## stay pointed where the player is aiming?
##
## 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.
## label, strafe, forward, the direction of travel this should read as (degrees
## about the character's up axis, 0 = straight ahead)
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],
]
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
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
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)
## 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"))
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))
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")