fix: re-export GLB with dense per-frame keyframes to prevent animation stripping

This commit is contained in:
2026-06-25 16:50:51 -04:00
parent 8415ceb926
commit b2e2ce2856
6 changed files with 229 additions and 898 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ func _register_default_skins() -> void:
var miku_skin = PlayerSkin.new()
miku_skin.skin_name = "Miku"
miku_skin.description = "Hatsune Miku — Virtual Idol"
miku_skin.model_path = "res://assets/characters/skins/miku_rigged_animated.glb"
miku_skin.model_path = "res://assets/characters/skins/miku_proper_anim.glb"
miku_skin.color_tint = Color(0.0, 0.75, 0.75)
miku_skin.is_unlocked = true
skins["miku"] = miku_skin
+69 -53
View File
@@ -39,8 +39,8 @@ func load_model(path: String) -> void:
# Read GLB file as bytes
if not FileAccess.file_exists(path):
print("SkinnedPlayerModel: file not found: %s" % path)
return
print("SkinnedPlayerModel: file not found: %s" % path)
return
var file = FileAccess.open(path, FileAccess.READ)
var bytes = file.get_buffer(file.get_length())
@@ -51,24 +51,19 @@ func load_model(path: String) -> void:
var state = GLTFState.new()
var err = gltf.append_from_buffer(bytes, "", state)
if err != OK:
print("SkinnedPlayerModel: GLTFDocument parse failed: %d" % err)
return
print("SkinnedPlayerModel: GLTFDocument parse failed: %d" % err)
return
print("SkinnedPlayerModel: parsed GLB, generating scene...")
# remove_immutable_tracks=false is CRITICAL — when true (default), Godot
# strips animation tracks whose values equal the rest pose. This causes
# partial T-pose because some bones only move in certain animations.
# Editor import sets this to false; runtime loading does not.
var scene = gltf.generate_scene(state, 30, false, false)
if not scene:
print("SkinnedPlayerModel: failed to generate scene")
return
print("SkinnedPlayerModel: failed to generate scene")
return
add_child(scene)
print("SkinnedPlayerModel: scene added: %s" % scene.name)
# Apply model scale (the Miku GLB is ~1.2m; scale_factor adjusts to target height)
# For models exported in centimeters (common from Mixamo/Blender), set scale_factor to 0.01.
scene.scale = Vector3(scale_factor, scale_factor, scale_factor)
scene.position = Vector3(0, position_y_offset * scale_factor, 0)
print("SkinnedPlayerModel: applied scale %.4f, y_offset %.4f" % [scale_factor, position_y_offset])
@@ -76,35 +71,28 @@ func load_model(path: String) -> void:
# Find skeleton FIRST (needed for mesh binding fix below)
skeleton = _find_skeleton(scene)
if skeleton:
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
# Cache bone indices for fast lookups
for i in range(skeleton.get_bone_count()):
_bone_cache[skeleton.get_bone_name(i)] = i
_bone_indices[skeleton.get_bone_name(i)] = i
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
# Cache bone indices for fast lookups
for i in range(skeleton.get_bone_count()):
_bone_cache[skeleton.get_bone_name(i)] = i
_bone_indices[skeleton.get_bone_name(i)] = i
else:
print("SkinnedPlayerModel: WARNING - no skeleton found")
print("SkinnedPlayerModel: WARNING - no skeleton found")
# Find mesh instance for first-person mode
_mesh_instance = scene.find_child("Tda Miku for fbx_mesh", true, false)
if not _mesh_instance:
var mesh_instances = scene.find_children("*", "MeshInstance3D", true, false)
if mesh_instances.size() > 0:
_mesh_instance = mesh_instances[0]
var mesh_instances = scene.find_children("*", "MeshInstance3D", true, false)
if mesh_instances.size() > 0:
_mesh_instance = mesh_instances[0]
# Fix skeleton binding: GLTF imports set MeshInstance.skeleton to ".."
# which should work for a direct parent, but in our case the MeshInstance
# is a child of Skeleton3D which is a child of MikuRig. The skeleton
# property needs to point to the Skeleton3D node for skin deformation.
# Set it explicitly to the skeleton's path relative to MeshInstance.
# Fix skeleton binding
if _mesh_instance and skeleton:
# Manually compute relative path since get_relative_path_to is not
# exposed for MeshInstance3D in Godot 4.2.
# MeshInstance is child of Skeleton3D, so ".." points to Skeleton3D.
_mesh_instance.skeleton = NodePath("..")
print("SkinnedPlayerModel: fixed mesh skeleton binding -> '%s'" % _mesh_instance.skeleton)
_mesh_instance.skeleton = NodePath("..")
print("SkinnedPlayerModel: fixed mesh skeleton binding -> '%s'" % _mesh_instance.skeleton)
if first_person_mode and _mesh_instance:
_setup_first_person()
_setup_first_person()
# Print tree for debugging
print("SkinnedPlayerModel: scene tree:")
@@ -113,31 +101,59 @@ func load_model(path: String) -> void:
# Find AnimationPlayer
animation_player = _find_animation_player(scene)
if animation_player:
# Ensure AnimationPlayer processes even if parent has process_mode disabled
animation_player.process_mode = Node.PROCESS_MODE_ALWAYS
animation_player.active = true
print("SkinnedPlayerModel: AP process_mode=%d active=%s" % [animation_player.process_mode, animation_player.active])
var anim_list = animation_player.get_animation_list()
print("SkinnedPlayerModel: %d animations available:" % anim_list.size())
for anim_name in anim_list:
var a = animation_player.get_animation(anim_name)
print(" - %s (%.2fs, loop=%s)" % [anim_name, a.length, a.loop_mode])
# Auto-play idle
if animation_player.has_animation("idle"):
animation_player.play("idle")
print("SkinnedPlayerModel: playing 'idle'")
elif animation_player.has_animation("Idle"):
animation_player.play("Idle")
print("SkinnedPlayerModel: playing 'Idle'")
elif anim_list.size() > 0:
animation_player.play(anim_list[0])
print("SkinnedPlayerModel: playing '%s'" % anim_list[0])
animation_player.process_mode = Node.PROCESS_MODE_ALWAYS
animation_player.active = true
print("SkinnedPlayerModel: AP process_mode=%d active=%s" % [animation_player.process_mode, animation_player.active])
var anim_list = animation_player.get_animation_list()
print("SkinnedPlayerModel: %d animations available:" % anim_list.size())
for a_name in anim_list:
var a = animation_player.get_animation(a_name)
print(" - %s (%.2fs, %d tracks)" % [a_name, a.length, a.get_track_count()])
# Auto-play idle
if animation_player.has_animation("idle"):
animation_player.play("idle")
print("SkinnedPlayerModel: playing 'idle'")
elif animation_player.has_animation("Idle"):
animation_player.play("Idle")
print("SkinnedPlayerModel: playing 'Idle'")
elif anim_list.size() > 0:
animation_player.play(anim_list[0])
print("SkinnedPlayerModel: playing '%s'" % anim_list[0])
_ensure_locomotion_animations(animation_player)
# Skip the animation creation - the GLB already has proper animations
# Just verify they have real motion
_verify_animations(animation_player)
else:
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
func _ensure_locomotion_animations(_ap: AnimationPlayer) -> void:
## AnimationPlayer doesn't properly update bone transforms for runtime
func _verify_animations(ap: AnimationPlayer) -> void:
## Verifies that animations have real motion (not stripped by exporter).
## If all animations are stripped, enables code-driven animation.
var has_real_anim := false
for a_name in ap.get_animation_list():
var anim = ap.get_animation(a_name)
if anim.get_track_count() < 10:
continue
var max_delta := 0.0
for t in range(anim.get_track_count()):
var kc = anim.track_get_key_count(t)
if kc < 2:
continue
for i in range(1, kc):
var v = anim.track_get_key_value(t, i)
var prev = anim.track_get_key_value(t, i - 1)
if v is Quaternion and prev is Quaternion:
max_delta = max(max_delta, v.angle_to(prev))
if max_delta > 0.05:
has_real_anim = true
print(" Animation '%s' has real motion (delta=%.3f)" % [a_name, max_delta])
if not has_real_anim:
print("SkinnedPlayerModel: All GLB animations are stripped, using code-driven mode")
_enable_code_driven_anim()
func _enable_code_driven_anim() -> void:
## Enables code-driven animation when GLB animations are stripped.
_code_driven_mode = true
## GLB skeletons. Animation resources are kept for reference but the
## actual animation is done code-driven in _process().
pass