PROCITY/web/js/interiors/batch.js
m3ultra 35981ade5f 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>
2026-07-14 23:01:04 +10:00

85 lines
4.3 KiB
JavaScript

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