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