Cover Textures

Texturing BookNest's Six Book Covers

The cover images from Canvas 2D's cover designer (booknest-cover-1.png to -6.png) become the books' albedo. A box maps the whole image onto every face, so placeBooks now passes CreateBox a faceUV array, one Vector4(u0, v0, u1, v1) per face (+z, -z, +x, -x, top, bottom), and wrap: true to stand side images upright. The +x face, the front cover when the spine faces you, gets the full image; the rest, the cover's dark left edge:

bookstore.js: the face UVs placeBooks now uses, and coverBooksJavaScript
const strip = new BABYLON.Vector4(0, 0, 0.07, 1);  // Section 7.5.9: the cover's dark left edge
const BOOK_UV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];  // +x
function coverBooks(scene, books) {               // Section 7.5.9: cover art as albedo
  return books.map((mesh, i) => {
    const material = new BABYLON.PBRMaterial(`coverArt${i + 1}`, scene);
    material.albedoTexture = new BABYLON.Texture(`booknest-cover-${i + 1}.png`, scene);
    material.metallic = 0;                          // paper, not metal
    material.roughness = 0.5;                       // a satin laminate
    mesh.material = material;
    return material;
  });
}

The figure also clones each book onto the shelf above, turned a quarter turn to face out as bookstores show new titles, and reads inStock from booknest-data.json, the shop's catalog:

BookNest's books with their cover art, spines below and face out aboveHTML
<canvas id="view" style="width: 620px; height: 210px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
{
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  const books = BookNestStore.placeBooks(scene, left);            // shelf 2, spines out
  const covers = BookNestStore.coverBooks(scene, books);
  books.forEach((book, i) => {
    const shown = book.clone(`shown${i + 1}`, left);    // shares geometry and material
    shown.rotation.y = Math.PI / 2;                     // front cover toward the aisle
    shown.position.set(-1.3 + i * 0.26, 0.12 + 3 * 0.5 + 0.15, -0.02);   // shelf 3
  });
  const target = new BABYLON.Vector3(-2.2, 1.45, 2.6);
  new BABYLON.ArcRotateCamera('browse', -1.82, 1.5, 1.45, target, scene).minZ = 0.05;
  fetch('booknest-data.json').then((reply) => reply.json()).then((catalog) => {
    for (const { id, inStock } of catalog.books) {    // the shop's stock field
      if (!inStock) new BookNestStore.SoldOutPlugin(covers[id - 1]);   // Section 7.5.7
    }
    BookNestStore.renderWhenReady(engine, scene, 10);
  });
}
</script>
Browser output of Listing 7.30
Browser output of 30

The Material Plugin System's plugin grays Salt and Saffron, the one title the catalog marks out of stock, spine and cover alike, since clones share their original's material. Textures are mipmapped (trilinear) by default, and anisotropicFilteringLevel (default 4) keeps covers sharp at grazing angles. Lit by the sky light alone, the covers look flat; Room Lighting and HDR Room Lighting light the room properly.

BookNest's six books with generated cover art mapped by faceUV, spines out and faced out, one sold outHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; }
  canvas { width: 100%; max-width: 600px; height: 340px; display: block; touch-action: none; }
</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// The shop's catalog, inline instead of fetched from booknest-data.json
const CATALOG = [
  { id: 1, title: 'The Quiet Harbor', color: '#1f5f8b', pages: 312, inStock: true },
  { id: 2, title: 'Patterns of the Deep Web', color: '#5b3f99', pages: 428, inStock: true },
  { id: 3, title: 'Salt and Saffron', color: '#e09a10', pages: 256, inStock: false },
  { id: 4, title: 'Small Steps to Big Summits', color: '#3f7d3a', pages: 198, inStock: true },
  { id: 5, title: "The Clockmaker's Paradox", color: '#b5452f', pages: 344, inStock: true },
  { id: 6, title: 'Gardens in Glass', color: '#2a9d8f', pages: 176, inStock: true },
];
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.6), scene).intensity = 1.6;

// Cover art drawn on a canvas (the book loads booknest-cover-1.png ... -6.png)
function drawCover({ title, color }) {
  const tex = new BABYLON.DynamicTexture(title, { width: 256, height: 384 }, scene);
  const c = tex.getContext();
  c.fillStyle = color; c.fillRect(0, 0, 256, 384);
  c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384);      // the dark left edge (7%)
  c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
  c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
  const words = title.split(' '), lines = [''];
  for (const word of words) {
    const test = (lines[lines.length - 1] + ' ' + word).trim();
    if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
  }
  lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
  c.beginPath(); c.arc(135, 290, 28, 0, Math.PI * 2); c.stroke();    // a simple emblem
  c.font = '16px Georgia'; c.fillText('BookNest Press', 135, 346);
  tex.update();
  return tex;
}

