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:
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).
<!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>