fix: re-export GLB with dense per-frame keyframes to prevent animation stripping
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user