Merging Geometries

Merging and Grouping Geometries

Each mesh costs a draw call (Bottlenecks). The BufferGeometryUtils add-on's mergeGeometries() concatenates geometries; with true it records a group per input, so a material array still colors each part:

Draw calls for the six books merged, with groups and with one materialJavaScript
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { createStage, createShelf } from './bookshelf.js';
const { renderer, scene, camera } = createStage({ width: 640, height: 170 });
const shelf = createShelf();
scene.add(shelf);
function calls() { renderer.render(scene, camera); return renderer.info.render.calls; }
console.log('draw calls, seven meshes:', calls());
const books = shelf.children.filter((child) => child.userData.book);
const merged = mergeGeometries(books.map((book) => {
  book.updateMatrix();
  return book.geometry.clone().applyMatrix4(book.matrix);   // local placement baked in
}), true);
shelf.remove(...books);
const mesh = new THREE.Mesh(merged, books.map((book) => book.material));
shelf.add(mesh);
console.log(`merged, ${merged.groups.length} groups:`, calls());
mesh.material = books[0].material;                          // one material ignores the groups
console.log('merged, one material:', calls());
Output
draw calls, seven meshes: 7
merged, 6 groups: 7
merged, one material: 2

Groups do not save draw calls: the renderer issues one per group. A single material does, so put per-part colors in a color attribute (Custom Geometry) or a texture atlas (Batching and Atlases). Merge static scenery only; objects that move or are picked on their own belong in an InstancedMesh (InstancedMesh).

Draw calls for seven meshes, one merged mesh with groups, and one merged mesh with one materialHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .ui { padding: 6px 8px; }
  button.on { background: #263238; color: #fff; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui" id="buttons"></div>
<div class="ui" id="out"></div>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 220);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 220, 0.1, 50);
camera.position.set(0, 0.4, 3.6);
camera.lookAt(0, -0.1, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);

const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
const books = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].map((color, i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
  book.position.x = (i - 2.5) * 0.5;
  book.rotation.y = (i - 2.5) * 0.08;
  return book;
});

// Bake each book's placement into a copy of its geometry, then merge with one group per input
const merged = mergeGeometries(books.map((book) => {
  book.updateMatrix();
  return book.geometry.clone().applyMatrix4(book.matrix);
}), true);
const withGroups = new THREE.Mesh(merged, books.map((b) => b.material));
const oneMaterial = new THREE.Mesh(merged, books[0].material);   // ignores the groups

const modes = {
  'Seven meshes': books,
  [`Merged, ${merged.groups.length} groups`]: [withGroups],
  'Merged, one material': [oneMaterial],
};
const calls = {};
function show(name) {
  scene.remove(...books, withGroups, oneMaterial);
  scene.add(...modes[name]);
  renderer.render(scene, camera);
  calls[name] = renderer.info.render.calls;             // includes the plank's call
  document.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.textContent === name));
  document.getElementById('out').textContent =
    Object.entries(calls).map(([n, c]) => `${n}: ${c} draw calls`).join(' · ');
}
for (const name of Object.keys(modes)) {
  const b = document.getElementById('buttons').appendChild(document.createElement('button'));
  b.textContent = name;
  b.onclick = () => show(name);
}
// Measure all three once, then settle on the grouped version
Object.keys(modes).forEach(show);
show(Object.keys(modes)[1]);
</script>