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:
2026-06-23 00:33:40 -04:00
parent 0672bebe7b
commit a83fa41b19
3 changed files with 412 additions and 16 deletions
Binary file not shown.
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Add animations to an already-rigged Miku GLB.
Uses the existing skeleton and skin weights from the original model.
Just adds new animation actions and re-exports.
"""
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 add_animations.py -- <input.glb> <output.glb>")
sys.exit(1)
# Clear scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Import the rigged GLB
print(f"Importing {input_path}...")
bpy.ops.import_scene.gltf(filepath=input_path)
# Find armature and mesh
armatures = [o for o in bpy.data.objects if o.type == 'ARMATURE']
meshes = [o for o in bpy.data.objects if o.type == 'MESH' and o.name != 'Icosphere']
if not armatures:
print("ERROR: No armature found")
sys.exit(1)
arm = armatures[0]
print(f"Armature: {arm.name} ({len(arm.data.bones)} bones)")
# List existing bones
for bone in arm.data.bones:
parent = bone.parent.name if bone.parent else "ROOT"
print(f" {bone.name:25s} parent={parent:25s} head={bone.head_local} tail={bone.tail_local}")
# List meshes
for m in meshes:
print(f"Mesh: {m.name} ({len(m.data.vertices)} verts)")
if m.parent:
print(f" Parent: {m.parent.name} ({m.parent.type})")
for mod in m.modifiers:
if mod.type == 'ARMATURE':
print(f" Armature mod: {mod.object.name if mod.object else 'None'}")
# List existing animations
print(f"\nExisting animations: {len(bpy.data.actions)}")
for action in bpy.data.actions:
print(f" {action.name} ({action.frame_range[0]:.0f}-{action.frame_range[1]:.0f})")
# Check if armature has animation_data
if arm.animation_data:
print(f"\nArmature animation_data: action={arm.animation_data.action}")
print(f" NLA tracks: {len(arm.animation_data.nla_tracks)}")
for track in arm.animation_data.nla_tracks:
print(f" Track: {track.name}")
for strip in track.strips:
print(f" Strip: {strip.name} ({strip.frame_start}-{strip.frame_end})")
# Enter pose mode
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode='POSE')
# Get bone names for reference
bone_names = [b.name for b in arm.data.bones]
print(f"\nBone names: {bone_names}")
# Define animation keyframes using the EXISTING bone names
# The bones are: Hips, Spine, Chest, Neck, Head, LeftUpperArm, LeftLowerArm, LeftHand,
# RightUpperArm, RightLowerArm, RightHand, LeftUpperLeg, LeftLowerLeg, LeftFoot,
# RightUpperLeg, RightLowerLeg, RightFoot, neutral_bone
def set_rest():
for bone in arm.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key(bone_name, frame, loc=None, rot=None):
bone = arm.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
print("\nCreating Idle...")
action_idle = bpy.data.actions.new(name="Idle")
arm.animation_data.action = action_idle
action_idle.frame_range = (1, 30)
set_rest()
key("Spine", 1, rot=(0.02, 0, 0))
key("Spine", 15, rot=(-0.02, 0, 0))
key("Spine", 30, rot=(0.02, 0, 0))
key("LeftUpperArm", 1, rot=(0, 0, 0.05))
key("LeftUpperArm", 15, rot=(0, 0, -0.05))
key("LeftUpperArm", 30, rot=(0, 0, 0.05))
key("RightUpperArm", 1, rot=(0, 0, -0.05))
key("RightUpperArm", 15, rot=(0, 0, 0.05))
key("RightUpperArm", 30, rot=(0, 0, -0.05))
# Walk
print("Creating Walk...")
action_walk = bpy.data.actions.new(name="Walk")
arm.animation_data.action = action_walk
action_walk.frame_range = (1, 24)
set_rest()
for f in range(1, 25):
t = (f - 1) / 24
phase = t * 2 * math.pi
key("Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.01))
key("LeftUpperLeg", f, rot=(math.sin(phase) * 0.4, 0, 0))
key("LeftLowerLeg", f, rot=(max(0, -math.sin(phase) * 0.3 + 0.2), 0, 0))
key("RightUpperLeg", f, rot=(math.sin(phase + math.pi) * 0.4, 0, 0))
key("RightLowerLeg", f, rot=(max(0, -math.sin(phase + math.pi) * 0.3 + 0.2), 0, 0))
key("LeftUpperArm", f, rot=(-math.sin(phase) * 0.3, 0, 0.05))
key("LeftLowerArm", f, rot=(-0.3 + max(0, math.sin(phase) * 0.2), 0, 0))
key("RightUpperArm", f, rot=(-math.sin(phase + math.pi) * 0.3, 0, -0.05))
key("RightLowerArm", f, rot=(-0.3 + max(0, math.sin(phase + math.pi) * 0.2), 0, 0))
key("Spine", f, rot=(0, math.sin(phase) * 0.05, 0))
# Run
print("Creating Run...")
action_run = bpy.data.actions.new(name="Run")
arm.animation_data.action = action_run
action_run.frame_range = (1, 20)
set_rest()
for f in range(1, 21):
t = (f - 1) / 20
phase = t * 2 * math.pi
key("Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.03))
key("LeftUpperLeg", f, rot=(math.sin(phase) * 0.8, 0, 0))
key("LeftLowerLeg", f, rot=(max(0.1, -math.sin(phase) * 0.6 + 0.3), 0, 0))
key("RightUpperLeg", f, rot=(math.sin(phase + math.pi) * 0.8, 0, 0))
key("RightLowerLeg", f, rot=(max(0.1, -math.sin(phase + math.pi) * 0.6 + 0.3), 0, 0))
key("LeftUpperArm", f, rot=(-math.sin(phase) * 0.7, 0, 0.1))
key("LeftLowerArm", f, rot=(-1.0 + max(0, math.sin(phase) * 0.3), 0, 0))
key("RightUpperArm", f, rot=(-math.sin(phase + math.pi) * 0.7, 0, -0.1))
key("RightLowerArm", f, rot=(-1.0 + max(0, math.sin(phase + math.pi) * 0.3), 0, 0))
key("Spine", f, rot=(0.1, math.sin(phase) * 0.08, 0))
# Jump
print("Creating Jump...")
action_jump = bpy.data.actions.new(name="Jump")
arm.animation_data.action = action_jump
action_jump.frame_range = (1, 20)
set_rest()
for f in range(1, 6):
t = (f - 1) / 4
key("Hips", f, loc=(0, 0, -t * 0.2))
key("LeftUpperLeg", f, rot=(-t * 0.6, 0, 0))
key("LeftLowerLeg", f, rot=(t * 1.0, 0, 0))
key("RightUpperLeg", f, rot=(-t * 0.6, 0, 0))
key("RightLowerLeg", f, rot=(t * 1.0, 0, 0))
key("LeftUpperArm", f, rot=(0, 0, -t * 0.5))
key("RightUpperArm", f, rot=(0, 0, t * 0.5))
for f in range(6, 9):
t = (f - 6) / 2
key("Hips", f, loc=(0, 0, -0.2 + t * 0.2))
key("LeftUpperLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
key("LeftLowerLeg", f, rot=(1.0 - t * 0.3, 0, 0))
key("RightUpperLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
key("RightLowerLeg", f, rot=(1.0 - t * 0.3, 0, 0))
key("LeftUpperArm", f, rot=(-t * 2.0, 0, -0.5 - t))
key("RightUpperArm", f, rot=(-t * 2.0, 0, 0.5 + t))
for f in range(9, 16):
key("LeftUpperArm", f, rot=(-2.0, 0, -1.5))
key("RightUpperArm", f, rot=(-2.0, 0, 1.5))
key("LeftLowerArm", f, rot=(-0.2, 0, 0))
key("RightLowerArm", f, rot=(-0.2, 0, 0))
key("LeftUpperLeg", f, rot=(0.2, 0, 0))
key("LeftLowerLeg", f, rot=(0.5, 0, 0))
key("RightUpperLeg", f, rot=(0.1, 0, 0))
key("RightLowerLeg", f, rot=(0.3, 0, 0))
for f in range(16, 21):
t = (f - 16) / 4
key("Hips", f, loc=(0, 0, -t * 0.2))
key("LeftUpperLeg", f, rot=(-t * 0.5, 0, 0))
key("LeftLowerLeg", f, rot=(t * 0.8, 0, 0))
key("RightUpperLeg", f, rot=(-t * 0.5, 0, 0))
key("RightLowerLeg", f, rot=(t * 0.8, 0, 0))
key("LeftUpperArm", f, rot=(-2.0 + t * 2.0, 0, -1.5 + t * 1.5))
key("RightUpperArm", f, rot=(-2.0 + t * 2.0, 0, 1.5 - t * 1.5))
# Crouch
print("Creating Crouch...")
action_crouch = bpy.data.actions.new(name="Crouch")
arm.animation_data.action = action_crouch
action_crouch.frame_range = (1, 1)
set_rest()
key("Hips", 1, loc=(0, 0, -0.4))
key("Spine", 1, rot=(0.3, 0, 0))
key("LeftUpperLeg", 1, rot=(-0.8, 0, 0))
key("LeftLowerLeg", 1, rot=(1.4, 0, 0))
key("RightUpperLeg", 1, rot=(-0.8, 0, 0))
key("RightLowerLeg", 1, rot=(1.4, 0, 0))
key("LeftUpperArm", 1, rot=(-0.5, 0, 0.2))
key("RightUpperArm", 1, rot=(-0.5, 0, -0.2))
# Death
print("Creating Death...")
action_death = bpy.data.actions.new(name="Death")
arm.animation_data.action = action_death
action_death.frame_range = (1, 30)
set_rest()
for f in range(1, 15):
t = (f - 1) / 13
key("Hips", f, loc=(0, 0, -t * 0.5), rot=(-t * 1.2, 0, t * 0.2))
key("Spine", f, rot=(-t * 0.5, t * 0.3, 0))
key("Chest", f, rot=(-t * 0.4, 0, 0))
key("LeftUpperArm", f, rot=(t * 2.0, 0, -t * 0.5))
key("RightUpperArm", f, rot=(t * 2.0, 0, t * 0.5))
key("LeftUpperLeg", f, rot=(-t * 0.3, 0, 0))
key("RightUpperLeg", f, rot=(-t * 0.3, 0, 0))
for f in range(15, 31):
t = (f - 15) / 15
key("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("Spine", f, rot=(-0.7 - t * 0.3, 0.5 * (1 - t), 0))
key("Chest", f, rot=(-0.6 - t * 0.2, 0, 0))
key("LeftHand", f, rot=(t * 0.3, t * 0.5, -0.7 - t * 0.5))
key("RightHand", f, rot=(t * 0.4, -t * 0.6, 0.8 + t * 0.4))
print(f"\nTotal animations: {len(bpy.data.actions)}")
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')
arm.select_set(True)
for m in meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = arm
# Export as GLB
print(f"\nExporting 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') == 'MikuRig':
skeleton_idx = i
break
if skeleton_idx is not None:
skins = gltf.get('skins', [])
if skins:
old = skins[0].get('skeleton')
skins[0]['skeleton'] = skeleton_idx
print(f"Set skeleton from {old} 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 ===")
+92 -16
View File
@@ -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