Lane A: bone-alias layer so CC_Base_*/Unreal-HumanIK/Rigify/Rokoko/Biped rigs fold onto the canonical mixamo names — lady.glb pairs 52/102 bones and retargets (53 tracks) where she previously threw 'missing hips'; mixamo→mixamo pairing asserted bit-identical. Backdrops gain frustum auto-fit, floor tint sampled from the plate, and FogExp2 in the plate's colour via a keyable param. Also caught: _buildLight ran _refreshDefaults before registration, so scene lights never dimmed the built-in hemi (every preset was washed out). Orchestrator SYNC-7 fixes (found only by rendering actual frames): - editor chrome (gizmo/grid/helpers/cam proxies) was baked into output mp4s - timeline's per-tick rest transform dragged fitted plates out of frame - fit used the viewport aspect, output renders at its own → letterboxed plates - plate offset put its top edge on the lens axis → void above centre-frame; now params.fitAnchor (default 0.75) picks the row that sits at eye level Verified: 5 suites green; 1280x720 render shows a CC character dancing a mixamo clip against an edge-to-edge Flow plate with a horizon and matched ground. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
281 lines
17 KiB
JavaScript
281 lines
17 KiB
JavaScript
// room3d.js — loaders + Mixamo retarget, ported from MESHGOD rigroom.html.
|
|
// Pure module: no DOM, no globals, no toast — throw Errors, callers handle UI.
|
|
// The bake algorithm is byte-for-byte rigroom's; only the target is now a
|
|
// parameter instead of a closed-over global.
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
|
|
|
|
// three r160 removed built-in KHR_materials_pbrSpecularGlossiness support, so Character Creator /
|
|
// Reallusion GLBs render flat white (diffuse lives in the extension). Minimal plugin: approximate
|
|
// spec-gloss as MeshStandard (diffuse→map/color, roughness≈1-glossiness, metalness 0). Only touches
|
|
// materials carrying the extension — metallic-roughness GLBs are unaffected.
|
|
const SPEC_GLOSS = 'KHR_materials_pbrSpecularGlossiness';
|
|
class SpecGlossPlugin {
|
|
constructor(parser){ this.parser = parser; this.name = SPEC_GLOSS; }
|
|
getMaterialType(i){ return this.parser.json.materials[i]?.extensions?.[SPEC_GLOSS] ? THREE.MeshStandardMaterial : null; }
|
|
extendMaterialParams(i, params){
|
|
const ext = this.parser.json.materials[i]?.extensions?.[SPEC_GLOSS];
|
|
if(!ext) return Promise.resolve();
|
|
const pending = [];
|
|
params.color = new THREE.Color(1,1,1); params.metalness = 0;
|
|
if(Array.isArray(ext.diffuseFactor)){ params.color.fromArray(ext.diffuseFactor); params.opacity = ext.diffuseFactor[3]; }
|
|
params.roughness = ext.glossinessFactor != null ? 1 - ext.glossinessFactor : 0.5;
|
|
if(ext.diffuseTexture) pending.push(this.parser.assignTexture(params, 'map', ext.diffuseTexture, THREE.SRGBColorSpace));
|
|
return Promise.all(pending);
|
|
}
|
|
}
|
|
|
|
export async function parseAny(buf, name){
|
|
name = (name||'').toLowerCase();
|
|
// extensionless routes (gallery/<id>/glb) + magic-byte fallback: gltf binary starts "glTF"
|
|
if(!/\.(glb|gltf|fbx|obj|bvh)$/.test(name)){
|
|
const head = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(16, buf.byteLength)));
|
|
if(head.startsWith('glTF')) name += '.glb';
|
|
else if(head.includes('Kaydara')) name += '.fbx';
|
|
else if(head.includes('HIERARCHY')) name += '.bvh';
|
|
else name += '.glb'; // worst case the loader throws a clear error
|
|
}
|
|
if(name.endsWith('.glb') || name.endsWith('.gltf')){
|
|
const loader = new GLTFLoader();
|
|
loader.register(p => new SpecGlossPlugin(p)); // Reallusion/CC exports need this (r160 dropped it)
|
|
const g = await loader.parseAsync(buf, ''); return {root:g.scene, anims:g.animations||[]}; }
|
|
if(name.endsWith('.fbx')){ const r = new FBXLoader().parse(buf, ''); return {root:r, anims:r.animations||[]}; }
|
|
if(name.endsWith('.obj')){ const r = new OBJLoader().parse(new TextDecoder().decode(buf));
|
|
r.traverse(o=>{ if(o.isMesh && !o.material.map) o.material = new THREE.MeshStandardMaterial({color:0x9aa4af, roughness:.7}); });
|
|
return {root:r, anims:[]}; }
|
|
if(name.endsWith('.bvh')){ const r = new BVHLoader().parse(new TextDecoder().decode(buf));
|
|
const root = new THREE.Object3D(); root.add(r.skeleton.bones[0]);
|
|
return {root, anims:[r.clip]}; }
|
|
throw new Error('unsupported type: '+name);
|
|
}
|
|
|
|
export function stats(root){
|
|
let tris=0, meshes=0, bones=0;
|
|
root.traverse(o=>{ if(o.isMesh){ meshes++; const g=o.geometry;
|
|
tris += (g.index ? g.index.count : g.attributes.position.count)/3; } if(o.isBone) bones++; });
|
|
return {tris:Math.round(tris), meshes, bones};
|
|
}
|
|
|
|
export function disposeRoot(root){
|
|
root.traverse(o=>{ if(o.geometry) o.geometry.dispose();
|
|
const m=o.material; if(m)(Array.isArray(m)?m:[m]).forEach(mt=>{ for(const k in mt){ const v=mt[k];
|
|
if(v && v.isTexture) v.dispose(); } mt.dispose(); }); });
|
|
}
|
|
|
|
// canonical bone key: "mixamorig:LeftUpLeg" / "mixamorig_LeftUpLeg" / "mixamorig4LeftUpLeg" → "leftupleg"
|
|
// (mixamo numbers the namespace per-download — strip digits too). Encodes real Mixamo quirks; keep as-is.
|
|
const rawCanon = n => n.replace(/^.*?mixamorig\d*[:_]?/i,'').replace(/[^a-z0-9]/gi,'').toLowerCase();
|
|
|
|
// ---- bone aliasing (M7) -------------------------------------------------------------------------
|
|
// The bake is name-driven, so a NON-mixamo rig (Character Creator, Unreal/HumanIK, Rigify, Rokoko,
|
|
// Unity/VRM) used to pair zero bones and die at "missing hips". canon() now folds foreign names onto
|
|
// the canonical mixamo names the baker already speaks. Anything that already canons to a mixamo bone
|
|
// is returned untouched → mixamo→mixamo pairing is bit-identical (room3d_test.mjs asserts all 65).
|
|
const LIMBS = ['shoulder','arm','forearm','hand','upleg','leg','foot','toebase'];
|
|
const FINGS = ['handthumb','handindex','handmiddle','handring','handpinky'];
|
|
export const CANON = new Set(['hips','spine','spine1','spine2','neck','head',
|
|
...['left','right'].flatMap(s => [...LIMBS.map(b => s+b),
|
|
...FINGS.flatMap(f => [1,2,3].map(i => s+f+i))])]);
|
|
|
|
// side-less bones. CC-qualified keys are listed where the generic reading differs: UE `spine_01` is
|
|
// the FIRST spine bone, CC's `CC_Base_Spine01` is the SECOND (CC_Base_Waist is first). Spine chains
|
|
// longer than mixamo's 3 (UE spine_04/05, rigify spine.003+) are deliberately left unmapped.
|
|
const EXACT = {
|
|
hip:'hips', pelvis:'hips',
|
|
waist:'spine', spine01:'spine', abdomen:'spine', ccbasewaist:'spine',
|
|
chest:'spine1', spine02:'spine1', spine001:'spine1', abdomen2:'spine1', ccbasespine01:'spine1',
|
|
upperchest:'spine2', spine03:'spine2', spine002:'spine2', ccbasespine02:'spine2',
|
|
neck01:'neck', neck1:'neck', necktwist01:'neck', ccbasenecktwist01:'neck',
|
|
};
|
|
// sided stems → canonical suffix (side prefix/suffix is peeled off first)
|
|
const STEMS = { shoulder:'shoulder', clavicle:'shoulder', collar:'shoulder',
|
|
arm:'arm', upperarm:'arm', armupper:'arm',
|
|
forearm:'forearm', lowerarm:'forearm', armlower:'forearm',
|
|
hand:'hand', wrist:'hand',
|
|
upleg:'upleg', thigh:'upleg', upperleg:'upleg', legupper:'upleg',
|
|
leg:'leg', calf:'leg', shin:'leg', lowerleg:'leg', leglower:'leg',
|
|
foot:'foot', ankle:'foot',
|
|
toebase:'toebase', toe:'toebase', toes:'toebase', ball:'toebase' };
|
|
for(const [k, v] of Object.entries({ thumb:'handthumb', index:'handindex', findex:'handindex',
|
|
mid:'handmiddle', middle:'handmiddle', fmiddle:'handmiddle', ring:'handring', fring:'handring',
|
|
pinky:'handpinky', little:'handpinky', fpinky:'handpinky' }))
|
|
for(const i of [1,2,3]){ STEMS[k+i] = v+i; STEMS[k+'0'+i] = v+i; }
|
|
// longest side spellings first; a decomposition only counts when the leftover stem is known
|
|
// (so "lowerarm" isn't read as left-"owerarm", and "shoulder" isn't read as "shoulde"-right).
|
|
const SIDES = [['left',/^left(.+)/], ['right',/^right(.+)/], ['left',/(.+)left$/], ['right',/(.+)right$/],
|
|
['left',/^l(.+)/], ['right',/^r(.+)/], ['left',/(.+)l$/], ['right',/(.+)r$/]];
|
|
const nsStrip = s => s.replace(/^.*[:|]/, ''); // "mixamorig:X", "Armature|X"
|
|
const pfxStrip = s => s.replace(/^(cc[_ ]?base|def|org|mch|bip\d*|biped)[._\-| ]+/i, '');
|
|
const idxStrip = s => s.replace(/[._\- ]\d+$/, ''); // CC's per-export "_02" tail
|
|
const flat = s => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
function alias(name){
|
|
const b = nsStrip(String(name));
|
|
// most-qualified first: "ccbasespine01" must beat the generic "spine01" reading
|
|
for(const s of [b, pfxStrip(b), idxStrip(b), pfxStrip(idxStrip(b))]){
|
|
const k = flat(s);
|
|
if(CANON.has(k)) return k;
|
|
if(EXACT[k]) return EXACT[k];
|
|
for(const [side, re] of SIDES){ const m = k.match(re), stem = m && STEMS[m[1]];
|
|
if(stem) return side + stem; }
|
|
}
|
|
return null; // never fabricate — an unmapped bone just stays unbaked
|
|
}
|
|
// what kind of skeleton is this? (error messages only — the mapping itself is per-bone)
|
|
export function rigStyle(names){
|
|
const s = (names || []).join('\n');
|
|
for(const [re, label] of [[/mixamorig/i,'mixamo'], [/CC_Base/i,'CC_Base'], [/^(DEF|ORG|MCH)-/m,'Rigify'],
|
|
[/(upperarm|lowerarm|thigh|calf|clavicle|ball)_[lr]\b/i,'Unreal/HumanIK'], [/Bip\d/,'3dsMax Biped'],
|
|
[/(LeftUpLeg|LeftForeArm|LeftToeBase)/i,'mixamo-style']])
|
|
if(re.test(s)) return label;
|
|
return 'unknown';
|
|
}
|
|
|
|
export const canon = n => { const raw = rawCanon(n); return CANON.has(raw) ? raw : (alias(n) || raw); };
|
|
export function boneMap(root){
|
|
const m={}; root.traverse(o=>{ if(o.isBone && !(canon(o.name) in m)) m[canon(o.name)] = o; }); return m;
|
|
}
|
|
const hipsOf = map => map['hips'] || null;
|
|
|
|
// target bone → source bone, by canonical name. Dedupe by canon key: fbx files often carry a
|
|
// DUPLICATE hierarchy, and aliased rigs can fold two bones onto one key (CC Hip + Pelvis → hips) —
|
|
// bind only the first, which is what boneMap picks. THREE-free so node can test it.
|
|
const MINPAIR = 8; // below this a "retarget" is garbage — fail loudly instead of animating soup
|
|
export function pairBones(tOrder, srcMap, hasRest = () => true){
|
|
const pairs = new Map(), used = new Set();
|
|
for(const tb of tOrder){ const k = canon(tb.name), sb = srcMap[k];
|
|
if(sb && hasRest(sb) && !used.has(k)){ pairs.set(tb, sb); used.add(k); } }
|
|
if(pairs.size < Math.min(MINPAIR, tOrder.length)){
|
|
const un = tOrder.find(tb => !pairs.has(tb));
|
|
throw new Error(`retarget: only ${pairs.size}/${tOrder.length} bones matched (target looks like `
|
|
+ `'${rigStyle(tOrder.map(b => b.name))}', source looks like `
|
|
+ `'${rigStyle(Object.values(srcMap).map(b => b.name))}'; first unmatched bone `
|
|
+ `'${un ? un.name : '?'}'); unsupported skeleton`);
|
|
}
|
|
return pairs;
|
|
}
|
|
|
|
// bind-pose snapshot — retargets are world-space deltas against THIS. Capture BEFORE any clip poses it.
|
|
export function captureRest(root){
|
|
root.updateMatrixWorld(true);
|
|
const rest = new Map(), order = [];
|
|
root.traverse(o=>{ if(o.isBone){ order.push(o);
|
|
rest.set(o, { wq:o.getWorldQuaternion(new THREE.Quaternion()),
|
|
lq:o.quaternion.clone(), lp:o.position.clone() }); } });
|
|
return {rest, order};
|
|
}
|
|
|
|
// WORLD-SPACE rotation-delta transfer, baked at 30fps. Raw quaternion copying only works when both
|
|
// rests share bone axes (fbx→fbx). Blender-exported GLB rigs orient bones differently, so we sample
|
|
// the SOURCE through the clip and move each target bone by the source bone's world-space delta.
|
|
export function bakeRetarget(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts={}){
|
|
const tgt = boneMap(tgtRoot), src = boneMap(srcRoot);
|
|
const tHips = hipsOf(tgt), sHips = hipsOf(src);
|
|
const sRest = srcRest.rest, tRest = tgtRest.rest, tOrder = tgtRest.order;
|
|
if(!tHips || !sHips) throw new Error(`retarget: no bone maps to 'hips' on the `
|
|
+ `${tHips ? 'source' : 'target'} (target looks like '${rigStyle(tOrder.map(b => b.name))}', `
|
|
+ `source looks like '${rigStyle(srcRest.order.map(b => b.name))}'); unsupported skeleton`);
|
|
const pairs = pairBones(tOrder, src, sb => sRest.has(sb));
|
|
let ratio=1; // hips travel: local hip-height ratio (cm fbx vs m glb sorts itself out)
|
|
if(sHips.position.y > 1e-6) ratio = tHips.position.y / sHips.position.y;
|
|
// ponytail: axis-swapped rigs (lady.glb hangs its hip height off LOCAL Z, not Y — CC via a Z-up
|
|
// exporter) land ratio 0, so the clip plays IN PLACE. That is the safe read: the source's travel
|
|
// delta is expressed in the SOURCE's axes, and scaling it into a Z-up hip would fly the character.
|
|
// Rotations are unaffected (they transfer through world space). Proper fix = rotate the travel
|
|
// delta through both rests' parent world quats — not needed until a CC rig has to walk a mark.
|
|
const fps=30, n=Math.max(2, Math.ceil(clip.duration*fps)+1);
|
|
const times = new Float32Array(n);
|
|
const qData = new Map([...pairs.keys()].map(tb=>[tb, new Float32Array(n*4)]));
|
|
const pData = new Float32Array(n*3);
|
|
const mixerS = new THREE.AnimationMixer(srcRoot);
|
|
mixerS.clipAction(clip).play();
|
|
const q1=new THREE.Quaternion(), q2=new THREE.Quaternion(), q3=new THREE.Quaternion();
|
|
for(let i=0;i<n;i++){
|
|
const t = Math.min(clip.duration, i/fps); times[i]=t;
|
|
mixerS.setTime(t); srcRoot.updateMatrixWorld(true);
|
|
const world = new Map(); // this frame's target world quats, parent-first (traverse order)
|
|
for(const tb of tOrder){
|
|
const parent = tb.parent;
|
|
const pw = world.get(parent) || parent.getWorldQuaternion(q3); // non-bone parents are static
|
|
let lq;
|
|
const sb = pairs.get(tb);
|
|
if(sb){
|
|
const sw = sb.getWorldQuaternion(q1); // source now
|
|
const delta = sw.multiply(q2.copy(sRest.get(sb).wq).invert()); // world delta from source rest
|
|
const tw = delta.multiply(tRest.get(tb).wq); // applied to target rest
|
|
lq = q2.copy(pw).invert().multiply(tw); // back to local
|
|
// hemisphere continuity: keep each bone on the same side as its last frame, else slerp garbage
|
|
const qd = qData.get(tb);
|
|
if(i && qd[(i-1)*4]*lq.x + qd[(i-1)*4+1]*lq.y + qd[(i-1)*4+2]*lq.z + qd[(i-1)*4+3]*lq.w < 0)
|
|
lq.set(-lq.x,-lq.y,-lq.z,-lq.w);
|
|
qd.set([lq.x,lq.y,lq.z,lq.w], i*4);
|
|
lq = new THREE.Quaternion(lq.x,lq.y,lq.z,lq.w);
|
|
} else lq = tRest.get(tb).lq;
|
|
world.set(tb, new THREE.Quaternion().multiplyQuaternions(pw, lq));
|
|
}
|
|
const hl = tRest.get(tHips).lp, sl = sRest.get(sHips).lp;
|
|
pData.set([ hl.x + (sHips.position.x - sl.x)*ratio, hl.y + (sHips.position.y - sl.y)*ratio,
|
|
hl.z + (sHips.position.z - sl.z)*ratio ], i*3);
|
|
}
|
|
mixerS.stopAllAction();
|
|
for(const [sb,r] of sRest){ sb.quaternion.copy(r.lq); sb.position.copy(r.lp); } // un-pose the source
|
|
if(opts.inPlace) for(let i=3;i<pData.length;i+=3){ pData[i]=pData[0]; pData[i+2]=pData[2]; }
|
|
const tracks = [...pairs.keys()].map(tb =>
|
|
new THREE.QuaternionKeyframeTrack(`${tb.name}.quaternion`, times, qData.get(tb)));
|
|
tracks.push(new THREE.VectorKeyframeTrack(`${tHips.name}.position`, times, pData));
|
|
return new THREE.AnimationClip(clip.name, clip.duration, tracks);
|
|
}
|
|
|
|
// ---- self-check: two 3-bone skeletons with DIFFERENT rests, bake a 1s clip across, assert the
|
|
// world-space rotation DELTA the target reproduces matches the source's, within 1e-3. --------------
|
|
function _mkSkel(prefix, restEuler){
|
|
// hips -> spine -> head, each 1 unit up its parent. restEuler tilts each bone's rest differently.
|
|
const names = ['Hips','Spine','Head'];
|
|
let parent=null, root=null; const bones=[];
|
|
names.forEach((nm,i)=>{
|
|
const b = new THREE.Bone(); b.name = prefix+nm;
|
|
b.position.set(0, i===0 ? 1.0 : 1.0, 0); // hips at y=1 so ratio is well-defined
|
|
b.rotation.set(restEuler[i][0], restEuler[i][1], restEuler[i][2]);
|
|
if(parent) parent.add(b); else root=b;
|
|
parent=b; bones.push(b);
|
|
});
|
|
const wrap = new THREE.Object3D(); wrap.add(root); wrap.updateMatrixWorld(true);
|
|
return {wrap, bones};
|
|
}
|
|
export function _selftest(){
|
|
// underscore namespaces (not colon) so three's PropertyBinding binds the clips; canon() still
|
|
// strips "mixamorig1_" / "mixamorig4_" to the same key, which is what we're actually testing.
|
|
const src = _mkSkel('mixamorig1_', [[0,0,0],[0.1,0,0.2],[0,0.15,0]]);
|
|
const tgt = _mkSkel('mixamorig4_', [[0,0.3,0],[-0.2,0,0.1],[0.05,0,-0.1]]); // different namespace + rests
|
|
const srcRest = captureRest(src.wrap), tgtRest = captureRest(tgt.wrap);
|
|
// source clip: rotate the spine over 1s
|
|
const times = new Float32Array([0, 0.5, 1.0]);
|
|
const q0=new THREE.Quaternion(), q1=new THREE.Quaternion().setFromEuler(new THREE.Euler(0.6,0,0.3));
|
|
const qm=q0.clone().slerp(q1,0.5);
|
|
const vals=new Float32Array([q0.x,q0.y,q0.z,q0.w, qm.x,qm.y,qm.z,qm.w, q1.x,q1.y,q1.z,q1.w]);
|
|
const clip = new THREE.AnimationClip('t', 1.0,
|
|
[new THREE.QuaternionKeyframeTrack(src.bones[1].name+'.quaternion', times, vals)]);
|
|
const baked = bakeRetarget(clip, src.wrap, srcRest, tgt.wrap, tgtRest);
|
|
|
|
const srcMix = new THREE.AnimationMixer(src.wrap); srcMix.clipAction(clip).play();
|
|
const tgtMix = new THREE.AnimationMixer(tgt.wrap); tgtMix.clipAction(baked).play();
|
|
const delta=(bone,rest)=>bone.getWorldQuaternion(new THREE.Quaternion())
|
|
.multiply(rest.wq.clone().invert());
|
|
let maxErr=0, srcMoved=0;
|
|
for(const t of [0, 0.5, 1.0]){
|
|
srcMix.setTime(t); src.wrap.updateMatrixWorld(true);
|
|
tgtMix.setTime(t); tgt.wrap.updateMatrixWorld(true);
|
|
for(let i=0;i<3;i++){
|
|
const ds=delta(src.bones[i], srcRest.rest.get(src.bones[i]));
|
|
const dt=delta(tgt.bones[i], tgtRest.rest.get(tgt.bones[i]));
|
|
let d = Math.abs(ds.x*dt.x + ds.y*dt.y + ds.z*dt.z + ds.w*dt.w); // |dot|→1 when equal (mod sign)
|
|
maxErr = Math.max(maxErr, 1 - d);
|
|
srcMoved = Math.max(srcMoved, 2*Math.acos(Math.min(1, Math.abs(ds.w)))); // world-delta angle (rad)
|
|
}
|
|
}
|
|
const ok = maxErr < 1e-3 && srcMoved > 0.05; // guard: a non-posing source (e.g. unbound clip) would pass trivially
|
|
return {ok, maxErr, srcMoved};
|
|
}
|