The .env of HDR and .env now surrounds the shop as a skybox and lights the PBR covers. It turns half a revolution to put its sun in front of the shop, where Room Lighting's shadows say it is, and the skybox, which holds a clone of the texture, turns with it:
function useEnvironment(scene, url = 'booknest-sunrise.env', rotationY = Math.PI) { // 7.8.7
const env = BABYLON.CubeTexture.CreateFromPrefilteredData(url, scene);
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 (Section 7.11)
return { env, skybox }; // call after lightRoom: no sky shadows
}Seen from the beach, with the sun behind the camera:
<canvas id="view" style="width: 620px; height: 180px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
(async () => {
const engine = new BABYLON.Engine(view, true, {}, true);
const scene = BookNestStore.createScene(engine);
BookNestStore.createRoom(scene);
const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
BookNestStore.createBookcase(scene, 'bookcaseRight', 1.8);
BookNestStore.coverBooks(scene, BookNestStore.placeBooks(scene, left));
await BookNestStore.createFrontWall(scene);
BookNestStore.lightRoom(scene);
BookNestStore.useEnvironment(scene); // after lightRoom
const target = new BABYLON.Vector3(-0.6, 1.5, 1);
new BABYLON.ArcRotateCamera('beach', -1.9, 1.42, 9, target, scene);
BookNestStore.renderWhenReady(engine, scene, 20);
})();
</script>
The dunes and sea come from the photograph, the sunlit patch inside from the shadowed sun. The environment costs one 189 KB download and one skybox draw call. The plaster and wood are still StandardMaterials and ignore it; PBR walls would respond, at the risk of Image-Based Lighting's washed-out interior.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 320px; display: block; touch-action: none; outline: none; }
</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
{ id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
{ id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
{ id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
{ id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
{ id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
{ id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
return scene;
}
function paint(scene, name, hex) { // a matte color
const material = new BABYLON.StandardMaterial(name, scene);
material.diffuseColor = BABYLON.Color3.FromHexString(hex);
material.specularColor = BABYLON.Color3.Black();
return material;
}
function createRoom(scene) { // floor and three walls, front open
const room = new BABYLON.TransformNode('room', scene);
const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
floor.material = paint(scene, 'oak', '#b08d6a');
floor.parent = room;
const plaster = paint(scene, 'plaster', '#fff3e0');
for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
wall.position.set(x, ROOM.height / 2, z);
wall.rotation.y = x ? Math.PI / 2 : 0;
wall.material = plaster;
wall.parent = room;
}
return room;
}
function createBookcase(scene, name, x) { // 8 boxes merged into 1 mesh
const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
part.position.set(px, py, pz);
return part;
}), true);
bookcase.name = name;
bookcase.material = paint(scene, 'walnut', '#6d4c33');
bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2); // against the back wall
return bookcase;
}
function drawCover(scene, book) { // cover art painted on a canvas
const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
const c = tex.getContext();
c.fillStyle = book.color; c.fillRect(0, 0, 256, 384);
c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384); // the dark left edge
c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
const lines = [''];
for (const word of book.title.split(' ')) {
const test = (lines[lines.length - 1] + ' ' + word).trim();
if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
}
lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
c.font = '20px Georgia'; c.fillText(book.author, 135, 330);
tex.update();
return tex;
}
function placeBooks(scene, bookcase, shelf = 2) { // six books, spines out, covers on +x
const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
let x = -0.35; // left end of the row, local to the bookcase
return BOOKS.map((record) => {
const width = record.pages / 4000 + 0.02; // spine thickness from page count
const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
book.parent = bookcase;
book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
x += width + 0.012;
book.material = new BABYLON.StandardMaterial(record.title, scene);
book.material.diffuseTexture = drawCover(scene, record);
book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
book.metadata = record; // the record, for picking
return book;
});
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
const box = (width, height, depth, x, y) => {
const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
mesh.position.set(x, y, -ROOM.depth / 2);
return mesh;
};
const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
.subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
[solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
return wall;
}
// ---- end of the bookstore helpers ----
function lightRoom(scene) { // call after the meshes exist
scene.getLightByName('sky').intensity = 0.5; // the sky light becomes fill light
const sun = new BABYLON.DirectionalLight('sun', new BABYLON.Vector3(0.2, -0.33, 1), scene);
sun.intensity = 0.8;
sun.diffuse = BABYLON.Color3.FromHexString('#fff1d6'); // warm afternoon sun
const ceiling = BABYLON.MeshBuilder.CreateBox('ceiling', { width: ROOM.width, height: ROOM.wall, depth: ROOM.depth }, scene);
ceiling.position.y = ROOM.height + ROOM.wall / 2;
ceiling.layerMask = 0x10000000; // no camera draws it; it still casts
const shadows = new BABYLON.CascadedShadowGenerator(2048, sun);
Object.assign(shadows, { numCascades: 3, shadowMaxZ: 20, lambda: 1,
autoCalcDepthBounds: true, bias: 0.002, normalBias: 0.01 });
for (const mesh of scene.meshes) {
if (mesh.name !== 'floor') shadows.addShadowCaster(mesh);
mesh.receiveShadows = true;
}
const lamp = new BABYLON.PointLight('lamp', new BABYLON.Vector3(-1.2, 2.3, 1.4), scene);
lamp.diffuse = BABYLON.Color3.FromHexString('#ffd9a0');
Object.assign(lamp, { intensity: 0.7, range: 5 }); // a warm reading lamp
return { sun, lamp, shadows };
}
// A 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 };
}
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const left = createBookcase(scene, 'bookcaseLeft', -1.8);
createBookcase(scene, 'bookcaseRight', 1.8);
for (const book of placeBooks(scene, left)) { // PBR covers respond to the environment
const cover = new BABYLON.PBRMaterial('pbr' + book.name, scene);
Object.assign(cover, { albedoTexture: book.material.diffuseTexture, metallic: 0, roughness: 0.5 });
book.material = cover;
}
createFrontWall(scene);
lightRoom(scene);
useEnvironment(scene); // after lightRoom: no sky shadows
const target = new BABYLON.Vector3(-0.6, 1.5, 1);
const camera = new BABYLON.ArcRotateCamera('beach', -1.9, 1.42, 9, target, scene);
camera.attachControl(canvas, true);
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>