Compositing in 3D

Compositing and Texturing with Canvas, Three.js and Babylon.js

The model paints; the page sets the type. composeCover() draws a painting into a 256 x 384 canvas, averages the luminance of its empty lower third (ImageData) to choose cream or ink lettering, and adds Canvas 2D's fitted title and mark. Each canvas is drawn on a strip and handed to Babylon.js 89,603 as a DynamicTexture on Cover Textures's face-out shelf:

The generated art under Canvas typography, then on the bookstore's shelfHTML
<canvas id="strip" style="width: 684px; height: 173px"></canvas>
<canvas id="view" style="width: 684px; height: 180px"></canvas>
<script src="hidpi.js"></script><script src="wrap.js"></script>
<script src="booknest-mark.js"></script><script src="booknest-data.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script type="module">
const W = 256, H = 384;
function composeCover(art, book) {                   // Chapter 2's cover, over the art
  const canvas = Object.assign(document.createElement('canvas'), { width: W, height: H });
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  ctx.drawImage(art, 0, 0, W, H);
  const { data } = ctx.getImageData(0, H * 0.68, W, H * 0.32);   // the empty third
  let luma = 0;                                       // Rec. 709 weights, 0-255
  for (let i = 0; i < data.length; i += 4) {
    luma += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
  }
  ctx.fillStyle = luma / (data.length / 4) < 150 ? '#fff3e0' : '#263238';
  let size = 36, lines;
  do {                                                // fitTitle: shrink to two lines
    ctx.font = `bold ${--size}px Georgia`;
    lines = wrapLines(ctx, book.title, W - 36);
  } while (lines.length > 2 || lines.some(line => ctx.measureText(line).width > W - 36));
  ctx.textAlign = 'center';
  lines.forEach((line, k) => ctx.fillText(line, W / 2, H - 92 + k * size * 1.1));
  ctx.font = 'italic 17px Georgia';
  ctx.fillText(book.author, W / 2, H - 22);
  drawMark(ctx, 14, H - 50, 0.2);
  return canvas;
}
const arts = BOOKS.map((book, i) =>
  Object.assign(new Image(), { src: `booknest-art-${i + 1}.jpg` }));
await Promise.all(arts.map(art => art.decode()));
const covers = arts.map((art, i) => composeCover(art, BOOKS[i]));
const ctx = hidpi(strip);
covers.forEach((cover, i) => ctx.drawImage(cover, 4 + i * 114, 4, 110, 165));
const engine = new BABYLON.Engine(view, true, {}, true);
const scene = BookNestStore.createScene(engine);
const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
const books = BookNestStore.placeBooks(scene, left, 3);
BookNestStore.coverBooks(scene, books).forEach((material, i) => {
  material.albedoTexture.dispose();                   // replace Section 7.5.9's cover
  material.albedoTexture = new BABYLON.DynamicTexture(`art${i + 1}`, covers[i], scene);
  material.albedoTexture.update();                    // upload the canvas to the GPU
  books[i].rotation.y = Math.PI / 2;                  // face out
  books[i].position.x = -1.3 + i * 0.26;
});
new BABYLON.ArcRotateCamera('browse', -1.66, 1.5, 0.75,
  new BABYLON.Vector3(-2.45, 1.78, 2.6), scene).minZ = 0.05;
BookNestStore.renderWhenReady(engine, scene, 10);
</script>
Browser output of Listing 8.22
Browser output of 22

The three cream-bottomed paintings took ink lettering and the three dark ones cream. In Three.js 30,815 the same canvas becomes a THREE.CanvasTexture with colorSpace = THREE.SRGBColorSpace (Texture Types and Color Space). A canvas title can be corrected or translated without paying for a new image.

