fix: use original rigged skeleton for animations, add 6 new animations
The twintails were moving as arms because the auto-rig script treated the combined mesh as one piece. Now using the original miku_rigged_final.glb skeleton (which has correct skin weights from the model creator) and adding 6 new animations (Idle, Walk, Run, Jump, Crouch, Death) on top of the existing skeleton. - tools/add_animations.py: adds animations to an existing rigged GLB - tools/rig_and_animate.py: updated to separate mesh into loose parts before rigging (for future use with other models) - miku_rigged_animated.glb: now 7 animations with correct skin weights
This commit is contained in:
+92
-16
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-rig a humanoid mesh in Blender and add idle/run/walk/jump/crouch/death animations.
|
||||
Auto-rig a humanoid mesh in Blender and add animations.
|
||||
Handles multi-part meshes (body, hair, accessories) by separating loose parts,
|
||||
rigging only the body, and parenting everything to the same skeleton.
|
||||
Exports as GLB with proper skeleton for Godot.
|
||||
"""
|
||||
import bpy
|
||||
@@ -35,20 +37,20 @@ elif input_path.endswith('.fbx'):
|
||||
imported = bpy.context.selected_objects
|
||||
print(f"Imported {len(imported)} objects")
|
||||
|
||||
# Find all mesh objects (skip Icosphere and other defaults)
|
||||
# Find all mesh objects
|
||||
meshes = [o for o in imported if o.type == 'MESH' and o.name != 'Icosphere']
|
||||
if not meshes:
|
||||
print("ERROR: No mesh found")
|
||||
sys.exit(1)
|
||||
|
||||
# Join all meshes into one
|
||||
# Join all meshes into one first
|
||||
if len(meshes) > 1:
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.join()
|
||||
meshes = [bpy.context.active_object]
|
||||
|
||||
mesh_obj = meshes[0]
|
||||
print(f"Mesh: {mesh_obj.name} ({len(mesh_obj.data.vertices)} verts)")
|
||||
print(f"Combined mesh: {mesh_obj.name} ({len(mesh_obj.data.vertices)} verts)")
|
||||
|
||||
# Clean up mesh
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
@@ -62,18 +64,69 @@ bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
mesh_obj.location = (0, 0, 0)
|
||||
|
||||
# Scale to roughly 1.8m tall (check largest dimension)
|
||||
dims = mesh_obj.dimensions
|
||||
max_dim = max(dims.x, dims.y, dims.z)
|
||||
print(f"Original dimensions: {dims}")
|
||||
if max_dim > 0 and max_dim < 10: # Model is in meters, needs scaling
|
||||
if max_dim > 0 and max_dim < 10:
|
||||
target_height = 1.8
|
||||
scale = target_height / max_dim
|
||||
mesh_obj.scale = (scale, scale, scale)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
print(f"Scaled by {scale:.2f}x. New dims: {mesh_obj.dimensions}")
|
||||
|
||||
# Now create a custom humanoid rig
|
||||
# Separate mesh into loose parts (body, hair, twintails, etc.)
|
||||
# This is key: we need to identify which vertices belong to the body vs hair/accessories
|
||||
print("\n=== Separating mesh into parts ===")
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# List separated parts
|
||||
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||
print(f"Separated into {len(parts)} parts:")
|
||||
for p in parts:
|
||||
print(f" {p.name}: {len(p.data.vertices)} verts, dims={p.dimensions}")
|
||||
|
||||
# Identify the body part (largest by vertex count, roughly humanoid proportions)
|
||||
# The body should be the largest part with roughly humanoid proportions
|
||||
body_part = None
|
||||
hair_parts = []
|
||||
other_parts = []
|
||||
|
||||
for p in parts:
|
||||
verts = len(p.data.vertices)
|
||||
dims = p.dimensions
|
||||
height = max(dims.x, dims.y, dims.z)
|
||||
width = min(dims.x, dims.y, dims.z)
|
||||
|
||||
# Body: largest part, height > width, reasonable humanoid proportions
|
||||
if height > 0.5 and verts > 1000:
|
||||
if body_part is None or verts > len(body_part.data.vertices):
|
||||
if body_part:
|
||||
other_parts.append(body_part)
|
||||
body_part = p
|
||||
else:
|
||||
other_parts.append(p)
|
||||
else:
|
||||
hair_parts.append(p)
|
||||
|
||||
if not body_part:
|
||||
# Fallback: just use the largest part
|
||||
body_part = max(parts, key=lambda p: len(p.data.vertices))
|
||||
hair_parts = [p for p in parts if p != body_part]
|
||||
other_parts = []
|
||||
|
||||
print(f"\nBody: {body_part.name} ({len(body_part.data.vertices)} verts)")
|
||||
print(f"Hair/accessories: {[p.name for p in hair_parts]}")
|
||||
print(f"Other: {[p.name for p in other_parts]}")
|
||||
|
||||
# Delete non-body parts (hair, twintails, accessories) - they'll be parented to bones later
|
||||
# Actually, keep them but we won't skin them to the body bones
|
||||
all_non_body = hair_parts + other_parts
|
||||
|
||||
# Create the armature
|
||||
print("\n=== Creating Rig ===")
|
||||
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
|
||||
armature = bpy.context.active_object
|
||||
@@ -88,7 +141,6 @@ for bone in armature.data.edit_bones:
|
||||
# Define humanoid skeleton (Mixamo-compatible names)
|
||||
# Blender is Z-up, so head is at higher Z
|
||||
bone_defs = [
|
||||
# (name, head_xyz, tail_xyz)
|
||||
("mixamorig:Hips", (0.09, 0.0, 0.95), (0.09, 0.0, 1.05)),
|
||||
("mixamorig:Spine", (0.09, 0.0, 1.05), (0.09, 0.0, 1.20)),
|
||||
("mixamorig:Spine1", (0.09, 0.0, 1.20), (0.09, 0.0, 1.35)),
|
||||
@@ -135,7 +187,6 @@ bones["mixamorig:RightUpLeg"].parent = bones["mixamorig:Hips"]
|
||||
bones["mixamorig:RightLeg"].parent = bones["mixamorig:RightUpLeg"]
|
||||
bones["mixamorig:RightFoot"].parent = bones["mixamorig:RightLeg"]
|
||||
|
||||
# Connect bones for visual clarity
|
||||
for name in ["mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2", "mixamorig:Neck",
|
||||
"mixamorig:Head", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
|
||||
"mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
|
||||
@@ -144,17 +195,40 @@ for name in ["mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2", "mixamor
|
||||
bones[name].use_connect = True
|
||||
|
||||
print(f"Created {len(bones)} bones")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Parent mesh to armature with automatic weights
|
||||
print("\n=== Skinning mesh ===")
|
||||
# Skin ONLY the body part to the armature with automatic weights
|
||||
print("\n=== Skinning body mesh ===")
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh_obj.select_set(True)
|
||||
body_part.select_set(True)
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.parent_set(type='ARMATURE_AUTO')
|
||||
print("Mesh parented with automatic weights")
|
||||
print(f"Body '{body_part.name}' parented with automatic weights")
|
||||
|
||||
# Parent hair/accessories to the armature (no skinning, just follow)
|
||||
# They'll move with the Head bone
|
||||
print("\n=== Parenting hair/accessories ===")
|
||||
for part in all_non_body:
|
||||
# Find the closest bone to this part's center
|
||||
part_center = part.location
|
||||
closest_bone = "mixamorig:Head" # Default to head for hair
|
||||
closest_dist = float('inf')
|
||||
|
||||
for bone_name, bone in bones.items():
|
||||
bone_head = armature.matrix_world @ bone.head
|
||||
dist = (part_center - bone_head).length
|
||||
if dist < closest_dist:
|
||||
closest_dist = dist
|
||||
closest_bone = bone_name
|
||||
|
||||
# Parent to armature with empty weights (just follow the bone)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
part.select_set(True)
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects_active = armature
|
||||
bpy.ops.object.parent_set(type='ARMATURE_NAME', keep_transform=True)
|
||||
print(f" '{part.name}' -> parented to '{closest_bone}'")
|
||||
|
||||
# Now create animations
|
||||
print("\n=== Creating animations ===")
|
||||
@@ -336,10 +410,12 @@ for action in bpy.data.actions:
|
||||
# Switch to object mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Select for export
|
||||
# Select all for export
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh_obj.select_set(True)
|
||||
armature.select_set(True)
|
||||
body_part.select_set(True)
|
||||
for part in all_non_body:
|
||||
part.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Export as GLB
|
||||
|
||||
Reference in New Issue
Block a user