To change one step of a built-in material, onBeforeCompile(shader) receives its GLSL before compilation: add uniforms to shader.uniforms and splice code at an #include marker. BookNest's rating filter (WebGPU did the filtering on the GPU) grays out books below 4.5 stars while their lighting and shadows stay intact:
export function addSaturation(material, saturation) {
material.onBeforeCompile = (shader) => {
shader.uniforms.saturation = saturation; // a { value } object, live and shareable
shader.fragmentShader = 'uniform float saturation;\n' + shader.fragmentShader.replace(
'#include <map_fragment>', `#include <map_fragment>
float gray = dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
diffuseColor.rgb = mix(vec3(gray), diffuseColor.rgb, saturation);`);
};
return material;
}<script src="three-map.js"></script>
<script type="module">
import * as BN from './bookshelf.js';
const stage = BN.createStage({ width: 640, height: 240 });
const bookcase = BN.createBookcase();
stage.scene.add(bookcase);
BN.lightBookcase(stage);
const salt = bookcase.getObjectByName('Salt and Saffron');
const foil = salt.material[2] = BN.foilMaterial(salt.material[2].map); // the cover
foil.uniforms.time.value = 1.3; // live: seconds from the animation loop
for (const book of bookcase.children.slice(1)) {
const saturation = { value: book.userData.book.rating >= 4.5 ? 1 : 0.1 };
book.material.filter((m) => m.isMeshStandardMaterial)
.forEach((m) => BN.addSaturation(m, saturation));
}
BN.renderFrames(stage.renderer, stage.scene, stage.camera);
</script>
The foil cover is unlit, so it reads flatter than its neighbors. Each book's materials share one saturation object, so setting its value fades the whole book without recompiling. Programs are cached by customProgramCacheKey(), which defaults to the callback's source text; override it when one callback text builds different shaders. Chunk names such as map_fragment can change between releases, so pin your version.
<!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; }
</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">Show books rated at least <input type="range" id="min" min="4" max="4.9" step="0.1" value="4.5"> <b id="v">4.5</b> in color</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);
}
// Splice two lines after the built-in map_fragment chunk; lighting and shadows stay intact
function addSaturation(material, saturation) {
material.onBeforeCompile = (shader) => {
shader.uniforms.saturation = saturation; // a { value } object, live and shareable
shader.fragmentShader = 'uniform float saturation;\n' + shader.fragmentShader.replace(
'#include <map_fragment>', `#include <map_fragment>
float gray = dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
diffuseColor.rgb = mix(vec3(gray), diffuseColor.rgb, saturation);`);
};
return material;
}
const stage = createStage(600, 270);
const bookcase = createBookcase();
stage.scene.add(bookcase);
lightBookcase(stage);
// One shared { value } per book: changing it fades the whole book without recompiling
const books = bookcase.children.slice(1).map((book) => {
const saturation = { value: 1 };
book.material.forEach((m) => addSaturation(m, saturation));
return { rating: book.userData.book.rating, saturation };
});
const slider = document.getElementById('min');
stage.renderer.setAnimationLoop(() => {
const min = +slider.value;
document.getElementById('v').textContent = min.toFixed(1);
for (const b of books) {
const target = b.rating >= min ? 1 : 0.1;
b.saturation.value += (target - b.saturation.value) * 0.15; // ease toward the target
}
stage.renderer.render(stage.scene, stage.camera);
});
</script>