# Mixamo FBX → Quaternius-skeleton GLB batch retargeter (Blender headless). # # Sibling of tools/kevin_retarget.py — same world-rotation-delta method, adapted for the # Mixamo rig ("mixamorig:*" bones, prefix auto-detected). Both rigs rest in a T-pose, so # for every mapped bone the world rotation delta from rest transfers 1:1: # q_target_world(f) = (q_src_world(f) · q_src_rest⁻¹) · q_target_rest # Pelvis translation transfers as a WORLD-space delta from rest (rotated through the # frame-alignment quaternion C, scaled by the hips→head size ratio) — unlike Kevin's # raw-fcurve read, because Mixamo roots the armature at the hips with no demo-scene # offset bone, and world deltas survive whatever local axes the importer picked. # Unmapped target bones (leaf/ball bones etc.) ride their parent rigidly at rest offset. # # Usage: # blender --background --python tools/mixamo_retarget.py -- \ # --src "[,…]" --out [--limit N] [--target ] import bpy, sys, os, re from mathutils import Matrix, Quaternion, Vector REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_TARGET_GLTF = os.path.join( REPO_ROOT, "assets", "quaternius", "base-characters", "Universal Base Characters[Standard]", "Base Characters", "Godot - UE", "Superhero_Male_FullBody.gltf") # Mixamo (prefix-stripped) → Quaternius UE names. Fingers map 1:1 (both rigs 3-segment). BONE_MAP = { "Hips": "pelvis", "Spine": "spine_01", "Spine1": "spine_02", "Spine2": "spine_03", "Neck": "neck_01", "Head": "Head", } for S, T in (("Left", "l"), ("Right", "r")): BONE_MAP[f"{S}Shoulder"] = f"clavicle_{T}" BONE_MAP[f"{S}Arm"] = f"upperarm_{T}" BONE_MAP[f"{S}ForeArm"] = f"lowerarm_{T}" BONE_MAP[f"{S}Hand"] = f"hand_{T}" BONE_MAP[f"{S}UpLeg"] = f"thigh_{T}" BONE_MAP[f"{S}Leg"] = f"calf_{T}" BONE_MAP[f"{S}Foot"] = f"foot_{T}" BONE_MAP[f"{S}ToeBase"] = f"ball_{T}" for i in (1, 2, 3): BONE_MAP[f"{S}HandIndex{i}"] = f"index_0{i}_{T}" BONE_MAP[f"{S}HandMiddle{i}"] = f"middle_0{i}_{T}" BONE_MAP[f"{S}HandRing{i}"] = f"ring_0{i}_{T}" BONE_MAP[f"{S}HandPinky{i}"] = f"pinky_0{i}_{T}" BONE_MAP[f"{S}HandThumb{i}"] = f"thumb_0{i}_{T}" def detect_prefix(arm): """Mixamo bones are usually 'mixamorig:Hips' but exports vary ('mixamorig1:', none).""" for b in arm.data.bones: if b.name.endswith("Hips"): return b.name[: -len("Hips")] raise RuntimeError("no *Hips bone found — not a Mixamo rig?") # ── UAL/Quaternius-source mode (2026-07-21): retarget stock-skeleton clips (UAL, # Kevin) onto a DIFFERENT-convention skeleton (e.g. the renamed Mixamo rig — its # bone local frames differ, so raw clip playback tips her face-down). Identity # bone map (names already match), source frame bones are the Quaternius names. QUAT_BONES = ["pelvis", "spine_01", "spine_02", "spine_03", "neck_01", "Head"] for _T in ("l", "r"): QUAT_BONES += [f"clavicle_{_T}", f"upperarm_{_T}", f"lowerarm_{_T}", f"hand_{_T}", f"thigh_{_T}", f"calf_{_T}", f"foot_{_T}", f"ball_{_T}"] for _i in (1, 2, 3): QUAT_BONES += [f"index_0{_i}_{_T}", f"middle_0{_i}_{_T}", f"ring_0{_i}_{_T}", f"pinky_0{_i}_{_T}", f"thumb_0{_i}_{_T}"] UAL_MAP = {n: n for n in QUAT_BONES} # Per-mode source-frame bones (see retarget_one): mixamo | ual SRC_FRAME_BONES = ("Hips", "Head", "LeftArm") def apply_object_transform(obj): """Bake importer object-level rotation/scale into the armature data (meters, Z-up).""" bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) bpy.context.view_layer.update() def clip_name(path): base = os.path.splitext(os.path.basename(path))[0] base = base.replace(" - ", "_").replace(" ", "") return re.sub(r"[^A-Za-z0-9_\-]", "", base) def hierarchy_order(arm): out, stack = [], [b for b in arm.data.bones if b.parent is None] while stack: b = stack.pop(0) out.append(b) stack.extend(b.children) return out def char_frame(rest, hips, head, upper_arm_l): """Orthonormal character frame from the rest pose: columns = (left-arm axis, hips→head up, their cross) — aligns the two rigs whatever world orientation the importer left them in.""" u = (rest[head].translation - rest[hips].translation).normalized() l = (rest[upper_arm_l].to_quaternion() @ Vector((0, 1, 0))).normalized() # bone Y = along bone f = l.cross(u).normalized() l = u.cross(f).normalized() m = Matrix((l, u, f)).transposed() return m.to_quaternion().normalized() def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit): f0, f1 = int(action.frame_range[0]), int(action.frame_range[1]) if src_arm.animation_data is None: src_arm.animation_data_create() src_arm.animation_data.action = action # Blender 5 slotted actions: bind the first slot so the action actually evaluates. if getattr(action, "slots", None) and src_arm.animation_data.action_slot is None: src_arm.animation_data.action_slot = action.slots[0] inv_map = {v: prefix + k for k, v in BONE_MAP.items()} tgt_rest = {b.name: (tgt_arm.matrix_world @ b.matrix_local) for b in tgt_arm.data.bones} src_rest = {b.name: (src_arm.matrix_world @ b.matrix_local) for b in src_arm.data.bones} src_rest_q = {n: m.to_quaternion().normalized() for n, m in src_rest.items()} tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()} C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l") @ char_frame(src_rest, prefix + SRC_FRAME_BONES[0], prefix + SRC_FRAME_BONES[1], prefix + SRC_FRAME_BONES[2]).inverted()).normalized() Cinv = C.inverted() # Uniform size ratio from hips→head world distance (unit/scale agnostic). h_src = (src_rest[prefix + SRC_FRAME_BONES[1]].translation - src_rest[prefix + SRC_FRAME_BONES[0]].translation).length h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length tscale = h_tgt / h_src if h_src > 1e-5 else 1.0 new_act = bpy.data.actions.new(name) if tgt_arm.animation_data is None: tgt_arm.animation_data_create() tgt_arm.animation_data.action = new_act order = hierarchy_order(tgt_arm) tw_inv = tgt_arm.matrix_world.inverted() for pb in tgt_arm.pose.bones: pb.rotation_mode = "QUATERNION" src_hips_rest_t = src_rest[prefix + SRC_FRAME_BONES[0]].translation for f in range(f0, f1 + 1): scn.frame_set(f) dg = bpy.context.evaluated_depsgraph_get() se = src_arm.evaluated_get(dg) spose = {pb.name: (src_arm.matrix_world @ pb.matrix) for pb in se.pose.bones} tgt_world = {} for tb in order: nt = tb.name rest_t = tgt_rest[nt] ns = inv_map.get(nt) if ns is None or ns not in spose: if tb.parent is None: tgt_world[nt] = rest_t else: rel = tgt_rest[tb.parent.name].inverted() @ rest_t tgt_world[nt] = tgt_world[tb.parent.name] @ rel continue q_delta = (spose[ns].to_quaternion().normalized() @ src_rest_q[ns].inverted()).normalized() q_delta = (C @ q_delta @ Cinv).normalized() # express the delta in TARGET world axes q_world = (q_delta @ tgt_rest_q[nt]).normalized() if nt == "pelvis": # World-space hips delta from rest → target world, size-scaled. Carries the # dance's floor work (crouches, drops) without caring about local axes. # × unit: transform_apply bakes the importer's cm→m scale into bone REST data, # but the hips location FCURVES stay in cm — the raw world delta is 100× real. d = C @ (spose[ns].translation - src_hips_rest_t) * tscale * unit t_world = rest_t.translation + d else: rel = tgt_rest[tb.parent.name].inverted() @ rest_t t_world = (tgt_world[tb.parent.name] @ rel).translation tgt_world[nt] = Matrix.LocRotScale(t_world, q_world, Vector((1, 1, 1))) for tb in order: pb = tgt_arm.pose.bones[tb.name] m_arm = tw_inv @ tgt_world[tb.name] if tb.parent: m_parent_arm = tw_inv @ tgt_world[tb.parent.name] rel_rest = tb.parent.matrix_local.inverted() @ tb.matrix_local basis = rel_rest.inverted() @ (m_parent_arm.inverted() @ m_arm) else: basis = tb.matrix_local.inverted() @ m_arm pb.matrix_basis = basis pb.keyframe_insert("rotation_quaternion", frame=f) if tb.name == "pelvis" or tb.parent is None: pb.keyframe_insert("location", frame=f) tgt_arm.animation_data.action = None return new_act def main(): global BONE_MAP, SRC_FRAME_BONES argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] args = dict(zip(argv[::2], argv[1::2])) srcs, out = args["--src"].split(","), args["--out"] limit = int(args.get("--limit", 0)) target = args.get("--target", DEFAULT_TARGET_GLTF) clips_filter = [c.strip().lower() for c in args.get("--clips", "").split(",") if c.strip()] if args.get("--map") == "ual": BONE_MAP = UAL_MAP # identity: source bones already quat-named SRC_FRAME_BONES = ("pelvis", "Head", "upperarm_l") fbx_files = [] for s in srcs: s = s.strip() if os.path.isdir(s): for root, _, files in os.walk(s): fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith((".fbx", ".glb", ".gltf"))] else: fbx_files.append(s) if limit: fbx_files = fbx_files[:limit] print(f"[mixamo] {len(fbx_files)} clips → {out}") bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete() scn = bpy.context.scene scn.render.fps = 30 bpy.ops.import_scene.gltf(filepath=target) tgt_arm = next(o for o in bpy.data.objects if o.type == "ARMATURE") tgt_arm.name = "Armature" # Drop meshes — animations-only GLB (the game binds clips to its own body). for o in [o for o in bpy.data.objects if o.type == "MESH"]: bpy.data.objects.remove(o, do_unlink=True) bpy.context.view_layer.update() apply_object_transform(tgt_arm) done = [] for i, f in enumerate(fbx_files): name = clip_name(f) pre = set(bpy.data.objects) pre_actions = set(bpy.data.actions) if f.lower().endswith((".glb", ".gltf")): bpy.ops.import_scene.gltf(filepath=f) else: bpy.ops.import_scene.fbx(filepath=f) new_objs = [o for o in bpy.data.objects if o not in pre] src_arm = next((o for o in new_objs if o.type == "ARMATURE"), None) new_acts = [a for a in bpy.data.actions if a not in pre_actions] if clips_filter: new_acts = [a for a in new_acts if any(c in a.name.lower() for c in clips_filter)] if src_arm is None or not new_acts: print(f"[mixamo] SKIP (no armature/action): {f}") else: bpy.context.view_layer.update() prefix = "" if args.get("--map") == "ual" else detect_prefix(src_arm) # Importer unit scale (cm→m 0.01) — capture BEFORE transform_apply wipes it; # the hips translation math needs it (fcurves stay in source units). unit = src_arm.scale.x apply_object_transform(src_arm) for act_src in new_acts: out_name = act_src.name if args.get("--map") == "ual" else name if args.get("--map") == "ual": act_src.name += "__src" # free the clean name — Blender dedupes the output to .001 otherwise act = retarget_one(src_arm, tgt_arm, act_src, out_name, scn, prefix, unit) done.append(act) print(f"[mixamo] {i + 1}/{len(fbx_files)} {out_name} ({int(act.frame_range[1])}f, prefix='{prefix}')") for o in new_objs: bpy.data.objects.remove(o, do_unlink=True) for a in new_acts: bpy.data.actions.remove(a) # Stash every baked action on an NLA track so the glTF exporter emits it as an animation. for act in done: tr = tgt_arm.animation_data.nla_tracks.new() tr.name = act.name tr.strips.new(act.name, 1, act) bpy.ops.object.select_all(action="DESELECT") tgt_arm.select_set(True) bpy.context.view_layer.objects.active = tgt_arm bpy.ops.export_scene.gltf( filepath=out, use_selection=True, export_animations=True, export_animation_mode="NLA_TRACKS", export_force_sampling=True, export_optimize_animation_size=False) print(f"[mixamo] EXPORTED {len(done)} clips → {out}") main()