Staying Fast

Keeping BookNest's Bookstore Fast on Modest Hardware

A real shop fills every shelf. bookstore.js gains fillShelves(scene, bookcase, shelves): books of random size, tinted from the palette, as thin instances of one box, one draw call per bookcase:

bookstore.js: stocking shelves with thin instancesJavaScript
function fillShelves(scene, bookcase, shelves = [0, 1, 3]) {   // Section 7.18.5
  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;
}

The color buffer (four floats per book) multiplies the material's white diffuse. The figure stocks both bookcases around the six real books:

Both bookcases stocked with thin instancesHTML
<canvas id="view" style="width: 620px; height: 180px"></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 cases = [['bookcaseLeft', -1.8], ['bookcaseRight', 1.8]]
    .map(([name, x]) => BookNestStore.createBookcase(scene, name, x));
  BookNestStore.coverBooks(scene, BookNestStore.placeBooks(scene, cases[0]));
  cases.forEach((bookcase) => BookNestStore.fillShelves(scene, bookcase));
  BookNestStore.lightRoom(scene);
  BookNestStore.createWalkCamera(scene);
  BookNestStore.renderWhenReady(engine, scene, 10);
})();
</script>
Browser output of Listing 7.115
Browser output of 115

With a SceneInstrumentation attached, this frame counted 316 books in 56 draw calls and 14 active meshes: 14 meshes drawn for the camera and for each of three shadow cascades. Unpacked into separate boxes, the same 310 stock books measured 1,288 draw calls and about 8 ms of CPU per frame against 1.1 ms. On modest hardware, then: thin instances for static repeats, frozen materials, fewer shadow cascades on phones, engine.setHardwareScalingLevel(1.5) when fill rate limits, and an optimizer list that never merges the pickable books.

Both bookcases filled with hundreds of tinted books as thin instances, one draw call per bookcase, under cascaded shadowsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 300px; display: block; touch-action: none; outline: none; }
  #info { max-width: 600px; padding: 4px 8px; font-family: monospace; }
</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 };
}

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

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const cases = [['bookcaseLeft', -1.8], ['bookcaseRight', 1.8]].map(([name, x]) => createBookcase(scene, name, x));
placeBooks(scene, cases[0]);
const stock = cases.map((bookcase) => fillShelves(scene, bookcase));
lightRoom(scene);
const camera = new BABYLON.UniversalCamera('walk', new BABYLON.Vector3(0, 1.6, -2.2), scene);
camera.setTarget(new BABYLON.Vector3(0, 1.4, 3));
camera.minZ = 0.05;
camera.attachControl(canvas, true);
const stats = new BABYLON.SceneInstrumentation(scene);
scene.onAfterRenderObservable.add(() => {
  document.getElementById('info').textContent = `${stock.reduce((n, s) => n + s.thinInstanceCount, 0) + 6} books, ` +
    `${stats.drawCallsCounter.current} draw calls (camera + 3 shadow cascades), ${scene.getActiveMeshes().length} active meshes`;
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>