init: animation pipeline hub — Mac↔PC bridge for converting and implementing animations
Skills (.claude/skills/): animation-creation, iclone-video-mocap, pose-estimation (MediaPipe models via LFS), retarget-animations (deprecated). Tools: cc/mixamo/kevin/mocap retargeters + composite baker. Plans: animation-gen-pipeline + skills-adoption. exchange/: incoming-fbx, converted-glb, reference-video (LFS for fbx/glb/mp4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
// Bake a combined "Run_Punch" animation: legs from Jog_Fwd_Loop + upper body from Punch_Jab.
|
||||
//
|
||||
// No Blender required — edits the glTF JSON chunk only. The new animation reuses the source
|
||||
// GLB's accessors/buffer verbatim (same skeleton); arm-lift and wind-up append fresh keyframes
|
||||
// to the binary chunk.
|
||||
//
|
||||
// --mode arms-only : spine+neck+head+legs from RUN (torso upright, gaze forward), arms from PUNCH
|
||||
// --arm-lift DEG : pitch both upperarms up (Y axis, mirrored per arm) → fist at eye level
|
||||
// --windup DEG : synthesize a wind-up on the PUNCHING (left) upperarm: neutral → cocked
|
||||
// (pulled back DEG on Z axis) → extended → recovered. Adds anticipation.
|
||||
// --windup-dur S : wind-up (pull-back) duration in seconds (default 0.18)
|
||||
//
|
||||
// Usage: node tools/bake_run_punch.mjs --mode arms-only --arm-lift 35 --windup 45
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 ? argv[i + 1] : fallback;
|
||||
};
|
||||
const mode = getOpt("--mode", "arms-only");
|
||||
const armLiftDeg = parseFloat(getOpt("--arm-lift", "0"));
|
||||
const windupDeg = parseFloat(getOpt("--windup", "0"));
|
||||
const windupDur = parseFloat(getOpt("--windup-dur", "0.18"));
|
||||
|
||||
const SRC = "assets/quaternius/anim-lib-1/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb";
|
||||
const OUT = "assets/quaternius/anim-combined/Run_Punch.glb";
|
||||
const RUN_NAME = "Jog_Fwd_Loop";
|
||||
const PUNCH_NAME = "Punch_Jab";
|
||||
const OUT_CLIP = "Run_Punch";
|
||||
const THROW_DUR = 0.15; // cocked → extended (the fast thrust)
|
||||
|
||||
function parseGlb(buf) {
|
||||
if (buf.toString("ascii", 0, 4) !== "glTF") throw new Error("not a GLB");
|
||||
const length = buf.readUInt32LE(8);
|
||||
let o = 12, json = null, bin = null;
|
||||
while (o < length) {
|
||||
const clen = buf.readUInt32LE(o);
|
||||
const ctype = buf.toString("ascii", o + 4, o + 8);
|
||||
const data = buf.subarray(o + 8, o + 8 + clen);
|
||||
if (ctype === "JSON") json = JSON.parse(data.toString("utf8"));
|
||||
else if (ctype.startsWith("BIN")) bin = Buffer.from(data);
|
||||
o += 8 + clen;
|
||||
}
|
||||
return { json, bin };
|
||||
}
|
||||
|
||||
function writeGlb(jsonObj, binBuf, outPath) {
|
||||
const jsonBytes = Buffer.from(JSON.stringify(jsonObj), "utf8");
|
||||
const jsonPad = (4 - (jsonBytes.length % 4)) % 4;
|
||||
const jsonPadded = Buffer.concat([jsonBytes, Buffer.alloc(jsonPad, 0x20)]);
|
||||
const binPad = (4 - (binBuf.length % 4)) % 4;
|
||||
const binPadded = Buffer.concat([binBuf, Buffer.alloc(binPad, 0x00)]);
|
||||
const total = 12 + 8 + jsonPadded.length + 8 + binPadded.length;
|
||||
const out = Buffer.alloc(total);
|
||||
let p = 0;
|
||||
out.write("glTF", p, "ascii"); p += 4;
|
||||
out.writeUInt32LE(2, p); p += 4;
|
||||
out.writeUInt32LE(total, p); p += 4;
|
||||
out.writeUInt32LE(jsonPadded.length, p); p += 4; out.write("JSON", p, "ascii"); p += 4;
|
||||
jsonPadded.copy(out, p); p += jsonPadded.length;
|
||||
out.writeUInt32LE(binPadded.length, p); p += 4; out.write("BIN\0", p, "ascii"); p += 4;
|
||||
binPadded.copy(out, p); p += binPadded.length;
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, out);
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── Quaternion + vector math (xyzw) ──
|
||||
function qMul(a, b) {
|
||||
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b;
|
||||
return [aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz];
|
||||
}
|
||||
function qAxis(axis, deg) {
|
||||
const sign = deg < 0 ? -1 : 1;
|
||||
const r = Math.abs(deg) * Math.PI / 180, h = Math.sin(r / 2) * sign;
|
||||
const v = [0, 0, 0]; v[axis] = h;
|
||||
return [v[0], v[1], v[2], Math.cos(r / 2)];
|
||||
}
|
||||
function qSlerp(a, b, t) {
|
||||
let [ax, ay, az, aw] = a;
|
||||
let [bx, by, bz, bw] = b;
|
||||
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
||||
if (dot < 0) { bx = -bx; by = -by; bz = -bz; bw = -bw; dot = -dot; }
|
||||
if (dot > 0.9995) {
|
||||
return [ax + t * (bx - ax), ay + t * (by - ay), az + t * (bz - az), aw + t * (bw - aw)].map(v => v);
|
||||
}
|
||||
const theta = Math.acos(dot), sinT = Math.sin(theta);
|
||||
const s0 = Math.sin((1 - t) * theta) / sinT, s1 = Math.sin(t * theta) / sinT;
|
||||
return [s0 * ax + s1 * bx, s0 * ay + s1 * by, s0 * az + s1 * bz, s0 * aw + s1 * bw];
|
||||
}
|
||||
// Lift quaternion per arm side (Y axis, mirrored: right +, left −). Raises the fist.
|
||||
const qLiftFor = (bone, deg) => bone.endsWith("_l") ? qAxis(1, -deg) : qAxis(1, deg);
|
||||
// Retract (wind-up) quaternion per arm side (Z axis, mirrored). Pulls the fist backward.
|
||||
const qRetractFor = (bone, deg) => bone.endsWith("_l") ? qAxis(2, -deg) : qAxis(2, deg);
|
||||
|
||||
const isLowerBody = b => !b ? false : b === "root" || b === "pelvis" || /thigh|calf|foot|ball/i.test(b);
|
||||
const isArm = b => !b ? false : /clavicle|upperarm|lowerarm|^hand_|hand_l|hand_r|index_|middle_|pinky_|ring_|thumb_/i.test(b);
|
||||
const fromRun = mode === "arms-only" ? (b => !isArm(b))
|
||||
: mode === "no-head" ? (b => isLowerBody(b) || b === "neck_01" || b === "Head")
|
||||
: isLowerBody;
|
||||
|
||||
const { json, bin } = parseGlb(fs.readFileSync(SRC));
|
||||
const nodes = json.nodes;
|
||||
const anims = json.animations;
|
||||
const accessors = json.accessors;
|
||||
const bufferViews = json.bufferViews;
|
||||
const run = anims.find(a => (a.name || "") === RUN_NAME);
|
||||
const punch = anims.find(a => (a.name || "") === PUNCH_NAME);
|
||||
if (!run || !punch) throw new Error(`source clips not found`);
|
||||
|
||||
// parent map (glTF uses children arrays)
|
||||
const parentOf = new Array(nodes.length).fill(-1);
|
||||
for (let i = 0; i < nodes.length; i++) if (nodes[i].children) for (const c of nodes[i].children) parentOf[c] = i;
|
||||
const ni = nm => nodes.findIndex(n => (n.name || "") === nm);
|
||||
|
||||
const newSamplers = [];
|
||||
const newChannels = [];
|
||||
const runBones = new Set(), punchBones = new Set();
|
||||
|
||||
function addChannels(srcAnim, include, set) {
|
||||
for (const ch of srcAnim.channels) {
|
||||
const bone = nodes[ch.target.node]?.name;
|
||||
if (!include(bone)) continue;
|
||||
set.add(bone);
|
||||
const ss = srcAnim.samplers[ch.sampler];
|
||||
newSamplers.push({ input: ss.input, output: ss.output, interpolation: ss.interpolation });
|
||||
newChannels.push({ sampler: newSamplers.length - 1, target: { node: ch.target.node, path: ch.target.path } });
|
||||
}
|
||||
}
|
||||
addChannels(run, fromRun, runBones);
|
||||
addChannels(punch, b => !fromRun(b), punchBones);
|
||||
|
||||
// ── Binary-append helpers (append float data + create bufferView + accessor) ──
|
||||
let outBin = bin;
|
||||
function appendFloats(floats, type, count) {
|
||||
const comps = type === "SCALAR" ? 1 : type === "VEC3" ? 3 : 4;
|
||||
const byteOffset = outBin.length;
|
||||
const buf = Buffer.alloc(count * comps * 4);
|
||||
for (let i = 0; i < floats.length; i++) buf.writeFloatLE(floats[i], i * 4);
|
||||
outBin = Buffer.concat([outBin, buf]);
|
||||
const bvIdx = bufferViews.length;
|
||||
bufferViews.push({ buffer: 0, byteOffset, byteLength: count * comps * 4 });
|
||||
const accIdx = accessors.length;
|
||||
const min = new Array(comps).fill(Infinity), max = new Array(comps).fill(-Infinity);
|
||||
for (let i = 0; i < count; i++) for (let c = 0; c < comps; c++) {
|
||||
const v = floats[i * comps + c];
|
||||
if (v < min[c]) min[c] = v; if (v > max[c]) max[c] = v;
|
||||
}
|
||||
accessors.push({ bufferView: bvIdx, componentType: 5126, count, type, min, max });
|
||||
return accIdx;
|
||||
}
|
||||
|
||||
// ── --arm-lift: pitch both upperarms up (mirrored Y) ──
|
||||
if (armLiftDeg !== 0) {
|
||||
for (const ch of newChannels) {
|
||||
const bone = nodes[ch.target.node]?.name;
|
||||
if ((bone !== "upperarm_l" && bone !== "upperarm_r") || ch.target.path !== "rotation") continue;
|
||||
const sampler = newSamplers[ch.sampler];
|
||||
const outAcc = accessors[sampler.output];
|
||||
const bv = bufferViews[outAcc.bufferView];
|
||||
const off = (bv.byteOffset || 0) + (outAcc.byteOffset || 0);
|
||||
const count = outAcc.count;
|
||||
const lift = qLiftFor(bone, armLiftDeg);
|
||||
const floats = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const q = [bin.readFloatLE(off + (i * 4) * 4), bin.readFloatLE(off + (i * 4 + 1) * 4),
|
||||
bin.readFloatLE(off + (i * 4 + 2) * 4), bin.readFloatLE(off + (i * 4 + 3) * 4)];
|
||||
const r = qMul(lift, q);
|
||||
floats.push(...r);
|
||||
}
|
||||
sampler.output = appendFloats(floats, "VEC4", count);
|
||||
}
|
||||
}
|
||||
|
||||
// ── --windup: synthesize neutral → cocked → extended → recovered on the punching (left) upperarm ──
|
||||
let windupApplied = false;
|
||||
if (windupDeg !== 0) {
|
||||
const bone = "upperarm_l";
|
||||
const boneIdx = ni(bone);
|
||||
const ch = newChannels.find(c => c.target.node === boneIdx && c.target.path === "rotation");
|
||||
if (ch) {
|
||||
const sampler = newSamplers[ch.sampler];
|
||||
const outAcc = accessors[sampler.output];
|
||||
const inAcc = accessors[sampler.input];
|
||||
const bvO = bufferViews[outAcc.bufferView], bvI = bufferViews[inAcc.bufferView];
|
||||
const offO = (bvO.byteOffset || 0) + (outAcc.byteOffset || 0);
|
||||
const offI = (bvI.byteOffset || 0) + (inAcc.byteOffset || 0);
|
||||
const count = outAcc.count;
|
||||
const rots = [], times = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rots.push([outBin.readFloatLE(offO + (i * 4) * 4), outBin.readFloatLE(offO + (i * 4 + 1) * 4),
|
||||
outBin.readFloatLE(offO + (i * 4 + 2) * 4), outBin.readFloatLE(offO + (i * 4 + 3) * 4)]);
|
||||
times.push(outBin.readFloatLE(offI + i * 4));
|
||||
}
|
||||
// Note: rots/times read from outBin (post-lift) so the lift is preserved in neutral/extended/recover.
|
||||
const L = times[times.length - 1]; // clip length (~0.93s, matches run)
|
||||
const neutral = rots[0];
|
||||
const recovered = rots[rots.length - 1];
|
||||
// find the extension key = max forward (Z) hand via FK on the left arm chain
|
||||
const chain = [];
|
||||
{ let i = boneIdx; while (i >= 0) { chain.unshift(i); i = parentOf[i]; } }
|
||||
function handZ(upperarmRot) {
|
||||
let M = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
|
||||
for (const idx of chain) {
|
||||
const nm = nodes[idx].name;
|
||||
let rot = nodes[idx].rotation ? [...nodes[idx].rotation] : [0,0,0,1];
|
||||
if (nm === bone && upperarmRot) rot = upperarmRot;
|
||||
else {
|
||||
const pch = punch.channels.find(c => c.target.node === idx && c.target.path === "rotation");
|
||||
if (pch) rot = rots0(pch); // falls back below
|
||||
}
|
||||
const r = rot, t = nodes[idx].translation || [0,0,0];
|
||||
const q2m = q => { const[x,y,z,w]=q,x2=x*x,y2=y*y,z2=z*z,xy=x*y,xz=x*z,yz=y*z,wx=w*x,wy=w*y,wz=w*z;
|
||||
return[[1-2*(y2+z2),2*(xy-wz),2*(xz+wy)],[2*(xy+wz),1-2*(x2+z2),2*(yz-wx)],[2*(xz-wy),2*(yz+wx),1-2*(x2+y2)]]; };
|
||||
const m = q2m(r);
|
||||
M = mm(M, [[m[0][0],m[0][1],m[0][2],t[0]],[m[1][0],m[1][1],m[1][2],t[1]],[m[2][0],m[2][1],m[2][2],t[2]],[0,0,0,1]]);
|
||||
}
|
||||
return M[2][3]; // hand world Z (forward)
|
||||
}
|
||||
function mm(a,b){const r=[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];for(let i=0;i<4;i++)for(let j=0;j<4;j++){let s=0;for(let k=0;k<4;k++)s+=a[i][k]*b[k][j];r[i][j]=s;}return r;}
|
||||
function rots0(pch){return null;} // placeholder — replaced by per-key lookup below
|
||||
// Proper FK: find extension key by scanning punch upperarm keys
|
||||
let extK = 0, extZ = -Infinity;
|
||||
for (let k = 0; k < count; k++) {
|
||||
// build chain with this key's upperarm rotation
|
||||
let M = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
|
||||
for (const idx of chain) {
|
||||
const nm = nodes[idx].name;
|
||||
let rot = nodes[idx].rotation ? [...nodes[idx].rotation] : [0,0,0,1];
|
||||
if (nm === bone) rot = rots[k];
|
||||
else {
|
||||
const pch = punch.channels.find(c => c.target.node === idx && c.target.path === "rotation");
|
||||
if (pch) {
|
||||
const ps = punch.samplers[pch.sampler];
|
||||
const pa = accessors[ps.output]; const pbv = bufferViews[pa.bufferView];
|
||||
const poff = (pbv.byteOffset||0)+(pa.byteOffset||0);
|
||||
rot = [bin.readFloatLE(poff+(k*4)*4),bin.readFloatLE(poff+(k*4+1)*4),bin.readFloatLE(poff+(k*4+2)*4),bin.readFloatLE(poff+(k*4+3)*4)];
|
||||
}
|
||||
}
|
||||
const q2m = q => { const[x,y,z,w]=q,x2=x*x,y2=y*y,z2=z*z,xy=x*y,xz=x*z,yz=y*z,wx=w*x,wy=w*y,wz=w*z;
|
||||
return[[1-2*(y2+z2),2*(xy-wz),2*(xz+wy)],[2*(xy+wz),1-2*(x2+z2),2*(yz-wx)],[2*(xz-wy),2*(yz+wx),1-2*(x2+y2)]]; };
|
||||
const m = q2m(rot), t = nodes[idx].translation||[0,0,0];
|
||||
M = mm(M, [[m[0][0],m[0][1],m[0][2],t[0]],[m[1][0],m[1][1],m[1][2],t[1]],[m[2][0],m[2][1],m[2][2],t[2]],[0,0,0,1]]);
|
||||
}
|
||||
const z = M[2][3];
|
||||
if (z > extZ) { extZ = z; extK = k; }
|
||||
}
|
||||
const extended = rots[extK];
|
||||
// cocked = neutral pulled back (retract in bone-local space, pre-multiply)
|
||||
const retract = qRetractFor(bone, windupDeg);
|
||||
const cocked = qMul(retract, neutral);
|
||||
// 4-key synthesized curve: neutral@0 → cocked@windUpDur → extended@(+throw) → recovered@L
|
||||
const extT = windupDur + THROW_DUR;
|
||||
const sTimes = [0, windupDur, extT, L];
|
||||
const sRots = [neutral, cocked, extended, recovered];
|
||||
const floats = [];
|
||||
for (const q of sRots) floats.push(...q);
|
||||
sampler.input = appendFloats(sTimes, "SCALAR", 4);
|
||||
sampler.output = appendFloats(floats, "VEC4", 4);
|
||||
sampler.interpolation = "LINEAR";
|
||||
windupApplied = true;
|
||||
console.log(` wind-up: punching arm '${bone}' → neutral@0 → cocked@${windupDur}s → extended@${extT}s (key ${extK}) → recovered@${L.toFixed(3)}s (retract Z ${windupDeg}°)`);
|
||||
}
|
||||
}
|
||||
|
||||
json.buffers[0].byteLength = outBin.length;
|
||||
const runPunch = { name: OUT_CLIP, channels: newChannels, samplers: newSamplers };
|
||||
const outJson = { ...json, animations: [runPunch] };
|
||||
const bytes = writeGlb(outJson, outBin, OUT);
|
||||
|
||||
const combinedLen = Math.max(...newSamplers.map(s => accessors[s.input]?.max?.[0] ?? 0));
|
||||
console.log(`baked ${OUT} [mode=${mode}, arm-lift=${armLiftDeg}°, windup=${windupDeg}°]`);
|
||||
console.log(` from RUN(${runBones.size}) + PUNCH(${punchBones.size}) = ${runBones.size + punchBones.size} bones, len=${combinedLen.toFixed(3)}s, ${(bytes/1024/1024).toFixed(2)}MB`);
|
||||
@@ -0,0 +1,268 @@
|
||||
# Reallusion Character Creator / iClone FBX → Quaternius-skeleton GLB retargeter.
|
||||
#
|
||||
# Sibling of tools/mixamo_retarget.py — identical world-rotation-delta method (CC rigs
|
||||
# rest in T-pose, verified on ActorBuild exports), adapted for the CC_Base_* skeleton:
|
||||
# - fixed "CC_Base_" naming, hips bone = CC_Base_Hip (parent of Pelvis+Waist)
|
||||
# - twist helpers (UpperarmTwist01...) are unmapped → ride their parent at rest offset
|
||||
# - game-optimized CC exports have 1-segment fingers (Index1, no Index2/3) — mapped
|
||||
# where present, absent segments ride the parent
|
||||
# - iClone FBX takes named "*TempMotion" (2 frames) mean the export had NO motion —
|
||||
# the take is skipped with a loud warning (re-export with Include Motion).
|
||||
#
|
||||
# Usage:
|
||||
# blender --background --python tools/cc_retarget.py -- \
|
||||
# --src "<dir-or-fbx>[,<more>…]" --out <out.glb> [--name <ClipName>] [--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")
|
||||
|
||||
BONE_MAP = {
|
||||
"CC_Base_Hip": "pelvis",
|
||||
"CC_Base_Waist": "spine_01",
|
||||
"CC_Base_Spine01": "spine_02",
|
||||
"CC_Base_Spine02": "spine_03",
|
||||
"CC_Base_NeckTwist01": "neck_01",
|
||||
"CC_Base_Head": "Head",
|
||||
}
|
||||
for S, T in (("L", "l"), ("R", "r")):
|
||||
BONE_MAP[f"CC_Base_{S}_Clavicle"] = f"clavicle_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Upperarm"] = f"upperarm_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Forearm"] = f"lowerarm_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Hand"] = f"hand_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Thigh"] = f"thigh_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Calf"] = f"calf_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Foot"] = f"foot_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_ToeBase"] = f"ball_{T}"
|
||||
for i in (1, 2, 3):
|
||||
BONE_MAP[f"CC_Base_{S}_Thumb{i}"] = f"thumb_0{i}_{T}"
|
||||
# game-optimized CC rigs: one segment per finger
|
||||
BONE_MAP[f"CC_Base_{S}_Index1"] = f"index_01_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Mid1"] = f"middle_01_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Ring1"] = f"ring_01_{T}"
|
||||
BONE_MAP[f"CC_Base_{S}_Pinky1"] = f"pinky_01_{T}"
|
||||
|
||||
|
||||
def apply_object_transform(obj):
|
||||
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):
|
||||
u = (rest[head].translation - rest[hips].translation).normalized()
|
||||
l = (rest[upper_arm_l].to_quaternion() @ Vector((0, 1, 0))).normalized()
|
||||
f = l.cross(u).normalized()
|
||||
l = u.cross(f).normalized()
|
||||
return Matrix((l, u, f)).transposed().to_quaternion().normalized()
|
||||
|
||||
|
||||
def action_fcurves(a):
|
||||
"""All fcurves across Blender 5 slotted-action layers/strips (legacy fallback)."""
|
||||
if hasattr(a, "fcurves") and a.fcurves is not None:
|
||||
return list(a.fcurves)
|
||||
fcs = []
|
||||
for layer in getattr(a, "layers", []):
|
||||
for strip in layer.strips:
|
||||
for bag in strip.channelbags:
|
||||
fcs.extend(bag.fcurves)
|
||||
return fcs
|
||||
|
||||
|
||||
def pick_action(src_arm, acts):
|
||||
"""The armature's motion take: fcurves must reference CC_Base_ bones; longest wins.
|
||||
TempMotion 2-frame placeholders (export without motion) are rejected."""
|
||||
best, best_len = None, 0.0
|
||||
for a in acts:
|
||||
if not any("CC_Base_" in fc.data_path for fc in action_fcurves(a)):
|
||||
continue
|
||||
length = a.frame_range[1] - a.frame_range[0]
|
||||
if length > best_len:
|
||||
best, best_len = a, length
|
||||
if best is not None and best_len < 5 and "TempMotion" in best.name:
|
||||
raise RuntimeError(
|
||||
f"'{best.name}' is a {int(best_len)+1}-frame iClone placeholder — the FBX was "
|
||||
"exported WITHOUT motion. Re-export with Include Motion / Export Motion checked.")
|
||||
return best
|
||||
|
||||
|
||||
def retarget_one(src_arm, tgt_arm, action, name, scn, 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
|
||||
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: 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, "CC_Base_Hip", "CC_Base_Head", "CC_Base_L_Upperarm").inverted()).normalized()
|
||||
Cinv = C.inverted()
|
||||
|
||||
h_src = (src_rest["CC_Base_Head"].translation - src_rest["CC_Base_Hip"].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["CC_Base_Hip"].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()
|
||||
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
|
||||
if nt == "pelvis":
|
||||
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"]
|
||||
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
||||
forced_name = args.get("--name")
|
||||
|
||||
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)
|
||||
print(f"[cc] {len(fbx_files)} clips → {out}")
|
||||
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
scn = bpy.context.scene
|
||||
scn.render.fps = 60 # iClone exports at 60; glTF export resamples fine
|
||||
|
||||
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"
|
||||
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 = forced_name if (forced_name and len(fbx_files) == 1) else 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" and any("CC_Base_Hip" == b.name for b in o.data.bones)), 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"[cc] SKIP (no CC armature/action): {f}")
|
||||
else:
|
||||
bpy.context.view_layer.update()
|
||||
unit = src_arm.scale.x # capture BEFORE transform_apply (fcurves stay in cm)
|
||||
apply_object_transform(src_arm)
|
||||
act = pick_action(src_arm, new_acts)
|
||||
if act is None:
|
||||
print(f"[cc] SKIP (no armature take): {f}")
|
||||
else:
|
||||
baked = retarget_one(src_arm, tgt_arm, act, name, scn, unit)
|
||||
done.append(baked)
|
||||
print(f"[cc] {i + 1}/{len(fbx_files)} {name} "
|
||||
f"({int(baked.frame_range[1])}f @60, from take '{act.name}')")
|
||||
for o in new_objs:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in new_acts:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
if not done:
|
||||
raise RuntimeError("nothing retargeted — see messages above")
|
||||
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"[cc] EXPORTED {len(done)} clips → {out}")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,254 @@
|
||||
# Kevin Iglesias FBX → Quaternius-skeleton GLB batch retargeter (Blender 5.x, headless).
|
||||
#
|
||||
# THE one-time migration path (Ozan 2026-07-07 "I want to do this migration once"):
|
||||
# originals live in ariki-assets/animations/kevin-iglesias (Unity FBX, T-pose "B-*" rig);
|
||||
# this script bakes them onto the game's Quaternius UE-named skeleton so the runtime loads
|
||||
# them exactly like the UAL GLBs (PlayerController.LoadUalLibrary) — no runtime retarget,
|
||||
# no deprecated Godot BoneMap import path.
|
||||
#
|
||||
# Method: world-rotation-delta retarget. Both rigs rest in a T-POSE (verified 2026-07-07:
|
||||
# Quaternius upperarm_l dir=(1,0,0), Kevin B-upperArm.L dir=(1,0,0)), 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 additionally carries scaled root translation. Unmapped target bones (spine_03,
|
||||
# leaf/ball bones, missing fingers) ride their parent rigidly at rest offset.
|
||||
#
|
||||
# Usage:
|
||||
# blender --background --python tools/kevin_retarget.py -- \
|
||||
# --src "<dir-or-fbx>[,<dir-or-fbx>…]" --out <out.glb> [--limit N]
|
||||
#
|
||||
# Each FBX becomes one glTF animation named from the file: "HumanM@Eat01_R - Loop" →
|
||||
# "Eat01_R_Loop". Output GLB = armature + all animations (no mesh).
|
||||
|
||||
import bpy, sys, os, re
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
TARGET_GLTF = "/Users/behcetozanbozkurt/Documents/tinqs-ltd/ariki-game/assets/quaternius/base-characters/Universal Base Characters[Standard]/Base Characters/Godot - UE/Superhero_Male_FullBody.gltf"
|
||||
|
||||
# Kevin "B-*" → Quaternius UE names. Fingers map 1:1 (both rigs have 3-segment fingers).
|
||||
BONE_MAP = {
|
||||
"B-hips": "pelvis",
|
||||
"B-spine": "spine_01",
|
||||
"B-chest": "spine_02",
|
||||
"B-neck": "neck_01",
|
||||
"B-head": "Head",
|
||||
}
|
||||
for S, T in (("L", "l"), ("R", "r")):
|
||||
BONE_MAP[f"B-shoulder.{S}"] = f"clavicle_{T}"
|
||||
BONE_MAP[f"B-upperArm.{S}"] = f"upperarm_{T}"
|
||||
BONE_MAP[f"B-forearm.{S}"] = f"lowerarm_{T}"
|
||||
BONE_MAP[f"B-hand.{S}"] = f"hand_{T}"
|
||||
BONE_MAP[f"B-thigh.{S}"] = f"thigh_{T}"
|
||||
BONE_MAP[f"B-shin.{S}"] = f"calf_{T}"
|
||||
BONE_MAP[f"B-foot.{S}"] = f"foot_{T}"
|
||||
BONE_MAP[f"B-toe.{S}"] = f"ball_{T}"
|
||||
for i in (1, 2, 3):
|
||||
BONE_MAP[f"B-indexFinger0{i}.{S}"] = f"index_0{i}_{T}"
|
||||
BONE_MAP[f"B-middleFinger0{i}.{S}"] = f"middle_0{i}_{T}"
|
||||
BONE_MAP[f"B-ringFinger0{i}.{S}"] = f"ring_0{i}_{T}"
|
||||
BONE_MAP[f"B-pinky0{i}.{S}"] = f"pinky_0{i}_{T}"
|
||||
BONE_MAP[f"B-thumb0{i}.{S}"] = f"thumb_0{i}_{T}"
|
||||
INV_MAP = {v: k for k, v in BONE_MAP.items()}
|
||||
|
||||
|
||||
def apply_object_transform(obj):
|
||||
"""Bake the importer's object-level rotation/scale into the armature data. Makes Blender
|
||||
world == data space (meters, Z-up) for BOTH rigs, so exported glTF matches what Godot's
|
||||
importer produced for the original Quaternius file — same units, same axes, same rests."""
|
||||
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.split("@", 1)[-1] # drop HumanM@ / HumanF@ / Villager@ …
|
||||
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). Lets us align two rigs whatever world orientation their importer left them in
|
||||
— the old pipeline's limb bug was exactly an unhandled world-frame mismatch."""
|
||||
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, 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]
|
||||
|
||||
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()}
|
||||
|
||||
# World-frame alignment C: rotates source-world axes onto target-world axes (both rigs are
|
||||
# T-pose, so the character frames coincide semantically). All rotation deltas and root
|
||||
# translation deltas are conjugated/rotated through C before applying to the target.
|
||||
C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l")
|
||||
@ char_frame(src_rest, "B-hips", "B-head", "B-upperArm.L").inverted()).normalized()
|
||||
Cinv = C.inverted()
|
||||
|
||||
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
|
||||
h_src = (src_rest["B-head"].translation - src_rest["B-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"
|
||||
|
||||
# ── Pelvis translation: the raw hips fcurve channel ──
|
||||
# Calibrated on Eat (static ≈0) + SleepGround Down (y 0→−86cm): Kevin's hips LOCAL location
|
||||
# is placement-free (the demo-scene offset lives on the unmapped B-root), cm-scaled, and
|
||||
# standing = 0 with −Y = down, X/Z = horizontal. Maps straight onto the target's Z-up world:
|
||||
# (x, z, y)·unit·tscale added to the pelvis rest. No world-frame algebra, no rest quirks.
|
||||
|
||||
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":
|
||||
loc = se.pose.bones["B-hips"].location
|
||||
t_world = rest_t.translation + Vector((loc.x, loc.z, loc.y)) * unit * tscale
|
||||
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, limit = args["--src"].split(","), args["--out"], int(args.get("--limit", 0))
|
||||
|
||||
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"[kevin] {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_GLTF)
|
||||
tgt_arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
|
||||
tgt_arm.name = "Armature"
|
||||
# Drop meshes — animations-only GLB (small, loads fast; 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) | set()
|
||||
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"[kevin] SKIP (no armature/action): {f}")
|
||||
else:
|
||||
bpy.context.view_layer.update()
|
||||
# transform_apply keeps the ROTATION math in one consistent world frame (engine-
|
||||
# validated); the pelvis TRANSLATION reads the raw hips fcurve channel instead, so
|
||||
# capture the importer's unit scale (cm→m 0.01) before apply wipes it.
|
||||
unit = src_arm.scale.x
|
||||
apply_object_transform(src_arm)
|
||||
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, unit)
|
||||
done.append(act)
|
||||
print(f"[kevin] {i + 1}/{len(fbx_files)} {name} ({int(act.frame_range[1])}f)")
|
||||
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"[kevin] EXPORTED {len(done)} clips → {out}")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
# 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()
|
||||
@@ -0,0 +1,274 @@
|
||||
# MediaPipe pose JSON → Quaternius-skeleton GLB animation (Blender headless).
|
||||
#
|
||||
# Sibling of tools/mixamo_retarget.py — same target rig, same NLA/export tail, but the
|
||||
# source is not an armature: it's per-frame 3D world landmarks from the pose-estimation
|
||||
# skill (extract_pose.py). Rotations are SOLVED, not transferred:
|
||||
# - limb bones (arms/legs/hands/feet): shortest-arc delta from the bone's REST world
|
||||
# direction to the landmark-derived direction, applied on top of the rest rotation —
|
||||
# preserves the rig's rest roll, so skinning stays sane (no twist control in v1).
|
||||
# - pelvis + spine chain: full-basis deltas built from the hip line and the hips→
|
||||
# shoulders up vector; spine_01..03 slerp from pelvis delta toward chest delta.
|
||||
# - pelvis HEIGHT: MediaPipe world landmarks are hip-centered (origin = mid-hips every
|
||||
# frame), so global translation is lost. We reconstruct the vertical: per-frame
|
||||
# pelvis-above-ankle extent vs the standing extent (95th pct) scales the rest height
|
||||
# — carries squats/crouches, which is most of a haka.
|
||||
# Landmarks are box-smoothed over time before solving (mocap jitter).
|
||||
#
|
||||
# MediaPipe world axes: x=subject-image right, y=down, z=depth (smaller=nearer camera).
|
||||
# Converted here to Blender Z-up with the subject facing -Y: b = (x, z, -y).
|
||||
#
|
||||
# Usage:
|
||||
# blender --background --python tools/mocap_retarget.py -- \
|
||||
# --pose <extract_pose.json> --out <out.glb> --name <ClipName> \
|
||||
# [--range lo:hi] [--smooth 5] [--target <gltf>] [--render-check <dir>]
|
||||
|
||||
import bpy, sys, os, json
|
||||
import numpy as np
|
||||
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")
|
||||
|
||||
# MediaPipe pose landmark indices
|
||||
NOSE, L_SHO, R_SHO, L_ELB, R_ELB, L_WRI, R_WRI = 0, 11, 12, 13, 14, 15, 16
|
||||
L_PINK, R_PINK, L_IDX, R_IDX = 17, 18, 19, 20
|
||||
L_HIP, R_HIP, L_KNE, R_KNE, L_ANK, R_ANK, L_FT, R_FT = 23, 24, 25, 26, 27, 28, 31, 32
|
||||
|
||||
# target bone -> (landmark_from, landmark_to): desired bone direction (bone +Y points from→to)
|
||||
AIM_BONES = {
|
||||
"upperarm_l": (L_SHO, L_ELB), "lowerarm_l": (L_ELB, L_WRI),
|
||||
"upperarm_r": (R_SHO, R_ELB), "lowerarm_r": (R_ELB, R_WRI),
|
||||
"thigh_l": (L_HIP, L_KNE), "calf_l": (L_KNE, L_ANK), "foot_l": (L_ANK, L_FT),
|
||||
"thigh_r": (R_HIP, R_KNE), "calf_r": (R_KNE, R_ANK), "foot_r": (R_ANK, R_FT),
|
||||
}
|
||||
HAND_BONES = {"hand_l": (L_WRI, L_IDX, L_PINK), "hand_r": (R_WRI, R_IDX, R_PINK)}
|
||||
SPINE_WEIGHTS = {"spine_01": 0.35, "spine_02": 0.7, "spine_03": 1.0,
|
||||
"neck_01": 1.0, "Head": 1.0}
|
||||
|
||||
|
||||
def apply_object_transform(obj):
|
||||
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 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 torso_quat(pts):
|
||||
"""Orthonormal torso basis from landmarks (Blender axes): columns = (subject-left,
|
||||
up, forward). Subject-left = l_hip - r_hip; up = mid-shoulders - mid-hips."""
|
||||
left = Vector(pts[L_HIP] - pts[R_HIP]).normalized()
|
||||
up = Vector((pts[L_SHO] + pts[R_SHO]) / 2 - (pts[L_HIP] + pts[R_HIP]) / 2).normalized()
|
||||
fwd = left.cross(up).normalized()
|
||||
left = up.cross(fwd).normalized()
|
||||
return Matrix((left, up, fwd)).transposed().to_quaternion().normalized()
|
||||
|
||||
|
||||
def chest_quat(pts):
|
||||
left = Vector(pts[L_SHO] - pts[R_SHO]).normalized()
|
||||
up = Vector((pts[L_SHO] + pts[R_SHO]) / 2 - (pts[L_HIP] + pts[R_HIP]) / 2).normalized()
|
||||
fwd = left.cross(up).normalized()
|
||||
left = up.cross(fwd).normalized()
|
||||
return Matrix((left, up, fwd)).transposed().to_quaternion().normalized()
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
args = dict(zip(argv[::2], argv[1::2]))
|
||||
pose_path, out, clip = args["--pose"], args["--out"], args.get("--name", "Mocap01")
|
||||
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
||||
smooth = int(args.get("--smooth", 5))
|
||||
render_dir = args.get("--render-check")
|
||||
lo, hi = 0.0, 1e9
|
||||
if "--range" in args:
|
||||
a, _, b = args["--range"].partition(":")
|
||||
lo, hi = float(a or 0), float(b) if b else 1e9
|
||||
|
||||
doc = json.load(open(pose_path))
|
||||
frames = [f for f in doc["frames"]
|
||||
if f.get("detected") and "world_landmarks" in f and lo <= f["t"] <= hi]
|
||||
if len(frames) < 4:
|
||||
raise RuntimeError(f"only {len(frames)} usable frames in range {lo}:{hi}")
|
||||
ts = np.array([f["t"] for f in frames]); ts -= ts[0]
|
||||
# MediaPipe (x right, y down, z depth) → Blender (x right, y=z_mp, z=-y_mp)
|
||||
raw = np.array([[[p["x"], p["z"], -p["y"]] for p in f["world_landmarks"]] for f in frames])
|
||||
if smooth > 1: # box smooth over time, per landmark/axis
|
||||
k = np.ones(smooth) / smooth
|
||||
pad = smooth // 2
|
||||
padded = np.concatenate([raw[:1].repeat(pad, 0), raw, raw[-1:].repeat(pad, 0)])
|
||||
raw = np.apply_along_axis(lambda v: np.convolve(v, k, mode="valid"), 0, padded)[:len(ts)]
|
||||
print(f"[mocap] {len(frames)} frames, {ts[-1]:.1f}s, smooth={smooth}")
|
||||
|
||||
# ── target rig ──
|
||||
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"
|
||||
bpy.context.view_layer.update()
|
||||
apply_object_transform(tgt_arm)
|
||||
|
||||
tgt_rest = {b.name: (tgt_arm.matrix_world @ b.matrix_local) for b in tgt_arm.data.bones}
|
||||
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
|
||||
|
||||
# Char-frame alignment, mixamo_retarget style: A maps landmark-space vectors and
|
||||
# rotation deltas into target world. Landmark space after conversion: subject-left=+X,
|
||||
# up=+Z, facing=-Y (NOT identity — it carries a 90° X rotation vs standard basis).
|
||||
up_t = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).normalized()
|
||||
left_t = (tgt_rest_q["upperarm_l"] @ Vector((0, 1, 0))).normalized() # bone Y = along bone
|
||||
fwd_t = left_t.cross(up_t).normalized()
|
||||
left_t = up_t.cross(fwd_t).normalized()
|
||||
Ct = Matrix((left_t, up_t, fwd_t)).transposed().to_quaternion().normalized()
|
||||
Cs = Matrix((Vector((1, 0, 0)), Vector((0, 0, 1)), Vector((0, -1, 0)))).transposed() \
|
||||
.to_quaternion().normalized()
|
||||
A = (Ct @ Cs.inverted()).normalized()
|
||||
Ainv = A.inverted()
|
||||
Cs_inv = Cs.inverted()
|
||||
|
||||
# Size + pelvis-height model
|
||||
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
|
||||
pelvis_up = raw[:, [L_ANK, R_ANK], 2].min(axis=1) * -1.0 # pelvis height above lowest ankle
|
||||
standing_h = float(np.percentile(pelvis_up, 95))
|
||||
rest_pelvis = tgt_rest["pelvis"].translation.copy()
|
||||
|
||||
act = bpy.data.actions.new(clip)
|
||||
if tgt_arm.animation_data is None:
|
||||
tgt_arm.animation_data_create()
|
||||
tgt_arm.animation_data.action = act
|
||||
order = hierarchy_order(tgt_arm)
|
||||
tw_inv = tgt_arm.matrix_world.inverted()
|
||||
for pb in tgt_arm.pose.bones:
|
||||
pb.rotation_mode = "QUATERNION"
|
||||
|
||||
check = []
|
||||
for i in range(len(ts)):
|
||||
pts = raw[i]
|
||||
frame = int(round(ts[i] * 30)) + 1
|
||||
|
||||
# delta from landmark rest basis (Cs), conjugated into target world axes
|
||||
q_pelvis_d = (A @ (torso_quat(pts) @ Cs_inv) @ Ainv).normalized()
|
||||
q_chest_d = (A @ (chest_quat(pts) @ Cs_inv) @ Ainv).normalized()
|
||||
|
||||
tgt_world = {}
|
||||
for tb in order:
|
||||
nt = tb.name
|
||||
rest_t = tgt_rest[nt]
|
||||
q_world = None
|
||||
if nt == "pelvis":
|
||||
q_world = (q_pelvis_d @ tgt_rest_q[nt]).normalized()
|
||||
elif nt in SPINE_WEIGHTS:
|
||||
qd = Quaternion(q_pelvis_d).slerp(q_chest_d, SPINE_WEIGHTS[nt])
|
||||
q_world = (qd @ tgt_rest_q[nt]).normalized()
|
||||
elif nt in AIM_BONES:
|
||||
a, b = AIM_BONES[nt]
|
||||
d_lm = Vector(pts[b] - pts[a])
|
||||
if d_lm.length > 1e-6:
|
||||
d_tgt = A @ d_lm
|
||||
rest_dir = tgt_rest_q[nt] @ Vector((0, 1, 0))
|
||||
q_delta = rest_dir.rotation_difference(d_tgt.normalized())
|
||||
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
|
||||
elif nt in HAND_BONES:
|
||||
w, ix, pk = HAND_BONES[nt]
|
||||
d_lm = Vector((pts[ix] + pts[pk]) / 2 - pts[w])
|
||||
if d_lm.length > 1e-6:
|
||||
d_tgt = A @ d_lm
|
||||
rest_dir = tgt_rest_q[nt] @ Vector((0, 1, 0))
|
||||
q_delta = rest_dir.rotation_difference(d_tgt.normalized())
|
||||
q_world = (q_delta @ tgt_rest_q[nt]).normalized()
|
||||
|
||||
if q_world is None: # unmapped: ride parent rigidly at rest offset
|
||||
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
|
||||
|
||||
if nt == "pelvis":
|
||||
ratio = float(np.clip(pelvis_up[i] / max(standing_h, 1e-5), 0.35, 1.15))
|
||||
t_world = Vector((rest_pelvis.x, rest_pelvis.y, rest_pelvis.z * ratio))
|
||||
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=frame)
|
||||
if tb.name == "pelvis" or tb.parent is None:
|
||||
pb.keyframe_insert("location", frame=frame)
|
||||
|
||||
# numeric sanity: solved elbow angle vs landmark elbow angle (should match closely)
|
||||
if i % max(1, len(ts) // 6) == 0:
|
||||
def ang(u, v):
|
||||
u, v = u.normalized(), v.normalized()
|
||||
return np.degrees(np.arccos(np.clip(u.dot(v), -1, 1)))
|
||||
lm = ang(Vector(pts[L_SHO] - pts[L_ELB]), Vector(pts[L_WRI] - pts[L_ELB]))
|
||||
ua = (tgt_world["upperarm_l"].to_quaternion() @ Vector((0, 1, 0)))
|
||||
la = (tgt_world["lowerarm_l"].to_quaternion() @ Vector((0, 1, 0)))
|
||||
rig = ang(-ua, la)
|
||||
check.append((ts[i], lm, rig))
|
||||
|
||||
print("[mocap] elbow-angle sanity (t, landmark°, rig°):")
|
||||
for t, a, b in check:
|
||||
print(f" t={t:5.1f} lm={a:6.1f} rig={b:6.1f} d={abs(a-b):5.1f}")
|
||||
|
||||
# optional visual check renders (with mesh, before it's dropped)
|
||||
if render_dir:
|
||||
os.makedirs(render_dir, exist_ok=True)
|
||||
cam_data = bpy.data.cameras.new("ChkCam")
|
||||
cam = bpy.data.objects.new("ChkCam", cam_data)
|
||||
scn.collection.objects.link(cam)
|
||||
cam.location = (0, -3.2, 1.0)
|
||||
cam.rotation_euler = (np.radians(87), 0, 0)
|
||||
scn.camera = cam
|
||||
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
|
||||
scn.collection.objects.link(sun)
|
||||
sun.rotation_euler = (np.radians(45), 0, np.radians(30))
|
||||
scn.render.resolution_x, scn.render.resolution_y = 480, 640
|
||||
for i in range(0, len(ts), max(1, len(ts) // 6)):
|
||||
scn.frame_set(int(round(ts[i] * 30)) + 1)
|
||||
scn.render.filepath = os.path.join(render_dir, f"chk_{ts[i]:05.1f}s.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print(f"[mocap] check renders → {render_dir}")
|
||||
|
||||
# animations-only GLB (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)
|
||||
tgt_arm.animation_data.action = None
|
||||
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"[mocap] EXPORTED '{clip}' ({len(ts)} keyed frames, {ts[-1]:.1f}s) → {out}")
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user