// Gray out a material after lighting (the material plugin from the previous section)
class SoldOutPlugin extends BABYLON.MaterialPluginBase {
  constructor(material) { super(material, 'SoldOut', 200, { SOLD_OUT: false }); this._enable(true); }
  prepareDefines(defines) { defines.SOLD_OUT = true; }
  getClassName() { return 'SoldOutPlugin'; }
  isCompatible() { return true; }
  getCustomCode(shaderType, language) {
    if (shaderType !== 'fragment') return null;
    const code = language === BABYLON.ShaderLanguage.WGSL
      ? `{ let luma = dot(fragmentOutputs.color.rgb, vec3f(0.299, 0.587, 0.114));
          fragmentOutputs.color = vec4f(mix(vec3f(luma), vec3f(0.93), 0.35), fragmentOutputs.color.a); }`
      : `{ float luma = dot(gl_FragColor.rgb, vec3(0.299, 0.587, 0.114));
          gl_FragColor.rgb = mix(vec3(luma), vec3(0.93), 0.35); }`;
    return { CUSTOM_FRAGMENT_MAIN_END: `#ifdef SOLD_OUT\n${code}\n#endif` };
  }
}

const paint = (hex) => {
  const m = new BABYLON.StandardMaterial(hex, scene);
  m.diffuseColor = BABYLON.Color3.FromHexString(hex);
  m.specularColor = BABYLON.Color3.Black();
  return m;
};
BABYLON.MeshBuilder.CreateGround('floor', { width: 8, height: 6 }, scene).material = paint('#b08d6a');
const backWall = BABYLON.MeshBuilder.CreateBox('backWall', { width: 8, height: 3, depth: 0.15 }, scene);
backWall.position.set(0, 1.5, 3);
backWall.material = paint('#fff3e0');

// A bookcase: two sides, a back and five boards merged into one mesh
const parts = [[0.04, 2.2, 0.35, -1.5, 1.1, 0], [0.04, 2.2, 0.35, 1.5, 1.1, 0], [3, 2.2, 0.04, 0, 1.1, 0.155]];
for (let i = 0; i < 5; i++) parts.push([3, 0.04, 0.35, 0, 0.1 + i * 0.5, 0]);
const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, x, y, z]) => {
  const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
  part.position.set(x, y, z);
  return part;
}), true);
bookcase.material = paint('#6d4c33');
bookcase.position.set(-1.8, 0, 2.75);

// One Vector4(u0, v0, u1, v1) per face: +z, -z, +x, -x, top, bottom
const strip = new BABYLON.Vector4(0, 0, 0.07, 1);     // the cover's dark left edge
const BOOK_UV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];   // +x: full cover
let x = -1.3;
CATALOG.forEach((record, i) => {
  const width = record.pages / 4000 + 0.02;
  const book = BABYLON.MeshBuilder.CreateBox(record.title,
    { width, height: 0.3, depth: 0.22, faceUV: BOOK_UV, wrap: true }, scene);   // wrap: side images upright
  book.parent = bookcase;
  book.position.set(x + width / 2, 0.12 + 2 * 0.5 + 0.15, -0.02);    // shelf 2, spines out
  x += width + 0.012;
  const material = new BABYLON.PBRMaterial(`coverArt${record.id}`, scene);
  material.albedoTexture = drawCover(record);
  material.metallic = 0;                             // paper, not metal
  material.roughness = 0.5;                          // a satin laminate
  book.material = material;
  if (!record.inStock) new SoldOutPlugin(material);
  const shown = book.clone(`shown${i + 1}`, bookcase);   // shares geometry and material
  shown.rotation.y = Math.PI / 2;                        // front cover toward the aisle
  shown.position.set(-1.3 + i * 0.26, 0.12 + 3 * 0.5 + 0.15, -0.02);   // shelf 3
});

const target = new BABYLON.Vector3(-2.2, 1.45, 2.6);
const camera = new BABYLON.ArcRotateCamera('browse', -1.82, 1.5, 1.6, target, scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>