Files
Papay-Shooter/tools/rig_and_animate.py
Hermes a83fa41b19 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
2026-06-23 00:33:40 -04:00

479 lines
18 KiB
Python

#!/usr/bin/env python3
"""
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
import sys
import os
import math
argv = sys.argv
if '--' in argv:
argv = argv[argv.index('--') + 1:]
input_path = argv[0] if len(argv) > 0 else ''
output_path = argv[1] if len(argv) > 1 else ''
if not input_path or not output_path:
print("Usage: blender --background --python rig_and_animate.py -- <input.glb> <output.glb>")
sys.exit(1)
# Clear scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Import the mesh
print(f"Importing {input_path}...")
if input_path.endswith('.glb') or input_path.endswith('.gltf'):
bpy.ops.import_scene.gltf(filepath=input_path)
elif input_path.endswith('.obj'):
bpy.ops.import_scene.obj(filepath=input_path, use_split_objects=False)
elif input_path.endswith('.fbx'):
bpy.ops.import_scene.fbx(filepath=input_path)
imported = bpy.context.selected_objects
print(f"Imported {len(imported)} objects")
# 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 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"Combined mesh: {mesh_obj.name} ({len(mesh_obj.data.vertices)} verts)")
# Clean up mesh
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.remove_doubles()
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
# Center and scale
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
mesh_obj.location = (0, 0, 0)
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:
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}")
# 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
armature.name = "Rig"
armature.data.display_type = 'STICK'
armature.data.show_axes = False
# Delete default bone
for bone in armature.data.edit_bones:
armature.data.edit_bones.remove(bone)
# Define humanoid skeleton (Mixamo-compatible names)
# Blender is Z-up, so head is at higher Z
bone_defs = [
("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)),
("mixamorig:Spine2", (0.09, 0.0, 1.35), (0.09, 0.0, 1.45)),
("mixamorig:Neck", (0.09, 0.0, 1.45), (0.09, 0.0, 1.52)),
("mixamorig:Head", (0.09, 0.0, 1.52), (0.09, 0.0, 1.70)),
("mixamorig:LeftArm", (-0.09, 0.0, 1.40), (-0.18, 0.0, 1.22)),
("mixamorig:LeftForeArm", (-0.18, 0.0, 1.22), (-0.18, 0.0, 1.00)),
("mixamorig:LeftHand", (-0.18, 0.0, 1.00), (-0.18, 0.0, 0.78)),
("mixamorig:RightArm", (0.27, 0.0, 1.40), (0.18, 0.0, 1.22)),
("mixamorig:RightForeArm", (0.18, 0.0, 1.22), (0.18, 0.0, 1.00)),
("mixamorig:RightHand", (0.18, 0.0, 1.00), (0.18, 0.0, 0.78)),
("mixamorig:LeftUpLeg", (0.0, 0.0, 0.95), (0.0, 0.0, 0.50)),
("mixamorig:LeftLeg", (0.0, 0.0, 0.50), (0.0, 0.0, 0.10)),
("mixamorig:LeftFoot", (0.0, 0.0, 0.10), (0.0, 0.0, 0.0)),
("mixamorig:RightUpLeg", (0.18, 0.0, 0.95), (0.18, 0.0, 0.50)),
("mixamorig:RightLeg", (0.18, 0.0, 0.50), (0.18, 0.0, 0.10)),
("mixamorig:RightFoot", (0.18, 0.0, 0.10), (0.18, 0.0, 0.0)),
]
bones = {}
for name, head, tail in bone_defs:
bone = armature.data.edit_bones.new(name)
bone.head = head
bone.tail = tail
bones[name] = bone
# Set parent hierarchy
bones["mixamorig:Spine"].parent = bones["mixamorig:Hips"]
bones["mixamorig:Spine1"].parent = bones["mixamorig:Spine"]
bones["mixamorig:Spine2"].parent = bones["mixamorig:Spine1"]
bones["mixamorig:Neck"].parent = bones["mixamorig:Spine2"]
bones["mixamorig:Head"].parent = bones["mixamorig:Neck"]
bones["mixamorig:LeftArm"].parent = bones["mixamorig:Spine2"]
bones["mixamorig:LeftForeArm"].parent = bones["mixamorig:LeftArm"]
bones["mixamorig:LeftHand"].parent = bones["mixamorig:LeftForeArm"]
bones["mixamorig:RightArm"].parent = bones["mixamorig:Spine2"]
bones["mixamorig:RightForeArm"].parent = bones["mixamorig:RightArm"]
bones["mixamorig:RightHand"].parent = bones["mixamorig:RightForeArm"]
bones["mixamorig:LeftUpLeg"].parent = bones["mixamorig:Hips"]
bones["mixamorig:LeftLeg"].parent = bones["mixamorig:LeftUpLeg"]
bones["mixamorig:LeftFoot"].parent = bones["mixamorig:LeftLeg"]
bones["mixamorig:RightUpLeg"].parent = bones["mixamorig:Hips"]
bones["mixamorig:RightLeg"].parent = bones["mixamorig:RightUpLeg"]
bones["mixamorig:RightFoot"].parent = bones["mixamorig:RightLeg"]
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",
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot",
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot"]:
bones[name].use_connect = True
print(f"Created {len(bones)} bones")
bpy.ops.object.mode_set(mode='OBJECT')
# Skin ONLY the body part to the armature with automatic weights
print("\n=== Skinning body mesh ===")
bpy.ops.object.select_all(action='DESELECT')
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(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 ===")
bpy.context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode='POSE')
def set_bone_rest(armature):
for bone in armature.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_bone(bone_name, frame, loc=None, rot=None):
bone = armature.pose.bones.get(bone_name)
if not bone:
return
if loc:
bone.location = loc
bone.keyframe_insert(data_path="location", frame=frame)
if rot:
bone.rotation_mode = 'XYZ'
bone.rotation_euler = rot
bone.keyframe_insert(data_path="rotation_euler", frame=frame)
# Idle animation
print(" Idle...")
if not armature.animation_data:
armature.animation_data_create()
action_idle = bpy.data.actions.new(name="Idle")
armature.animation_data.action = action_idle
action_idle.frame_range = (1, 30)
set_bone_rest(armature)
key_bone("mixamorig:Spine", 1, rot=(0.02, 0, 0))
key_bone("mixamorig:Spine", 15, rot=(-0.02, 0, 0))
key_bone("mixamorig:Spine", 30, rot=(0.02, 0, 0))
key_bone("mixamorig:LeftArm", 1, rot=(0, 0, 0.05))
key_bone("mixamorig:LeftArm", 15, rot=(0, 0, -0.05))
key_bone("mixamorig:LeftArm", 30, rot=(0, 0, 0.05))
key_bone("mixamorig:RightArm", 1, rot=(0, 0, -0.05))
key_bone("mixamorig:RightArm", 15, rot=(0, 0, 0.05))
key_bone("mixamorig:RightArm", 30, rot=(0, 0, -0.05))
# Walk animation
print(" Walk...")
action_walk = bpy.data.actions.new(name="Walk")
armature.animation_data.action = action_walk
action_walk.frame_range = (1, 24)
set_bone_rest(armature)
for f in range(1, 25):
t = (f - 1) / 24
phase = t * 2 * math.pi
key_bone("mixamorig:Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.01))
key_bone("mixamorig:LeftUpLeg", f, rot=(math.sin(phase) * 0.4, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(max(0, -math.sin(phase) * 0.3 + 0.2), 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(math.sin(phase + math.pi) * 0.4, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(max(0, -math.sin(phase + math.pi) * 0.3 + 0.2), 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(-math.sin(phase) * 0.3, 0, 0.05))
key_bone("mixamorig:LeftForeArm", f, rot=(-0.3 + max(0, math.sin(phase) * 0.2), 0, 0))
key_bone("mixamorig:RightArm", f, rot=(-math.sin(phase + math.pi) * 0.3, 0, -0.05))
key_bone("mixamorig:RightForeArm", f, rot=(-0.3 + max(0, math.sin(phase + math.pi) * 0.2), 0, 0))
key_bone("mixamorig:Spine", f, rot=(0, math.sin(phase) * 0.05, 0))
# Run animation
print(" Run...")
action_run = bpy.data.actions.new(name="Run")
armature.animation_data.action = action_run
action_run.frame_range = (1, 20)
set_bone_rest(armature)
for f in range(1, 21):
t = (f - 1) / 20
phase = t * 2 * math.pi
key_bone("mixamorig:Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.03))
key_bone("mixamorig:LeftUpLeg", f, rot=(math.sin(phase) * 0.8, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(max(0.1, -math.sin(phase) * 0.6 + 0.3), 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(math.sin(phase + math.pi) * 0.8, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(max(0.1, -math.sin(phase + math.pi) * 0.6 + 0.3), 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(-math.sin(phase) * 0.7, 0, 0.1))
key_bone("mixamorig:LeftForeArm", f, rot=(-1.0 + max(0, math.sin(phase) * 0.3), 0, 0))
key_bone("mixamorig:RightArm", f, rot=(-math.sin(phase + math.pi) * 0.7, 0, -0.1))
key_bone("mixamorig:RightForeArm", f, rot=(-1.0 + max(0, math.sin(phase + math.pi) * 0.3), 0, 0))
key_bone("mixamorig:Spine", f, rot=(0.1, math.sin(phase) * 0.08, 0))
# Jump animation
print(" Jump...")
action_jump = bpy.data.actions.new(name="Jump")
armature.animation_data.action = action_jump
action_jump.frame_range = (1, 20)
set_bone_rest(armature)
for f in range(1, 6):
t = (f - 1) / 4
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.2))
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.6, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(t * 1.0, 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.6, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(t * 1.0, 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(0, 0, -t * 0.5))
key_bone("mixamorig:RightArm", f, rot=(0, 0, t * 0.5))
for f in range(6, 9):
t = (f - 6) / 2
key_bone("mixamorig:Hips", f, loc=(0, 0, -0.2 + t * 0.2))
key_bone("mixamorig:LeftUpLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(1.0 - t * 0.3, 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(1.0 - t * 0.3, 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(-t * 2.0, 0, -0.5 - t))
key_bone("mixamorig:RightArm", f, rot=(-t * 2.0, 0, 0.5 + t))
for f in range(9, 16):
key_bone("mixamorig:LeftArm", f, rot=(-2.0, 0, -1.5))
key_bone("mixamorig:RightArm", f, rot=(-2.0, 0, 1.5))
key_bone("mixamorig:LeftForeArm", f, rot=(-0.2, 0, 0))
key_bone("mixamorig:RightForeArm", f, rot=(-0.2, 0, 0))
key_bone("mixamorig:LeftUpLeg", f, rot=(0.2, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(0.5, 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(0.1, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(0.3, 0, 0))
for f in range(16, 21):
t = (f - 16) / 4
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.2))
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.5, 0, 0))
key_bone("mixamorig:LeftLeg", f, rot=(t * 0.8, 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.5, 0, 0))
key_bone("mixamorig:RightLeg", f, rot=(t * 0.8, 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(-2.0 + t * 2.0, 0, -1.5 + t * 1.5))
key_bone("mixamorig:RightArm", f, rot=(-2.0 + t * 2.0, 0, 1.5 - t * 1.5))
# Crouch animation
print(" Crouch...")
action_crouch = bpy.data.actions.new(name="Crouch")
armature.animation_data.action = action_crouch
action_crouch.frame_range = (1, 1)
set_bone_rest(armature)
key_bone("mixamorig:Hips", 1, loc=(0, 0, -0.4))
key_bone("mixamorig:Spine", 1, rot=(0.3, 0, 0))
key_bone("mixamorig:LeftUpLeg", 1, rot=(-0.8, 0, 0))
key_bone("mixamorig:LeftLeg", 1, rot=(1.4, 0, 0))
key_bone("mixamorig:RightUpLeg", 1, rot=(-0.8, 0, 0))
key_bone("mixamorig:RightLeg", 1, rot=(1.4, 0, 0))
key_bone("mixamorig:LeftArm", 1, rot=(-0.5, 0, 0.2))
key_bone("mixamorig:RightArm", 1, rot=(-0.5, 0, -0.2))
# Death animation
print(" Death...")
action_death = bpy.data.actions.new(name="Death")
armature.animation_data.action = action_death
action_death.frame_range = (1, 30)
set_bone_rest(armature)
for f in range(1, 15):
t = (f - 1) / 13
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.5), rot=(-t * 1.2, 0, t * 0.2))
key_bone("mixamorig:Spine", f, rot=(-t * 0.5, t * 0.3, 0))
key_bone("mixamorig:Spine2", f, rot=(-t * 0.4, 0, 0))
key_bone("mixamorig:LeftArm", f, rot=(t * 2.0, 0, -t * 0.5))
key_bone("mixamorig:RightArm", f, rot=(t * 2.0, 0, t * 0.5))
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.3, 0, 0))
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.3, 0, 0))
for f in range(15, 31):
t = (f - 15) / 15
key_bone("mixamorig:Hips", f, loc=(0, 0, -0.5 - t * 0.2), rot=(-1.2 - t * 0.5, 0.8 * (1 - t), 0.2 + t * 0.3))
key_bone("mixamorig:Spine", f, rot=(-0.7 - t * 0.3, 0.5 * (1 - t), 0))
key_bone("mixamorig:Spine2", f, rot=(-0.6 - t * 0.2, 0, 0))
key_bone("mixamorig:LeftHand", f, rot=(t * 0.3, t * 0.5, -0.7 - t * 0.5))
key_bone("mixamorig:RightHand", f, rot=(t * 0.4, -t * 0.6, 0.8 + t * 0.4))
print(f"\nCreated {len(bpy.data.actions)} animations:")
for action in bpy.data.actions:
print(f" {action.name} ({action.frame_range[0]:.0f}-{action.frame_range[1]:.0f})")
# Switch to object mode
bpy.ops.object.mode_set(mode='OBJECT')
# Select all for export
bpy.ops.object.select_all(action='DESELECT')
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
print(f"\n=== Exporting to {output_path} ===")
bpy.ops.export_scene.gltf(
filepath=output_path,
export_format='GLB',
export_apply=True,
export_animations=True,
export_animation_mode='ACTIONS',
export_skins=True,
export_yup=True,
)
print(f"Exported! {os.path.getsize(output_path)} bytes")
# Patch skeleton reference
import struct
import json
with open(output_path, 'rb') as f:
data = f.read()
json_len = struct.unpack('<I', data[12:16])[0]
json_data = data[20:20+json_len]
gltf = json.loads(json_data)
nodes = gltf.get('nodes', [])
skeleton_idx = None
for i, n in enumerate(nodes):
if n.get('name') == 'Rig':
skeleton_idx = i
break
if skeleton_idx is not None:
skins = gltf.get('skins', [])
if skins:
skins[0]['skeleton'] = skeleton_idx
print(f"Set skeleton to {skeleton_idx}")
new_json = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
while len(new_json) % 4 != 0:
new_json += b' '
bin_start = 20 + json_len
bin_len = struct.unpack('<I', data[bin_start:bin_start+4])[0]
new_length = 12 + 8 + len(new_json) + 8 + bin_len
new_data = b'glTF'
new_data += struct.pack('<I', 2)
new_data += struct.pack('<I', new_length)
new_data += struct.pack('<I', len(new_json))
new_data += b'JSON'
new_data += new_json
new_data += data[bin_start:]
with open(output_path, 'wb') as f:
f.write(new_data)
print(f"Final: {os.path.getsize(output_path)} bytes, {len(gltf.get('animations', []))} animations")
print("=== Complete ===")