Storefront Look's look goes last, on the walk camera. It was graded for a view from outside, so the final lines let it follow the visitor: outside, exposure 1 and the glass sheen; indoors, exposure 1.8, as eyes adapt to a dimmer room, and no sheen:
const look = createStorefrontLook(scene, camera); // 7.21.7: sign, glow, 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);
return { scene, camera, books, select, details, cards, lights, ambience, look };
}onApply runs as the sheen's pass draws, after the observer has set the flag. A page using the store stays small; this one builds it on Engine, then on WebGPUEngine, and shows both from the sidewalk at frame 150:
<style>canvas { width: 305px; height: 190px; margin-right: 6px }</style>
<canvas id="gl"></canvas><canvas id="gpu"></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 webgpu = new BABYLON.WebGPUEngine(gpu, { antialias: true, adaptToDeviceRatio: true });
await webgpu.initAsync(); // Section 7.2.3
const scenes = [];
for (const engine of [new BABYLON.Engine(gl, true, {}, true), webgpu]) {
const canvas = engine.getRenderingCanvas();
const { scene, camera } = await BookNestStore.openStore(engine, canvas);
camera.position.set(-0.9, 1.6, -5.9); // on the sidewalk, facing the window
camera.setTarget(new BABYLON.Vector3(-1.2, 1.9, -3));
await scene.whenReadyAsync();
scenes.push(scene);
}
scenes.forEach((scene) => scene.getEngine().runRenderLoop(() => {
scene.render();
window.__done = scenes.every((each) => each.getFrameId() > 150); // the pile has settled
}));
})();
</script>
Everything ported but one piece: the CSG wall, the identical Havok 516,349 heap, cascaded shadows, the environment, the glowing sign, the pipeline and the sheen's GLSL, which WebGPU translated. The floating cards are missing on the right: with any post-process on the camera, even a lone PassPostProcess, WebGPUEngine 9.28.0 drew nothing of the 3D GUI's utility layer. autoClearDepthAndStencil = true on its utilityLayerScene brought the cards back, drawn over the walls. The 2D panel and picking work on both.
<!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; padding: 4px 8px; font-family: monospace; font-size: 12px; }
</style>
<canvas id="view"></canvas>
<div id="info"></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, 7).then(({ scene, camera, look }) => {
const info = document.getElementById('info');
let frames = 0;
const reset = () => { // on the sidewalk, facing the door
camera.position.set(2.1, 1.6, -5.6);
camera.setTarget(new BABYLON.Vector3(1.4, 1.6, 0));
frames = 0;
};
reset();
scene.onBeforeRenderObservable.add(() => { // an automatic stroll in through the door
if (frames > 90 && frames < 330) camera.cameraDirection.set(0, 0, 0.02);
if (++frames === 480) reset();
info.textContent = `z ${camera.position.z.toFixed(2)}: ${camera.position.z < -ROOM.depth / 2 ? 'outside' : 'inside'}, ` +
`exposure ${look.pipeline.imageProcessing.exposure}`;
});
engine.runRenderLoop(() => scene.render());
});
addEventListener('resize', () => engine.resize());
</script>