This commit is contained in:
Nicholas Butzke
2026-08-09 01:31:44 -04:00
parent 922983429e
commit 9358746582
43 changed files with 4324 additions and 481 deletions
+222 -48
View File
@@ -1,6 +1,8 @@
extends Node3D
class_name SkinnedPlayerModel
const MECHA_KIT_SCRIPT := preload("res://characters/anime_mecha_kit.gd")
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
## with a Mixamo-compatible skeleton and the canonical animation set.
##
@@ -18,6 +20,7 @@ class_name SkinnedPlayerModel
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@export var mecha_theme: String = ""
@export var first_person_mode: bool = false
@export var shadows_only: bool = false
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
@@ -89,20 +92,21 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
"RunForward", "RunBackward", "RunLeft", "RunRight", "Dance",
"EmoteStretch", "EmoteCall", "EmoteYes", "EmoteNo", "PistolIdle"]
const BLEND_TIME := 0.32
const BLEND_TIME := 0.36
## Per-clip blend overrides. Reaction moves still need to read as instant, but
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
## drifts across their thresholds, and that is where hard cuts were most
## visible.
const BLEND_TIMES := {
"Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16,
"Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24,
"Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28,
"Slide": 0.30, "Death": 0.34, "Throw": 0.20, "PistolReload": 0.30,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28,
"WallRunLeft": 0.18, "WallRunRight": 0.18,
"WallCling": 0.28, "Grapple": 0.28,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32,
"WallRunLeft": 0.26, "WallRunRight": 0.26,
"WallCling": 0.32, "Grapple": 0.32,
}
const GROUND_STATE_BLEND := 0.30
## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter.
@@ -120,11 +124,14 @@ var loaded: bool = false
## performance's forward axis with gameplay travel; it never rotates bones or
## manufactures poses.
var _motion_root: Node3D
var _wall_run_velocity_world := Vector3.ZERO
var _wall_glide_velocity_world := Vector3.ZERO
var _motion_yaw := 0.0
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
## True only for the ignored local evaluation builds made from the copyrighted
## pilot reference. Shipping characters retain the original jet-glide fallback.
var _has_titanfall_motion_reference := false
var _weapon_attachment: BoneAttachment3D
var _grapple_attachment: BoneAttachment3D
var _grapple_shoulder_attachment: BoneAttachment3D
@@ -138,6 +145,7 @@ var _rig_info: Dictionary = {}
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var _mecha_kit: AnimeMechaKit
var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks.
@@ -189,6 +197,12 @@ var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _target_wall: float = 0.0
var _loco_blend_target := Vector2.ZERO
var _loco_blend_visual := Vector2.ZERO
var _loco_scale_target := 1.0
var _loco_scale_visual := 1.0
var _crouch_blend_target := 0.0
var _crouch_blend_visual := 0.0
var _cur_ads: float = 0.0
var _cur_wall: float = 0.0
var _owner_visible: bool = false
@@ -206,15 +220,23 @@ func load_model(path: String) -> void:
skeleton = null
animation_player = null
_motion_root = null
_wall_run_velocity_world = Vector3.ZERO
_wall_glide_velocity_world = Vector3.ZERO
_motion_yaw = 0.0
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
_loco_scale_visual = 1.0
_crouch_blend_target = 0.0
_crouch_blend_visual = 0.0
loaded = false
_resolved_clips.clear()
_current_clip = ""
_has_titanfall_motion_reference = false
_current_state_node = ""
_weapon_attachment = null
_grapple_attachment = null
_grapple_shoulder_attachment = null
_mecha_kit = null
# Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST
# character's sidecar.
@@ -240,6 +262,7 @@ func load_model(path: String) -> void:
else:
_ensure_meshes_bound(scene)
_rig_info = _load_rig_info(path)
_apply_rig_animation_speeds()
# `weights_authored` is MEASURED at build time, not inferred from which
# pipeline branch ran: a model that arrives unrigged still gets a
# sidecar, and its nearest-bone weights still need the repair below.
@@ -303,6 +326,12 @@ func load_model(path: String) -> void:
# what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces)
if skeleton and mecha_theme != "":
_apply_mecha_base_suit(scene)
_mecha_kit = MECHA_KIT_SCRIPT.new()
_mecha_kit.name = "AnimeMechaKit"
add_child(_mecha_kit)
_mecha_kit.setup(skeleton, _rig_info.get("roles", {}), mecha_theme)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
@@ -319,6 +348,42 @@ func load_model(path: String) -> void:
_play_ground_locomotion(0.0, false)
## The imported garment is the actual zero-clearance synthetic skin. Recoloring
## that authored, skinned surface is what makes the robotics follow every curve
## and deformation; the procedural kit then only has to add seams and tools.
func _apply_mecha_base_suit(scene: Node) -> void:
if mecha_theme != "sakura" or _surfaces == null:
return
for mesh_node in scene.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := mesh_node as MeshInstance3D
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_index in mesh_instance.mesh.get_surface_count():
var source := mesh_instance.mesh.surface_get_material(surface_index) as BaseMaterial3D
if source == null:
continue
var resolved: Array = _surfaces.resolve(
mesh_instance.name, surface_index, source)
var surface_class := str(resolved[0])
var is_garment := surface_class == SkinSurfaces.CLOTH
# Sakura's import keeps the face on material "Head" and the remaining
# anatomy on material "Body". Turning only that second surface into the
# membrane closes the waist, thigh, arm and hand gaps without tinting her
# face or eyes.
var is_body_membrane := surface_class == SkinSurfaces.BODY \
and source.resource_name.to_lower() == "body"
if not is_garment and not is_body_membrane:
continue
var current := mesh_instance.get_surface_override_material(surface_index)
if current is ShaderMaterial:
var membrane := (current as ShaderMaterial).duplicate(true) as ShaderMaterial
membrane.set_shader_parameter("albedo_color",
Color("303852") if is_garment else Color("3b4563"))
membrane.set_shader_parameter("shadow_color", Color("171b31"))
membrane.set_shader_parameter("mid_tone", 0.86)
membrane.set_shader_parameter("rim_strength", 0.13)
mesh_instance.set_surface_override_material(surface_index, membrane)
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
@@ -414,6 +479,30 @@ func _cloth_hulls(scene: Node) -> Dictionary:
return out
## Character-specific authored gait speeds, measured from source root motion
## before it is stripped for gameplay. This keeps native clips at their
## original cadence instead of imposing the generic library's timing.
func _apply_rig_animation_speeds() -> void:
var speeds: Dictionary = _rig_info.get("animation_speeds", {})
if speeds.is_empty():
return
walk_anim_reference_speed = maxf(
float(speeds.get("walk", walk_anim_reference_speed)), 0.01)
run_anim_reference_speed = maxf(
float(speeds.get("run", run_anim_reference_speed)), 0.01)
sprint_anim_reference_speed = maxf(
float(speeds.get("sprint", sprint_anim_reference_speed)), 0.01)
strafe_walk_anim_reference_speed = maxf(
float(speeds.get("strafe_walk", strafe_walk_anim_reference_speed)), 0.01)
strafe_run_anim_reference_speed = maxf(
float(speeds.get("strafe_run", strafe_run_anim_reference_speed)), 0.01)
print("SkinnedPlayerModel: native gait speeds walk=%.2f run=%.2f sprint=%.2f strafe=%.2f/%.2f m/s" % [
walk_anim_reference_speed, run_anim_reference_speed,
sprint_anim_reference_speed, strafe_walk_anim_reference_speed,
strafe_run_anim_reference_speed,
])
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
@@ -475,6 +564,8 @@ func _ensure_meshes_bound(scene: Node) -> void:
## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void:
var available := animation_player.get_animation_list()
_has_titanfall_motion_reference = \
_find_clip(available, "ReviewWallHangFront") != ""
for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate)
@@ -576,8 +667,8 @@ func _setup_anim_tree(scene: Node) -> void:
var upper := AnimationNodeOneShot.new()
# The upper-body one-shot pops in and out over the locomotion clip, so its
# own fades matter as much as the locomotion cross-fade.
upper.fadein_time = 0.14
upper.fadeout_time = 0.22
upper.fadein_time = 0.20
upper.fadeout_time = 0.30
upper.filter_enabled = true
_upper_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
@@ -740,9 +831,13 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
"slide":
clip = "Slide"
"wall_run":
# Separate authored performances: the contact leg and balancing arm
# are different on each side. This is never a renamed ground run.
clip = "WallRunRight" if _target_wall > 0.0 else "WallRunLeft"
# The jets still own propulsion, but the authored pilot pack supplies a
# distinct compact wall-performance for each side. This is body motion,
# not gameplay root motion or procedural feet planted against the wall.
if _has_titanfall_motion_reference:
clip = "WallRunRight" if _target_wall < 0.0 else "WallRunLeft"
else:
clip = "Grapple"
"wall_cling":
clip = "WallCling"
"wall_climb":
@@ -769,11 +864,9 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
var scale := 1.0
if clip == "Slide":
scale = clampf(speed / run_anim_reference_speed, 0.75, 1.8)
elif clip in ["WallRunLeft", "WallRunRight"]:
scale = clampf(speed / wall_run_anim_reference_speed, 0.85, 1.65)
elif clip == "Grapple":
# Grapple is a held, authored flight pose. Its cadence does not
# represent footsteps and must not accelerate with pull velocity.
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
# Grapple is held, and the wall clips are compact propulsion loops.
# Neither should become a frantic treadmill at high traversal speed.
scale = 1.0
elif clip == "Dash":
scale = clampf(speed / 13.0, 0.75, 1.8)
@@ -790,22 +883,25 @@ func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none.
## Used only to release the wall-side weapon hand; the clip owns body motion.
## Wall side during a wall glide: -1 wall on left, +1 wall on right, 0 none.
func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## Gameplay velocity for aligning the authored lateral wall-run performance.
## Uisco supplies the body lean, contact leg, arm balance, and cadence. This
## supplies only the wall-tangent coordinate frame that an offline clip cannot
## know.
## Gameplay velocity aligns the held glide silhouette with the live wall
## tangent. The mechanic still reports `wall_run` internally for save/network
## compatibility, but the character presentation is entirely propulsion-based.
func set_wall_glide_motion(velocity_world: Vector3) -> void:
_wall_glide_velocity_world = velocity_world
## Compatibility entry point for older controller scenes and debug tools.
func set_wall_run_motion(velocity_world: Vector3) -> void:
_wall_run_velocity_world = velocity_world
set_wall_glide_motion(velocity_world)
## Regression telemetry: the authored model's forward direction in world space.
func wall_run_forward_debug() -> Vector3:
func wall_glide_forward_debug() -> Vector3:
if not is_instance_valid(_motion_root):
return Vector3.ZERO
var source_forward := Vector3.FORWARD if facing_flip else Vector3.BACK
@@ -814,6 +910,32 @@ func wall_run_forward_debug() -> Vector3:
).normalized()
func wall_run_forward_debug() -> Vector3:
return wall_glide_forward_debug()
func set_helmet_closed(closed: bool, immediate: bool = false) -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("set_helmet_closed"):
_mecha_kit.set_helmet_closed(closed, immediate)
func toggle_helmet() -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("toggle_helmet"):
_mecha_kit.toggle_helmet()
func helmet_closed_debug() -> bool:
return _mecha_kit.helmet_closed() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_closed") \
else false
func helmet_progress_debug() -> float:
return _mecha_kit.helmet_progress_debug() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_progress_debug") \
else 0.0
## Live grapple mechanics layered over the authored airborne performance.
## The clip supplies weight and secondary motion; gameplay supplies the one
## constraint an authored fixed pose cannot know: the actual cable direction.
@@ -876,6 +998,8 @@ var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
_update_motion_orientation(delta)
_update_mecha_nozzles()
_update_animation_smoothing(delta)
if not _pose_mod:
return
_hold_weapon_still()
@@ -926,11 +1050,9 @@ func _process(delta: float) -> void:
"slide":
hold_l = 0.0 # trailing arm braces the ground
"wall_run":
# The wall-side arm reaches for the wall.
if _cur_wall > 0.05:
hold_r = 0.0
elif _cur_wall < -0.05:
hold_l = 0.0
# A glide is stabilized by the suit; neither hand needs to touch
# the wall, so weapon posture can remain coherent.
pass
"grapple":
hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r
@@ -949,14 +1071,14 @@ func _update_motion_orientation(delta: float) -> void:
var wanted_yaw := 0.0
if _pose_mod and _pose_mod.state == "wall_run":
var local_velocity := global_transform.basis.inverse() \
* _wall_run_velocity_world
* _wall_glide_velocity_world
local_velocity.y = 0.0
if local_velocity.length_squared() > 0.01:
var source_forward := Vector3.FORWARD \
if facing_flip else Vector3.BACK
wanted_yaw = source_forward.signed_angle_to(
local_velocity.normalized(), Vector3.UP)
var blend := 1.0 - exp(-14.0 * delta)
var blend := 1.0 - exp(-8.5 * delta)
_motion_yaw = lerp_angle(_motion_yaw, wanted_yaw, blend)
_motion_root.rotation.y = _motion_yaw
@@ -990,21 +1112,45 @@ func _update_cloth_lod(delta: float) -> void:
## even though gameplay acceleration reached running speed in one physics frame.
## That is the visible "sliding, then slow walk, then run" failure. Here the
## BlendSpace position is physical metres/second on this call, so the feet react
## on the same frame as the body.
## immediately but ease over a few rendered frames instead of teleporting from
## one gait/direction pose to another.
func _update_animation_smoothing(delta: float) -> void:
if not _anim_tree:
return
var current_speed := _loco_blend_visual.length()
var target_speed := _loco_blend_target.length()
var reversing := current_speed > 0.2 and target_speed > 0.2 \
and _loco_blend_visual.dot(_loco_blend_target) < 0.0
var response := 10.0 if target_speed > current_speed else 6.5
if reversing:
response = 5.5
var position_weight := 1.0 - exp(-response * delta)
_loco_blend_visual = _loco_blend_visual.lerp(
_loco_blend_target, position_weight)
_crouch_blend_visual = lerpf(_crouch_blend_visual,
_crouch_blend_target, 1.0 - exp(-8.0 * delta))
_loco_scale_visual = lerpf(_loco_scale_visual,
_loco_scale_target, 1.0 - exp(-7.0 * delta))
_anim_tree.set("parameters/loco_blend/blend_position", _loco_blend_visual)
_anim_tree.set("parameters/crouch_blend/blend_position", _crouch_blend_visual)
if _current_state_node in ["Locomotion", "CrouchLocomotion"]:
_anim_tree.set("parameters/loco_scale/scale", _loco_scale_visual)
func _play_ground_locomotion(speed: float, crouched: bool) -> void:
if not _anim_tree:
return
var state_node := "CrouchLocomotion" if crouched else "Locomotion"
if _current_state_node != state_node:
_state_trans.xfade_time = 0.16
_state_trans.xfade_time = GROUND_STATE_BLEND
_anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node
if crouched:
var authored_speed := minf(speed, crouch_anim_reference_speed)
_anim_tree.set("parameters/crouch_blend/blend_position", authored_speed)
_anim_tree.set("parameters/loco_scale/scale",
maxf(1.0, speed / maxf(crouch_anim_reference_speed, 0.01)))
_crouch_blend_target = authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(crouch_anim_reference_speed, 0.01))
_current_clip = _resolved_clips.get(
"CrouchWalk" if speed > 0.1 else "Crouch", "")
return
@@ -1028,10 +1174,9 @@ func _play_ground_locomotion(speed: float, crouched: bool) -> void:
)
var authored_limit := 1.0 / maxf(inverse_limit, 0.01)
var authored_speed := minf(speed, authored_limit)
_anim_tree.set("parameters/loco_blend/blend_position",
direction * authored_speed)
_anim_tree.set("parameters/loco_scale/scale",
maxf(1.0, speed / maxf(authored_limit, 0.01)))
_loco_blend_target = direction * authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(authored_limit, 0.01))
var canonical := "Idle"
if speed > 0.1:
@@ -1069,7 +1214,7 @@ func get_brake_debug() -> float:
return 0.0
## Physical velocity currently driving the authored ground BlendSpace.
## Smoothed visual velocity currently driving the authored ground BlendSpace.
func locomotion_blend_debug() -> Vector2:
if not _anim_tree:
return Vector2.ZERO
@@ -1077,7 +1222,11 @@ func locomotion_blend_debug() -> Vector2:
return value if value is Vector2 else Vector2.ZERO
## Velocity represented by the authored stride after playback-rate calibration.
func locomotion_blend_target_debug() -> Vector2:
return _loco_blend_target
## Visual velocity represented by the authored stride after playback-rate calibration.
## The raw BlendSpace position stops at the source clip's measured velocity;
## TimeScale covers faster gameplay without changing the pose or foot phase.
func locomotion_effective_speed_debug() -> float:
@@ -1091,7 +1240,7 @@ func locomotion_effective_speed_debug() -> float:
## Animated world-space anchors for the two shoulder and two foot jets.
## Offsets use the skeleton frame, not each limb's twisting frame, so the
## nozzles stay on the character's back/heels through every authored clip.
func get_jet_socket_world_positions() -> Array[Vector3]:
func _animated_jet_socket_world_positions() -> Array[Vector3]:
if not skeleton:
return []
var roles_and_fallbacks := [
@@ -1118,6 +1267,31 @@ func get_jet_socket_world_positions() -> Array[Vector3]:
return sockets
func _update_mecha_nozzles() -> void:
if not is_instance_valid(_mecha_kit) or not skeleton:
return
var sockets := _animated_jet_socket_world_positions()
if sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
func get_jet_socket_world_positions() -> Array[Vector3]:
var sockets := _animated_jet_socket_world_positions()
if is_instance_valid(_mecha_kit) and sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
return _mecha_kit.nozzle_world_positions()
return sockets
func mecha_nozzle_count_debug() -> int:
return _mecha_kit.nozzle_count() if is_instance_valid(_mecha_kit) else 0
func mecha_detail_part_count_debug() -> int:
return _mecha_kit.detail_part_count_debug() \
if is_instance_valid(_mecha_kit) else 0
## Every canonical clip this character resolved to something real.
##
## Canonical rather than raw, because the raw names differ per character — one
@@ -1442,7 +1616,7 @@ func _role_bone(role: String, fallbacks: Array) -> int:
var roles: Dictionary = _rig_info.get("roles", {})
var actual := String(roles.get(role, ""))
if actual != "" and skeleton:
var b := skeleton.find_bone(actual)
var b := RigRoles.find_imported_bone(skeleton, actual)
if b >= 0:
return b
return _find_bone(fallbacks)
@@ -1688,8 +1862,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t)
# The authored clip owns cadence and weight. Grappling adds the physical
# pendulum frame before the arm constraint is solved.
# Authored clips own the full silhouette. Traversal adds only constraints
# an offline clip cannot know, such as the live grapple cable.
_apply_grapple_body(skel, delta)
if absf(aim_pitch) > 0.01 and not grapple_active:
_apply_aim_pitch(skel)