diff --git a/logs/laneA.md b/logs/laneA.md index f9de794..990630b 100644 --- a/logs/laneA.md +++ b/logs/laneA.md @@ -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 `` — 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 — `` + `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). diff --git a/logs/shots/m3-pip-swap.png b/logs/shots/m3-pip-swap.png new file mode 100644 index 0000000..4de7bd6 Binary files /dev/null and b/logs/shots/m3-pip-swap.png differ diff --git a/scenegod/web/room3d.js b/scenegod/web/room3d.js index 152527f..2b610b0 100644 --- a/scenegod/web/room3d.js +++ b/scenegod/web/room3d.js @@ -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//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}); }); diff --git a/scenegod/web/stage.js b/scenegod/web/stage.js index f89150c..8fc2205 100644 --- a/scenegod/web/stage.js +++ b/scenegod/web/stage.js @@ -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 + 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 diff --git a/scenegod/web/style.css b/scenegod/web/style.css index be3c8d5..a7b3d1f 100644 --- a/scenegod/web/style.css +++ b/scenegod/web/style.css @@ -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}