- PiP: sized in _resize (~24% w, 16:9, bottom-right) — CSS aspect-ratio is unreliable on <canvas> and had ballooned it. Click-to-swap main<->active cam; _render(cam,canvas) restores aspect after a pip blit so swap never distorts. renderActiveCamera/pause/resume contract unchanged (M3 finalRender safe). - room3d: SpecGlossPlugin on GLTFLoader — CC/Reallusion GLBs use KHR_materials_pbrSpecularGlossiness (dropped in three r160) and rendered flat white; map spec-gloss -> MeshStandard. lady.glb now withMap=26/26. Verified live at :8020, zero console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
11 KiB
JavaScript
194 lines
11 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.
|
|
export const canon = n => n.replace(/^.*?mixamorig\d*[:_]?/i,'').replace(/[^a-z0-9]/gi,'').toLowerCase();
|
|
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;
|
|
|
|
// 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);
|
|
if(!tHips || !sHips) throw new Error('retarget: missing hips (mixamorig skeleton?)');
|
|
const sRest = srcRest.rest, tRest = tgtRest.rest, tOrder = tgtRest.order;
|
|
const pairs = new Map(); // target bone → source bone. Dedupe by canon key: fbx files often
|
|
const used = new Set(); // carry a DUPLICATE hierarchy — bind only the first (what boneMap picks)
|
|
for(const tb of tOrder){ const k=canon(tb.name), sb=src[k];
|
|
if(sb && sRest.has(sb) && !used.has(k)){ pairs.set(tb, sb); used.add(k); } }
|
|
if(!pairs.size) throw new Error('retarget: no matching bones');
|
|
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;
|
|
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};
|
|
}
|