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:
co-authored by
Claude Fable 5
parent
d05477c135
commit
64bbbf93c6
+161
-14
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user