In WebGPURenderer every material is a node material (a MeshStandardMaterial is swapped for its node twin), and a node builder (WGSLNodeBuilder or GLSLNodeBuilder) compiles its graph in three stages: setup expands nodes, analyze counts uses so shared results become variables, and generate emits code, which buildCode() wraps in bindings and entry points. Modules are cached by source text, pipelines by module pair and render state.
import * as THREE from 'three/webgpu';
import * as BN from './bookshelf.js';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
const { scene, camera } = BN.createStage({ width: 320, height: 200, renderer });
const bookcase = BN.createBookcase();
scene.add(bookcase);
BN.lightBookcase({ renderer, scene, camera }, THREE.PMREMGenerator);
const start = performance.now();
await renderer.compileAsync(scene, camera); // build every pipeline, draw nothing
const compiled = performance.now();
renderer.render(scene, camera);
console.log(`compileAsync ${(compiled - start).toFixed(0)} ms, first frame`,
`${(performance.now() - compiled).toFixed(0)} ms, modules ${renderer.info.memory.programs}`);
const plank = bookcase.getObjectByName('plank');
const { fragmentShader } = await renderer.debug.getShaderAsync(scene, camera, plank);
const lines = fragmentShader.split('\n');
console.log(lines.length, 'lines;', lines.find((line) => line.includes('DiffuseColor =')).trim());
window.__done = true;It printed compileAsync 685 ms, first frame 17 ms, modules 14: the pipelines were built with createRenderPipelineAsync(), while a first render() without them compiled synchronously in about 125 ms. Seven pipelines cover the scene. The plank's shader is 909 lines of WGSL, mostly lighting, and its color arrives as a uniform (DiffuseColor = vec4<f32>( object.nodeUniform0, 1.0 );), so recoloring never recompiles.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-start; padding: 4px; }
canvas { display: block; }
pre { margin: 0; width: 260px; height: 230px; overflow: auto; background: #1d2327; color: #cfe3d4; font-size: 10px; padding: 6px; }
p { margin: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.tsl.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="row" id="row"><pre id="code">compiling…</pre></div>
<p id="out"></p>
<script type="module">
import * as THREE from 'three/webgpu';
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 renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
const stage = createStage(320, 230, renderer);
document.getElementById('row').prepend(renderer.domElement);
const bookcase = createBookcase();
stage.scene.add(bookcase);
lightBookcase(stage, THREE.PMREMGenerator);
stage.camera.position.set(0.1, 0.5, 3.6);
// Every material becomes a node material; a node builder turns its graph into WGSL (or GLSL)
const start = performance.now();
await renderer.compileAsync(stage.scene, stage.camera); // build every pipeline, draw nothing
const compiled = performance.now();
renderer.render(stage.scene, stage.camera);
const first = performance.now() - compiled;
const plank = bookcase.getObjectByName('plank');
const { fragmentShader } = await renderer.debug.getShaderAsync(stage.scene, stage.camera, plank);
const lines = fragmentShader.split('\n');
const color = lines.findIndex((line) => line.includes('DiffuseColor ='));
// Show the lines around the base color: it arrives as a uniform, so recoloring never recompiles
document.getElementById('code').textContent = lines.slice(Math.max(0, color - 12), color + 18).join('\n');
document.getElementById('out').textContent =
`${renderer.backend.isWebGPUBackend ? 'WGSL' : 'GLSL'}: compileAsync ${(compiled - start).toFixed(0)} ms, ` +
`first frame ${first.toFixed(0)} ms, plank fragment shader ${lines.length} lines`;
renderer.setAnimationLoop(() => renderer.render(stage.scene, stage.camera));
</script>