fix: replace stripped GLB animations with procedural rotation-based animations

Blender's GLTF exporter aggressively strips animation tracks that are
near rest pose. The Miku GLB has 54 tracks per animation but all
keyframe values are essentially rest pose (max delta 0.008 units),
causing the model to appear frozen in T-pose despite the AnimationPlayer
playing animations correctly.

This fix:
- Adds _is_animation_broken() to detect near-zero motion in animations
- Changes _ensure_locomotion_animations() to replace broken animations
  (not just create when missing)
- Uses TYPE_VALUE tracks with ':rotation' suffix for proper bone rotation
- Fixes hips bounce to use position track instead of rotation
- Adds _add_rotation_track, _add_position_track, _add_position_track_catmull
  helper functions
This commit is contained in:
2026-06-25 10:38:19 -04:00
parent d6da221583
commit 86a13bc782
+135 -29
View File
@@ -139,14 +139,15 @@ 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 if missing from the GLB. ## Creates procedural Walk, Run, Jump, and Idle animations.
## These use the bone names discovered in the imported skeleton. ## Replaces GLB animations that were stripped by Blender's GLTF exporter
## Track paths are relative to the AnimationPlayer's parent (MikuRig node). ## (tracks exist but contain near-zero motion — model appears to T-pose).
## These procedural animations use rotation tracks for visible skeletal motion.
# Bone names match the GLB skeleton exactly # Bone names match the GLB skeleton exactly
var hips: String = _get_bone_name("Hips") var hips: String = _get_bone_name("Hips")
var spine: String = _get_bone_name("Spine") var spine: String = _get_bone_name("Spine")
var _chest: String = _get_bone_name("Chest") var chest: String = _get_bone_name("Chest")
var head: String = _get_bone_name("Head") var head: String = _get_bone_name("Head")
var left_upper_arm: String = _get_bone_name("LeftUpperArm") var left_upper_arm: String = _get_bone_name("LeftUpperArm")
var left_lower_arm: String = _get_bone_name("LeftLowerArm") var left_lower_arm: String = _get_bone_name("LeftLowerArm")
@@ -165,8 +166,16 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
# and Skeleton3D is a child of MikuRig. The GLB's own animations use this path format. # and Skeleton3D is a child of MikuRig. The GLB's own animations use this path format.
var prefix: String = "MikuRig/Skeleton3D:" var prefix: String = "MikuRig/Skeleton3D:"
if not ap.has_animation("Walk"): # Always replace animations that have near-zero motion (Blender GLTF track stripping)
print("SkinnedPlayerModel: creating Walk animation") var broken_walk := _is_animation_broken(ap, "Walk")
var broken_run := _is_animation_broken(ap, "Run")
var broken_jump := _is_animation_broken(ap, "Jump")
var broken_idle := _is_animation_broken(ap, "Idle")
if not broken_idle:
broken_idle = _is_animation_broken(ap, "idle")
if broken_walk:
print("SkinnedPlayerModel: Walk animation has near-zero motion — replacing with procedural")
var anim = _create_locomotion_anim( var anim = _create_locomotion_anim(
prefix, true, prefix, true,
left_upper_leg, left_lower_leg, left_foot, left_upper_leg, left_lower_leg, left_foot,
@@ -177,8 +186,8 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
) )
_add_animation_to_player(ap, "Walk", anim) _add_animation_to_player(ap, "Walk", anim)
if not ap.has_animation("Run"): if broken_run:
print("SkinnedPlayerModel: creating Run animation") print("SkinnedPlayerModel: Run animation has near-zero motion — replacing with procedural")
var anim = _create_locomotion_anim( var anim = _create_locomotion_anim(
prefix, true, prefix, true,
left_upper_leg, left_lower_leg, left_foot, left_upper_leg, left_lower_leg, left_foot,
@@ -189,34 +198,112 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
) )
_add_animation_to_player(ap, "Run", anim) _add_animation_to_player(ap, "Run", anim)
if not ap.has_animation("Jump"): if broken_jump:
print("SkinnedPlayerModel: creating Jump animation") print("SkinnedPlayerModel: Jump animation has near-zero motion — replacing with procedural")
var anim = Animation.new() var anim = Animation.new()
anim.length = 1.0 anim.length = 1.0
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
# Arms raised, knees slightly bent, brief hop # Arms raised, knees slightly bent, brief hop
_add_bone_track(anim, prefix + left_upper_arm, [{ "time": 0.0, "value": Vector3(-0.8, 0, 0.3) }, { "time": 0.5, "value": Vector3(-1.2, 0, 0.5) }, { "time": 1.0, "value": Vector3(-0.8, 0, 0.3) }], prefix) _add_rotation_track(anim, prefix + left_upper_arm, [
_add_bone_track(anim, prefix + right_upper_arm, [{ "time": 0.0, "value": Vector3(-0.8, 0, -0.3) }, { "time": 0.5, "value": Vector3(-1.2, 0, -0.5) }, { "time": 1.0, "value": Vector3(-0.8, 0, -0.3) }], prefix) {"time": 0.0, "value": Vector3(-0.8, 0, 0.3)},
_add_bone_track(anim, prefix + left_lower_arm, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 0.5, "value": Vector3(-0.4, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0, 0) }], prefix) {"time": 0.5, "value": Vector3(-1.2, 0, 0.5)},
_add_bone_track(anim, prefix + right_lower_arm, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 0.5, "value": Vector3(-0.4, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0, 0) }], prefix) {"time": 1.0, "value": Vector3(-0.8, 0, 0.3)}
_add_bone_track(anim, prefix + left_upper_leg, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 0.5, "value": Vector3(-0.3, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0, 0) }], prefix) ])
_add_bone_track(anim, prefix + right_upper_leg, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 0.5, "value": Vector3(-0.3, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0, 0) }], prefix) _add_rotation_track(anim, prefix + right_upper_arm, [
_add_bone_track(anim, prefix + spine, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 0.5, "value": Vector3(0.1, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0, 0) }], prefix) {"time": 0.0, "value": Vector3(-0.8, 0, -0.3)},
{"time": 0.5, "value": Vector3(-1.2, 0, -0.5)},
{"time": 1.0, "value": Vector3(-0.8, 0, -0.3)}
])
_add_rotation_track(anim, prefix + left_lower_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.4, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + right_lower_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.4, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + left_upper_leg, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.3, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + right_upper_leg, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.3, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + spine, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(0.1, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_animation_to_player(ap, "Jump", anim) _add_animation_to_player(ap, "Jump", anim)
if not ap.has_animation("Idle"): if broken_idle:
print("SkinnedPlayerModel: creating Idle animation") print("SkinnedPlayerModel: Idle animation has near-zero motion — replacing with procedural")
var anim = Animation.new() var anim = Animation.new()
anim.length = 2.0 anim.length = 2.0
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
# Gentle breathing/sway # Gentle breathing/sway — use rotation tracks for visible motion
_add_bone_track(anim, prefix + spine, [{ "time": 0.0, "value": Vector3(0, 0.005, 0) }, { "time": 1.0, "value": Vector3(0, -0.005, 0) }, { "time": 2.0, "value": Vector3(0, 0.005, 0) }], prefix) _add_rotation_track(anim, prefix + spine, [
_add_bone_track(anim, prefix + head, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 1.0, "value": Vector3(0.02, 0, 0) }, { "time": 2.0, "value": Vector3(0, 0, 0) }], prefix) {"time": 0.0, "value": Vector3(0, 0, 0)},
_add_bone_track(anim, prefix + left_upper_arm, [{ "time": 0.0, "value": Vector3(0, 0, 0.1) }, { "time": 1.0, "value": Vector3(0, 0, 0.12) }, { "time": 2.0, "value": Vector3(0, 0, 0.1) }], prefix) {"time": 1.0, "value": Vector3(0.02, 0, 0)},
_add_bone_track(anim, prefix + right_upper_arm, [{ "time": 0.0, "value": Vector3(0, 0, -0.1) }, { "time": 1.0, "value": Vector3(0, 0, -0.12) }, { "time": 2.0, "value": Vector3(0, 0, -0.1) }], prefix) {"time": 2.0, "value": Vector3(0, 0, 0)}
_add_bone_track(anim, prefix + hips, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0.003, 0) }, { "time": 2.0, "value": Vector3(0, 0, 0) }], prefix) ])
_add_rotation_track(anim, prefix + head, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 1.0, "value": Vector3(0.03, 0.02, 0)},
{"time": 2.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + left_upper_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0.05)},
{"time": 1.0, "value": Vector3(0.02, 0, 0.08)},
{"time": 2.0, "value": Vector3(0, 0, 0.05)}
])
_add_rotation_track(anim, prefix + right_upper_arm, [
{"time": 0.0, "value": Vector3(0, 0, -0.05)},
{"time": 1.0, "value": Vector3(0.02, 0, -0.08)},
{"time": 2.0, "value": Vector3(0, 0, -0.05)}
])
_add_position_track(anim, prefix + hips, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0.005, 0)},
{"time": 2.0, "value": Vector3(0, 0, 0)}
])
_add_animation_to_player(ap, "Idle", anim) _add_animation_to_player(ap, "Idle", anim)
func _is_animation_broken(ap: AnimationPlayer, anim_name: String) -> bool:
## Returns true if the animation has near-zero motion (Blender GLTF track stripping).
## Checks the maximum delta between any two keyframe values on any track.
if not ap.has_animation(anim_name):
return true
var anim = ap.get_animation(anim_name)
if anim.get_track_count() == 0:
return true
# Check max delta across all tracks
var max_delta: float = 0.0
for t in range(anim.get_track_count()):
var key_count = anim.track_get_key_count(t)
if key_count < 2:
continue
var path = String(anim.track_get_path(t))
var is_rotation_track = path.ends_with(":rotation")
for i in range(1, key_count):
var v = anim.track_get_key_value(t, i)
if v is Vector3:
var prev_v = anim.track_get_key_value(t, i - 1)
var delta: float
if is_rotation_track:
# For rotation tracks, sum absolute euler angle differences
delta = abs(v.x - prev_v.x) + abs(v.y - prev_v.y) + abs(v.z - prev_v.z)
else:
delta = v.distance_to(prev_v)
max_delta = max(max_delta, delta)
# Threshold: rotation < 0.02 rad total (~1.1 deg) or position < 0.005 units
return max_delta < 0.02
func _get_bone_name(bone_name: String) -> String: func _get_bone_name(bone_name: String) -> String:
## Returns the bone name as stored in the skeleton (uses cache). ## Returns the bone name as stored in the skeleton (uses cache).
if _bone_cache.has(bone_name): if _bone_cache.has(bone_name):
@@ -283,8 +370,8 @@ func _create_locomotion_anim(
0.0, 0.05, 0.0, -0.05, 0.0 0.0, 0.05, 0.0, -0.05, 0.0
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]) ], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
# Hips bounce # Hips bounce — position offset (not rotation)
_add_bone_track_catmull(anim, prefix + hips, [t1, t2, t3, t4, t5], [ _add_position_track_catmull(anim, prefix + hips, [t1, t2, t3, t4, t5], [
0.0, 0.0, 0.0, 0.0, 0.0 0.0, 0.0, 0.0, 0.0, 0.0
], [0.0, 0.02, 0.0, 0.02, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]) ], [0.0, 0.02, 0.0, 0.02, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
@@ -292,14 +379,33 @@ func _create_locomotion_anim(
func _add_bone_track_catmull(anim: Animation, bone_path: String, times: Array, x_vals: Array, y_vals: Array, z_vals: Array) -> void: func _add_bone_track_catmull(anim: Animation, bone_path: String, times: Array, x_vals: Array, y_vals: Array, z_vals: Array) -> void:
## Adds a rotation track with Catmull-Rom-style keyframes from separate axis arrays. ## Adds a rotation track with Catmull-Rom-style keyframes from separate axis arrays.
## Uses ":rotation" suffix on the path to target bone rotation.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path + ":rotation")
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
func _add_rotation_track(anim: Animation, bone_path: String, keyframes: Array) -> void:
## Adds a rotation track from an array of {time, value} dictionaries.
## Uses ":rotation" suffix on the path to target bone rotation.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path + ":rotation")
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
for kf in keyframes:
anim.track_insert_key(track_idx, kf["time"], kf["value"])
func _add_position_track_catmull(anim: Animation, bone_path: String, times: Array, x_vals: Array, y_vals: Array, z_vals: Array) -> void:
## Adds a position/value track with Catmull-Rom-style keyframes from separate axis arrays.
## No ":rotation" suffix — targets bone position directly.
var track_idx = anim.add_track(Animation.TYPE_VALUE) var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path) anim.track_set_path(track_idx, bone_path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR) anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
for i in range(times.size()): for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i])) anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
func _add_bone_track(anim: Animation, bone_path: String, keyframes: Array, _prefix: String = "") -> void: func _add_position_track(anim: Animation, bone_path: String, keyframes: Array) -> void:
## Adds a value track from an array of {time, value} dictionaries. ## Adds a position/value track from an array of {time, value} dictionaries.
var track_idx = anim.add_track(Animation.TYPE_VALUE) var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path) anim.track_set_path(track_idx, bone_path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR) anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)