For shapes no builder makes, fill a VertexData with the flat arrays Buffers and VAOs uploaded with bufferData, and apply it to an empty Mesh. VertexData.ComputeNormals averages face normals at shared vertices, Gouraud Versus Phong Shading's smooth shading. This builds an open book's pages as a strip of quads, beside a flat-shaded copy:
<canvas id="view" style="width: 460px; height: 150px"></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);
new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, 0.9, 1.6, BABYLON.Vector3.Zero(), scene);
const positions = [], indices = [], normals = [], columns = 8;
for (let i = 0; i <= columns; i++) { // two vertex rows across the spread
const x = i / columns - 0.5; // -0.5 (left edge) to 0.5 (right edge)
positions.push(x, 0.12 * Math.sqrt(Math.abs(x) * 2), -0.35, // pages rise from the spine
x, 0.12 * Math.sqrt(Math.abs(x) * 2), 0.35);
const a = 2 * i - 2; // quad between columns i - 1 and i
if (i > 0) indices.push(a, a + 3, a + 1, a, a + 2, a + 3);
}
BABYLON.VertexData.ComputeNormals(positions, indices, normals);
const pages = new BABYLON.Mesh('pages', scene);
Object.assign(new BABYLON.VertexData(), { positions, indices, normals }).applyToMesh(pages);
pages.material = BookNestStore.paint(scene, 'paper', '#fff3e0');
const flat = pages.clone('flat');
flat.makeGeometryUnique(); // clones share geometry by default
flat.convertToFlatShadedMesh(); // 3 vertices per triangle, face normals
[pages.position.x, flat.position.x] = [-0.56, 0.56];
BookNestStore.renderWhenReady(engine, scene);
}
</script>
The left spread's 18 shared vertices blend normals across the fold; the flat copy has 48, three per triangle. Two traps: index order sets the front face (with a, a + 1, a + 3 the pages faced down and were culled), and without makeGeometryUnique() flattening the clone flattened the original too. Add uvs and colors the same way.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 300px; display: block; touch-action: none; }
#info { max-width: 600px; padding: 4px 8px; font-family: monospace; }
</style>
<canvas id="view"></canvas>
<div id="info"></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
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');
const camera = new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, 0.9, 1.7, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
camera.minZ = 0.05;
// A low, raking light makes the shading difference easy to see
new BABYLON.DirectionalLight('sun', new BABYLON.Vector3(0.8, -0.5, 0.4), scene).intensity = 1.1;
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0, 1, 0), scene).intensity = 0.35;
const positions = [], indices = [], normals = [], columns = 8;
for (let i = 0; i <= columns; i++) { // two vertex rows across the spread
const x = i / columns - 0.5; // -0.5 (left edge) to 0.5 (right edge)
const y = 0.12 * Math.sqrt(Math.abs(x) * 2); // pages rise from the spine
positions.push(x, y, -0.35, x, y, 0.35);
const a = 2 * i - 2; // quad between columns i - 1 and i
if (i > 0) indices.push(a, a + 3, a + 1, a, a + 2, a + 3); // this winding faces up
}
BABYLON.VertexData.ComputeNormals(positions, indices, normals); // averaged: smooth shading
const pages = new BABYLON.Mesh('pages', scene);
Object.assign(new BABYLON.VertexData(), { positions, indices, normals }).applyToMesh(pages);
const paper = new BABYLON.StandardMaterial('paper', scene);
paper.diffuseColor = BABYLON.Color3.FromHexString('#fff3e0');
paper.specularColor = BABYLON.Color3.Black();
pages.material = paper;
const flat = pages.clone('flat');
flat.makeGeometryUnique(); // clones share geometry by default
flat.convertToFlatShadedMesh(); // 3 vertices per triangle, face normals
[pages.position.x, flat.position.x] = [-0.56, 0.56];
// A leather cover under each spread
for (const x of [-0.56, 0.56]) {
const cover = BABYLON.MeshBuilder.CreateBox('cover', { width: 1.06, height: 0.02, depth: 0.76 }, scene);
cover.position.set(x, -0.012, 0);
cover.material = new BABYLON.StandardMaterial('leather', scene);
cover.material.diffuseColor = BABYLON.Color3.FromHexString('#b5452f');
}
document.getElementById('info').textContent =
`left (smooth): ${pages.getTotalVertices()} shared vertices right (flat): ${flat.getTotalVertices()} vertices`;
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>