VR Bookstore

Entering BookNest's Bookstore in Virtual Reality

BookNest's room is built in meters, so VR needs only the helper, the floor and a starting camera. This page enters VR on the emulated Quest, aims the right controller at Salt and Saffron and pulls the trigger:

BookNest's bookstore in immersive VR on an emulated Quest 3, selecting a bookHTML
<canvas id="view" style="width: 640px; height: 300px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/iwer@2.5.0/build/iwer.min.js"></script>
<script src="l1-iwer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-loaders@9.28.0"></script>  <!-- glTF -->
<script src="bookstore.js"></script>
<script>
(async () => {
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  const { floor } = BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  BookNestStore.createBookcase(scene, 'bookcaseRight', 1.8);
  const books = BookNestStore.placeBooks(scene, left);
  new BABYLON.FreeCamera('visitor', new BABYLON.Vector3(0, 1.6, 0), scene);  // XR starts here
  const xr = await scene.createDefaultXRExperienceAsync({ floorMeshes: [floor],
    pointerSelectionOptions: { preferredHandedness: 'right' } });   // laser on the right
  scene.onPointerDown = (event, pick) => {                   // the trigger is a pointer
    if (pick.pickedMesh?.metadata) console.log(pick.pickedMesh.metadata.title);
  };
  engine.runRenderLoop(() => scene.render());
  await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor');   // the button's job
  const hand = quest.controllers.right;                      // aim at a spine, flipping z
  const { x, y, z } = books[2].getAbsolutePosition().subtractFromFloats(0, 0, 0.11);
  Object.assign(hand.quaternion, IWER.lookRotation(IWER.directionTo(hand.position,
    { x, y, z: -z })));                                      // WebXR is right-handed
  setTimeout(() => hand.updateButtonValue('trigger', 1), 1000);        // pull the trigger
  setTimeout(() => { window.__done = true; }, 2000);
})();
</script>
Browser output of Listing 7.124
Browser output of 124

The figure shows one eye. The laser ends on the third book, and the page logged Salt and Saffron from its metadata. For VR, put GUI panels on meshes (Fullscreen vs Mesh GUI), since headsets show no full-screen overlay, and go easy on post-processing, which runs once per eye at 72 to 120 Hz.

BookNest's bookstore from a VR visitor's two eyes, a laser from the right controller selecting Salt and SaffronHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 280px; 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">Checking for WebXR…</div>
<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 ----

// Offer real WebXR when the browser has it; otherwise say so and keep the desktop view.
// (A sandboxed preview frame is not allowed to start immersive sessions.)
async function offerXR(scene, options, status) {
  let supported = false;
  const policy = document.permissionsPolicy || document.featurePolicy;   // may this frame use XR at all?
  const allowed = policy?.allowsFeature ? policy.allowsFeature('xr-spatial-tracking') : true;
  if (navigator.xr && allowed) {
    try {
      supported = await BABYLON.WebXRSessionManager.IsSessionSupportedAsync('immersive-vr');
    } catch (error) { /* no immersive sessions here */ }
  }
  if (!supported) {
    status('immersive-vr is not available here, so this is a desktop stand-in');
    return null;
  }
  const xr = await scene.createDefaultXRExperienceAsync(options);
  status('immersive-vr supported: press the VR button in the corner');
  return xr;
}
// Two eye cameras side by side, as the WebXRCamera's rig renders a headset frame
function stereoRig(scene, position, target, ipd = 0.064) {
  const forward = target.subtract(position).normalize();
  const right = BABYLON.Vector3.Cross(BABYLON.Vector3.Up(), forward).normalize();
  return [-1, 1].map((side) => {
    const eye = new BABYLON.UniversalCamera(side < 0 ? 'leftEye' : 'rightEye', position.add(right.scale(side * ipd / 2)), scene);
    eye.setTarget(target.add(right.scale(side * ipd / 2)));
    eye.minZ = 0.05;
    eye.fov = 1.2;
    eye.viewport = new BABYLON.Viewport(side < 0 ? 0 : 0.5, 0, 0.5, 1);
    scene.activeCameras.push(eye);
    return eye;
  });
}

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);
const books = placeBooks(scene, left);
const floor = scene.getMeshByName('floor');
new BABYLON.FreeCamera('visitor', new BABYLON.Vector3(0, 1.6, 0), scene);   // XR starts here
const info = document.getElementById('info');
// The trigger arrives as an ordinary pointer event, so picking code works unchanged
scene.onPointerDown = (event, pick) => {
  if (pick.pickedMesh?.metadata) info.textContent = `selected ${pick.pickedMesh.metadata.title}`;
};

offerXR(scene, { floorMeshes: [floor], pointerSelectionOptions: { preferredHandedness: 'right' } },
  (text) => { info.textContent = text; }).then((xr) => {
  if (xr) return;                                    // a real headset takes over from here
  scene.onAfterRenderObservable.addOnce(() => standIn());   // after a frame: world matrices are current
});
function standIn() {
  // Desktop stand-in: the headset's two eye views and the right controller's laser
  const eyes = stereoRig(scene, new BABYLON.Vector3(-0.9, 1.6, 0.8), new BABYLON.Vector3(-1.9, 1.3, 2.6));
  const hand = new BABYLON.Vector3(-1.55, 1.15, 1.2);
  const target = books[2].getBoundingInfo().boundingBox.centerWorld;
  const laser = BABYLON.MeshBuilder.CreateTube('laser', { path: [hand, target], radius: 0.004 }, scene);
  laser.material = new BABYLON.StandardMaterial('laserMat', scene);
  laser.material.emissiveColor = BABYLON.Color3.FromHexString('#4fc3f7');
  laser.isPickable = false;
  const dot = BABYLON.MeshBuilder.CreateSphere('laserDot', { diameter: 0.02 }, scene);
  dot.position.copyFrom(target);
  dot.material = laser.material;
  // Pull the trigger: a pick along the laser, as pointer selection does in VR
  const hit = scene.pickWithRay(new BABYLON.Ray(hand, target.subtract(hand).normalize()));
  info.textContent = `desktop stand-in (no immersive-vr here): the laser picks ${hit.pickedMesh.metadata.title}`;
  books[2].scaling.setAll(1.1);
  eyes.forEach((eye) => { eye.fov = 1.1; });
}
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>