Final Polish

A Post-Processed Storefront and Final Polish

Storefront Look's look goes last, on the walk camera. It was graded for a view from outside, so the final lines let it follow the visitor: outside, exposure 1 and the glass sheen; indoors, exposure 1.8, as eyes adapt to a dimmer room, and no sheen:

bookstore.js: the end of openStore, a storefront look that follows the visitorJavaScript
  const look = createStorefrontLook(scene, camera);   // 7.21.7: sign, glow, color grade
  let outside = true;
  scene.onBeforeRenderObservable.add(() => {        // the look follows the visitor
    outside = camera.position.z < -ROOM.depth / 2;  // in front of the front wall
    look.pipeline.imageProcessing.exposure = outside ? 1 : 1.8;   // eyes adapt indoors
  });
  look.sheen.onApply = (effect) => effect.setFloat('strength', outside ? 0.15 : 0);
  return { scene, camera, books, select, details, cards, lights, ambience, look };
}

onApply runs as the sheen's pass draws, after the observer has set the flag. A page using the store stays small; this one builds it on Engine, then on WebGPUEngine, and shows both from the sidewalk at frame 150:

The finished store from the sidewalk on Engine (left) and WebGPUEngine (right)HTML
<style>canvas { width: 305px; height: 190px; margin-right: 6px }</style>
<canvas id="gl"></canvas><canvas id="gpu"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script src="bookstore.js"></script>
<script src="l2-physics.js"></script>
<script src="l2-particles.js"></script>
<script>
(async () => {
  const webgpu = new BABYLON.WebGPUEngine(gpu, { antialias: true, adaptToDeviceRatio: true });
  await webgpu.initAsync();                        // Section 7.2.3
  const scenes = [];
  for (const engine of [new BABYLON.Engine(gl, true, {}, true), webgpu]) {
    const canvas = engine.getRenderingCanvas();
    const { scene, camera } = await BookNestStore.openStore(engine, canvas);
    camera.position.set(-0.9, 1.6, -5.9);          // on the sidewalk, facing the window
    camera.setTarget(new BABYLON.Vector3(-1.2, 1.9, -3));
    await scene.whenReadyAsync();
    scenes.push(scene);
  }
  scenes.forEach((scene) => scene.getEngine().runRenderLoop(() => {
    scene.render();
    window.__done = scenes.every((each) => each.getFrameId() > 150);   // the pile has settled
  }));
})();
</script>
Browser output of Listing 7.132
Browser output of 132

Everything ported but one piece: the CSG wall, the identical Havok 516,349 heap, cascaded shadows, the environment, the glowing sign, the pipeline and the sheen's GLSL, which WebGPU translated. The floating cards are missing on the right: with any post-process on the camera, even a lone PassPostProcess, WebGPUEngine 9.28.0 drew nothing of the 3D GUI's utility layer. autoClearDepthAndStencil = true on its utilityLayerScene brought the cards back, drawn over the walls. The 2D panel and picking work on both.

The storefront look on the walk camera, adapting as the visitor walks in: exposure 1 and the sheen outside, 1.8 indoorsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 320px; display: block; touch-action: none; outline: none; }
  #info { max-width: 600px; padding: 4px 8px; font-family: monospace; font-size: 12px; }
