258 lines
11 KiB
Python
258 lines
11 KiB
Python
|
|
# 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 "<dir-or-fbx>[,<dir-or-fbx>…]" --out <out.glb> [--limit N] [--target <gltf>]
|
|||
|
|
|
|||
|
|
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?")
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 + "Hips", prefix + "Head", prefix + "LeftArm").inverted()).normalized()
|
|||
|
|
Cinv = C.inverted()
|
|||
|
|
|
|||
|
|
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
|
|||
|
|
h_src = (src_rest[prefix + "Head"].translation - src_rest[prefix + "Hips"].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 + "Hips"].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():
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
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")]
|
|||
|
|
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)
|
|||
|
|
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 src_arm is None or not new_acts:
|
|||
|
|
print(f"[mixamo] SKIP (no armature/action): {f}")
|
|||
|
|
else:
|
|||
|
|
bpy.context.view_layer.update()
|
|||
|
|
prefix = 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)
|
|||
|
|
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, prefix, unit)
|
|||
|
|
done.append(act)
|
|||
|
|
print(f"[mixamo] {i + 1}/{len(fbx_files)} {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()
|