The cover images from Canvas 2D's cover designer (booknest-cover-1.png to -6.png) become the books' albedo. A box maps the whole image onto every face, so placeBooks now passes CreateBox a faceUV array, one Vector4(u0, v0, u1, v1) per face (+z, -z, +x, -x, top, bottom), and wrap: true to stand side images upright. The +x face, the front cover when the spine faces you, gets the full image; the rest, the cover's dark left edge:
const strip = new BABYLON.Vector4(0, 0, 0.07, 1); // Section 7.5.9: the cover's dark left edge
const BOOK_UV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip]; // +x
function coverBooks(scene, books) { // Section 7.5.9: cover art as albedo
return books.map((mesh, i) => {
const material = new BABYLON.PBRMaterial(`coverArt${i + 1}`, scene);
material.albedoTexture = new BABYLON.Texture(`booknest-cover-${i + 1}.png`, scene);
material.metallic = 0; // paper, not metal
material.roughness = 0.5; // a satin laminate
mesh.material = material;
return material;
});
}The figure also clones each book onto the shelf above, turned a quarter turn to face out as bookstores show new titles, and reads inStock from booknest-data.json, the shop's catalog:
<canvas id="view" style="width: 620px; height: 210px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
{
const engine = new BABYLON.Engine(view, true, {}, true);
const scene = BookNestStore.createScene(engine);
BookNestStore.createRoom(scene);
const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
const books = BookNestStore.placeBooks(scene, left); // shelf 2, spines out
const covers = BookNestStore.coverBooks(scene, books);
books.forEach((book, i) => {
const shown = book.clone(`shown${i + 1}`, left); // shares geometry and material
shown.rotation.y = Math.PI / 2; // front cover toward the aisle
shown.position.set(-1.3 + i * 0.26, 0.12 + 3 * 0.5 + 0.15, -0.02); // shelf 3
});
const target = new BABYLON.Vector3(-2.2, 1.45, 2.6);
new BABYLON.ArcRotateCamera('browse', -1.82, 1.5, 1.45, target, scene).minZ = 0.05;
fetch('booknest-data.json').then((reply) => reply.json()).then((catalog) => {
for (const { id, inStock } of catalog.books) { // the shop's stock field
if (!inStock) new BookNestStore.SoldOutPlugin(covers[id - 1]); // Section 7.5.7
}
BookNestStore.renderWhenReady(engine, scene, 10);
});
}
</script>
The Material Plugin System's plugin grays Salt and Saffron, the one title the catalog marks out of stock, spine and cover alike, since clones share their original's material. Textures are mipmapped (trilinear) by default, and anisotropicFilteringLevel (default 4) keeps covers sharp at grazing angles. Lit by the sky light alone, the covers look flat; Room Lighting and HDR Room Lighting light the room properly.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; }
canvas { width: 100%; max-width: 600px; height: 340px; display: block; touch-action: none; }
</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// The shop's catalog, inline instead of fetched from booknest-data.json
const CATALOG = [
{ id: 1, title: 'The Quiet Harbor', color: '#1f5f8b', pages: 312, inStock: true },
{ id: 2, title: 'Patterns of the Deep Web', color: '#5b3f99', pages: 428, inStock: true },
{ id: 3, title: 'Salt and Saffron', color: '#e09a10', pages: 256, inStock: false },
{ id: 4, title: 'Small Steps to Big Summits', color: '#3f7d3a', pages: 198, inStock: true },
{ id: 5, title: "The Clockmaker's Paradox", color: '#b5452f', pages: 344, inStock: true },
{ id: 6, title: 'Gardens in Glass', color: '#2a9d8f', pages: 176, inStock: true },
];
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.6), scene).intensity = 1.6;
// Cover art drawn on a canvas (the book loads booknest-cover-1.png ... -6.png)
function drawCover({ title, color }) {
const tex = new BABYLON.DynamicTexture(title, { width: 256, height: 384 }, scene);
const c = tex.getContext();
c.fillStyle = 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 (7%)
c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
const words = title.split(' '), lines = [''];
for (const word of words) {
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.beginPath(); c.arc(135, 290, 28, 0, Math.PI * 2); c.stroke(); // a simple emblem
c.font = '16px Georgia'; c.fillText('BookNest Press', 135, 346);
tex.update();
return tex;
}
// Gray out a material after lighting (the material plugin from the previous section)
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` };
}
}
const paint = (hex) => {
const m = new BABYLON.StandardMaterial(hex, scene);
m.diffuseColor = BABYLON.Color3.FromHexString(hex);
m.specularColor = BABYLON.Color3.Black();
return m;
};
BABYLON.MeshBuilder.CreateGround('floor', { width: 8, height: 6 }, scene).material = paint('#b08d6a');
const backWall = BABYLON.MeshBuilder.CreateBox('backWall', { width: 8, height: 3, depth: 0.15 }, scene);
backWall.position.set(0, 1.5, 3);
backWall.material = paint('#fff3e0');
// A bookcase: two sides, a back and five boards merged into one mesh
const parts = [[0.04, 2.2, 0.35, -1.5, 1.1, 0], [0.04, 2.2, 0.35, 1.5, 1.1, 0], [3, 2.2, 0.04, 0, 1.1, 0.155]];
for (let i = 0; i < 5; i++) parts.push([3, 0.04, 0.35, 0, 0.1 + i * 0.5, 0]);
const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, x, y, z]) => {
const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
part.position.set(x, y, z);
return part;
}), true);
bookcase.material = paint('#6d4c33');
bookcase.position.set(-1.8, 0, 2.75);
// One Vector4(u0, v0, u1, v1) per face: +z, -z, +x, -x, top, bottom
const strip = new BABYLON.Vector4(0, 0, 0.07, 1); // the cover's dark left edge
const BOOK_UV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip]; // +x: full cover
let x = -1.3;
CATALOG.forEach((record, i) => {
const width = record.pages / 4000 + 0.02;
const book = BABYLON.MeshBuilder.CreateBox(record.title,
{ width, height: 0.3, depth: 0.22, faceUV: BOOK_UV, wrap: true }, scene); // wrap: side images upright
book.parent = bookcase;
book.position.set(x + width / 2, 0.12 + 2 * 0.5 + 0.15, -0.02); // shelf 2, spines out
x += width + 0.012;
const material = new BABYLON.PBRMaterial(`coverArt${record.id}`, scene);
material.albedoTexture = drawCover(record);
material.metallic = 0; // paper, not metal
material.roughness = 0.5; // a satin laminate
book.material = material;
if (!record.inStock) new SoldOutPlugin(material);
const shown = book.clone(`shown${i + 1}`, bookcase); // shares geometry and material
shown.rotation.y = Math.PI / 2; // front cover toward the aisle
shown.position.set(-1.3 + i * 0.26, 0.12 + 3 * 0.5 + 0.15, -0.02); // shelf 3
});
const target = new BABYLON.Vector3(-2.2, 1.45, 2.6);
const camera = new BABYLON.ArcRotateCamera('browse', -1.82, 1.5, 1.6, target, scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>