[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:
parent
c602ce8dea
commit
04e15d3e39
@ -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
|
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.
|
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≈1−glossiness, 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
BIN
logs/shots/m3-pip-swap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@ -8,6 +8,26 @@ import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
|||||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
import { BVHLoader } from 'three/addons/loaders/BVHLoader.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){
|
export async function parseAny(buf, name){
|
||||||
name = (name||'').toLowerCase();
|
name = (name||'').toLowerCase();
|
||||||
// extensionless routes (gallery/<id>/glb) + magic-byte fallback: gltf binary starts "glTF"
|
// 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
|
else name += '.glb'; // worst case the loader throws a clear error
|
||||||
}
|
}
|
||||||
if(name.endsWith('.glb') || name.endsWith('.gltf')){
|
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('.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));
|
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}); });
|
r.traverse(o=>{ if(o.isMesh && !o.material.map) o.material = new THREE.MeshStandardMaterial({color:0x9aa4af, roughness:.7}); });
|
||||||
|
|||||||
@ -26,9 +26,13 @@ export class Stage {
|
|||||||
view.appendChild(r.domElement);
|
view.appendChild(r.domElement);
|
||||||
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
|
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');
|
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);
|
view.appendChild(pip);
|
||||||
|
|
||||||
const scene = this.scene = new THREE.Scene();
|
const scene = this.scene = new THREE.Scene();
|
||||||
@ -68,15 +72,19 @@ export class Stage {
|
|||||||
if(this._paused) return; // M3: caller drives rendering deterministically
|
if(this._paused) return; // M3: caller drives rendering deterministically
|
||||||
ctr.update();
|
ctr.update();
|
||||||
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
|
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
|
// inset first (it scribbles onto the main canvas), then the main view last so it wins
|
||||||
if(this._activeCamId && pip.style.display !== 'none') this.renderActiveCamera(pip);
|
if(this._activeCamId && pip.style.display !== 'none')
|
||||||
r.render(scene, this._dirCam);
|
this._render(this._pipSwap ? this._dirCam : this._activeCam(), pip);
|
||||||
|
this._render((this._pipSwap && this._activeCamId) ? this._activeCam() : this._dirCam, null);
|
||||||
};
|
};
|
||||||
loop();
|
loop();
|
||||||
}
|
}
|
||||||
|
|
||||||
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
|
_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 ----
|
// ---- callbacks ----
|
||||||
onSelect(cb){ this._selCbs.push(cb); }
|
onSelect(cb){ this._selCbs.push(cb); }
|
||||||
@ -322,17 +330,20 @@ export class Stage {
|
|||||||
}
|
}
|
||||||
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
|
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
|
||||||
return en && en.cam ? en.cam : this._dirCam; }
|
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
|
// 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.
|
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
|
||||||
renderActiveCamera(canvas){
|
renderActiveCamera(canvas){ this._render(this._activeCam(), 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); }
|
|
||||||
}
|
|
||||||
pause(){ this._paused = true; }
|
pause(){ this._paused = true; }
|
||||||
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
|
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
#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}
|
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 — active camera preview inset (bottom-right, ~24% wide, click to swap). Size set in JS. */
|
||||||
.pip{position:absolute;top:12px;right:12px;width:256px;height:144px;border:1px solid var(--teal);
|
.pip{position:absolute;right:12px;bottom:12px;border:1px solid var(--teal);border-radius:6px;
|
||||||
border-radius:6px;background:#000;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
|
background:#000;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
|
||||||
.pip::after{content:'CAM'}
|
.pip:hover{border-color:var(--ink)}
|
||||||
|
|
||||||
/* dock "add rig" toolbar (cameras + lights have no asset file) */
|
/* dock "add rig" toolbar (cameras + lights have no asset file) */
|
||||||
.addbar{display:flex;gap:6px;margin-bottom:10px}
|
.addbar{display:flex;gap:6px;margin-bottom:10px}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user