diff --git a/C-progress.md b/C-progress.md index 43563be..497ffd3 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,31 @@ *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* -Last updated: 2026-07-14 (round 5) · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-07-14 (round 6) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law) + +Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). **Diagnosed** the two +drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item — +then batched (new `web/js/interiors/batch.js`): +- **stock.js**: boxes are now single-material; every stock mesh tagged `userData.isStock`. +- **batch.js** (`batchRoom`, called from interiors.js before the GLB upgrade): two-tier merge — + room-level merge of all `isStock` across every fitting (grouped by material, world transforms baked) + + per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB + upgrade can hide the fixture). `mergeGeometries` from vendored BufferGeometryUtils. +- **glb.js**: frame-hide is now by `isStock` tag (not frame-count), so batched fixtures hide and the + room-level stock rides on the GLB. + +**Result** (drawSweep, all 9 types × 6 archetypes, seed 1990): +- GLB-off worst **152** (opshop/hall), GLB-on worst **313** (opshop/hall) — book 1,245 → **189**. **≤350 ✓**. +- Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit + target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical. +- **Sweep now ASSERTS ≤350** (`drawSweep()` in the test page, folded into the soak; `window.PROCITY_C. + drawSweep` for F's harness — see LANE_C_NOTES for the hook). + +Still in flight this round: C2 map glass_case/magazine_rack/crate_stack. --- diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md index 191316d..1f9bdbc 100644 --- a/docs/LANES/LANE_C_NOTES.md +++ b/docs/LANES/LANE_C_NOTES.md @@ -1,5 +1,17 @@ # LANE C — cross-lane notes (PROCITY-C) +## → Lane F: interior ≤350-draw assertion for the harness (round-6 C1) + +C1 batching landed — worst room is now **313 draws GLB-on** / 152 GLB-off (was 1,245). The test page +exposes the assertion for your harness: + +- `window.PROCITY_C.drawSweep({ glb: true|false })` → `{ worst, worstAt, pass, law: 350, perType }`. + Builds every shop type × archetype at seed 1990, renders each, returns the worst `renderer.info.render.calls`. + It's already folded into the interior soak's PASS/FAIL. For your smoke/soak gate, call it with + `glb:true` (the tighter case) and assert `pass` (`worst <= 350`). Headless-safe, ~1s. +- The draw law is a Lane-C constant (`DRAW_LAW = 350` in interior_test.html) so it's one number to bump + if the budget ever changes. Ping me if you'd rather it read from a shared config. + ## → Fable / Lane F: v1.1 is ready (round-5 C1 done) **C1 committed.** Lane E's re-exports are mapped + validated in `web/js/interiors/glb.js`: diff --git a/web/interior_test.html b/web/interior_test.html index 8d37813..5b72666 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -252,6 +252,32 @@ function move(dt) { if (walkable(p.x, p.z + nz)) p.z += nz; } +// ── draw sweep: worst-room draw count must be ≤350 (round-6 law, decision #2). Asserts so it +// can't regress; F mirrors this in the qa harness. Measures every type × archetype, GLB off AND on. +const DRAW_LAW = 350; +async function drawSweep(opts = {}) { + const wasCurrent = current; + for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) if (s !== (current && current.group)) scene.remove(s); + let manifest = null; + if (opts.glb) { manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); } + const measure = async (type, arch) => { + const r = buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE, + { archetype: arch, useGLB: opts.glb, manifest }); + scene.add(r.group); if (opts.glb) await r.glbReady; + camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8); camera.lookAt(0, 0.9, -r.dims.D * 0.15); + renderer.info.reset(); renderer.render(scene, camera); + const d = renderer.info.render.calls; scene.remove(r.group); r.dispose(); return d; + }; + let worst = 0, worstAt = ''; const perType = {}; + for (const t of SHOP_TYPES) { + let tw = 0; + for (const a of [undefined, ...ARCHETYPE_KEYS]) { const d = await measure(t, a); if (d > tw) tw = d; if (d > worst) { worst = d; worstAt = `${t}/${a || 'auto'}`; } } + perType[t] = tw; + } + rebuild(); + return { worst, worstAt, pass: worst <= DRAW_LAW, law: DRAW_LAW, perType }; +} + // ── soak test: build+dispose 50 rooms, assert leak-free + <50ms + deterministic ── async function soak(N = 50) { const el = $('soak'); el.classList.remove('bad'); el.textContent = 'running soak…'; @@ -283,13 +309,15 @@ async function soak(N = 50) { const leakGeo = renderer.info.memory.geometries - baseGeo; const leakTex = renderer.info.memory.textures - baseTex; const avg = total / N; - const pass = leakGeo <= 0 && leakTex <= 0 && worst < 50 && detFail === 0; + const draws = await drawSweep(); // round-6 ≤350-draw law + const pass = leakGeo <= 0 && leakTex <= 0 && worst < 50 && detFail === 0 && draws.pass; el.classList.toggle('bad', !pass); el.textContent = `${pass ? '✅ PASS' : '❌ CHECK'} · ${N} rooms\n` + `avg ${avg.toFixed(1)}ms · worst ${worst.toFixed(1)}ms (budget 50)\n` + `leak: geo ${leakGeo} · tex ${leakTex} (want ≤0)\n` + - `determinism: ${detFail === 0 ? 'identical ✓' : detFail + ' MISMATCH'}`; + `determinism: ${detFail === 0 ? 'identical ✓' : detFail + ' MISMATCH'}\n` + + `worst draws: ${draws.worst} @ ${draws.worstAt} (law ≤${draws.law}) ${draws.pass ? '✓' : '✗'}`; rebuild(); } $('soakBtn').onclick = () => soak(50); @@ -341,7 +369,7 @@ function frame() { } // expose for headless verification (screenshot harness, workflow checks) -window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, rebuild, get current() { return current; }, scene, camera, renderer, +window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer, DIG_ON, digSoak, openDigOn, binUnderAim, get dig() { return dig; } }; rebuild(); diff --git a/web/js/interiors/batch.js b/web/js/interiors/batch.js new file mode 100644 index 0000000..5439411 --- /dev/null +++ b/web/js/interiors/batch.js @@ -0,0 +1,84 @@ +// PROCITY Lane C — draw-call batcher (round 6, decision #2: ≤350 draws/room is law). +// +// Interiors were 1 draw per stock item (a book barn = ~600 spine meshes; a multi-material box cost +// one draw PER face). Two-tier merge collapses hundreds of draws into dozens: +// +// • STOCK (products: sleeves/spines/boxes/garments/snacks/treasures — tagged userData.isStock) is +// merged at the ROOM level, grouped by material, across ALL fittings. World transforms are baked +// so the merged meshes sit in the room group. A hall full of clothes racks → ~8 garment meshes +// total, not ~8 per rack. +// • FIXTURE frames (shelf uprights, cabinets, counters) are merged WITHIN each fitting group, so the +// group + its userData survive (record bins stay raycast targets for ?dig=1) and the GLB upgrade +// can still hide the fixture (isFrame) while the room-level stock rides on top of the GLB. +// +// Constraints honoured: determinism (merge follows deterministic child order), dig-compat (bins keep +// their frame as a hit target; the dig regenerates its own sleeves), leak-free (merged geometries are +// ctx-tracked, clones disposed immediately), keeper/stock/API contracts unchanged. Meshes whose +// material is unique in their scope are LEFT in place (nothing to merge). + +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; + +// A visual signature for a single-material mesh; same key ⇒ mergeable into one draw. +function matKey(m) { + if (!m || Array.isArray(m)) return null; // multi-material meshes are not merged (there are none post-fix) + const c = m.color ? m.color.getHexString() : '------'; + const map = m.map ? m.map.uuid : ''; + return `${map}|${c}|${m.transparent ? 1 : 0}|${m.side}|${m.alphaTest || 0}|${m.emissive ? m.emissive.getHexString() : ''}`; +} + +// Merge each material bucket of ≥2 meshes into one mesh appended to `target`; ≤1 stays put. +// `bake(mesh)` returns the geometry-space matrix for a mesh (room-local for stock, group-local for frames). +function flushBuckets(ctx, target, buckets, stockTag, bake) { + const THREE = ctx.THREE; + let saved = 0; + for (const b of buckets.values()) { + if (b.meshes.length < 2) continue; // unique material — leave the mesh in place + const geos = b.meshes.map(m => { const g = m.geometry.clone(); g.applyMatrix4(bake(m)); return g; }); + let merged; + try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; } + geos.forEach(g => g.dispose()); + if (!merged) continue; + ctx._geometries.add(merged); + for (const m of b.meshes) m.parent && m.parent.remove(m); + const mm = new THREE.Mesh(merged, b.material); + mm.userData = stockTag ? { isStock: true, batched: true } : { isFrame: true, batched: true }; + target.add(mm); + saved += b.meshes.length - 1; + } + return saved; +} + +function bucketPush(map, key, mesh) { + let b = map.get(key); if (!b) { b = { material: mesh.material, meshes: [] }; map.set(key, b); } + b.meshes.push(mesh); +} + +export function batchRoom(ctx, roomGroup) { + const THREE = ctx.THREE; + roomGroup.updateMatrixWorld(true); + const roomInv = roomGroup.matrixWorld.clone().invert(); + const stock = new Map(); // room-level stock, keyed by material + const frames = []; // [{group, buckets}] per fitting + + for (const fit of roomGroup.children) { + if (!fit.isGroup) continue; // shell meshes + wall decor stay as-is + fit.updateMatrixWorld(true); + const frame = new Map(); + for (const mesh of fit.children) { + if (!mesh.isMesh) continue; + const key = matKey(mesh.material); + if (key == null || (mesh.userData && mesh.userData.noBatch)) continue; + if (mesh.userData && mesh.userData.isStock) bucketPush(stock, key, mesh); + else bucketPush(frame, key, mesh); + } + frames.push({ group: fit, buckets: frame }); + } + + const tmp = new THREE.Matrix4(); + const stockBake = (m) => { m.updateMatrixWorld(true); return tmp.multiplyMatrices(roomInv, m.matrixWorld); }; + const frameBake = (m) => { m.updateMatrix(); return m.matrix; }; + + let saved = flushBuckets(ctx, roomGroup, stock, true, stockBake); // room-level stock + for (const f of frames) saved += flushBuckets(ctx, f.group, f.buckets, false, frameBake); // per-fitting frames + return saved; +} diff --git a/web/js/interiors/glb.js b/web/js/interiors/glb.js index 9921f7e..02870c2 100644 --- a/web/js/interiors/glb.js +++ b/web/js/interiors/glb.js @@ -90,10 +90,14 @@ export function upgradeFitting(ctx, fitting, kind, manifest) { const s = Math.min(1, Math.max(want.w, want.d) / glbW); inst.scale.setScalar(s); inst.position.y -= bb.min.y * s; // plant on the floor (glb_law minY≈0) - // hide the primitive FRAME (the first frameCount children) but keep procedural stock — which was - // added after the frame — visible on top of the detailed GLB shelf/counter. - const frameCount = fitting.frameCount ?? fitting.group.children.length; - for (let i = 0; i < frameCount && i < fitting.group.children.length; i++) fitting.group.children[i].visible = false; + // hide the primitive FRAME (fixture) but keep the procedural STOCK visible on top of the detailed + // GLB shelf/counter. Post-batch (batch.js) the children are merged meshes tagged isStock / isFrame; + // pre-batch they're individual meshes (stock tagged isStock, frame untagged). Hide everything that + // isn't stock and isn't the GLB we're adding. + for (const c of fitting.group.children) { + if (c.userData && (c.userData.isStock || c.userData.glbUpgrade)) continue; + c.visible = false; + } inst.userData = { glbUpgrade: true, kind }; fitting.group.add(inst); }); diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 89e34ce..cdfefc8 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -38,6 +38,7 @@ import { Ctx } from './context.js'; import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js'; import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js'; import { layout } from './layout.js'; +import { batchRoom } from './batch.js'; import { preloadManifest, upgradeRoom } from './glb.js'; import { xmur3 } from '../core/prng.js'; @@ -79,6 +80,12 @@ export function buildInterior(shop, THREE, opts) { // fittings + wall decor + stock + walkable-path guarantee const lay = layout(ctx, { recipe, dims, shell, adapter: opts.stockAdapter || null, shop: norm }); + // DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes + // inside each fitting group into a few merged geometries. Fitting groups (and their userData) are + // kept, so bins stay raycast targets for ?dig=1. Runs BEFORE the GLB upgrade so its frame-hide sees + // the merged fixture meshes. opts.noBatch disables it (diagnostics only). + if (!opts.noBatch) batchRoom(ctx, shell.group); + // OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and // unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest) // or passed as opts.manifest. Never blocks the build; never required for a fully-usable room. diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js index 6186b6a..b6563a8 100644 --- a/web/js/interiors/stock.js +++ b/web/js/interiors/stock.js @@ -122,6 +122,7 @@ function binFan(ctx, parent, slot, opts, r) { const sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent); sl.rotation.x = tilt + (r() - 0.5) * 0.05; sl.rotation.z = (r() - 0.5) * 0.04; + sl.userData.isStock = true; } } @@ -142,18 +143,19 @@ function shelfLine(ctx, parent, slot, opts, r) { slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2), slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent); b.rotation.y = (r() - 0.5) * 0.6; + b.userData.isStock = true; } return; } if (kind === 'spine') { // books / VHS: thin spines edge-out - const th = Math.min(0.03, run / (n + 2)); const step = (run - 0.04) / n; for (let i = 0; i < n; i++) { const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; const bh = clear * (0.8 + r() * 0.2); const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent); if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over + b.userData.isStock = true; } return; } @@ -164,23 +166,24 @@ function shelfLine(ctx, parent, slot, opts, r) { const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; const b = ctx.box(step * 0.9, clear * 0.9, 0.01, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + clear * 0.45, slot.z, parent); b.rotation.x = slot.tilt ?? -0.35; + b.userData.isStock = true; } return; } - // default 'box' / 'snack': upright cartons in a row, faces forward (+Z) + // default 'box' / 'snack': upright cartons in a row, faces forward (+Z). + // SINGLE material (cover on all faces) so the whole run merges to one draw in batch.js — a + // multi-material box cost one draw PER face (6×), the biggest draw-count offender pre-round-6. const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28); const step = run / n; for (let i = 0; i < n; i++) { const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26); - const m = ctx.mat('#ffffff', 0.7); + const m = ctx.mat('#ffffff', 0.8); m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true; - const side = ctx.mat(pick(r, kind === 'snack' ? SNACK : CARD), 0.85); - const g = ctx.boxGeo(bw * 0.88, bh, bd); // shared geometry - const mesh = new ctx.THREE.Mesh(g, [side, side, side, side, m, side]); // face (+Z) wears the cover - mesh.position.set(slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01); - parent.add(mesh); // face `m` + `side` already tracked via ctx.mat; geometry via ctx.geom + const mesh = ctx.box(bw * 0.88, bh, bd, m, + slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent); + mesh.userData.isStock = true; } } @@ -196,6 +199,7 @@ function hang(ctx, parent, slot, opts, r) { const p = new ctx.THREE.Mesh(g, m); p.position.set(gx, slot.y - 0.05 - h / 2, slot.z + (r() - 0.5) * 0.03); p.rotation.y = (r() - 0.5) * 0.15; + p.userData.isStock = true; parent.add(p); } } @@ -209,5 +213,6 @@ function treasures(ctx, parent, slot, opts, r) { const b = ctx.box(s * (1 + r()), s, s, ctx.mat(pick(r, cols), 0.4, { metalness: 0.3 }), slot.x - run / 2 + (run) * ((i + 0.5) / n), slot.y + s / 2, slot.z + (r() - 0.5) * (slot.depth || 0.2) * 0.5, parent); b.rotation.y = r() * Math.PI; + b.userData.isStock = true; } }