WebXR

WebXR for Virtual and Augmented Reality

The WebXR Device API gives a page a headset's pose and displays. Set renderer.xr.enabled, add the VRButton (or ARButton) add-on, and draw with setAnimationLoop(), since a headset runs its own frame clock; while presenting, the renderer swaps your camera for one per eye:

Entering BookNest's shelf in VR and picking a book with a controllerJavaScript
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import * as BN from './bookshelf.js';
const { renderer, scene, camera } = BN.createStage();
const bookcase = BN.createBookcase();
bookcase.position.set(0, 1.2, -1);                    // at chest height, one meter ahead
scene.add(bookcase);
renderer.xr.enabled = true;
document.body.append(VRButton.createButton(renderer));
const controller = renderer.xr.getController(0);      // a pointing ray from the first hand
scene.add(controller);
const raycaster = new THREE.Raycaster();
controller.addEventListener('select', () => {          // the trigger was pulled
  raycaster.setFromXRController(controller);
  const hit = raycaster.intersectObjects(bookcase.children.slice(1))[0];
  if (hit) hit.object.position.z += 0.3;
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));

The headset part is not run here: in headless Chrome 1 , isSessionSupported('immersive-vr') resolved false and the button read "VR NOT SUPPORTED". Test on a Meta Quest's browser, or with Meta's Immersive Web Emulator 465 for Chrome and Edge (github.com/meta-quest/immersive-web-emulator (https://github.com/meta-quest/immersive-web-emulator 465 )). WebXR needs HTTPS; Firefox 555 and desktop Safari 10 leave it off by default. Babylon.js 89,603 's WebXR is WebXR.

A WebXR-ready bookcase: VRButton, a controller ray and select events, previewed with the mouseHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; position: relative; }
  canvas { display: block; max-width: 100%; }
  p { margin: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<p id="out">Without a headset, click a book to fire the same handler a controller's trigger would.</p>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { VRButton } from 'three/addons/webxr/VRButton.js';

const BOOKS = [
  { title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b', rating: 4.6, pages: 312 },
  { title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99', rating: 4.3, pages: 428 },
  { title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10', rating: 4.8, pages: 256 },
  { title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a', rating: 4.1, pages: 198 },
  { title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f', rating: 4.5, pages: 344 },
  { title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f', rating: 4.4, pages: 176 },
];

// --- Book model: pages, two boards and a curved spine, merged with one group per part ---
const thickness = (book) => 0.05 + book.pages * 0.0003;          // metres, from the page count
function bookGeometry(book, height, depth = 0.62) {
  const t = thickness(book), b = 0.012, y = height / 2, z = -depth / 2;
  const box = (w, h, d, x, zz = z) => new THREE.BoxGeometry(w, h, d).translate(x, y, zz);
  const spine = new THREE.CylinderGeometry(t / 2, t / 2, height, 24, 1, true, -Math.PI / 2, Math.PI)
    .scale(1, 1, 0.5).translate(0, y, 0);
  return mergeGeometries([box(t - 2 * b, height - 0.03, depth - 0.02, 0, z + 0.01),
    box(b, height, depth, (b - t) / 2), box(b, height, depth, (t - b) / 2), spine], true);
}
function paint(book, width, height, draw) {
  const canvas = Object.assign(document.createElement('canvas'), { width, height });
  const g = canvas.getContext('2d');
  g.fillStyle = book.color;  g.fillRect(0, 0, width, height);
  g.fillStyle = '#ffffff';
  draw(g);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}
const coverArt = (book) => paint(book, 360, 520, (g) => {
  g.font = 'bold 50px Georgia, serif';
  book.title.split(' ').forEach((word, i) => g.fillText(word, 28, 84 + i * 56, 304));
  g.font = 'italic 28px Georgia, serif';
  g.fillText(`${book.author}, ${book.rating} / 5`, 28, 460);
});
const spineArt = (book) => paint(book, 128, 640, (g) => {
  g.rotate(Math.PI / 2);
  g.textBaseline = 'middle';
  g.font = 'bold 44px Georgia, serif';
  g.fillText(book.title, 24, -64, 440);
  g.font = '28px Georgia, serif';
  g.fillText(book.author.split(' ').at(-1), 490, -64, 130);
});
function createSpineBook(book, height = 0.9) {
  const material = (options) => new THREE.MeshStandardMaterial({ roughness: 0.6, ...options });
  const mesh = new THREE.Mesh(bookGeometry(book, height), [material({ color: '#f3ecdc' }),
    material({ color: book.color }), material({ map: coverArt(book) }), material({ map: spineArt(book) })]);
  return Object.assign(mesh, { name: book.title, userData: { book } });
}

// --- The arrangement ---
function createBookcase() {
  const bookcase = new THREE.Group();
  const plank = new THREE.Mesh(new THREE.BoxGeometry(3.5, 0.06, 0.8), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
  plank.name = 'plank';
  plank.position.set(0, -0.52, 0);
  bookcase.add(plank);
  const top = BOOKS.reduce((best, book) => (book.rating > best.rating ? book : best));
  let x = -0.85;
  BOOKS.forEach((book, i) => {
    const mesh = createSpineBook(book, 0.84 + 0.05 * (i % 3));   // three trim heights
    if (book === top) {
      mesh.position.set(0.3, -0.49, 0.05);
      mesh.rotation.y = -Math.PI / 2 + 0.35;                     // cover forward, turned a little
    } else {
      mesh.position.set(x + thickness(book) / 2, -0.49, 0.3);    // running x plus half a thickness
      x += thickness(book) + 0.005;
    }
    bookcase.add(mesh);
  });
  return bookcase;
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 280);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#dfe6ea');
const camera = new THREE.PerspectiveCamera(50, 600 / 280, 0.05, 50);
camera.position.set(0, 1.6, 0.6);                     // eye height; the headset replaces this pose
camera.lookAt(0, 1.2, -1);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.5));
const sun = new THREE.DirectionalLight('#ffffff', 2);
sun.position.set(1, 3, 2);
scene.add(sun);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(8, 8), new THREE.MeshStandardMaterial({ color: '#cbbfa9' }));
floor.rotation.x = -Math.PI / 2;
scene.add(floor);

const bookcase = createBookcase();
bookcase.position.set(0, 1.2, -1);                    // chest height, one metre ahead
scene.add(bookcase);

renderer.xr.enabled = true;
// VRButton asks navigator.xr whether immersive-vr is supported. An embedded frame must be
// allowed the xr-spatial-tracking feature first, so check the permissions policy before asking.
const policy = document.permissionsPolicy ?? document.featurePolicy;
const xrAllowed = 'xr' in navigator && policy?.allowsFeature('xr-spatial-tracking') !== false;
const button = xrAllowed ? VRButton.createButton(renderer)    // "ENTER VR", or "VR NOT SUPPORTED"
  : Object.assign(document.createElement('button'), { textContent: 'VR NOT ALLOWED IN THIS FRAME', disabled: true });
button.style.cssText += ';position:absolute;left:12px;bottom:40px;';
document.body.append(button);

// A controller: a pointing ray from the first hand, with a visible line
const controller = renderer.xr.getController(0);
controller.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -3)]),
  new THREE.LineBasicMaterial({ color: '#e09a10' })));
scene.add(controller);
const raycaster = new THREE.Raycaster();
const books = bookcase.children.slice(1);
function select(hit) {                                // pull the book toward the reader
  if (!hit) return;
  hit.object.position.z = hit.object.position.z > 0.5 ? hit.object.position.z - 0.3 : hit.object.position.z + 0.3;
  document.getElementById('out').textContent = `selected ${hit.object.name}`;
}
controller.addEventListener('select', () => {          // the trigger was pulled
  raycaster.setFromXRController(controller);
  select(raycaster.intersectObjects(books)[0]);
});
// Desktop preview: the mouse stands in for the controller
renderer.domElement.addEventListener('click', (e) => {
  const r = renderer.domElement.getBoundingClientRect();
  raycaster.setFromCamera(new THREE.Vector2((e.clientX - r.left) / r.width * 2 - 1, 1 - (e.clientY - r.top) / r.height * 2), camera);
  select(raycaster.intersectObjects(books)[0]);
});

renderer.setAnimationLoop(() => renderer.render(scene, camera));   // a headset runs its own clock
</script>