diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index e515f7a..3d054cf 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -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 diff --git a/debug/travel_dir_check.gd b/debug/travel_dir_check.gd new file mode 100644 index 0000000..1892573 --- /dev/null +++ b/debug/travel_dir_check.gd @@ -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")