fix: T-posing skinned models — autorig produced an orphan skin with no weights
The model animated its skeleton but rendered a permanent T-pose: the mesh wasn't skin-bound. Root cause was in autorig's binding, introduced while chasing an earlier "skins:0" export: - The earlier "skins:0" was actually caused by bone-heat weighting failing (0 weighted verts → empty skin gets dropped), NOT by the Armature modifier. - The "fix" then stripped the Armature modifier and used parent_type=ARMATURE. That makes the glTF exporter emit a skin OBJECT but with no node.skin reference and no per-vertex JOINTS/WEIGHTS — an orphan skin. The mesh then renders its bind pose (T-pose) forever while the skeleton animates unseen. Fix: keep the standard ARMATURE_AUTO result (Armature modifier + vertex groups). With the nearest-bone fallback ensuring real weights, the exporter now writes a COMPLETE skin (verified: meshnode.skin=0, JOINTS/WEIGHTS present). Also: - skinned_player_model: _ensure_meshes_bound() re-binds any skinned mesh whose skeleton NodePath doesn't resolve at load — graceful degradation instead of a silent T-pose for imperfect GLBs. - smoke test now asserts the spawned player's mesh is bound to its skeleton, so this class of bug fails the test instead of shipping. 30/30 pass. - regenerated miku_test.glb with the corrected pipeline. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e2fbc424a7
commit
22cb0a58b7
Binary file not shown.
@@ -88,6 +88,8 @@ func load_model(path: String) -> void:
|
|||||||
|
|
||||||
if not skeleton:
|
if not skeleton:
|
||||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||||
|
else:
|
||||||
|
_ensure_meshes_bound(scene)
|
||||||
if animation_player:
|
if animation_player:
|
||||||
_index_animations()
|
_index_animations()
|
||||||
else:
|
else:
|
||||||
@@ -103,6 +105,19 @@ func load_model(path: String) -> void:
|
|||||||
_play_clip("Idle")
|
_play_clip("Idle")
|
||||||
|
|
||||||
|
|
||||||
|
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||||
|
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
||||||
|
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||||
|
## pose (a permanent T-pose) while the skeleton animates invisibly. This repairs
|
||||||
|
## that at load time so a bad export degrades gracefully instead of T-posing.
|
||||||
|
func _ensure_meshes_bound(scene: Node) -> void:
|
||||||
|
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.skin == null:
|
||||||
|
continue # not a skinned mesh
|
||||||
|
if mi.skeleton.is_empty() or mi.get_node_or_null(mi.skeleton) != skeleton:
|
||||||
|
mi.skeleton = mi.get_path_to(skeleton)
|
||||||
|
|
||||||
|
|
||||||
## Map canonical clip names to whatever actually shipped in the GLB and set
|
## Map canonical clip names to whatever actually shipped in the GLB and set
|
||||||
## loop modes (glTF has no loop flag, so we set it here).
|
## loop modes (glTF has no loop flag, so we set it here).
|
||||||
func _index_animations() -> void:
|
func _index_animations() -> void:
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
|||||||
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
||||||
_check(skinned.loaded, "GLB model loaded")
|
_check(skinned.loaded, "GLB model loaded")
|
||||||
_check(skinned.skeleton != null, "skeleton found in GLB")
|
_check(skinned.skeleton != null, "skeleton found in GLB")
|
||||||
|
# The mesh must be bound to the skeleton, else it renders its bind
|
||||||
|
# pose (T-pose) while the skeleton animates invisibly.
|
||||||
|
var bound := false
|
||||||
|
if skinned.skeleton:
|
||||||
|
for mi in skinned.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.skin != null and mi.get_node_or_null(mi.skeleton) == skinned.skeleton:
|
||||||
|
bound = true
|
||||||
|
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
|
||||||
if skinned.animation_player:
|
if skinned.animation_player:
|
||||||
_check(skinned.animation_player.is_playing(), "animation playing")
|
_check(skinned.animation_player.is_playing(), "animation playing")
|
||||||
print(" clips resolved: ", skinned._resolved_clips)
|
print(" clips resolved: ", skinned._resolved_clips)
|
||||||
|
|||||||
+10
-13
@@ -269,13 +269,18 @@ def rigid_nearest_bone_weights(mesh, arm):
|
|||||||
|
|
||||||
|
|
||||||
def bind_mesh_to_armature(mesh, arm):
|
def bind_mesh_to_armature(mesh, arm):
|
||||||
"""Bind mesh to armature robustly and in a form the glTF exporter skins.
|
"""Bind mesh to armature so the glTF exporter writes a COMPLETE skin
|
||||||
|
(skin object + node.skin reference + per-vertex JOINTS/WEIGHTS).
|
||||||
|
|
||||||
1. Try Blender automatic (bone-heat) weights for smooth deformation.
|
1. Try Blender automatic (bone-heat) weights for smooth deformation.
|
||||||
2. If that assigns (almost) nothing, fall back to rigid nearest-bone.
|
2. If that assigns (almost) nothing — common on layered hair/clothing
|
||||||
3. Present the result as a parent_type='ARMATURE' relationship with NO
|
meshes where bone-heat fails — fall back to rigid nearest-bone.
|
||||||
Armature modifier — the Blender 5.x glTF exporter only emits a skin
|
|
||||||
for that exact configuration (a lingering modifier yields skins:0).
|
Keep the standard ARMATURE_AUTO result: an Armature modifier plus vertex
|
||||||
|
groups (parent_type stays OBJECT). That is exactly what the exporter needs
|
||||||
|
to write the vertex weights. (An earlier version stripped the modifier and
|
||||||
|
used parent_type='ARMATURE'; that produced an ORPHAN skin with no weights,
|
||||||
|
so the mesh rendered its bind pose — a permanent T-pose — in game.)
|
||||||
"""
|
"""
|
||||||
bpy.ops.object.select_all(action="DESELECT")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
mesh.select_set(True)
|
mesh.select_set(True)
|
||||||
@@ -291,14 +296,6 @@ def bind_mesh_to_armature(mesh, arm):
|
|||||||
weighted = rigid_nearest_bone_weights(mesh, arm)
|
weighted = rigid_nearest_bone_weights(mesh, arm)
|
||||||
print(f"Bound mesh: {weighted}/{total} verts weighted")
|
print(f"Bound mesh: {weighted}/{total} verts weighted")
|
||||||
|
|
||||||
# Normalize to the exporter-friendly form: parent_type=ARMATURE, no modifier.
|
|
||||||
for m in list(mesh.modifiers):
|
|
||||||
if m.type == "ARMATURE":
|
|
||||||
mesh.modifiers.remove(m)
|
|
||||||
if mesh.parent != arm:
|
|
||||||
mesh.parent = arm
|
|
||||||
mesh.parent_type = "ARMATURE"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
clear_scene()
|
clear_scene()
|
||||||
|
|||||||
Reference in New Issue
Block a user