A composer owns two render targets, both HalfFloatType by default so values above 1.0 survive until tone mapping. Each pass reads the previous result from readBuffer, writes to writeBuffer, and the composer swaps them when the pass sets needsSwap. A RenderPass draws the scene first; effects follow; an OutputPass ends the chain, and only the last enabled pass draws to the canvas. You call composer.render() in the animation loop instead of renderer.render(), and composer.setSize() on resize.

Set a pass's enabled to switch it off at run time. Each pass fills every pixel at least once per frame, so on phones keep chains short or lower composer.setPixelRatio().
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
#passes { display: flex; gap: 4px; padding: 6px; flex-wrap: wrap; align-items: center; }
#passes label { background: #fff; border: 1px solid #cfc6b5; border-radius: 4px; padding: 3px 6px; }
#passes span { color: #8d6e63; }
</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 id="passes"></div>
<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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { SepiaShader } from 'three/addons/shaders/SepiaShader.js';
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 260);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#1d2327');
const camera = new THREE.PerspectiveCamera(35, 600 / 260, 0.1, 50);
camera.position.set(0, 0.3, 3.4);
camera.lookAt(0, -0.05, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1));
const sun = new THREE.DirectionalLight('#ffffff', 2);
sun.position.set(2, 3, 4);
scene.add(sun);
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
const books = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].map((color, i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
book.position.x = (i - 2.5) * 0.5;
scene.add(book);
return book;
});
// A reading lamp: emissive above 1.0, so bloom picks it out
const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.1), new THREE.MeshStandardMaterial({ emissive: '#ffd27a', emissiveIntensity: 6 }));
lamp.position.set(1.2, 0.75, 0.2);
scene.add(lamp);
// The composer ping-pongs between two half-float render targets; each pass reads one, writes the other
const composer = new EffectComposer(renderer);
const passes = [
['RenderPass', new RenderPass(scene, camera)],
['UnrealBloomPass', new UnrealBloomPass(new THREE.Vector2(600, 260), 0.8, 0.4, 1.0)],
['ShaderPass(Sepia)', new ShaderPass(SepiaShader)],
['OutputPass', new OutputPass()], // tone mapping and sRGB, last
];
const bar = document.getElementById('passes');
passes.forEach(([name, pass], i) => {
composer.addPass(pass);
if (i) bar.append(Object.assign(document.createElement('span'), { textContent: '→' }));
const label = bar.appendChild(document.createElement('label'));
label.innerHTML = `<input type="checkbox" ${name.includes('Sepia') ? '' : 'checked'}> ${name}`;
pass.enabled = !name.includes('Sepia');
label.firstChild.addEventListener('change', (e) => { pass.enabled = e.target.checked; }); // switch at run time
});
renderer.setAnimationLoop((time) => {
books.forEach((b, i) => { b.rotation.y = Math.sin(time / 1000 + i) * 0.3; });
composer.render(); // instead of renderer.render()
});
</script>