Inside, at the left bookcase, this page taps Patterns of the Deep Web as BookNest's Book Details Panel did, with a ray through the book's center and a simulated pointer down and up:
<canvas id="view" style="width: 620px; height: 240px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script src="bookstore.js"></script>
<script src="l2-physics.js"></script>
<script src="l2-particles.js"></script>
<script>
(async () => {
const engine = new BABYLON.Engine(view, true, {}, true);
const { scene, camera, books } = await BookNestStore.openStore(engine, view);
camera.position.set(-1.35, 1.6, 0.5); // walked up to the left bookcase
camera.setTarget(new BABYLON.Vector3(-1.8, 1.9, 2.6));
await scene.whenReadyAsync();
engine.runRenderLoop(() => scene.render());
scene.render(); // the camera's matrices are now current
const aim = books[1].getBoundingInfo().boundingBox.centerWorld.subtract(camera.position);
const hit = scene.pickWithRay(new BABYLON.Ray(camera.position, aim.normalize()));
scene.simulatePointerDown(hit);
scene.simulatePointerUp(hit); // a tap on the second book
setTimeout(() => { window.__done = true; }, 1000); // after its 40-frame slide
})();
</script>
The frame holds most of the chapter: the cards, the stock, the gray sold-out cover of Salt and Saffron, the chosen book slid out under The Node Material Editor's rim light, the panel over the graded room, and dust in the lamplight.
A test page stood mid-shop facing the left bookcase and timed scene.render() over 240 frames, switching one feature off at a time with the scene's postProcessesEnabled, shadowsEnabled and particlesEnabled flags (the Inspector's Debug switches, Inspecting the Scene) and the cards' utility layer; physics was timed between its before and after observables. GPU timers read zero on both engines, so these are CPU times:
| The store | Draw calls | CPU time per frame |
|---|---|---|
| Complete, WebGL 2 | 179 | 1.00 (5.8 to 5.9 ms) |
| Without post-processing and glow | 150 | 0.79 to 0.81 |
| Without cascaded shadows | 89 | 0.76 |
| Without dust particles | 178 | 0.83 to 0.86 |
| Without the 3D GUI cards | 167 | 0.85 to 0.86 |
| Without all four | 47 | 0.42 to 0.43 |
| Havok 516,349 's step alone | none | 0.016 to 0.017 |
| Complete, WebGPUEngine | 179 | 1.00 to 1.02 |
Shadows and post-processing each cost about a fifth of the frame, and the 1,500 CPU particles nearly as much in one draw call, which a GPUParticleSystem (GPU Particles) would cut. The sleeping pile costs almost nothing. WebGPU gained nothing: 179 draws are too few for per-draw overhead to dominate, and moving dust and books rule out snapshot rendering (Draw Call Cost). Under 6 ms of CPU leaves most of a 60 Hz frame free.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 340px; display: block; touch-action: none; outline: none; }
#info { max-width: 600px; padding: 4px 8px; font-size: 12px; }
</style>
<canvas id="view"></canvas>
<div id="info">Walk with WASD or the arrows, drag to look, tap a spine or a card.</div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.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` };
}
}
function createSpineHighlight(scene, texture, rimHex = '#ffe082') {
const material = new BABYLON.NodeMaterial('spineHighlight', scene, { emitComments: true });
const input = (name, value) => { // attribute, system value or uniform
const node = new BABYLON.InputBlock(name);
if (value === 'attribute') node.setAsAttribute(name);
else if (typeof value !== 'string') node.value = value;
else node.setAsSystemValue(BABYLON.NodeMaterialSystemValues[value]);
return node.output;
};
const block = (type, name, inputs, props = {}) => { // a block with its inputs wired
const node = Object.assign(new BABYLON[type](name), props);
for (const [port, source] of Object.entries(inputs)) source.connectTo(node[port]);
return node;
};
const world = input('world', 'World');
const eye = input('cameraPosition', 'CameraPosition');
const worldPos = block('TransformBlock', 'worldPos',
{ vector: input('position', 'attribute'), transform: world }).output;
const clipPos = block('TransformBlock', 'clipPos',
{ vector: worldPos, transform: input('viewProjection', 'ViewProjection') });
const vertexOut = block('VertexOutputBlock', 'vertexOut', { vector: clipPos.output });
const normal = block('TransformBlock', 'worldNormal', { vector: input('normal', 'attribute'),
transform: world }, { complementW: 0 }).output; // a direction: no translation
const cover = block('TextureBlock', 'cover', { uv: input('uv', 'attribute') }, { texture });
const light = block('LightBlock', 'light', { worldPosition: worldPos, worldNormal: normal,
cameraPosition: eye, diffuseColor: cover.rgb, view: input('view', 'View') }); // the scene's lights
const toEye = block('ViewDirectionBlock', 'toEye', { worldPosition: worldPos, cameraPosition: eye });
const fresnel = block('FresnelBlock', 'fresnel', { worldNormal: normal,
viewDirection: toEye.output, bias: input('bias', 0), power: input('power', 1) });
const edge = block('OneMinusBlock', 'edge', { input: fresnel.fresnel }); // 1 at the rim
const amount = block('MultiplyBlock', 'amount', { left: edge.output, right: input('strength', 1) });
const rim = block('ScaleBlock', 'rim', { factor: amount.output,
input: input('rimColor', BABYLON.Color3.FromHexString(rimHex)) });
const sum = block('AddBlock', 'sum', { left: light.diffuseOutput, right: rim.output });
const fragmentOut = block('FragmentOutputBlock', 'fragmentOut', { rgb: sum.output });
[vertexOut, fragmentOut].forEach((node) => material.addOutputNode(node));
material.build();
return material;
}
function presentBook(scene, book) { // slide out, lift, turn the cover to the aisle
const group = new BABYLON.AnimationGroup('present' + book.name, scene);
const track = (property, type, keys) => {
const animation = new BABYLON.Animation(property, property, 60, type);
animation.setKeys(keys.map(([frame, value]) => ({ frame, value })));
const ease = new BABYLON.CubicEase();
ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
animation.setEasingFunction(ease);
group.addTargetedAnimation(animation, book);
};
const { z, y } = book.position;
const FLOAT = BABYLON.Animation.ANIMATIONTYPE_FLOAT;
track('position.z', FLOAT, [[0, z], [20, z - 0.3], [40, z - 0.3]]); // 0.3 m out in 20 frames
track('position.y', FLOAT, [[0, y], [20, y], [40, y + 0.12]]); // then lift it
track('rotation.y', FLOAT, [[0, 0], [20, 0], [40, Math.PI / 2]]); // and face the +x cover out
return group;
}
function enableBookPicking(scene, books, onSelect) { // returns select(book) for other callers
let selected = null;
const effects = new Map(); // built on a book's first selection
function select(book) {
if (book === selected) return;
if (selected) { // shelve the previous one
const { cover, group } = effects.get(selected);
selected.material = cover;
group.start(false, 1, 40, 0);
}
selected = book;
if (book) {
if (!effects.has(book)) effects.set(book, { cover: book.material, group: presentBook(scene, book),
rim: createSpineHighlight(scene, book.material.diffuseTexture) });
const { rim, group } = effects.get(book);
book.material = rim;
group.start(false, 1, 0, 40);
}
onSelect(book ? book.metadata : null);
}
scene.onPointerObservable.add(({ type, pickInfo }) => {
if (type !== BABYLON.PointerEventTypes.POINTERTAP) return; // drags select nothing
select(books.includes(pickInfo?.pickedMesh) ? pickInfo.pickedMesh : null);
});
return { select };
}
// The catalog fields BOOKS lacks (the book fetches booknest-data.json; here it is inline)
const CATALOG = {
1: { year: 2021, summary: 'A lighthouse keeper and a stranded sailor wait out a winter storm.' },
2: { year: 2024, summary: 'How the hidden layers of the web are built, searched and defended.' },
3: { year: 2019, summary: 'Spice-market recipes and the stories of the cooks behind them.' },
4: { year: 2022, summary: 'Habits, one small step at a time, from a mountain guide.' },
5: { year: 2020, summary: 'A watchmaker finds a clock that runs a minute into tomorrow.' },
6: { year: 2023, summary: 'Terrariums, glasshouses and the gardeners who tend them.' },
};
function createDetailsPanel(scene) {
const GUI = BABYLON.GUI;
const ui = GUI.AdvancedDynamicTexture.CreateFullscreenUI('details', true, scene);
ui.idealWidth = 600; // size controls in CSS pixels
const panel = Object.assign(new GUI.Rectangle('panel'), { width: '300px', height: '180px', left: '140px',
background: 'rgba(255,255,255,0.95)', cornerRadius: 10, color: '#c9bfae', isPointerBlocker: true, isVisible: false });
ui.addControl(panel);
const grid = new GUI.Grid('grid');
grid.addColumnDefinition(96, true); // the cover column
grid.addColumnDefinition(1);
grid.paddingLeft = grid.paddingTop = '10px';
panel.addControl(grid);
const cover = Object.assign(new GUI.Image('cover'), { width: '84px', height: '126px', verticalAlignment: 0 });
grid.addControl(cover, 0, 0);
const stack = Object.assign(new GUI.StackPanel('text'), { paddingLeft: '6px', paddingRight: '8px' });
grid.addControl(stack, 0, 1);
const text = (size, weight = 'normal', height = size + 8) => {
const block = Object.assign(new GUI.TextBlock(), { fontSize: size, fontWeight: weight, height: `${height}px`,
color: '#333', textWrapping: true, textHorizontalAlignment: 0 });
stack.addControl(block);
return block;
};
const title = text(15, 'bold', 38), byline = text(12), summary = text(11, 'normal', 44);
const buy = GUI.Button.CreateSimpleButton('buy', 'Add to cart');
Object.assign(buy, { height: '32px', cornerRadius: 6, color: 'white', paddingTop: '4px' });
stack.addControl(buy);
return {
layer: ui.layer, // the Layer that draws this fullscreen GUI
show(record) { // null hides the panel
panel.isVisible = !!record;
if (!record) return;
const extra = CATALOG[record.id];
cover.source = drawCover(scene, record).getContext().canvas.toDataURL();
title.text = record.title;
byline.text = `${record.author}, ${extra.year} · $${record.price.toFixed(2)}`;
summary.text = extra.summary;
buy.isEnabled = record.inStock; // out of stock: gray and disabled
buy.background = record.inStock ? '#1f5f8b' : '#9e9e9e';
buy.textBlock.text = record.inStock ? 'Add to cart' : 'Out of stock';
},
};
}
function createShelfCards(scene, bookcase, books, onPick = () => {}) {
const GUI = BABYLON.GUI;
const manager = new GUI.GUI3DManager(scene); // controls live in a utility layer
const panel = new GUI.PlanePanel('shelfCards');
manager.addControl(panel); // add first, then link
panel.linkToTransformNode(bookcase); // position is now bookcase space
panel.position.set(0, 2.62, -0.1); // just above the top board
panel.scaling.setAll(0.36); // 1-unit buttons become 36 cm cards
Object.assign(panel, { columns: 6, margin: 0.08, blockLayout: true });
const cards = books.map((book, i) => {
const { title, price, rating } = book.metadata;
const label = `$${price.toFixed(2)} ★ ${rating}`; // price and stars
const card = new GUI.HolographicButton(`card${i + 1}`);
panel.addControl(card); // set content after addControl
const face = card.content = new GUI.StackPanel(); // 2D controls on the card
const coverUrl = book.material.diffuseTexture.getContext().canvas.toDataURL(); // the book's cover art
face.addControl(Object.assign(new GUI.Image('cover', coverUrl), { width: '140px', height: '210px' }));
face.addControl(Object.assign(new GUI.TextBlock('label', label), { color: 'white', fontSize: 30, height: '40px' }));
card.tooltipText = title; // shown while the pointer hovers
card.onPointerClickObservable.add(() => onPick(book));
return card;
});
panel.blockLayout = false; // lay out all six at once
return { manager, panel, cards };
}
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 createDotTexture(scene) { // a soft white dot painted on a canvas
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 64;
const g = canvas.getContext('2d');
const glow = g.createRadialGradient(32, 32, 0, 32, 32, 32);
glow.addColorStop(0, 'rgba(255,255,255,1)');
glow.addColorStop(0.4, 'rgba(255,255,255,0.6)');
glow.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = glow;
g.fillRect(0, 0, 64, 64);
return new BABYLON.Texture(canvas.toDataURL(), scene); // loaded like an image file
}
function addDust(scene) { // the dust motes, across the whole shop
const [v, c] = [(...a) => new BABYLON.Vector3(...a), (...a) => new BABYLON.Color4(...a)];
const dust = new BABYLON.ParticleSystem('dust', 1500, scene);
dust.particleTexture = createDotTexture(scene);
dust.emitter = v(0, 1.4, 0);
dust.createBoxEmitter(v(-0.05, -0.05, -0.05), v(0.05, 0.05, 0.05), v(-3.5, -1.1, -2.5), v(3.5, 1.1, 2.5)); // 7 x 2.2 x 5 m
Object.assign(dust, { emitRate: 150, minLifeTime: 8, maxLifeTime: 12, updateSpeed: 1 / 60,
minSize: 0.012, maxSize: 0.03, minEmitPower: 0.2, maxEmitPower: 0.6,
color1: c(1, 0.95, 0.8, 0.9), color2: c(1, 0.85, 0.6, 0.6), colorDead: c(1, 0.9, 0.7, 0),
gravity: v(0, -0.005, 0), blendMode: BABYLON.ParticleSystem.BLENDMODE_ADD, preWarmCycles: 600 });
dust.start();
return dust;
}
async function createAmbience(scene, camera) { // room tone and a ticking clock
const audio = await BABYLON.CreateAudioEngineAsync({ disableDefaultUI: true }); // our own button unlocks it
const rate = 48000;
const buffer = (seconds, sample) => { // an AudioBuffer from a function of time
const data = new Float32Array(rate * seconds).map((_, i) => sample(i / rate));
const result = new AudioBuffer({ length: data.length, sampleRate: rate });
result.copyToChannel(data, 0);
return result;
};
let level = 0; // brown noise: a soft, low room tone
const hum = buffer(4, () => (level = (level + 0.02 * (Math.random() * 2 - 1)) / 1.02) * 3.5);
const tick = buffer(1, (t) => Math.sin(2 * Math.PI * 2000 * t) * Math.exp(-400 * t));
const ambience = await BABYLON.CreateAudioBusAsync('ambience', { volume: 0.4 });
const options = { loop: true, autoplay: true, outBus: ambience }; // start once unlocked
const roomTone = await BABYLON.CreateSoundAsync('roomTone', hum, options);
const clock = await BABYLON.CreateSoundAsync('clock', tick,
{ ...options, spatialEnabled: true }); // one tick a second, placed in the room
clock.spatial.attach(scene.getMeshByName('bookcaseLeft')); // on the left case
audio.listener.attach(camera); // hear from the shopper's position
return { audio, ambience, roomTone, clock };
}
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 };
}
// ---- 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
cards.manager.utilityLayer.shouldRender = !outside; // the cards' layer would show through the wall
});
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).then(({ scene, camera, books }) => { // every stage
camera.position.set(-1.35, 1.6, 0.5); // walked up to the left bookcase
camera.setTarget(new BABYLON.Vector3(-1.8, 1.9, 2.6));
engine.runRenderLoop(() => scene.render());
scene.executeWhenReady(() => { // tap Patterns of the Deep Web
scene.render();
const aim = books[1].getBoundingInfo().boundingBox.centerWorld.subtract(camera.position);
const hit = scene.pickWithRay(new BABYLON.Ray(camera.position, aim.normalize()));
scene.simulatePointerDown(hit);
scene.simulatePointerUp(hit);
});
});
addEventListener('resize', () => engine.resize());
</script>