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>
85 lines
4.3 KiB
JavaScript
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;
|
|
}
|