Physically based lights and environments produce values above 1.0 that the display clips, so bright colors lose hue and highlights turn flat. renderer.toneMapping compresses the range first, scaled by renderer.toneMappingExposure. The page below renders one over-lit shelf with no tone mapping, ACES Filmic, AgX and Neutral, left to right:
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { createStage, createShelf, addEnvironment, renderFrames } from './bookshelf.js';
document.body.style.display = 'flex';
for (const operator of ['No', 'ACESFilmic', 'AgX', 'Neutral']) {
const { renderer, scene, camera } = createStage({ width: 160, height: 110 });
renderer.toneMapping = THREE[`${operator}ToneMapping`];
renderer.toneMappingExposure = 1; // the default; raise or lower to taste
camera.position.multiplyScalar(1.3); // back off to fit the narrow canvas
scene.add(createShelf());
addEnvironment(renderer, scene, 1.2); // deliberately too bright
renderFrames(renderer, scene, camera);
}
</script>
Without tone mapping the orange book clips to pure yellow and the teal to cyan. ACES lifts and washes out the colors, AgX mutes them toward gray, and Khronos PBR Neutral keeps them closest to the palette, which suits product and catalog views. Tone mapping applies only when rendering to the screen; with post-processing, an OutputPass applies it at the end (Anti-Aliasing).
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 4px; flex-wrap: wrap; padding: 4px; }
figure { margin: 0; text-align: center; }
canvas { display: block; }
.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="row" id="row"></div>
<div class="ui">toneMappingExposure <input type="range" id="exp" min="0.2" max="2" step="0.05" value="1"> <span id="v">1</span></div>
<script type="module">
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
function buildScene(renderer) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 1.2; // deliberately too bright
pmrem.dispose();
const sun = new THREE.DirectionalLight('#ffffff', 4);
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);
['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].forEach((color, i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color, roughness: 0.6 }));
book.position.x = (i - 2.5) * 0.5;
scene.add(book);
});
return scene;
}
const camera = new THREE.PerspectiveCamera(35, 145 / 120, 0.1, 50);
camera.position.set(0, 0.4, 4.3);
camera.lookAt(0, -0.1, 0);
const views = ['No', 'ACESFilmic', 'AgX', 'Neutral'].map((operator) => {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(145, 120);
renderer.toneMapping = THREE[`${operator}ToneMapping`]; // compresses values above 1.0
const figure = document.getElementById('row').appendChild(document.createElement('figure'));
figure.append(renderer.domElement, operator === 'No' ? 'NoToneMapping' : operator);
return { renderer, scene: buildScene(renderer) };
});
function render(exposure) {
for (const { renderer, scene } of views) {
renderer.toneMappingExposure = exposure;
renderer.render(scene, camera);
}
}
const slider = document.getElementById('exp');
slider.addEventListener('input', () => {
document.getElementById('v').textContent = slider.value;
render(+slider.value);
});
render(1);
</script>