[laneA] M3-polish: PiP cap+swap, spec-gloss GLB fix (lady renders textured)

- 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>
This commit is contained in:
type-two 2026-07-18 20:49:22 +10:00
parent c602ce8dea
commit 04e15d3e39
5 changed files with 85 additions and 20 deletions

View File

@ -115,3 +115,35 @@ BLOCKED/REQUESTS — INTEGRATION GAP (orchestrator, please route):
NEXT: once the entity-mirror seam is decided, the "lady + man + street + 2 cameras + sunset" SYNC-2
demo should play. Holding at the M2 boundary until the orchestrator flips the milestone line.
## 2026-07-18 session 3 (M3-polish)
DONE: both M3-polish items the orchestrator flagged, verified live at :8020.
- **PiP cap + swap.** Was fixed 256×144 and (with the CSS `aspect-ratio`/`%` combo) blew up to
340×413 portrait on a `<canvas>` — CSS aspect-ratio is unreliable on replaced elements. Now sized
in `_resize()`: `clamp(180, view.width*0.24, 340)` wide × 16:9, bottom-right. Click the inset to
swap main↔active-cam (`_pipSwap`). Refactored `renderActiveCamera`→ private `_render(cam,canvas)`
that restores the cam's aspect after a pip blit, so swapping never distorts either camera
(verified: dirCam.aspect stays 1.79 while it's shown in the inset). `renderActiveCamera(canvas)`
is now a one-line wrapper over `_render(this._activeCam(), canvas)` — M3 contract unchanged;
`pause()/resume()` untouched. Verified 180×101 @1.78 ratio, swap flips the views cleanly.
- **lady.glb flat-white diagnosed + fixed.** It's the ASSET: a Reallusion/Character-Creator export
with `extensionsRequired:["KHR_materials_pbrSpecularGlossiness"]` — diffuse lives in that
extension, which three r160 dropped, so all 26 materials fell back to white MeshStandard. (rigroom
would show the same — no port regression.) Since SCENEGOD must eat any dropped character, added a
minimal `SpecGlossPlugin` registered on GLTFLoader in room3d.js: spec-gloss → MeshStandard
(diffuse→map/color, roughness≈1glossiness, metalness 0). Only touches materials carrying the
extension; metallic-rough GLBs and FBX untouched. Verified: lady loads with withMap=26/26 and
renders fully textured (skin/clothes/face). Shot: `logs/shots/m3-pip-swap.png`.
DECISIONS:
- Runtime spec-gloss shim over an offline gltf-transform convert: keeps CC characters "just work"
in the drop-on-stage flow and in the render pipeline, no per-asset conversion step. Approximation
(ignores the specular map) is fine for machinima lighting; upgrade path = full spec→metal convert
if a hero asset needs it.
- PiP size lives in JS, not CSS — `<canvas>` + `aspect-ratio` is the trap that produced the giant
inset. CSS keeps only position/border.
BLOCKED/REQUESTS: none.
NEXT: hold at M3-polish boundary. Available for SYNC-3 findings (finalRender drives
`pause`/`resume`/`renderActiveCamera` — kept stable per the hard contract).

BIN
logs/shots/m3-pip-swap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -8,6 +8,26 @@ 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"
@ -19,7 +39,9 @@ export async function parseAny(buf, name){
else name += '.glb'; // worst case the loader throws a clear error
}
if(name.endsWith('.glb') || name.endsWith('.gltf')){
const g = await new GLTFLoader().parseAsync(buf, ''); return {root:g.scene, anims:g.animations||[]}; }
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}); });

View File

@ -26,9 +26,13 @@ export class Stage {
view.appendChild(r.domElement);
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
// PiP overlay — the active camera's view, shown top-right when a camera is active
// PiP overlay — the active camera's view (bottom-right). CSS sizes it (~24% w); the buffer
// stays small. Click to swap which view is main vs inset.
const pip = this._pip = document.createElement('canvas');
pip.width = 256; pip.height = 144; pip.className = 'pip'; pip.style.display = 'none';
pip.width = 320; pip.height = 180; pip.className = 'pip'; pip.style.display = 'none';
pip.title = 'click to swap with the main view';
this._pipSwap = false;
pip.addEventListener('click', () => { this._pipSwap = !this._pipSwap; });
view.appendChild(pip);
const scene = this.scene = new THREE.Scene();
@ -68,15 +72,19 @@ export class Stage {
if(this._paused) return; // M3: caller drives rendering deterministically
ctr.update();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
// PiP first (it scribbles the active cam onto the main canvas), then the director view last
if(this._activeCamId && pip.style.display !== 'none') this.renderActiveCamera(pip);
r.render(scene, this._dirCam);
// inset first (it scribbles onto the main canvas), then the main view last so it wins
if(this._activeCamId && pip.style.display !== 'none')
this._render(this._pipSwap ? this._dirCam : this._activeCam(), pip);
this._render((this._pipSwap && this._activeCamId) ? this._activeCam() : this._dirCam, null);
};
loop();
}
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix(); }
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix();
// size the inset here — CSS aspect-ratio/% is unreliable on a <canvas>
const pw = Math.max(180, Math.min(340, w*0.24));
this._pip.style.width = Math.round(pw)+'px'; this._pip.style.height = Math.round(pw*9/16)+'px'; }
// ---- callbacks ----
onSelect(cb){ this._selCbs.push(cb); }
@ -322,17 +330,20 @@ export class Stage {
}
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
// render `cam` to the main WebGL canvas at the target's aspect; if `canvas` given, blit there and
// restore the cam's aspect (so using a cam for the inset never distorts it in the main view).
_render(cam, canvas){
const w = canvas ? canvas.width : this.renderer.domElement.width;
const h = canvas ? canvas.height : this.renderer.domElement.height;
const prev = cam.aspect;
if(cam.isPerspectiveCamera){ cam.aspect = w/h; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ canvas.getContext('2d').drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height);
if(cam.isPerspectiveCamera && cam.aspect !== prev){ cam.aspect = prev; cam.updateProjectionMatrix(); } }
}
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
renderActiveCamera(canvas){
const cam = this._activeCam();
const aspect = canvas ? canvas.width/canvas.height
: this.renderer.domElement.width/this.renderer.domElement.height;
if(cam.isPerspectiveCamera){ cam.aspect = aspect; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ const ctx = canvas.getContext('2d');
ctx.drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height); }
}
renderActiveCamera(canvas){ this._render(this._activeCam(), canvas); }
pause(){ this._paused = true; }
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump

View File

@ -66,10 +66,10 @@ body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--panel);
border:1px solid var(--teal);border-radius:9px;padding:9px 16px;font-size:13px;display:none;z-index:50;max-width:80vw}
/* PiP — active camera preview overlay */
.pip{position:absolute;top:12px;right:12px;width:256px;height:144px;border:1px solid var(--teal);
border-radius:6px;background:#000;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
.pip::after{content:'CAM'}
/* PiP — active camera preview inset (bottom-right, ~24% wide, click to swap). Size set in JS. */
.pip{position:absolute;right:12px;bottom:12px;border:1px solid var(--teal);border-radius:6px;
background:#000;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
.pip:hover{border-color:var(--ink)}
/* dock "add rig" toolbar (cameras + lights have no asset file) */
.addbar{display:flex;gap:6px;margin-bottom:10px}