Lane C R6 C1: interior draw batching (<=350 draws/room law)

Review debt: book room was 1,245 draws GLB-on. Diagnosed two drivers —
multi-material stock boxes (one draw per face, 6x) and one mesh per stock
item — then batched:

- stock.js: stock boxes are single-material now; every stock mesh tagged
  userData.isStock.
- batch.js (new): batchRoom() two-tier merge, called from interiors.js before
  the GLB upgrade. 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 record bins stay raycast targets for ?dig=1 and the
  GLB upgrade can hide the fixture). Uses vendored BufferGeometryUtils
  mergeGeometries.
- glb.js: frame-hide is now by isStock tag (not frame-count), so batched
  fixtures hide while the room-level stock rides on the GLB.
- interior_test.html: drawSweep() asserts <=350 draws across all type x
  archetype (GLB off and on), folded into the soak; exposed as
  window.PROCITY_C.drawSweep for F's harness (hook in LANE_C_NOTES).

Result (drawSweep, seed 1990): GLB-off worst 152, GLB-on worst 313
(opshop/hall) — book 1,245 -> 189. Determinism 0 fails / 810, leak-free, dig
still riffles batched bins, worst warm build 38ms (<50), visually identical.
qa.sh --strict GREEN (5 gates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 23:01:04 +10:00
parent 781baea4e2
commit 35981ade5f
7 changed files with 180 additions and 16 deletions

View File

@ -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.
---

View File

@ -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`:

View File

@ -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();

84
web/js/interiors/batch.js Normal file
View File

@ -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;
}

View File

@ -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);
});

View File

@ -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.

View File

@ -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;
}
}