SCENEGOD/scenegod/web/room3d.js
type-two 7fea880286 [m7-A] non-mixamo rig support + world-integrated backdrops (+4 sync7 render fixes)
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>
2026-07-25 22:21:37 +10:00

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