Storefront Look

A Post-Processed Look for BookNest's Storefront Window

Seen from the beach, the shop gets an evening grade: a neon sign for the glow layer, a pipeline with MSAA, soft bloom, ACES tone mapping, contrast and a vignette, and Custom Post-Process's sheen on top:

bookstore.js: the storefront's sign, glow and camera pipelineJavaScript
function createStorefrontLook(scene, camera) {     // Section 7.16.8
  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 };
}
BookNest's storefront through the post-processing pipelineHTML
<canvas id="view" style="width: 620px; height: 200px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
(async () => {
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  BookNestStore.coverBooks(scene, BookNestStore.placeBooks(scene, left));
  await BookNestStore.createFrontWall(scene);
  BookNestStore.lightRoom(scene);
  BookNestStore.useEnvironment(scene);
  const target = new BABYLON.Vector3(-1.2, 1.8, -3);
  const camera = new BABYLON.ArcRotateCamera('street', -1.75, 1.5, 5, target, scene);
  BookNestStore.createStorefrontLook(scene, camera);
  BookNestStore.renderWhenReady(engine, scene, 20);
})();
</script>
Browser output of Listing 7.107
Browser output of 107

Against HDR Room Lighting's daylight view, ACES deepens the sky into dusk, the sunlit floor and sign bloom, the vignette pulls the eye to the window, and the sheen crosses the glass. The camera's passes run highlights, horizontal blur, vertical blur, bloomMerge, imageProcessing, glassSheen: the sheen was created last. Final Polish applies this look to the finished store.

BookNest's storefront at dusk: a glowing neon sign, bloom, ACES tone mapping, a vignette and the custom glass sheenHTMLLive
<!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>
// ---- 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 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 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 };
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
placeBooks(scene, createBookcase(scene, 'bookcaseLeft', -1.8));
createFrontWall(scene);
lightRoom(scene);
useEnvironment(scene);
const target = new BABYLON.Vector3(-1.2, 1.8, -3);
const camera = new BABYLON.ArcRotateCamera('street', -1.75, 1.5, 5, target, scene);
camera.attachControl(canvas, true);
createStorefrontLook(scene, camera);
document.getElementById('info').textContent = 'passes: ' +
  camera._postProcesses.filter(Boolean).map((pass) => pass.name).join(', ');
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>