refactor: use AnimationPlayer with TYPE_ROTATION tracks instead of manual bone poses

After extensive testing discovered:
- set_bone_global_pose_override() does NOT affect the GPU skinning pipeline
- set_bone_pose_rotation() only works when AP is stopped but changes don't
  appear during render because _update_skeleton() runs during render pass
- The AnimationPlayer DOES work in the actual game (position logs prove it)
- GLB animations are stripped (near-zero motion) causing T-pose
- Solution: Create proper Animation resources with type=1 (ROTATION) tracks
  using Quaternion keyframes and play through the AnimationPlayer
- Removed all manual bone pose code that was fighting the render pipeline
This commit is contained in:
2026-06-25 12:05:01 -04:00
parent 6e73fd7703
commit 92ebd6c8d2
+125 -144
View File
@@ -23,17 +23,8 @@ var _anim_debug_timer: float = 0.0
var _mesh_instance: MeshInstance3D var _mesh_instance: MeshInstance3D
var _bone_cache: Dictionary = {} # bone_name -> index var _bone_cache: Dictionary = {} # bone_name -> index
## Code-driven animation system — applies bone rotations directly each frame.
## Godot 4.2.1 AnimationPlayer cannot rotate runtime-loaded GLB bones via tracks,
## so we interpolate and apply poses manually using set_bone_pose_rotation().
var _current_anim_name: String = ""
var _anim_time: float = 0.0
var _procedural_anims: Dictionary = {} # name -> {length, loop, tracks}
## Bone indices for procedural animation (cached at load time) ## Bone indices for procedural animation (cached at load time)
var _bone_indices: Dictionary = {} # bone_name -> index var _bone_indices: Dictionary = {} # bone_name -> index
var _bone_rest_local_rot: Dictionary = {} # bone_idx -> Quaternion (rest local rotation)
var _bone_rest_local_pos: Dictionary = {} # bone_idx -> Vector3 (rest local position)
func _ready() -> void: func _ready() -> void:
if model_path != "": if model_path != "":
@@ -44,12 +35,6 @@ func load_model(path: String) -> void:
child.queue_free() child.queue_free()
_bone_cache.clear() _bone_cache.clear()
_bone_indices.clear() _bone_indices.clear()
_procedural_anims.clear()
_bone_rest_local_rot.clear()
_bone_rest_local_pos.clear()
_current_anim_name = ""
_anim_time = 0.0
print("SkinnedPlayerModel: loading %s via GLTFDocument" % path) print("SkinnedPlayerModel: loading %s via GLTFDocument" % path)
# Read GLB file as bytes # Read GLB file as bytes
@@ -92,14 +77,10 @@ func load_model(path: String) -> void:
skeleton = _find_skeleton(scene) skeleton = _find_skeleton(scene)
if skeleton: if skeleton:
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()]) print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
# Cache bone indices for fast lookups in procedural animations # Cache bone indices for fast lookups
for i in range(skeleton.get_bone_count()): for i in range(skeleton.get_bone_count()):
_bone_cache[skeleton.get_bone_name(i)] = i _bone_cache[skeleton.get_bone_name(i)] = i
_bone_indices[skeleton.get_bone_name(i)] = i _bone_indices[skeleton.get_bone_name(i)] = i
# Cache rest pose local transforms
var rest = skeleton.get_bone_rest(i)
_bone_rest_local_rot[i] = rest.basis.get_rotation_quaternion()
_bone_rest_local_pos[i] = rest.origin
else: else:
print("SkinnedPlayerModel: WARNING - no skeleton found") print("SkinnedPlayerModel: WARNING - no skeleton found")
@@ -162,37 +143,124 @@ func load_model(path: String) -> void:
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found") print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
func _ensure_locomotion_animations(ap: AnimationPlayer) -> void: func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
## Creates procedural Walk, Run, Jump, and Idle animations. ## Creates proper Animation resources for Walk, Run, Jump, and Idle.
## Replaces GLB animations that were stripped by Blender's GLTF exporter ## Replaces GLB animations that were stripped by Blender's GLTF exporter.
## (tracks exist but contain near-zero motion — model appears to T-pose). ## Uses AnimationPlayer tracks (type=0 for position, type=1 for rotation).
## Uses code-driven bone rotation applied directly via set_bone_pose_rotation().
# Always replace all locomotion animations — GLB is known to be stripped # Always replace all locomotion animations — GLB is known to be stripped
# by Blender's GLTF exporter (all tracks have near-zero motion) _create_and_play_anim(ap, "Walk", _create_walk_anim())
var broken_walk := true _create_and_play_anim(ap, "Run", _create_run_anim())
var broken_run := true _create_and_play_anim(ap, "Jump", _create_jump_anim())
var broken_jump := true _create_and_play_anim(ap, "Idle", _create_idle_anim())
var broken_idle := true
if broken_walk: func _create_and_play_anim(ap: AnimationPlayer, name: String, anim: Animation) -> void:
print("SkinnedPlayerModel: Walk animation has near-zero motion — replacing with procedural") var lib = ap.get_animation_library("")
_procedural_anims["Walk"] = _create_locomotion_data(true) if lib.has_animation(name):
_add_animation_to_player(ap, "Walk", Animation.new()) # placeholder for AP list lib.remove_animation(name)
lib.add_animation(name, anim)
if broken_run: func _create_walk_anim() -> Animation:
print("SkinnedPlayerModel: Run animation has near-zero motion — replacing with procedural") var anim = Animation.new()
_procedural_anims["Run"] = _create_locomotion_data(true, 1.8) anim.length = 1.0
_add_animation_to_player(ap, "Run", Animation.new()) anim.loop_mode = Animation.LOOP_LINEAR
var cycle = 1.0 / locomotion_cycle_speed
anim.length = cycle
# Add rotation tracks for each bone using type=1 (ROTATION)
_add_rot_track(anim, "LeftUpperLeg", cycle, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(-0.6, 0, 0))},
{"t": cycle * 0.5, "q": Quaternion.from_euler(Vector3(0.6, 0, 0))},
{"t": cycle, "q": Quaternion.from_euler(Vector3(-0.6, 0, 0))}
])
_add_rot_track(anim, "RightUpperLeg", cycle, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0.6, 0, 0))},
{"t": cycle * 0.5, "q": Quaternion.from_euler(Vector3(-0.6, 0, 0))},
{"t": cycle, "q": Quaternion.from_euler(Vector3(0.6, 0, 0))}
])
_add_rot_track(anim, "LeftUpperArm", cycle, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(-0.4, 0, 0))},
{"t": cycle * 0.5, "q": Quaternion.from_euler(Vector3(0.4, 0, 0))},
{"t": cycle, "q": Quaternion.from_euler(Vector3(-0.4, 0, 0))}
])
_add_rot_track(anim, "RightUpperArm", cycle, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0.4, 0, 0))},
{"t": cycle * 0.5, "q": Quaternion.from_euler(Vector3(-0.4, 0, 0))},
{"t": cycle, "q": Quaternion.from_euler(Vector3(0.4, 0, 0))}
])
_add_rot_track(anim, "Spine", cycle, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": cycle * 0.25, "q": Quaternion.from_euler(Vector3(0, 0.1, 0))},
{"t": cycle * 0.5, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": cycle * 0.75, "q": Quaternion.from_euler(Vector3(0, -0.1, 0))},
{"t": cycle, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
return anim
if broken_jump: func _create_run_anim() -> Animation:
print("SkinnedPlayerModel: Jump animation has near-zero motion — replacing with procedural") var anim = _create_walk_anim()
_procedural_anims["Jump"] = _create_jump_data() anim.length = 0.5 # faster
_add_animation_to_player(ap, "Jump", Animation.new()) return anim
if broken_idle: func _create_jump_anim() -> Animation:
print("SkinnedPlayerModel: Idle animation has near-zero motion — replacing with procedural") var anim = Animation.new()
_procedural_anims["Idle"] = _create_idle_data() anim.length = 1.0
_add_animation_to_player(ap, "Idle", Animation.new()) anim.loop_mode = Animation.LOOP_LINEAR
_add_rot_track(anim, "LeftUpperArm", 1.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 0.5, "q": Quaternion.from_euler(Vector3(-1.2, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "RightUpperArm", 1.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 0.5, "q": Quaternion.from_euler(Vector3(-1.2, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "LeftUpperLeg", 1.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 0.5, "q": Quaternion.from_euler(Vector3(-0.3, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "RightUpperLeg", 1.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 0.5, "q": Quaternion.from_euler(Vector3(-0.3, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
return anim
func _create_idle_anim() -> Animation:
var anim = Animation.new()
anim.length = 2.0
anim.loop_mode = Animation.LOOP_LINEAR
_add_rot_track(anim, "Spine", 2.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0.02, 0, 0))},
{"t": 2.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "Head", 2.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0.03, 0.02, 0))},
{"t": 2.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "LeftUpperArm", 2.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0.02, 0, 0.05))},
{"t": 2.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
_add_rot_track(anim, "RightUpperArm", 2.0, [
{"t": 0.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))},
{"t": 1.0, "q": Quaternion.from_euler(Vector3(0.02, 0, -0.05))},
{"t": 2.0, "q": Quaternion.from_euler(Vector3(0, 0, 0))}
])
return anim
func _add_rot_track(anim: Animation, bone_name: String, length: float, keyframes: Array) -> void:
var track = anim.add_track(1) # TYPE_ROTATION
anim.track_set_path(track, "MikuRig/Skeleton3D:" + bone_name)
anim.track_set_interpolation_type(track, Animation.INTERPOLATION_LINEAR)
for kf in keyframes:
anim.track_insert_key(track, kf["t"], kf["q"])
func _is_animation_broken(ap: AnimationPlayer, anim_name: String) -> bool: func _is_animation_broken(ap: AnimationPlayer, anim_name: String) -> bool:
## Returns true if the animation has near-zero motion (Blender GLTF track stripping). ## Returns true if the animation has near-zero motion (Blender GLTF track stripping).
@@ -460,9 +528,9 @@ func _warn_missing(anim_name: String) -> void:
print("SkinnedPlayerModel: WARNING - '%s' not available (using fallback)" % anim_name) print("SkinnedPlayerModel: WARNING - '%s' not available (using fallback)" % anim_name)
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not skeleton: if not animation_player:
return return
# Get movement state from parent PlayerMovementController # Get movement state from parent PlayerMovementController
var parent = get_parent() var parent = get_parent()
if not parent: if not parent:
@@ -503,101 +571,14 @@ func _process(delta: float) -> void:
_: _:
target_anim = "Idle" target_anim = "Idle"
# Switch animation if needed if animation_player.current_animation != target_anim or not animation_player.is_playing():
if target_anim != _current_anim_name: if animation_player.has_animation(target_anim):
_current_anim_name = target_anim
_anim_time = 0.0
# For procedural anims: stop AP completely so it doesn't overwrite our poses
# For GLB anims (Death, Crouch): play on AP
if _procedural_anims.has(target_anim):
if animation_player:
animation_player.stop()
animation_player.active = false
# Reset all bones to rest pose before applying procedural animation
if skeleton:
# Clear any previous global pose overrides
for i in range(skeleton.get_bone_count()):
skeleton.set_bone_global_pose_override(i, Transform3D(), 0.0, true)
skeleton.set_bone_pose_rotation(i, _bone_rest_local_rot.get(i, Quaternion.IDENTITY))
skeleton.set_bone_pose_position(i, _bone_rest_local_pos.get(i, Vector3.ZERO))
elif animation_player and animation_player.has_animation(target_anim):
animation_player.active = true
animation_player.play(target_anim) animation_player.play(target_anim)
# Advance animation time # Debug output
if _procedural_anims.has(_current_anim_name): _anim_debug_timer += delta
var data = _procedural_anims[_current_anim_name] if _anim_debug_timer > 2.0:
var length: float = data["length"] _anim_debug_timer = 0.0
var loop: bool = data["loop"] if animation_player.is_playing():
_anim_time += delta print("SkinnedPlayerModel: playing '%s' (pos: %.2f)" % [animation_player.current_animation, animation_player.current_animation_position])
if loop: pass # end of _process
_anim_time = fmod(_anim_time, length)
else:
_anim_time = min(_anim_time, length)
# Apply procedural animation to skeleton
_apply_procedural_animation(data, _anim_time)
else:
# For non-procedural anims (Death, Crouch from GLB), use AP
if animation_player:
call_deferred("_update_skin")
# Debug output
_anim_debug_timer += delta
if _anim_debug_timer > 2.0:
_anim_debug_timer = 0.0
if animation_player.is_playing():
print("SkinnedPlayerModel: playing '%s' (pos: %.2f)" % [animation_player.current_animation, animation_player.current_animation_position])
# Force skeleton skin update every frame (deferred to run after pose updates)
if skeleton:
call_deferred("_update_skin")
func _apply_procedural_animation(data: Dictionary, time: float) -> void:
## Applies procedural animation data to the skeleton at the given time.
## Sets local pose = rest_local * offset_quat for correct skinning.
if not skeleton:
return
var tracks = data["tracks"]
for track in tracks:
var bone_name = track["bone"]
var bone_idx = _bone_indices.get(bone_name, -1)
if bone_idx < 0:
continue
var keyframes = track["keyframes"]
if keyframes.size() == 0:
continue
# Find the two keyframes surrounding the current time
var prev_kf = keyframes[0]
var next_kf = keyframes[keyframes.size() - 1]
for i in range(keyframes.size()):
if keyframes[i]["t"] <= time:
prev_kf = keyframes[i]
if keyframes[i]["t"] >= time:
next_kf = keyframes[i]
break
# Interpolate between keyframes
var segment_length: float = next_kf["t"] - prev_kf["t"]
var t: float = 0.0
if segment_length > 0.001:
t = (time - prev_kf["t"]) / segment_length
t = clamp(t, 0.0, 1.0)
if prev_kf.has("rot") and next_kf.has("rot"):
var rot: Vector3 = lerp(prev_kf["rot"], next_kf["rot"], t)
# Compute offset quaternion from euler angles
var offset_quat = Quaternion.from_euler(rot)
# Get rest pose local rotation for this bone
var rest_quat: Quaternion = _bone_rest_local_rot.get(bone_idx, Quaternion.IDENTITY)
# local_pose = rest_local * offset (so final = rest * offset)
var new_local_quat = rest_quat * offset_quat
skeleton.set_bone_pose_rotation(bone_idx, new_local_quat)
# Keep rest pose local position
var rest_pos: Vector3 = _bone_rest_local_pos.get(bone_idx, Vector3.ZERO)
skeleton.set_bone_pose_position(bone_idx, rest_pos)
elif prev_kf.has("pos") and next_kf.has("pos"):
var pos: Vector3 = lerp(prev_kf["pos"], next_kf["pos"], t)
# Position is additive on top of rest pose position
var rest_pos: Vector3 = _bone_rest_local_pos.get(bone_idx, Vector3.ZERO)
skeleton.set_bone_pose_position(bone_idx, rest_pos + pos)