fix: make the character pipeline work end-to-end in Blender 5.1 + bundle CC0 animation library

Verified the full chain on a real Sketchfab model (Miku UID -> rigged ->
13 clips -> loads and animates in-engine, 24/24 smoke checks pass).

Fixes found while running it for real:
- sketchfab_import: read .sketchfab_token as UTF-16/BOM too (PowerShell's
  `echo >` writes UTF-16LE, which crashed the UTF-8 reader)
- autorig: rig to the animation library's OWN skeleton when _library.glb
  is present (zero retargeting); robust binding — fall back to rigid
  nearest-bone weights when Blender bone-heat fails on complex meshes
  (Miku's joined hair/clothing gave 0 weighted verts); export as
  parent_type=ARMATURE with no modifier (Blender 5.x only emits a glTF
  skin in that exact form — a lingering Armature modifier gave skins:0);
  export_apply=False (applying modifiers baked away the skin); flatten
  the Sketchfab empty hierarchy before binding
- merge_animations: support a single multi-clip library GLB via
  LIBRARY_CLIP_MAP; version-safe fcurve access for Blender 4.4+/5.x
  slotted actions (Action.fcurves was removed)

Assets/tooling:
- bundle assets/characters/animations/_library.glb — CC0 Quaternius
  Universal Animation Library (13 game clips), + .gdignore so Godot
  skips the pipeline-input folder
- requirements.txt (stdlib-only; documents Blender-python separation)
- pipeline docs updated for the bundled library + swap instructions

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-06 12:42:31 -04:00
co-authored by Claude Fable 5
parent d05477c135
commit 64bbbf93c6
11 changed files with 385 additions and 90 deletions
+161 -14
View File
@@ -10,7 +10,15 @@ animation (or CC0 pack retargeted to Mixamo names) can be merged onto the
result with tools/merge_animations.py — rig once, reuse every animation.
Usage:
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height] [library.glb]
Skeleton source, in order of preference:
1. If a 4th arg (or assets/characters/animations/_library.glb) exists, the
armature from that animation-library GLB is used as the template. This
guarantees every library clip fits the character perfectly — same rig,
no retargeting.
2. Otherwise a Mixamo-named skeleton is built from heuristics (works with
Mixamo-skeleton clip files instead).
If the input already contains an armature, it is kept as-is (bones are only
renamed to Mixamo convention when obvious matches exist) and the mesh is
@@ -35,6 +43,10 @@ if len(argv) < 2:
INPUT = argv[0]
OUTPUT = argv[1]
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
_default_library = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "characters", "animations", "_library.glb")
LIBRARY = argv[3] if len(argv) > 3 else (_default_library if os.path.exists(_default_library) else "")
# Mixamo bone names Godot/our animation library expects.
MIX = "mixamorig:"
@@ -115,6 +127,53 @@ def mesh_slice_width(obj, y_frac, height, min_z):
return max_x
def import_library_armature(height):
"""Import the animation library's armature as the rig template.
Rigging to the exact skeleton the clips were authored on means zero
retargeting: merge_animations.py can apply every clip verbatim.
"""
before = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=LIBRARY)
new_objects = [o for o in bpy.data.objects if o not in before]
arm = None
for o in new_objects:
if o.type == "ARMATURE":
arm = o
if not arm:
print("WARNING: no armature in library, falling back to heuristic skeleton")
for o in new_objects:
bpy.data.objects.remove(o, do_unlink=True)
return None
# Drop the library's mannequin mesh and all bundled actions — we only
# want the bare skeleton here; clips get merged in the next stage.
for o in new_objects:
if o is not arm:
bpy.data.objects.remove(o, do_unlink=True)
if arm.animation_data:
arm.animation_data_clear()
for a in [a for a in bpy.data.actions if a not in before_actions]:
bpy.data.actions.remove(a)
# Scale the rig uniformly so its height matches the character's.
bpy.context.view_layer.objects.active = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
arm.location = (0, 0, 0)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
rig_top = max((arm.matrix_world @ b.head_local).z for b in arm.data.bones)
rig_top = max(rig_top, max((arm.matrix_world @ b.tail_local).z for b in arm.data.bones))
if rig_top > 0.01:
s = height / rig_top
arm.scale = (s, s, s)
bpy.ops.object.transform_apply(scale=True)
print(f"Library rig scaled by {s:.3f} to height {height:.2f}")
return arm
def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds.
@@ -167,6 +226,80 @@ def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
return arm
def _count_weighted_verts(mesh):
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
def rigid_nearest_bone_weights(mesh, arm):
"""Assign every vertex fully to its nearest deform bone.
A guaranteed-to-work fallback when bone-heat weighting fails (common on
imported meshes with layered hair/clothing). Produces rigid skinning — no
smooth falloff at joints — but the character follows the skeleton and every
animation plays. Good enough for a fast-paced shooter at gameplay distance.
"""
# Build (name, head, tail) for deform bones in world space.
segs = []
for b in arm.data.bones:
if not b.use_deform:
continue
head = arm.matrix_world @ b.head_local
tail = arm.matrix_world @ b.tail_local
segs.append((b.name, head, tail))
if not segs:
return 0
# Fresh vertex groups.
for vg in list(mesh.vertex_groups):
mesh.vertex_groups.remove(vg)
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
def dist_to_seg(p, a, b):
ab = b - a
denom = ab.dot(ab)
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
return (p - (a + ab * t)).length
mw = mesh.matrix_world
for v in mesh.data.vertices:
p = mw @ v.co
best_name = min(segs, key=lambda s: dist_to_seg(p, s[1], s[2]))[0]
groups[best_name].add([v.index], 1.0, "REPLACE")
return _count_weighted_verts(mesh)
def bind_mesh_to_armature(mesh, arm):
"""Bind mesh to armature robustly and in a form the glTF exporter skins.
1. Try Blender automatic (bone-heat) weights for smooth deformation.
2. If that assigns (almost) nothing, fall back to rigid nearest-bone.
3. Present the result as a parent_type='ARMATURE' relationship with NO
Armature modifier — the Blender 5.x glTF exporter only emits a skin
for that exact configuration (a lingering modifier yields skins:0).
"""
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
weighted = _count_weighted_verts(mesh)
total = len(mesh.data.vertices)
if weighted < total * 0.5:
print(f"Automatic weights covered {weighted}/{total} verts — "
"falling back to rigid nearest-bone weights")
weighted = rigid_nearest_bone_weights(mesh, arm)
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():
clear_scene()
print(f"Importing {INPUT}...")
@@ -186,7 +319,19 @@ def main():
if len(meshes) > 1:
bpy.ops.object.join()
mesh = bpy.context.active_object
# Detach from the importer's hierarchy (Sketchfab wraps meshes in scaled/
# rotated empties) so the mesh sits in clean world space, then bake its
# own transform. Otherwise bind + normalization fight the parent transform.
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
if mesh.parent:
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
for o in list(bpy.data.objects):
if o.type == "EMPTY":
bpy.data.objects.remove(o, do_unlink=True)
if armatures:
print("Input already rigged — keeping existing skeleton, renaming bones")
@@ -214,19 +359,17 @@ def main():
min_z = 0.0
height = TARGET_HEIGHT
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
arm = None
if LIBRARY:
print(f"Using animation library skeleton: {LIBRARY}")
arm = import_library_armature(height)
if not arm:
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
# Bind with automatic weights.
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
print("Bound mesh with automatic weights")
bind_mesh_to_armature(mesh, arm)
print(f"Exporting {OUTPUT}...")
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
@@ -234,7 +377,11 @@ def main():
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_apply=True,
# NOTE: export_apply must stay False — applying modifiers on a skinned
# mesh bakes away the Armature modifier and drops the skin binding
# (glTF then exports bones as plain nodes with skins:0). Transforms are
# already applied in code before binding, so nothing is lost here.
export_apply=False,
export_animations=True,
export_skins=True,
)