#!/usr/bin/env python3 """ Headless Blender auto-rigger for Papaya-Shooter. Takes an UNRIGGED humanoid mesh (GLB/glTF/FBX/OBJ), fits a Mixamo-compatible skeleton to it, binds with automatic weights, and exports a rigged GLB. Because the output skeleton uses standard Mixamo bone names, ANY Mixamo 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 -- [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 re-exported normalized. Use tools/merge_animations.py next either way. Heuristics assume a roughly upright humanoid in T-pose or A-pose. For models that fail (extreme proportions, non-humanoids), use an external auto-rigger (Mixamo web, AccuRig, Tripo/UniRig) and feed the rigged GLB straight to merge_animations.py instead. """ import bpy import sys import os from mathutils import Vector argv = sys.argv argv = argv[argv.index("--") + 1:] if "--" in argv else [] if len(argv) < 2: print("Usage: blender --background --python tools/autorig.py -- [target_height]") sys.exit(1) 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:" # Common bone-name aliases -> Mixamo names, used when a rig already exists. BONE_ALIASES = { "hips": "Hips", "pelvis": "Hips", "spine": "Spine", "spine1": "Spine1", "spine2": "Spine2", "chest": "Spine1", "upperchest": "Spine2", "neck": "Neck", "head": "Head", "leftshoulder": "LeftShoulder", "rightshoulder": "RightShoulder", "leftarm": "LeftArm", "leftupperarm": "LeftArm", "rightarm": "RightArm", "rightupperarm": "RightArm", "leftforearm": "LeftForeArm", "leftlowerarm": "LeftForeArm", "rightforearm": "RightForeArm", "rightlowerarm": "RightForeArm", "lefthand": "LeftHand", "righthand": "RightHand", "leftupleg": "LeftUpLeg", "leftupperleg": "LeftUpLeg", "leftthigh": "LeftUpLeg", "rightupleg": "RightUpLeg", "rightupperleg": "RightUpLeg", "rightthigh": "RightUpLeg", "leftleg": "LeftLeg", "leftlowerleg": "LeftLeg", "leftshin": "LeftLeg", "leftcalf": "LeftLeg", "rightleg": "RightLeg", "rightlowerleg": "RightLeg", "rightshin": "RightLeg", "rightcalf": "RightLeg", "leftfoot": "LeftFoot", "rightfoot": "RightFoot", "lefttoebase": "LeftToeBase", "lefttoe": "LeftToeBase", "righttoebase": "RightToeBase", "righttoe": "RightToeBase", } def clear_scene(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete() for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.actions): for block in list(block_list): if block.users == 0: block_list.remove(block) def import_model(path): ext = os.path.splitext(path)[1].lower() if ext in (".glb", ".gltf"): bpy.ops.import_scene.gltf(filepath=path) elif ext == ".fbx": bpy.ops.import_scene.fbx(filepath=path) elif ext == ".obj": if hasattr(bpy.ops.wm, "obj_import"): bpy.ops.wm.obj_import(filepath=path) else: bpy.ops.import_scene.obj(filepath=path) else: print(f"ERROR: unsupported format {ext}") sys.exit(1) def normalize_alias(name): key = name.lower().replace("mixamorig:", "").replace("_", "").replace(" ", "").replace(".", "") key = key.replace("l_", "left").replace("r_", "right") return BONE_ALIASES.get(key) def rename_existing_rig(arm): renamed = 0 for bone in arm.data.bones: target = normalize_alias(bone.name) if target and not bone.name.startswith(MIX): bone.name = MIX + target renamed += 1 print(f"Renamed {renamed} bones to Mixamo convention") def mesh_slice_width(obj, y_frac, height, min_z): """Max |x| of vertices within a thin horizontal slice at y_frac of height.""" z_lo = min_z + height * (y_frac - 0.03) z_hi = min_z + height * (y_frac + 0.03) max_x = 0.0 mat = obj.matrix_world for v in obj.data.vertices: co = mat @ v.co if z_lo <= co.z <= z_hi: max_x = max(max_x, abs(co.x)) return max_x def fit_arms_to_mesh(arm, mesh): """Rotate each arm bone chain to lie along the mesh's actual arm direction. Sketchfab humanoids are usually modelled in an A-pose (arms angled down), but the library skeleton rests in a T-pose (arms horizontal). If we bind the A-pose mesh to T-pose arm bones, bone-heat weighting fails (bones sit outside the arms) and the animations shove the already-drooped arms across the body. Reorienting the arm bones down the real arm axis makes the rest pose fit the mesh, so bone-heat succeeds. The animations are then retargeted from the library's T-pose rest to this fitted rest in merge_animations.py. """ mw = mesh.matrix_world verts = [mw @ v.co for v in mesh.data.vertices] bpy.context.view_layer.objects.active = arm bpy.ops.object.mode_set(mode="EDIT") eb = arm.data.edit_bones for side, sx in (("L", 1.0), ("R", -1.0)): chain = [eb.get(f"DEF-upper_arm.{side}"), eb.get(f"DEF-forearm.{side}"), eb.get(f"DEF-hand.{side}")] chain = [b for b in chain if b] if not chain: continue shoulder = chain[0].head.copy() # Hand tip = farthest mesh vertex to this side, near arm height. arm_z = shoulder.z cand = [v for v in verts if (v.x * sx) > abs(shoulder.x) * 0.8 and abs(v.z - arm_z) < 0.35] if not cand: continue hand_tip = max(cand, key=lambda v: v.x * sx) cur = chain[-1].tail - shoulder tgt = hand_tip - shoulder if cur.length < 1e-4 or tgt.length < 1e-4: continue rot = cur.normalized().rotation_difference(tgt.normalized()) # Rotate the whole chain about the shoulder joint. for b in chain: b.head = shoulder + rot @ (b.head - shoulder) b.tail = shoulder + rot @ (b.tail - shoulder) sh = eb.get(f"DEF-shoulder.{side}") if sh: sh.tail = chain[0].head bpy.ops.object.mode_set(mode="OBJECT") print("Fitted arm bones to mesh A-pose") 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 clips need only a rest-pose retarget (handled in merge_animations.py). """ 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. Proportions are standard humanoid ratios of total height; good enough for automatic weights on typical game characters. """ bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0)) arm = bpy.context.active_object arm.name = "Armature" eb = arm.data.edit_bones for b in list(eb): eb.remove(b) def z(frac): return min_z + height * frac def add(name, head, tail, parent=None, connect=False): b = eb.new(MIX + name) b.head = Vector(head) b.tail = Vector(tail) if parent: b.parent = eb[MIX + parent] b.use_connect = connect return b sw = shoulder_w * 0.75 # shoulder joint x hw = max(hip_w * 0.45, height * 0.055) # hip joint x add("Hips", (0, 0, z(0.53)), (0, 0, z(0.58))) add("Spine", (0, 0, z(0.58)), (0, 0, z(0.66)), "Hips", True) add("Spine1", (0, 0, z(0.66)), (0, 0, z(0.74)), "Spine", True) add("Spine2", (0, 0, z(0.74)), (0, 0, z(0.82)), "Spine1", True) add("Neck", (0, 0, z(0.82)), (0, 0, z(0.87)), "Spine2", True) add("Head", (0, 0, z(0.87)), (0, 0, z(1.00)), "Neck", True) for side, sx in (("Left", 1), ("Right", -1)): add(f"{side}Shoulder", (sx * sw * 0.25, 0, z(0.80)), (sx * sw, 0, z(0.80)), "Spine2") # Arms along +/-X (T-pose-ish); automatic weights tolerate A-pose meshes. arm_len = height * 0.16 add(f"{side}Arm", (sx * sw, 0, z(0.80)), (sx * (sw + arm_len), 0, z(0.78)), f"{side}Shoulder", True) add(f"{side}ForeArm", (sx * (sw + arm_len), 0, z(0.78)), (sx * (sw + arm_len * 2), 0, z(0.76)), f"{side}Arm", True) add(f"{side}Hand", (sx * (sw + arm_len * 2), 0, z(0.76)), (sx * (sw + arm_len * 2.4), 0, z(0.75)), f"{side}ForeArm", True) add(f"{side}UpLeg", (sx * hw, 0, z(0.52)), (sx * hw, 0, z(0.29)), "Hips") add(f"{side}Leg", (sx * hw, 0, z(0.29)), (sx * hw, 0, z(0.06)), f"{side}UpLeg", True) add(f"{side}Foot", (sx * hw, 0, z(0.06)), (sx * hw, -height * 0.08, z(0.015)), f"{side}Leg", True) add(f"{side}ToeBase", (sx * hw, -height * 0.08, z(0.015)), (sx * hw, -height * 0.13, z(0.015)), f"{side}Foot", True) bpy.ops.object.mode_set(mode="OBJECT") return arm def _count_weighted_verts(mesh): return sum(1 for v in mesh.data.vertices if len(v.groups) > 0) # Bones that must never deform the mesh: the root/master and any rig control # or mechanism bones. A deform-flagged root runs up the body centre-line, so # without this it captures inner-leg/heel verts and stretches them to centre. _NON_DEFORM_HINTS = ("root", "master", "mch-", "mch_", "ctrl", "org-", "-ik", "_ik", "pole", "target", "properties") def _deform_segments(arm): segs = [] for b in arm.data.bones: if not b.use_deform: continue if any(h in b.name.lower() for h in _NON_DEFORM_HINTS): continue head = arm.matrix_world @ b.head_local tail = arm.matrix_world @ b.tail_local segs.append((b.name, head, tail)) return segs def rigid_nearest_bone_weights(mesh, arm): """Weight each vertex to its nearest deform bones (distance-falloff blend). A guaranteed-to-work substitute when bone-heat weighting fails — which it does on most imported characters, whose joined hair/clothing/body meshes have the interior and overlapping geometry the heat solver chokes on. Each vertex is blended across its nearest few bone segments with an inverse-distance falloff, so joints deform smoothly instead of tearing. Control/root bones are excluded (see _NON_DEFORM_HINTS) so the mesh follows real limbs, not the centre-line master bone. """ segs = _deform_segments(arm) if not segs: return 0 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 K = 4 # blend across up to this many nearest bones FALLOFF = 3.0 # higher = tighter to the single nearest bone mw = mesh.matrix_world for v in mesh.data.vertices: p = mw @ v.co dists = sorted(((dist_to_seg(p, s[1], s[2]), s[0]) for s in segs), key=lambda d: d[0])[:K] nearest = max(dists[0][0], 1e-5) # Inverse-distance weights, relative to the nearest bone. raw = [((nearest / max(d, 1e-5)) ** FALLOFF, name) for d, name in dists] total = sum(w for w, _ in raw) for w, name in raw: groups[name].add([v.index], w / total, "REPLACE") return _count_weighted_verts(mesh) def bind_mesh_to_armature(mesh, arm): """Bind mesh to armature so the glTF exporter writes a COMPLETE skin (skin object + node.skin reference + per-vertex JOINTS/WEIGHTS). 1. Try Blender automatic (bone-heat) weights for smooth deformation. 2. If that assigns (almost) nothing — common on layered hair/clothing meshes where bone-heat fails — fall back to rigid nearest-bone. Keep the standard ARMATURE_AUTO result: an Armature modifier plus vertex groups (parent_type stays OBJECT). That is exactly what the exporter needs to write the vertex weights. (An earlier version stripped the modifier and used parent_type='ARMATURE'; that produced an ORPHAN skin with no weights, so the mesh rendered its bind pose — a permanent T-pose — in game.) """ 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") def main(): clear_scene() print(f"Importing {INPUT}...") import_model(INPUT) meshes = [o for o in bpy.data.objects if o.type == "MESH"] armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"] if not meshes: print("ERROR: no mesh in input") sys.exit(1) # Join meshes so weights/normalization apply uniformly. bpy.ops.object.select_all(action="DESELECT") for m in meshes: m.select_set(True) bpy.context.view_layer.objects.active = meshes[0] 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") rename_existing_rig(armatures[0]) else: # Normalize: feet on ground, centered, target height. bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box] min_z = min(c.z for c in bb) max_z = max(c.z for c in bb) cur_h = max_z - min_z if cur_h <= 0: print("ERROR: degenerate mesh bounds") sys.exit(1) s = TARGET_HEIGHT / cur_h mesh.scale = (s, s, s) bpy.ops.object.transform_apply(scale=True) bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box] min_z = min(c.z for c in bb) cx = (min(c.x for c in bb) + max(c.x for c in bb)) / 2 cy = (min(c.y for c in bb) + max(c.y for c in bb)) / 2 mesh.location.x -= cx mesh.location.y -= cy mesh.location.z -= min_z bpy.ops.object.transform_apply(location=True) min_z = 0.0 height = TARGET_HEIGHT arm = None from_library = False if LIBRARY: print(f"Using animation library skeleton: {LIBRARY}") arm = import_library_armature(height) from_library = arm is not None 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) # Fit the arm bones to the mesh's real (usually A-pose) arm direction so # bone-heat weighting fits and the rest pose matches the geometry. The # library-clip retarget in merge_animations.py compensates for the # rest-pose change. if from_library: fit_arms_to_mesh(arm, mesh) bind_mesh_to_armature(mesh, arm) print(f"Exporting {OUTPUT}...") os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True) bpy.ops.export_scene.gltf( filepath=OUTPUT, export_format="GLB", export_yup=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, ) print("Done.") main()