JavaScript's garbage collector frees objects, not the GPU buffers, textures and programs Three.js 30,815 created for them. Removing a mesh from the scene leaves all three allocated until you call dispose(), as renderer.info.memory shows:
import * as BN from './bookshelf.js';
const stage = BN.createStage({ width: 320, height: 200 });
const { renderer, scene, camera } = stage;
const report = (label) => console.log(label.padEnd(10), JSON.stringify(renderer.info.memory),
'programs:', renderer.info.programs.length);
BN.lightBookcase(stage); // wall, lights, environment map
renderer.render(scene, camera);
report('empty');
const bookcase = BN.createBookcase();
scene.add(bookcase);
renderer.render(scene, camera);
report('bookcase');
scene.remove(bookcase);
renderer.render(scene, camera);
report('removed'); // out of the scene, still on the GPU
bookcase.traverse((object) => {
if (!object.isMesh) return;
object.geometry.dispose();
[object.material].flat().forEach((m) => { m.map?.dispose(); m.dispose(); }); // maps too
});
renderer.render(scene, camera);
report('disposed');
window.__done = true;Output
empty {"geometries":2,"textures":4} programs: 5
bookcase {"geometries":9,"textures":16} programs: 6
removed {"geometries":9,"textures":16} programs: 6
disposed {"geometries":2,"textures":4} programs: 5The bookcase added seven geometries and twelve canvas textures; removing it freed nothing, and disposing returned exactly to the empty scene. Materials do not own their (often shared) textures, so dispose those too, plus render targets, replaced environment maps and, when a viewer closes, the renderer.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; padding: 4px; }
canvas { display: block; }
table { border-collapse: collapse; background: #fff; }
td, th { border: 1px solid #ddd; padding: 3px 8px; text-align: right; }
th:first-child, td:first-child { text-align: left; }
.ui { padding: 4px 8px; }
</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"><button id="add">Add bookcase</button> <button id="remove">scene.remove()</button> <button id="dispose">dispose()</button></div>
<div class="row" id="row"><table id="t"><tr><th>step</th><th>geometries</th><th>textures</th><th>programs</th></tr></table></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(330, 220);
document.getElementById('row').prepend(stage.renderer.domElement);
const { renderer, scene, camera } = stage;
lightBookcase(stage); // wall, lights, environment map
camera.position.set(0.1, 0.5, 3.6);
function report(label) {
renderer.render(scene, camera);
const { geometries, textures } = renderer.info.memory;
document.getElementById('t').insertAdjacentHTML('beforeend',
`<tr><td>${label}</td><td>${geometries}</td><td>${textures}</td><td>${renderer.info.programs.length}</td></tr>`);
}
let bookcase = null;
const add = () => { bookcase = createBookcase(); scene.add(bookcase); report('bookcase added'); };
const remove = () => { scene.remove(bookcase); report('removed (still on GPU)'); };
// Materials don't own their textures: dispose the maps too
const dispose = () => {
bookcase.traverse((object) => {
if (!object.isMesh) return;
object.geometry.dispose();
[object.material].flat().forEach((m) => { m.map?.dispose(); m.dispose(); });
});
bookcase = null;
report('disposed');
};
document.getElementById('add').onclick = add;
document.getElementById('remove').onclick = remove;
document.getElementById('dispose').onclick = dispose;
// Run the whole sequence once; the canvas keeps the last view with a bookcase
report('empty scene');
add();
remove();
dispose();
add();
</script>