Falling Books

Physics for BookNest's Falling and Stacked Books

New arrivals are tipped onto the display table. dropBooks(scene, shelved, x, z) in l2-physics.js clones each shelved book, unparents the clone, stacks the six 20 cm apart from 1 m up with slight tilts, and gives each a box aggregate (2 g per page, friction 0.6, restitution 0.1). The figure renders exactly 150 fixed steps, so every run captures the same pile:

Six new arrivals tipped onto the display table, frame 150HTML
<canvas id="view" style="width: 620px; height: 260px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script src="l2-physics.js"></script>
<script>
(async () => {
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  const { floor } = BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  const shelved = BookNestStore.placeBooks(scene, left);
  BookNestStore.coverBooks(scene, shelved);
  await BookNestPhysics.enableHavok(scene);        // fixed 1/60 s steps
  new BABYLON.PhysicsAggregate(floor, BABYLON.PhysicsShapeType.BOX, { mass: 0 }, scene);
  BookNestPhysics.createDisplayTable(scene, -1.6, 1.7);
  BookNestPhysics.dropBooks(scene, shelved, -1.6, 1.7);
  BookNestStore.lightRoom(scene);                  // after the meshes: they all cast
  new BABYLON.ArcRotateCamera('browse', -1.95, 1.1, 1.9,
    new BABYLON.Vector3(-1.6, 0.85, 1.9), scene).minZ = 0.05;
  await scene.whenReadyAsync();
  for (let frame = 0; frame < 150; frame++) scene.render();   // 2.5 simulated seconds
  window.__done = true;                            // the canvas keeps frame 150
})();
</script>
Browser output of Listing 7.93
Browser output of 93

The Quiet Harbor lies cover up in front, Patterns of the Deep Web landed on end, and The Clockmaker's Paradox leans out over the edge; the shelved originals have no bodies and stay put. To restack, reuse the bodies with a one-frame pre-step (PhysicsAggregate). For hundreds of loose books, one PhysicsBody on a thin-instanced mesh simulates every instance in a single draw call.

Six new arrivals tipped onto BookNest's display table, falling, bouncing and piling up under simulated gravityHTMLLive
<!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; }
</style>
<canvas id="view"></canvas>
<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 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;
  });
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const left = createBookcase(scene, 'bookcaseLeft', -1.8);
const shelved = placeBooks(scene, left);

let world, books = [];
function start() {                                   // a fresh drop every few seconds
  books.forEach((book) => book.dispose());
  scene.getMeshByName('displayTable')?.dispose();
  world = new MiniWorld();                           // stands for enableHavok(scene)
  world.addBox(scene.getMeshByName('floor'), { mass: 0, half: new BABYLON.Vector3(4, 0.05, 3),
    center: new BABYLON.Vector3(0, -0.05, 0) });     // the floor as a thin static box
  createDisplayTable(scene, world, -1.6, 1.7);
  books = dropBooks(scene, world, shelved, -1.6, 1.7);
}
start();
lightRoom(scene);                                    // after the meshes: they all cast
const shadows = scene.lights.find((l) => l.name === 'sun').getShadowGenerator();
const camera = new BABYLON.ArcRotateCamera('browse', -1.95, 1.1, 2.3, new BABYLON.Vector3(-1.6, 0.85, 1.9), scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);

let frame = 0;
scene.onBeforeRenderObservable.add(() => {
  if (frame++ < 150) world.step(1 / 60);            // 150 fixed steps: 2.5 simulated seconds
  if (frame === 150 + 120) {                         // hold the pile for 2 s, then drop again
    start();
    for (const mesh of [...books, scene.getMeshByName('displayTable')]) { shadows.addShadowCaster(mesh); mesh.receiveShadows = true; }
    frame = 0;
  }
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>