Camera and Collisions

Wiring the Walkthrough Camera and Collisions

The camera already has its ellipsoid, gravity and keys; openStore's next two lines put it on the sidewalk at (2.3, 1.6, -5), facing the door. The CSG wall, sidewalk, bookcases and table all check collisions. To test them without a keyboard, a page can do what a held arrow key does and feed the camera a step through cameraDirection, which passes through the collision system:

Walking in from the sidewalk, toward the door and toward the windowJavaScript
const { scene, camera } = await BookNestStore.openStore(engine, view);
engine.runRenderLoop(() => scene.render());
for (const [place, x] of [['door', 2.3], ['window', -1.4]]) {
  camera.position.set(x, 1.6, -5);                       // on the sidewalk, facing the shop
  camera.setTarget(new BABYLON.Vector3(x, 1.6, 0));
  const step = scene.onBeforeRenderObservable.add(() =>  // "forward" held down, in code:
    camera.cameraDirection.set(0, 0, 0.025));            // 2.5 cm a frame, 1.5 m/s
  await new Promise((done) => setTimeout(done, 5000));
  scene.onBeforeRenderObservable.remove(step);
  const at = camera.position.asArray().map((v) => v.toFixed(2)).join(', ');
  console.log(`toward the ${place}: stopped at (${at})`);
}
Output
toward the door: stopped at (2.30, 1.61, 2.28)
toward the window: stopped at (-1.40, 1.61, -3.38)

Through the door, the visitor crossed the shop and stopped 0.3 m, the ellipsoid's radius, short of the right bookcase (z 2.575); at the window the wall's outer face (z -3.075) stopped them. The window is as tall as the ellipsoid, but FreeCamera hangs the ellipsoid below the eye, from the floor to 1.6 m, so the sill blocks it.

Two walks from the sidewalk with "forward" held in code: through the door to the far bookcase, and into the window wallHTMLLive
<!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; margin: 0; padding: 4px 8px; font-family: monospace; white-space: pre-wrap; }
</style>
<canvas id="view"></canvas>
<pre id="info"></pre>
<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 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` };
  }
}

// ---- 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
  });
  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, 3).then(({ scene, camera }) => {
  camera.detachControl();
  const overview = new BABYLON.ArcRotateCamera('overview', -2.1, 0.75, 13, new BABYLON.Vector3(0.3, 0.5, -1), scene);
  overview.attachControl(canvas, true);
  scene.activeCamera = overview;                     // watch the walker from above
  const body = BABYLON.MeshBuilder.CreateSphere('body', { diameterX: 0.6, diameterY: 1.6, diameterZ: 0.6 }, scene);
  body.material = paint(scene, 'bodyMat', '#2a9d8f');
  body.isPickable = false;
  const trail = [];
  let line = null;

  const info = document.getElementById('info');
  const results = [];
  const WALKS = [['door', 2.3], ['window', -1.4]];
  let walk = 0, frames = 0;
  const start = () => {
    const [, x] = WALKS[walk];
    camera.position.set(x, 1.6, -5);                 // on the sidewalk, facing the shop
    camera.setTarget(new BABYLON.Vector3(x, 1.6, 0));
    trail.length = 0;
    frames = 0;
  };
  start();
  scene.onBeforeRenderObservable.add(() => {
    camera.cameraDirection.set(0, 0, 0.025);         // "forward" held down: 2.5 cm a frame
    camera.update();                                 // the move passes through the collisions
    body.position.copyFrom(camera.position).y -= 0.8;
    if (frames++ % 5 === 0) {
      trail.push(new BABYLON.Vector3(camera.position.x, 0.03, camera.position.z));
      line?.dispose();
      if (trail.length > 1) {
        line = BABYLON.MeshBuilder.CreateLines('trail', { points: trail }, scene);
        line.color = BABYLON.Color3.FromHexString('#b5452f');
      }
    }
    if (frames === 300) {                            // five seconds per walk
      const at = camera.position.asArray().map((v) => v.toFixed(2)).join(', ');
      results[walk] = `toward the ${WALKS[walk][0]}: stopped at (${at})`;
      info.textContent = results.join('\n');
      walk = (walk + 1) % WALKS.length;
      start();
    }
  });
  engine.runRenderLoop(() => scene.render());
});
addEventListener('resize', () => engine.resize());
</script>