Shelf Lighting

Lighting and Shadows for the Shelf

The lights reuse Lights and Shadows: the stage's sun and hemisphere, a RoomEnvironment fill (Environment Maps), Neutral tone mapping to keep the palette true (Tone Mapping), and a wall to catch the shadows:

bookshelf.js: lightBookcase()JavaScript
export function lightBookcase({ renderer, scene, camera }, PMREM = THREE.PMREMGenerator) {
  renderer.toneMapping = THREE.NeutralToneMapping;
  addEnvironment(renderer, scene, 0.5, PMREM);
  const wall = new THREE.Mesh(new THREE.PlaneGeometry(12, 6),
    new THREE.MeshStandardMaterial({ color: '#eceff1' }));
  wall.position.set(0, 1, -0.45);
  scene.add(wall);
  enableShadows({ renderer, scene });
  scene.getObjectByName('sun').position.set(-1.5, 3, 4);  // light from the left, shadows right
  camera.position.set(0.15, 0.45, 2.75);
  camera.lookAt(-0.1, -0.05, 0);
}

The sun moves left so shadows fall right, across the wall. Each caster is drawn again into the shadow map.

The bookcase lit by sun, hemisphere and RoomEnvironment, with Neutral tone mapping and shadows on the wallHTMLLive
<!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">Sun from the left ↔ right <input type="range" id="sun" min="-3" max="3" step="0.1" value="-1.5">
  <label><input type="checkbox" id="shadows" checked> shadows</label></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);
}

const stage = createStage(600, 280);
stage.scene.add(createBookcase());
lightBookcase(stage);

const sun = stage.scene.getObjectByName('sun');
const $ = (id) => document.getElementById(id);
stage.renderer.setAnimationLoop(() => {
  sun.position.x = +$('sun').value;
  sun.castShadow = $('shadows').checked;             // each caster is drawn again into the shadow map
  stage.renderer.render(stage.scene, stage.camera);
});
</script>