Order matters from the first lines. createWalkCamera (Walkthrough Camera) adds the invisible frontCollider, which createFrontWall (CSG2) disposes of once the window and door are cut, so the camera comes first or the stand-in wall would block the door. Paving outside gives the visitor somewhere to stand:
async function openStore(engine, canvas) { // Section 7.21: the whole bookstore
const scene = createScene(engine);
const { floor } = createRoom(scene); // 7.21.1: room, walls, sidewalk, cases
const camera = createWalkCamera(scene, canvas); // before the wall replaces its collider
await createFrontWall(scene);
const size = { width: ROOM.width, height: 3 };
const sidewalk = BABYLON.MeshBuilder.CreateGround('sidewalk', size, 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));The function is async because CSG2 loads WebAssembly and later steps fetch the catalog, Havok 516,349 and the audio engine. Every mesh gets scene explicitly: Final Polish builds two stores on one page, and a helper that fell back on the last created scene would put its mesh in the wrong one.
<!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 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;
}
// ---- 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, 1).then(({ scene, camera }) => { // stage 1 only: the layout
// Watch from outside; the walk camera (a small marker) waits on the sidewalk
const overview = new BABYLON.ArcRotateCamera('overview', -1.9, 1.05, 13, new BABYLON.Vector3(0, 0.5, -1), scene);
overview.attachControl(canvas, true);
scene.activeCamera = overview;
camera.position.set(2.3, 1.6, -5); // the walk camera waits on the sidewalk
const marker = BABYLON.MeshBuilder.CreateCapsule('visitor', { height: 1.6, radius: 0.3 }, scene);
marker.position.set(camera.position.x, 0.8, camera.position.z);
marker.material = paint(scene, 'visitorMat', '#2a9d8f');
engine.runRenderLoop(() => scene.render());
});
addEventListener('resize', () => engine.resize());
</script>