lil-gui 1,638 (github.com/georgealways/lil-gui (https://github.com/georgealways/lil-gui 1,638 ), MIT, 0.21.0) builds a panel bound to object properties: gui.add(object, 'property', min, max) gives a slider, a checkbox for a boolean and a dropdown for an array of options. It replaced the dormant dat.gui 7,728 ; Three.js 30,815 's examples import a copy from three/addons/libs/lil-gui.module.min.js (0.17.0 in r186).
<script src="three-map.js"></script>
<script type="module">
import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.21.0/+esm';
import * as BN from './bookshelf.js';
document.body.style.cssText = 'margin: 0; display: flex; align-items: flex-start';
const stage = BN.createStage({ width: 430, height: 240 });
const { renderer, scene, camera } = stage;
const bookcase = BN.createBookcase();
scene.add(bookcase);
BN.lightBookcase(stage);
camera.position.set(0.2, 0.6, 3.7); // a narrower canvas needs more distance
camera.lookAt(-0.1, -0.05, 0);
const draw = () => renderer.render(scene, camera); // render on change
const params = { book: 'Patterns of the Deep Web', pull: 0 };
const gui = new GUI({ title: 'Bookshelf', width: 240, container: document.body });
gui.add(scene.getObjectByName('sun'), 'intensity', 0, 5, 0.1).name('sun').onChange(draw);
gui.add(params, 'book', BN.BOOKS.map((book) => book.title));
gui.add(params, 'pull', 0, 0.6).onChange((z) => {
bookcase.getObjectByName(params.book).position.z = 0.3 + z;
draw();
});
gui.controllers[2].setValue(0.4); // as if the reader dragged the slider
gui.controllers[0].setValue(3.5);
window.__done = true;
</script>
setValue() fires onChange() just as a drag does; gui.save() returns every value for pasting back into code. Bind colors through a hex string and material.color.set(): bound to a THREE.Color itself, the swatch shows linear values (Question 7 in Test Yourself!).
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; display: flex; align-items: flex-start; flex-wrap: wrap; }
canvas { display: block; }
</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>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.21.0/+esm';
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(350, 260);
const { renderer, scene, camera } = stage;
const bookcase = createBookcase();
scene.add(bookcase);
lightBookcase(stage);
camera.position.set(0.2, 0.6, 3.9); // a narrower canvas needs more distance
camera.lookAt(-0.1, -0.05, 0);
const draw = () => renderer.render(scene, camera); // render on change, no loop
const params = { book: 'Patterns of the Deep Web', pull: 0, wall: '#eceff1' };
const gui = new GUI({ title: 'Bookshelf', width: 240, container: document.body });
gui.add(scene.getObjectByName('sun'), 'intensity', 0, 5, 0.1).name('sun').onChange(draw); // a slider
gui.add(params, 'book', BOOKS.map((book) => book.title)).onChange(() => { // a dropdown
books.forEach((b) => { b.position.z = home.get(b); });
params.pull = 0; gui.controllers[2].updateDisplay(); draw();
});
gui.add(params, 'pull', 0, 0.6).onChange((z) => {
const book = bookcase.getObjectByName(params.book);
book.position.z = home.get(book) + z;
draw();
});
// Bind colors through a hex string and material.color.set()
gui.addColor(params, 'wall').onChange((hex) => { scene.getObjectByName('wall').material.color.set(hex); draw(); });
const books = bookcase.children.slice(1);
const home = new Map(books.map((b) => [b, b.position.z]));
gui.controllers[2].setValue(0.4); // setValue() fires onChange, as a drag would
gui.controllers[0].setValue(3.5);
</script>