UnrealBloomPass(resolution, strength, radius, threshold) keeps pixels brighter than threshold, blurs them at several mip levels and adds them back (Gaussian Blur and Bloom). OutlinePass edges a mask of selected objects without touching their materials. ShaderPass runs your fragment shader over tDiffuse, the previous result. Here Salt and Saffron's cover glows, Gardens in Glass is outlined and the corners darken:
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { CopyShader } from 'three/addons/shaders/CopyShader.js';
import * as BN from './bookshelf.js';
const stage = BN.createStage({ width: 640, height: 240 });
const { renderer, scene, camera } = stage;
const bookcase = BN.createBookcase();
scene.add(bookcase);
BN.lightBookcase(stage);
const cover = bookcase.getObjectByName('Salt and Saffron').material[2]; // the front board
Object.assign(cover, { emissiveMap: cover.map, emissiveIntensity: 4 }); // over 1.0: blooms
cover.emissive.set('#ffffff');
const size = new THREE.Vector2(640, 240);
const outline = new OutlinePass(size, scene, camera, [bookcase.children[6]]); // Gardens...
Object.assign(outline, { edgeStrength: 8, edgeThickness: 2 });
outline.visibleEdgeColor.set('#e09a10');
const vignette = new ShaderPass({
uniforms: { tDiffuse: { value: null }, strength: { value: 1.2 } },
vertexShader: CopyShader.vertexShader, // passes vUv through
fragmentShader: `uniform sampler2D tDiffuse; uniform float strength; varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
vec2 d = vUv - 0.5;
gl_FragColor = vec4(color.rgb * (1.0 - strength * dot(d, d)), color.a);
}`,
});
const composer = new EffectComposer(renderer);
[new RenderPass(scene, camera), outline, new UnrealBloomPass(size, 0.7, 0.4, 2.6), vignette,
new OutputPass()].forEach((pass) => composer.addPass(pass));
let frames = 0;
renderer.setAnimationLoop(() => {
composer.render();
if (++frames === 3) window.__done = true;
});
</script>
The threshold matters most: at 1.2, and still at 2, the sunlit wall bloomed and washed out the frame, since lit surfaces exceed 1.0 before tone mapping; at 2.6 only the cover, emissive at 4, glows. (threejs.org's webgl_postprocessing_unreal_bloom_selective example shows bloom limited to chosen objects.)
<!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">bloom threshold <input type="range" id="th" min="0.5" max="4" step="0.1" value="2.6"> <span id="thv">2.6</span>
vignette <input type="range" id="vig" min="0" max="3" step="0.1" value="1.2"></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';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { CopyShader } from 'three/addons/shaders/CopyShader.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, 260);
const { renderer, scene, camera } = stage;
const bookcase = createBookcase();
scene.add(bookcase);
lightBookcase(stage);
// Salt and Saffron's cover emits at 4: brighter than any lit surface, so only it blooms
const cover = bookcase.getObjectByName('Salt and Saffron').material[2];
Object.assign(cover, { emissiveMap: cover.map, emissiveIntensity: 4 });
cover.emissive.set('#ffffff');
const size = new THREE.Vector2(600, 260);
const outline = new OutlinePass(size, scene, camera, [bookcase.getObjectByName('Gardens in Glass')]);
Object.assign(outline, { edgeStrength: 8, edgeThickness: 2 });
outline.visibleEdgeColor.set('#e09a10');
const bloom = new UnrealBloomPass(size, 0.6, 0.4, 2.6); // strength, radius, threshold
// A custom pass: darken the corners of tDiffuse, the previous pass's result
const vignette = new ShaderPass({
uniforms: { tDiffuse: { value: null }, strength: { value: 1.2 } },
vertexShader: CopyShader.vertexShader,
fragmentShader: `uniform sampler2D tDiffuse; uniform float strength; varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
vec2 d = vUv - 0.5;
gl_FragColor = vec4(color.rgb * (1.0 - strength * dot(d, d)), color.a);
}`,
});
const composer = new EffectComposer(renderer);
[new RenderPass(scene, camera), outline, bloom, vignette, new OutputPass()].forEach((p) => composer.addPass(p));
const $ = (id) => document.getElementById(id);
renderer.setAnimationLoop(() => {
bloom.threshold = +$('th').value;
$('thv').textContent = $('th').value;
vignette.uniforms.strength.value = +$('vig').value;
composer.render();
});
</script>