// 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//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 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}; }