WebGLRenderer.render(scene, camera) updates world matrices, then walks the graph in projectObject(), skipping objects that are invisible or outside the camera's layers or frustum, collecting lights, and pushing one render item per mesh, or per geometry group when the material is an array:

After one frame of the lit bookcase, renderer.renderLists.get(scene, 0).opaque held 26 items (plank, wall and six books of four groups) and info.render.calls read 52: the shadow pass draws every caster again. Items sort by material id before depth, so neighbors share GL state; a book moved out of view left both passes (22 items, 44 calls). Instancing (InstancedMesh) cuts calls, the real cost.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
.ui { padding: 4px 8px; }
#out { font: 12px monospace; }
</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">Pan the camera <input type="range" id="pan" min="-2.5" max="2.5" step="0.05" value="0">
<label><input type="checkbox" id="shadows" checked> shadows</label> · click a book to hide it</div>
<div class="ui" id="out"></div>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const BOOKS = [
{ title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b', rating: 4.6, pages: 312 },
{ title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99', rating: 4.3, pages: 428 },
{ title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10', rating: 4.8, pages: 256 },
{ title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a', rating: 4.1, pages: 198 },
{ title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f', rating: 4.5, pages: 344 },
{ title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f', rating: 4.4, pages: 176 },
];
// --- Book model: pages, two boards and a curved spine, merged with one group per part ---
const thickness = (book) => 0.05 + book.pages * 0.0003; // metres, from the page count
function bookGeometry(book, height, depth = 0.62) {
const t = thickness(book), b = 0.012, y = height / 2, z = -depth / 2;
const box = (w, h, d, x, zz = z) => new THREE.BoxGeometry(w, h, d).translate(x, y, zz);
const spine = new THREE.CylinderGeometry(t / 2, t / 2, height, 24, 1, true, -Math.PI / 2, Math.PI)
.scale(1, 1, 0.5).translate(0, y, 0);
return mergeGeometries([box(t - 2 * b, height - 0.03, depth - 0.02, 0, z + 0.01),
box(b, height, depth, (b - t) / 2), box(b, height, depth, (t - b) / 2), spine], true);
}
function paint(book, width, height, draw) {
const canvas = Object.assign(document.createElement('canvas'), { width, height });
const g = canvas.getContext('2d');
g.fillStyle = book.color; g.fillRect(0, 0, width, height);
g.fillStyle = '#ffffff';
draw(g);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
const coverArt = (book) => paint(book, 360, 520, (g) => {
g.font = 'bold 50px Georgia, serif';
book.title.split(' ').forEach((word, i) => g.fillText(word, 28, 84 + i * 56, 304));
g.font = 'italic 28px Georgia, serif';
g.fillText(`${book.author}, ${book.rating} / 5`, 28, 460);
});
const spineArt = (book) => paint(book, 128, 640, (g) => {
g.rotate(Math.PI / 2);
g.textBaseline = 'middle';
g.font = 'bold 44px Georgia, serif';
g.fillText(book.title, 24, -64, 440);
g.font = '28px Georgia, serif';
g.fillText(book.author.split(' ').at(-1), 490, -64, 130);
});
function createSpineBook(book, height = 0.9) {
const material = (options) => new THREE.MeshStandardMaterial({ roughness: 0.6, ...options });
const mesh = new THREE.Mesh(bookGeometry(book, height), [material({ color: '#f3ecdc' }),
material({ color: book.color }), material({ map: coverArt(book) }), material({ map: spineArt(book) })]);
return Object.assign(mesh, { name: book.title, userData: { book } });
}
// --- The arrangement ---
function createBookcase() {
const bookcase = new THREE.Group();
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.5, 0.06, 0.8), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.name = 'plank';
plank.position.set(0, -0.52, 0);
bookcase.add(plank);
const top = BOOKS.reduce((best, book) => (book.rating > best.rating ? book : best));
let x = -0.85;
BOOKS.forEach((book, i) => {
const mesh = createSpineBook(book, 0.84 + 0.05 * (i % 3)); // three trim heights
if (book === top) {
mesh.position.set(0.3, -0.49, 0.05);
mesh.rotation.y = -Math.PI / 2 + 0.35; // cover forward, turned a little
} else {
mesh.position.set(x + thickness(book) / 2, -0.49, 0.3); // running x plus half a thickness
x += thickness(book) + 0.005;
}
bookcase.add(mesh);
});
return bookcase;
}
// --- Stage and lighting: sun and hemisphere, RoomEnvironment fill, Neutral tone mapping, shadows ---
function createStage(width, height, renderer = new THREE.WebGLRenderer({ antialias: true })) {
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(width, height);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#eceff1');
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 50);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.name = 'sun';
scene.add(sun);
return { renderer, scene, camera };
}
function lightBookcase({ renderer, scene, camera }, PMREM = THREE.PMREMGenerator) {
renderer.toneMapping = THREE.NeutralToneMapping;
const pmrem = new PMREM(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.5;
pmrem.dispose();
const wall = new THREE.Mesh(new THREE.PlaneGeometry(12, 6), new THREE.MeshStandardMaterial({ color: '#eceff1' }));
wall.name = 'wall';
wall.position.set(0, 1, -0.45);
scene.add(wall);
renderer.shadowMap.enabled = true;
const sun = scene.getObjectByName('sun');
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.normalBias = 0.02;
Object.assign(sun.shadow.camera, { left: -2, right: 2, top: 1.5, bottom: -1.5 });
scene.traverse((o) => { if (o.isMesh) o.castShadow = o.receiveShadow = true; });
sun.position.set(-1.5, 3, 4); // light from the left, shadows fall right
camera.position.set(0.15, 0.45, 2.75);
camera.lookAt(-0.1, -0.05, 0);
}
const stage = createStage(600, 250);
const { renderer, scene, camera } = stage;
const bookcase = createBookcase();
scene.add(bookcase);
lightBookcase(stage);
const books = bookcase.children.slice(1);
// Clicking hides a book: projectObject() skips invisible objects entirely
const raycaster = new THREE.Raycaster();
renderer.domElement.addEventListener('click', (e) => {
const r = renderer.domElement.getBoundingClientRect();
raycaster.setFromCamera(new THREE.Vector2((e.clientX - r.left) / r.width * 2 - 1, 1 - (e.clientY - r.top) / r.height * 2), camera);
const hit = raycaster.intersectObjects(books.filter((b) => b.visible))[0];
if (hit) hit.object.visible = false;
});
const $ = (id) => document.getElementById(id);
renderer.setAnimationLoop(() => {
camera.position.x = 0.15 + +$('pan').value; // move sideways: books leave the frustum
camera.lookAt(-0.1 + +$('pan').value, -0.05, 0);
scene.getObjectByName('sun').castShadow = $('shadows').checked;
renderer.render(scene, camera);
// One render item per mesh, or per geometry group when the material is an array
const list = renderer.renderLists.get(scene, 0);
const { calls, triangles } = renderer.info.render;
$('out').textContent = `opaque render items ${list.opaque.length} · draw calls ${calls}` +
` (the shadow pass draws casters again) · triangles ${triangles}`;
});
</script>