</style>
<canvas id="view"></canvas>
<div id="info"></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
  { id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
  { id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
  { id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
  { id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
  { id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
  { id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
  const scene = new BABYLON.Scene(engine);
  scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
  const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
  sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
  return scene;
}
function paint(scene, name, hex) {                  // a matte color
  const material = new BABYLON.StandardMaterial(name, scene);
  material.diffuseColor = BABYLON.Color3.FromHexString(hex);
  material.specularColor = BABYLON.Color3.Black();
  return material;
}
function createRoom(scene) {                        // floor and three walls, front open
  const room = new BABYLON.TransformNode('room', scene);
  const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
  floor.material = paint(scene, 'oak', '#b08d6a');
  floor.parent = room;
  const plaster = paint(scene, 'plaster', '#fff3e0');
  for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
    const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
    wall.position.set(x, ROOM.height / 2, z);
    wall.rotation.y = x ? Math.PI / 2 : 0;
    wall.material = plaster;
    wall.parent = room;
  }
  return room;
}
function createBookcase(scene, name, x) {           // 8 boxes merged into 1 mesh
  const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
  const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
  for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
  const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
    part.position.set(px, py, pz);
    return part;
  }), true);
  bookcase.name = name;
  bookcase.material = paint(scene, 'walnut', '#6d4c33');
  bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2);   // against the back wall
  return bookcase;
}
function drawCover(scene, book) {                   // cover art painted on a canvas
  const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
  const c = tex.getContext();
  c.fillStyle = book.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
  c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
  c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
  const lines = [''];
  for (const word of book.title.split(' ')) {
    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.font = '20px Georgia'; c.fillText(book.author, 135, 330);
  tex.update();
  return tex;
}
function placeBooks(scene, bookcase, shelf = 2) {   // six books, spines out, covers on +x
  const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
  const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
  let x = -0.35;                                    // left end of the row, local to the bookcase
  return BOOKS.map((record) => {
    const width = record.pages / 4000 + 0.02;       // spine thickness from page count
    const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
    book.parent = bookcase;
    book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
    x += width + 0.012;
    book.material = new BABYLON.StandardMaterial(record.title, scene);
    book.material.diffuseTexture = drawCover(scene, record);
    book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
    book.metadata = record;                         // the record, for picking
    return book;
  });
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
  const box = (width, height, depth, x, y) => {
    const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
    mesh.position.set(x, y, -ROOM.depth / 2);
    return mesh;
  };
  const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
  const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
  const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
    .subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
  [solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
  return wall;
}
// ---- end of the bookstore helpers ----

function createWalkCamera(scene, canvas) {         // walk at eye height
  const start = new BABYLON.Vector3(0, 1.6, -2.2);  // eye height, near the open front
  const camera = new BABYLON.UniversalCamera('walk', start, scene);
  camera.setTarget(new BABYLON.Vector3(0, 1.4, 3));
  Object.assign(camera, {
    minZ: 0.05,                                     // the default 1 m clips nearby shelves
    speed: 0.05, angularSensibility: 4000,          // about 1.6 m/s, slower mouse look
    ellipsoid: new BABYLON.Vector3(0.3, 0.8, 0.3),  // a visitor 1.6 m tall, 0.6 m wide
    checkCollisions: true, applyGravity: true,
    needMoveForGravity: true,                       // fall even while no key is held
  });
  camera.keysUp.push(87); camera.keysDown.push(83);     // W and S beside the arrows
  camera.keysLeft.push(65); camera.keysRight.push(68);  // A and D
  scene.gravity = new BABYLON.Vector3(0, -9.81 / 60, 0);  // units per frame, not m/s^2
  scene.collisionsEnabled = true;
  const size = { width: ROOM.width, height: ROOM.height, depth: ROOM.wall };
  const glass = BABYLON.MeshBuilder.CreateBox('frontCollider', size, scene);
  glass.position.set(0, ROOM.height / 2, -ROOM.depth / 2);
  glass.isVisible = false;                          // the open front still stops you
  for (const name of ['floor', 'backWall', 'leftWall', 'rightWall', 'frontCollider']) {
    scene.getMeshByName(name).checkCollisions = true;
  }
  if (canvas) camera.attachControl(canvas, true);
  scene.activeCamera = camera;
  return camera;
}

// ---- A tiny stand-in for Havok ----
// Havok's WebAssembly module has to be downloaded, which this sandboxed preview cannot do, so
// these pages simulate with a few lines of their own: axis-aligned boxes that fall, bounce,
// slide with friction, stack and sleep (no rotation), stepped at a fixed 1/60 s like
// new BABYLON.HavokPlugin(false, havok). Comments name the Havok call each part stands for.
class MiniWorld {
  constructor(gravity = -9.81) {
    this.gravity = gravity;
    this.bodies = [];
    this.contacts = new Set();                       // pairs touching in the last step
    this.onCollision = null;                         // ({ a, b, impulse }) on first contact
    this.onTrigger = null;                           // ({ trigger, body }) when a body enters
  }
  // Like new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass, restitution, friction })
  addBox(mesh, { mass = 0, restitution = 0.2, friction = 0.6, isTrigger = false, half = null, center = null } = {}) {
    mesh.computeWorldMatrix(true);
    const box = mesh.getBoundingInfo().boundingBox;
    const body = { mesh, mass, restitution, friction, isTrigger, sleeping: false, still: 0,
      half: half ?? box.extendSizeWorld.clone(), center: (center ?? box.centerWorld).clone(),
      velocity: BABYLON.Vector3.Zero() };
    body.offset = mesh.getAbsolutePosition().subtract(body.center);
    this.bodies.push(body);
    return body;
  }
  // A static collider with no mesh of its own, like one child shape of a PhysicsShapeContainer
  addStaticBox(center, half) {
    const body = { mesh: { uniqueId: `static${this.bodies.length}`, parent: true }, mass: 0, restitution: 0.2,
      friction: 0.6, isTrigger: false, sleeping: false, half, center, velocity: BABYLON.Vector3.Zero() };
    this.bodies.push(body);
    return body;
  }
  step(dt = 1 / 60) {
    for (const b of this.bodies) {                   // integrate: gravity, then velocity
      if (!b.mass || b.sleeping) continue;
      b.velocity.y += this.gravity * dt;
      b.center.addInPlace(b.velocity.scale(dt));
    }
    const touching = new Set();
    for (let iteration = 0; iteration < 6; iteration++) {   // relax the contacts a few times
      for (let i = 0; i < this.bodies.length; i++) for (let j = i + 1; j < this.bodies.length; j++) {
        const pair = this.resolve(this.bodies[i], this.bodies[j], iteration === 0);
        if (pair) touching.add(pair);
      }
    }
    this.contacts = touching;
    for (const b of this.bodies) {                   // sleep bodies that have come to rest
      if (!b.mass) continue;
      b.still = b.velocity.length() < 0.05 ? b.still + 1 : 0;
      if (b.still > 20) { b.sleeping = true; b.velocity.setAll(0); }
      if (!b.mesh.parent) b.mesh.position.copyFrom(b.center.add(b.offset));
    }
  }
  resolve(a, b, firstPass) {
    if ((!a.mass || a.sleeping) && (!b.mass || b.sleeping)) return null;
    const d = a.center.subtract(b.center);
    const ox = a.half.x + b.half.x - Math.abs(d.x), oy = a.half.y + b.half.y - Math.abs(d.y),
      oz = a.half.z + b.half.z - Math.abs(d.z);
    if (ox <= 0 || oy <= 0 || oz <= 0) return null;  // no overlap on some axis: no contact
    const key = a.mesh.uniqueId + ':' + b.mesh.uniqueId;
    if (a.isTrigger || b.isTrigger) {                // triggers report, never push
      if (firstPass && !this.contacts.has(key) && this.onTrigger) {
        this.onTrigger(a.isTrigger ? { trigger: a, body: b } : { trigger: b, body: a });
      }
      return key;
    }
    const n = new BABYLON.Vector3();                 // push apart along the shallowest axis
    const depth = Math.min(ox, oy, oz);
    if (depth === oy) n.y = Math.sign(d.y) || 1; else if (depth === ox) n.x = Math.sign(d.x) || 1; else n.z = Math.sign(d.z) || 1;
    const ia = a.mass && !a.sleeping ? 1 / a.mass : 0, ib = b.mass && !b.sleeping ? 1 / b.mass : 0;
    a.center.addInPlace(n.scale(depth * ia / (ia + ib)));
    b.center.subtractInPlace(n.scale(depth * ib / (ia + ib)));
    const vn = BABYLON.Vector3.Dot(a.velocity.subtract(b.velocity), n);
    if (vn < 0) {                                    // approaching: bounce with restitution
      const e = vn < -0.5 ? Math.min(a.restitution, b.restitution) : 0;
      const impulse = -(1 + e) * vn / (ia + ib);
      a.velocity.addInPlace(n.scale(impulse * ia));
      b.velocity.subtractInPlace(n.scale(impulse * ib));
      const mu = Math.sqrt(a.friction * b.friction); // friction slows the sliding part
      for (const body of [a, b]) {
        const tangent = body.velocity.subtract(n.scale(BABYLON.Vector3.Dot(body.velocity, n)));
        body.velocity.subtractInPlace(tangent.scale(Math.min(mu * 0.5, 1)));
      }
      for (const body of [a, b]) {                   // a hard hit wakes a sleeping body
        if (impulse > 0.5 && body.mass && body.sleeping) { body.sleeping = false; body.still = 0; }
      }
      if (firstPass && !this.contacts.has(key) && this.onCollision) this.onCollision({ a, b, impulse });
    }
    return key;
  }
  // Like physicsEngine.raycastToRef(from, to, result): the nearest box a segment enters
  raycast(from, to) {
    const ray = BABYLON.Ray.CreateNewFromTo(from, to);
    let best = null;
    for (const body of this.bodies) {
      if (body.isTrigger) continue;
      const min = body.center.subtract(body.half), max = body.center.add(body.half);
      if (!ray.intersectsBoxMinMax(min, max)) continue;
      let distance = 0;                              // slab test: where the ray enters the box
      for (const axis of ['x', 'y', 'z']) {
        const inv = 1 / (ray.direction[axis] || 1e-9);
        const t1 = (min[axis] - ray.origin[axis]) * inv, t2 = (max[axis] - ray.origin[axis]) * inv;
        distance = Math.max(distance, Math.min(t1, t2));
      }
      if (distance <= ray.length && (!best || distance < best.distance)) {
        best = { body, distance, point: from.add(ray.direction.scale(distance)) };
      }
    }
    return best;
  }
}
// ---- end of the stand-in ----

function createDisplayTable(scene, world, x, z) {   // a top and four legs, one render mesh
  const parts = [[1.2, 0.05, 0.8, 0, 0.725, 0]];    // width, height, depth, offset
  for (const [lx, lz] of [[-0.55, -0.35], [0.55, -0.35], [-0.55, 0.35], [0.55, 0.35]]) parts.push([0.06, 0.7, 0.06, lx, 0.35, lz]);
  const meshes = parts.map(([w, h, d, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width: w, height: h, depth: d }, scene);
    part.position.set(x + px, py, z + pz);
    return part;
  });
  // one static collider per part, like the child shapes of a PhysicsShapeContainer
  const colliders = meshes.map((part) => world.addBox(part, { mass: 0 }));
  const table = BABYLON.Mesh.MergeMeshes(meshes, true);
  table.name = 'displayTable';
  table.material = paint(scene, 'tableWood', '#8d6e4f');
  colliders.forEach((body) => { body.mesh = table; });   // the merged mesh stands for them all
  return { table, colliders };
}
function dropBooks(scene, world, shelved, x, z) {   // copies of the shelved books, tipped in
  return shelved.map((original, i) => {
    const book = original.clone(original.name + 'Copy');   // keeps metadata and material
    book.parent = null;
    book.position.set(x - 0.25 + (i % 3) * 0.22, 1 + i * 0.2, z - 0.1 + (i % 2) * 0.2);
    if (i % 3 !== 1) book.rotation.z = Math.PI / 2;  // most land flat, some stand up
    if (i % 2) book.rotation.y = Math.PI / 2;
    book.computeWorldMatrix(true);
    const pages = book.metadata.pages;
    world.addBox(book, { mass: pages * 0.002, friction: 0.6, restitution: 0.1 });   // 2 g a page
    return book;
  });
}

function fillShelves(scene, bookcase, shelves = [0, 1, 3]) {
  const stock = BABYLON.MeshBuilder.CreateBox(`${bookcase.name}Stock`, { depth: 0.2 }, scene);
  Object.assign(stock, { parent: bookcase, material: paint(scene, 'stock', '#ffffff') });
  let seed = 7;                                      // a repeatable pseudo-random sequence
  const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  const matrices = [], colors = [];
  for (const shelf of shelves) {
    for (let x = -1.44; x < 1.36;) {                 // left to right along the board
      const width = 0.025 + 0.045 * random(), height = 0.22 + 0.1 * random();
      const at = new BABYLON.Vector3(x + width / 2, 0.12 + shelf * 0.5 + height / 2, -0.02);
      matrices.push(...BABYLON.Matrix.Compose(new BABYLON.Vector3(width, height, 1),
        BABYLON.Quaternion.Identity(), at).m);       // scale the unit box, then place it
      const color = BABYLON.Color3.FromHexString(BOOKS[Math.floor(random() * 6)].color);
      colors.push(...color.scale(0.6 + 0.5 * random()).asArray(), 1);   // palette, shaded
      x += width + 0.004;
    }
  }
  stock.thinInstanceSetBuffer('matrix', new Float32Array(matrices), 16);   // one draw call
  stock.thinInstanceSetBuffer('color', new Float32Array(colors), 4);       // per-book tint
  stock.freezeWorldMatrix();                         // the stock never moves
  return stock;
}

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` };
  }
}

function createSpineHighlight(scene, texture, rimHex = '#ffe082') {
  const material = new BABYLON.NodeMaterial('spineHighlight', scene, { emitComments: true });
  const input = (name, value) => {                  // attribute, system value or uniform
    const node = new BABYLON.InputBlock(name);
    if (value === 'attribute') node.setAsAttribute(name);
    else if (typeof value !== 'string') node.value = value;
    else node.setAsSystemValue(BABYLON.NodeMaterialSystemValues[value]);
    return node.output;
  };
  const block = (type, name, inputs, props = {}) => {   // a block with its inputs wired
    const node = Object.assign(new BABYLON[type](name), props);
    for (const [port, source] of Object.entries(inputs)) source.connectTo(node[port]);
    return node;
  };
  const world = input('world', 'World');
  const eye = input('cameraPosition', 'CameraPosition');
  const worldPos = block('TransformBlock', 'worldPos',
    { vector: input('position', 'attribute'), transform: world }).output;
  const clipPos = block('TransformBlock', 'clipPos',
    { vector: worldPos, transform: input('viewProjection', 'ViewProjection') });
  const vertexOut = block('VertexOutputBlock', 'vertexOut', { vector: clipPos.output });
  const normal = block('TransformBlock', 'worldNormal', { vector: input('normal', 'attribute'),
    transform: world }, { complementW: 0 }).output;       // a direction: no translation
  const cover = block('TextureBlock', 'cover', { uv: input('uv', 'attribute') }, { texture });
  const light = block('LightBlock', 'light', { worldPosition: worldPos, worldNormal: normal,
    cameraPosition: eye, diffuseColor: cover.rgb, view: input('view', 'View') });   // the scene's lights
  const toEye = block('ViewDirectionBlock', 'toEye', { worldPosition: worldPos, cameraPosition: eye });
  const fresnel = block('FresnelBlock', 'fresnel', { worldNormal: normal,
    viewDirection: toEye.output, bias: input('bias', 0), power: input('power', 1) });
  const edge = block('OneMinusBlock', 'edge', { input: fresnel.fresnel });  // 1 at the rim
  const amount = block('MultiplyBlock', 'amount', { left: edge.output, right: input('strength', 1) });
  const rim = block('ScaleBlock', 'rim', { factor: amount.output,
    input: input('rimColor', BABYLON.Color3.FromHexString(rimHex)) });
  const sum = block('AddBlock', 'sum', { left: light.diffuseOutput, right: rim.output });
  const fragmentOut = block('FragmentOutputBlock', 'fragmentOut', { rgb: sum.output });
  [vertexOut, fragmentOut].forEach((node) => material.addOutputNode(node));
  material.build();
  return material;
}

function presentBook(scene, book) {                 // slide out, lift, turn the cover to the aisle
  const group = new BABYLON.AnimationGroup('present' + book.name, scene);
  const track = (property, type, keys) => {
    const animation = new BABYLON.Animation(property, property, 60, type);
    animation.setKeys(keys.map(([frame, value]) => ({ frame, value })));
    const ease = new BABYLON.CubicEase();
    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
    animation.setEasingFunction(ease);
    group.addTargetedAnimation(animation, book);
  };
  const { z, y } = book.position;
  const FLOAT = BABYLON.Animation.ANIMATIONTYPE_FLOAT;
  track('position.z', FLOAT, [[0, z], [20, z - 0.3], [40, z - 0.3]]);        // 0.3 m out in 20 frames
  track('position.y', FLOAT, [[0, y], [20, y], [40, y + 0.12]]);             // then lift it
  track('rotation.y', FLOAT, [[0, 0], [20, 0], [40, Math.PI / 2]]);          // and face the +x cover out
  return group;
}

function enableBookPicking(scene, books, onSelect) {   // returns select(book) for other callers
  let selected = null;
  const effects = new Map();                         // built on a book's first selection
  function select(book) {
    if (book === selected) return;
    if (selected) {                                  // shelve the previous one
      const { cover, group } = effects.get(selected);
      selected.material = cover;
      group.start(false, 1, 40, 0);
    }
    selected = book;
    if (book) {
      if (!effects.has(book)) effects.set(book, { cover: book.material, group: presentBook(scene, book),
        rim: createSpineHighlight(scene, book.material.diffuseTexture) });
      const { rim, group } = effects.get(book);
      book.material = rim;
      group.start(false, 1, 0, 40);
    }
    onSelect(book ? book.metadata : null);
  }
  scene.onPointerObservable.add(({ type, pickInfo }) => {
    if (type !== BABYLON.PointerEventTypes.POINTERTAP) return;   // drags select nothing
    select(books.includes(pickInfo?.pickedMesh) ? pickInfo.pickedMesh : null);
  });
  return { select };
}

// The catalog fields BOOKS lacks (the book fetches booknest-data.json; here it is inline)
const CATALOG = {
  1: { year: 2021, summary: 'A lighthouse keeper and a stranded sailor wait out a winter storm.' },
  2: { year: 2024, summary: 'How the hidden layers of the web are built, searched and defended.' },
  3: { year: 2019, summary: 'Spice-market recipes and the stories of the cooks behind them.' },
  4: { year: 2022, summary: 'Habits, one small step at a time, from a mountain guide.' },
  5: { year: 2020, summary: 'A watchmaker finds a clock that runs a minute into tomorrow.' },
  6: { year: 2023, summary: 'Terrariums, glasshouses and the gardeners who tend them.' },
};
function createDetailsPanel(scene) {
  const GUI = BABYLON.GUI;
  const ui = GUI.AdvancedDynamicTexture.CreateFullscreenUI('details', true, scene);
  ui.idealWidth = 600;                               // size controls in CSS pixels
  const panel = Object.assign(new GUI.Rectangle('panel'), { width: '300px', height: '180px', left: '140px',
    background: 'rgba(255,255,255,0.95)', cornerRadius: 10, color: '#c9bfae', isPointerBlocker: true, isVisible: false });
  ui.addControl(panel);
  const grid = new GUI.Grid('grid');
  grid.addColumnDefinition(96, true);                // the cover column
  grid.addColumnDefinition(1);
  grid.paddingLeft = grid.paddingTop = '10px';
  panel.addControl(grid);
  const cover = Object.assign(new GUI.Image('cover'), { width: '84px', height: '126px', verticalAlignment: 0 });
  grid.addControl(cover, 0, 0);
  const stack = Object.assign(new GUI.StackPanel('text'), { paddingLeft: '6px', paddingRight: '8px' });
  grid.addControl(stack, 0, 1);
  const text = (size, weight = 'normal', height = size + 8) => {
    const block = Object.assign(new GUI.TextBlock(), { fontSize: size, fontWeight: weight, height: `${height}px`,
      color: '#333', textWrapping: true, textHorizontalAlignment: 0 });
    stack.addControl(block);
    return block;
  };
  const title = text(15, 'bold', 38), byline = text(12), summary = text(11, 'normal', 44);
  const buy = GUI.Button.CreateSimpleButton('buy', 'Add to cart');
  Object.assign(buy, { height: '32px', cornerRadius: 6, color: 'white', paddingTop: '4px' });
  stack.addControl(buy);
  return {
    layer: ui.layer,                                 // the Layer that draws this fullscreen GUI
    show(record) {                                   // null hides the panel
      panel.isVisible = !!record;
      if (!record) return;
      const extra = CATALOG[record.id];
      cover.source = drawCover(scene, record).getContext().canvas.toDataURL();
      title.text = record.title;
      byline.text = `${record.author}, ${extra.year} · $${record.price.toFixed(2)}`;
      summary.text = extra.summary;
      buy.isEnabled = record.inStock;                // out of stock: gray and disabled
      buy.background = record.inStock ? '#1f5f8b' : '#9e9e9e';
      buy.textBlock.text = record.inStock ? 'Add to cart' : 'Out of stock';
    },
  };
}

function createShelfCards(scene, bookcase, books, onPick = () => {}) {
  const GUI = BABYLON.GUI;
  const manager = new GUI.GUI3DManager(scene);      // controls live in a utility layer
  const panel = new GUI.PlanePanel('shelfCards');
  manager.addControl(panel);                        // add first, then link
  panel.linkToTransformNode(bookcase);              // position is now bookcase space
  panel.position.set(0, 2.62, -0.1);                // just above the top board
  panel.scaling.setAll(0.36);                       // 1-unit buttons become 36 cm cards
  Object.assign(panel, { columns: 6, margin: 0.08, blockLayout: true });
  const cards = books.map((book, i) => {
    const { title, price, rating } = book.metadata;
    const label = `$${price.toFixed(2)}  ★ ${rating}`;   // price and stars
    const card = new GUI.HolographicButton(`card${i + 1}`);
    panel.addControl(card);                         // set content after addControl
    const face = card.content = new GUI.StackPanel();   // 2D controls on the card
    const coverUrl = book.material.diffuseTexture.getContext().canvas.toDataURL();   // the book's cover art
    face.addControl(Object.assign(new GUI.Image('cover', coverUrl), { width: '140px', height: '210px' }));
    face.addControl(Object.assign(new GUI.TextBlock('label', label), { color: 'white', fontSize: 30, height: '40px' }));
    card.tooltipText = title;                       // shown while the pointer hovers
    card.onPointerClickObservable.add(() => onPick(book));
    return card;
  });
  panel.blockLayout = false;                        // lay out all six at once
  return { manager, panel, cards };
}

function lightRoom(scene) {                         // call after the meshes exist
  scene.getLightByName('sky').intensity = 0.5;      // the sky light becomes fill light
  const sun = new BABYLON.DirectionalLight('sun', new BABYLON.Vector3(0.2, -0.33, 1), scene);
  sun.intensity = 0.8;
  sun.diffuse = BABYLON.Color3.FromHexString('#fff1d6');   // warm afternoon sun
  const ceiling = BABYLON.MeshBuilder.CreateBox('ceiling', { width: ROOM.width, height: ROOM.wall, depth: ROOM.depth }, scene);
  ceiling.position.y = ROOM.height + ROOM.wall / 2;
  ceiling.layerMask = 0x10000000;                   // no camera draws it; it still casts
  const shadows = new BABYLON.CascadedShadowGenerator(2048, sun);
  Object.assign(shadows, { numCascades: 3, shadowMaxZ: 20, lambda: 1,
    autoCalcDepthBounds: true, bias: 0.002, normalBias: 0.01 });
  for (const mesh of scene.meshes) {
    if (mesh.name !== 'floor') shadows.addShadowCaster(mesh);
    mesh.receiveShadows = true;
  }
  const lamp = new BABYLON.PointLight('lamp', new BABYLON.Vector3(-1.2, 2.3, 1.4), scene);
  lamp.diffuse = BABYLON.Color3.FromHexString('#ffd9a0');
  Object.assign(lamp, { intensity: 0.7, range: 5 });   // a warm reading lamp
  return { sun, lamp, shadows };
}

// A procedural HDR sunrise sky, computed texel by texel into six float faces. It stands in for
// an .hdr or .env download, which this sandboxed preview cannot fetch.
function createSkyTexture(scene, size = 128) {
  const sunDir = new BABYLON.Vector3(0.45, 0.28, 0.85).normalize();
  const faceDirection = [                           // (s, t) in -1..1 to a direction, per face
    (s, t) => [1, -t, -s], (s, t) => [-1, -t, s], (s, t) => [s, 1, t],
    (s, t) => [s, -1, -t], (s, t) => [s, -t, 1], (s, t) => [-s, -t, -1]];
  const faces = faceDirection.map((toDirection) => {
    const data = new Float32Array(size * size * 4);
    for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
      const [dx, dy, dz] = toDirection((2 * x + 1) / size - 1, (2 * y + 1) / size - 1);
      const len = Math.hypot(dx, dy, dz), up = dy / len;
      const k = Math.sqrt(Math.max(up, 0));            // 0 at the horizon, 1 straight up
      const ground = Math.min(Math.max(-up * 8, 0), 1);   // fade to the ground below the horizon
      const r = (1 - 0.62 * k) * (1 - ground) + 0.36 * ground;    // peach horizon, blue zenith,
      const g = (0.82 - 0.22 * k) * (1 - ground) + 0.3 * ground;  // warm brown ground
      const b = (0.68 + 0.3 * k) * (1 - ground) + 0.24 * ground;
      const cosSun = Math.max((dx * sunDir.x + dy * sunDir.y + dz * sunDir.z) / len, 0);
      const sun = 30 * Math.pow(cosSun, 900) + 0.35 * Math.pow(cosSun, 24);   // disc + glow, HDR
      const i = (y * size + x) * 4;
      data[i] = r + sun; data[i + 1] = g + sun * 0.85; data[i + 2] = b + sun * 0.6; data[i + 3] = 1;
    }
    return data;
  });
  const sky = new BABYLON.RawCubeTexture(scene, faces, size, BABYLON.Constants.TEXTUREFORMAT_RGBA,
    BABYLON.Constants.TEXTURETYPE_FLOAT, true, false, BABYLON.Constants.TEXTURE_TRILINEAR_SAMPLINGMODE);
  sky.gammaSpace = false;                            // linear HDR values, like a loaded .hdr
  return sky;
}

// The book loads booknest-sunrise.env with CubeTexture.CreateFromPrefilteredData; this sandbox
// cannot download it, so useEnvironment takes the procedural sky from createSkyTexture instead.
function useEnvironment(scene, rotationY = Math.PI) {
  const env = createSkyTexture(scene);
  new BABYLON.HDRFiltering(scene.getEngine()).prefilter(env);   // an .env arrives prefiltered
  env.rotationY = rotationY;                        // sun in front, matching lightRoom's
  scene.environmentTexture = env;                   // image-based light for PBR materials
  scene.environmentIntensity = 0.35;                // IBL ignores walls: keep it low indoors
  const skybox = scene.createDefaultSkybox(env, true, 200, 0.05);  // PBR skybox, slight blur
  skybox.material.reflectionTexture.rotationY = rotationY;   // the skybox holds a clone
  skybox.isPickable = false;                        // clicks go to the books
  return { env, skybox };
}

function createDotTexture(scene) {                  // a soft white dot painted on a canvas
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = 64;
  const g = canvas.getContext('2d');
  const glow = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  glow.addColorStop(0, 'rgba(255,255,255,1)');
  glow.addColorStop(0.4, 'rgba(255,255,255,0.6)');
  glow.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = glow;
  g.fillRect(0, 0, 64, 64);
  return new BABYLON.Texture(canvas.toDataURL(), scene);   // loaded like an image file
}

function addDust(scene) {                            // the dust motes, across the whole shop
  const [v, c] = [(...a) => new BABYLON.Vector3(...a), (...a) => new BABYLON.Color4(...a)];
  const dust = new BABYLON.ParticleSystem('dust', 1500, scene);
  dust.particleTexture = createDotTexture(scene);
  dust.emitter = v(0, 1.4, 0);
  dust.createBoxEmitter(v(-0.05, -0.05, -0.05), v(0.05, 0.05, 0.05), v(-3.5, -1.1, -2.5), v(3.5, 1.1, 2.5));   // 7 x 2.2 x 5 m
  Object.assign(dust, { emitRate: 150, minLifeTime: 8, maxLifeTime: 12, updateSpeed: 1 / 60,
    minSize: 0.012, maxSize: 0.03, minEmitPower: 0.2, maxEmitPower: 0.6,
    color1: c(1, 0.95, 0.8, 0.9), color2: c(1, 0.85, 0.6, 0.6), colorDead: c(1, 0.9, 0.7, 0),
    gravity: v(0, -0.005, 0), blendMode: BABYLON.ParticleSystem.BLENDMODE_ADD, preWarmCycles: 600 });
  dust.start();
  return dust;
}

async function createAmbience(scene, camera) {     // room tone and a ticking clock
  const audio = await BABYLON.CreateAudioEngineAsync({ disableDefaultUI: true });   // our own button unlocks it
  const rate = 48000;
  const buffer = (seconds, sample) => {             // an AudioBuffer from a function of time
    const data = new Float32Array(rate * seconds).map((_, i) => sample(i / rate));
    const result = new AudioBuffer({ length: data.length, sampleRate: rate });
    result.copyToChannel(data, 0);
    return result;
  };
  let level = 0;                                    // brown noise: a soft, low room tone
  const hum = buffer(4, () => (level = (level + 0.02 * (Math.random() * 2 - 1)) / 1.02) * 3.5);
  const tick = buffer(1, (t) => Math.sin(2 * Math.PI * 2000 * t) * Math.exp(-400 * t));
  const ambience = await BABYLON.CreateAudioBusAsync('ambience', { volume: 0.4 });
  const options = { loop: true, autoplay: true, outBus: ambience };   // start once unlocked
  const roomTone = await BABYLON.CreateSoundAsync('roomTone', hum, options);
  const clock = await BABYLON.CreateSoundAsync('clock', tick,
    { ...options, spatialEnabled: true });           // one tick a second, placed in the room
  clock.spatial.attach(scene.getMeshByName('bookcaseLeft'));   // on the left case
  audio.listener.attach(camera);                    // hear from the shopper's position
  return { audio, ambience, roomTone, clock };
}

function addGlassSheen(camera, strength = 0.2) {    // a custom post-process
  BABYLON.Effect.ShadersStore.glassSheenFragmentShader = `
    precision highp float;
    varying vec2 vUV;                               // 0 to 1 across the screen
    uniform sampler2D textureSampler;               // the frame rendered so far
    uniform float strength;
    void main(void) {
      vec4 color = texture2D(textureSampler, vUV);
      float streak = smoothstep(0.06, 0.0, abs(vUV.x + 0.5 * vUV.y - 0.7));  // a diagonal
      gl_FragColor = vec4(color.rgb + strength * streak, color.a);
    }`;
  const sheen = new BABYLON.PostProcess('glassSheen', 'glassSheen', ['strength'], null, 1,
    camera);                                        // name, shader, uniforms, samplers, ratio
  sheen.onApply = (effect) => effect.setFloat('strength', strength);
  return sheen;
}

function createStorefrontLook(scene, camera) {     // neon sign, glow, pipeline, sheen
  const sign = BABYLON.MeshBuilder.CreatePlane('shopSign', { width: 2.4, height: 0.5 }, scene);
  sign.position.set(-1.4, 2.7, -3.09);              // above the window, outside
  const text = new BABYLON.DynamicTexture('signText', { width: 1024, height: 214 }, scene);
  text.drawText('BookNest', null, 165, 'bold 160px Georgia', '#ffe082', '#1f5f8b', true);
  sign.material = Object.assign(new BABYLON.StandardMaterial('neonSign', scene),
    { emissiveTexture: text, disableLighting: true });
  const glow = new BABYLON.GlowLayer('signGlow', scene);   // blurs every emissive surface
  glow.intensity = 1.5;
  const pipeline = new BABYLON.DefaultRenderingPipeline('storefront', true, scene, [camera]);
  Object.assign(pipeline, { samples: 4, bloomEnabled: true, bloomThreshold: 0.7,
    bloomWeight: 0.5 });                            // MSAA and a soft bloom
  Object.assign(pipeline.imageProcessing, { toneMappingEnabled: true,
    toneMappingType: BABYLON.ImageProcessingConfiguration.TONEMAPPING_ACES,
    contrast: 1.15, vignetteEnabled: true, vignetteWeight: 2.5 });
  const sheen = addGlassSheen(camera);              // created last, so it runs last
  return { sign, glow, pipeline, sheen };
}

// ---- openStore: the whole bookstore, assembled step by step (stage limits how far it goes) ----
async function openStore(engine, canvas, stage = 7) {
  const scene = createScene(engine);
  createRoom(scene);                                // 1: room, walls, sidewalk, cases
  const camera = createWalkCamera(scene, canvas);   // before the wall replaces its collider
  const wall = createFrontWall(scene);
  wall.checkCollisions = true;
  scene.getMeshByName('frontCollider')?.dispose();
  const sidewalk = BABYLON.MeshBuilder.CreateGround('sidewalk', { width: ROOM.width, height: 3 }, scene);
  Object.assign(sidewalk, { material: paint(scene, 'paving', '#9e9e9e'), checkCollisions: true });
  sidewalk.position.z = -ROOM.depth / 2 - 1.5;      // 3 m of pavement outside the front
  const cases = [['bookcaseLeft', -1.8], ['bookcaseRight', 1.8]].map(([name, x]) => createBookcase(scene, name, x));
  cases.forEach((bookcase) => { bookcase.checkCollisions = true; });
  const store = { scene, camera, cases };
  if (stage < 2) return store;

  const books = placeBooks(scene, cases[0]);        // 2: spine widths from page counts
  books.forEach((book) => book.metadata.inStock || new SoldOutPlugin(book.material));   // the catalog's flags
  fillShelves(scene, cases[0]);                     // stock around the six
  fillShelves(scene, cases[1], [0, 1, 2, 3]);
  const world = new MiniWorld();                    // new arrivals tipped onto a table in the window
  world.addStaticBox(new BABYLON.Vector3(0, -0.05, 0), new BABYLON.Vector3(4, 0.05, 3));
  const { table } = createDisplayTable(scene, world, -1.4, -2.3);
  table.checkCollisions = true;
  dropBooks(scene, world, books, -1.4, -2.3);
  let steps = 0;
  scene.onBeforeRenderObservable.add(() => { if (steps++ < 150) world.step(1 / 60); });   // then it sleeps
  Object.assign(store, { books, world });
  camera.position.set(2.3, 1.6, -5);                // 3: on the sidewalk, facing the door
  camera.setTarget(new BABYLON.Vector3(2.3, 1.6, 0));
  if (stage < 4) return store;

  const details = createDetailsPanel(scene);        // 4: a tap opens the panel
  const { select } = enableBookPicking(scene, books, details.show);
  const cards = createShelfCards(scene, cases[0], books, select);   // so does a card
  Object.assign(store, { details, select, cards });
  if (stage < 5) return store;

  store.lights = lightRoom(scene);                  // 5: everything above casts
  scene.getMeshByName('ceiling').layerMask = camera.layerMask;   // walkers see a ceiling
  useEnvironment(scene);
  if (stage < 6) return store;

  store.dust = addDust(scene);                      // 6: motes, and sound after the first click
  store.ambience = await createAmbience(scene, camera).catch(() => null);
  if (stage < 7) return store;

  const look = createStorefrontLook(scene, camera); // 7: sign, glow, color grade
  details.layer.applyPostProcess = false;           // draw the GUI after the color grade
  let outside = true;
  scene.onBeforeRenderObservable.add(() => {        // the look follows the visitor
    outside = camera.position.z < -ROOM.depth / 2;  // in front of the front wall
    look.pipeline.imageProcessing.exposure = outside ? 1 : 1.8;   // eyes adapt indoors
    cards.manager.utilityLayer.shouldRender = !outside;   // the cards' layer would show through the wall
  });
  look.sheen.onApply = (effect) => effect.setFloat('strength', outside ? 0.15 : 0);
  store.look = look;
  return store;
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
openStore(engine, canvas, 7).then(({ scene, camera, look }) => {
  const info = document.getElementById('info');
  let frames = 0;
  const reset = () => {                              // on the sidewalk, facing the door
    camera.position.set(2.1, 1.6, -5.6);
    camera.setTarget(new BABYLON.Vector3(1.4, 1.6, 0));
    frames = 0;
  };
  reset();
  scene.onBeforeRenderObservable.add(() => {         // an automatic stroll in through the door
    if (frames > 90 && frames < 330) camera.cameraDirection.set(0, 0, 0.02);
    if (++frames === 480) reset();
    info.textContent = `z ${camera.position.z.toFixed(2)}: ${camera.position.z < -ROOM.depth / 2 ? 'outside' : 'inside'}, ` +
      `exposure ${look.pipeline.imageProcessing.exposure}`;
  });
  engine.runRenderLoop(() => scene.render());
});
addEventListener('resize', () => engine.resize());
</script>