composeCover(): the page sets the type over each painting, choosing cream or ink from the lower third's luminanceHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
</style>
<canvas id="strip" width="600" height="270"></canvas>
<p>Titles are canvas text, so they can be corrected or translated without paying for a new image. Each canvas can become a THREE.CanvasTexture (with SRGBColorSpace) or a Babylon DynamicTexture.</p>
<script>
  const BOOKS = [
    { title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b', light: false },
    { title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99', light: false },
    { title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10', light: true },
    { title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a', light: true },
    { title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f', light: false },
    { title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f', light: true },
  ];
  const W = 256, H = 384;
  // Stand-in for booknest-art-N.jpg: a painting in the book's color, its lower third light or dark
  function painting(book) {
    const c = Object.assign(document.createElement('canvas'), { width: W, height: H }), g = c.getContext('2d');
    const grad = g.createLinearGradient(0, 0, 0, H);
    grad.addColorStop(0, book.color);  grad.addColorStop(0.62, book.color);
    grad.addColorStop(0.72, book.light ? '#f3e6cc' : '#1b2b36');  grad.addColorStop(1, book.light ? '#fff3e0' : '#101a22');
    g.fillStyle = grad;  g.fillRect(0, 0, W, H);
    g.fillStyle = 'rgba(255,243,224,0.5)';
    for (let i = 0; i < 5; i++) { g.beginPath();  g.arc(60 + i * 35, 120 + (i % 2) * 40, 24 - i * 2, 0, 7);  g.fill(); }
    return c;
  }
  function wrapLines(g, text, maxWidth) {
    const lines = [];  let line = '';
    for (const word of text.split(' ')) {
      const test = line ? line + ' ' + word : word;
      if (line && g.measureText(test).width > maxWidth) { lines.push(line);  line = word; } else line = test;
    }
    return lines.concat(line ? [line] : []);
  }
  function composeCover(art, book) {
    const canvas = Object.assign(document.createElement('canvas'), { width: W, height: H });
    const ctx = canvas.getContext('2d', { willReadFrequently: true });
    ctx.drawImage(art, 0, 0, W, H);
    const { data } = ctx.getImageData(0, H * 0.68, W, H * 0.32);   // the empty third
    let luma = 0;                                                  // Rec. 709 weights, 0-255
    for (let i = 0; i < data.length; i += 4) luma += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
    luma /= data.length / 4;
    ctx.fillStyle = luma < 150 ? '#fff3e0' : '#263238';
    let size = 36, lines;
    do {                                                           // fitTitle: shrink to two lines
      ctx.font = `bold ${--size}px Georgia`;
      lines = wrapLines(ctx, book.title, W - 36);
    } while (lines.length > 2 || lines.some(line => ctx.measureText(line).width > W - 36));
    ctx.textAlign = 'center';
    lines.forEach((line, k) => ctx.fillText(line, W / 2, H - 92 + k * size * 1.1));
    ctx.font = 'italic 17px Georgia';
    ctx.fillText(book.author, W / 2, H - 22);
    return { canvas, luma };
  }
  const strip = document.getElementById('strip').getContext('2d');
  BOOKS.forEach((book, i) => {
    const { canvas, luma } = composeCover(painting(book), book);
    strip.drawImage(canvas, 6 + i * 99, 6, 92, 138);
    strip.fillStyle = '#263238';  strip.font = '11px system-ui';
    strip.fillText(`luma ${Math.round(luma)}`, 6 + i * 99, 160);
    strip.fillText(luma < 150 ? '→ cream type' : '→ ink type', 6 + i * 99, 174);
  });
  // A simple face-out shelf, in perspective, from the same canvases
  strip.fillStyle = '#8d6e63';  strip.fillRect(0, 252, 600, 12);
  BOOKS.forEach((book, i) => {
    const { canvas } = composeCover(painting(book), book);
    strip.save();  strip.transform(1, -0.08, 0, 1, 20 + i * 95, 190 + i * 1.5);
    strip.drawImage(canvas, 0, 0, 44, 66);  strip.restore();
  });
</script>