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