Aller au contenu
learn

11. WebXR

Code : la page dans src/11-xr/main.ts.

La WebXR Device API est l’accès du navigateur aux casques et aux téléphones en AR ou en VR. Si vous avez utilisé OpenXR, l’API C derrière Windows Mixed Reality, SteamVR et les applications natives du Quest, les concepts se correspondent de près ; WebXR en est l’équivalent pour le navigateur, et Chromium l’implémente par-dessus OpenXR sous Windows.

OpenXR WebXR three.js
Y a-t-il un appareil ? xrGetSystem navigator.xr.isSessionSupported('immersive-vr') VRButton le vérifie et affiche le bouton
Démarrer xrCreateSession navigator.xr.requestSession(mode, features), depuis un geste de l’utilisateur renderer.xr.setSession(session)
Systèmes de coordonnées espaces de référence : VIEW, LOCAL, STAGE viewer, local, local-floor, bounded-floor, unbounded renderer.xr.setReferenceSpaceType('local-floor'), la valeur par défaut
Boucle d’images xrWaitFrame, xrBeginFrame, xrEndFrame session.requestAnimationFrame renderer.setAnimationLoop, qui bascule sur la boucle de la session
Yeux des vues issues de xrLocateViews frame.getViewerPose(space).views une ArrayCamera avec une caméra par œil
Où dessiner des swapchains XRWebGLLayer ou WebXR Layers ; XRGPUBinding pour WebGPU, en cours la render target XR du renderer
Manettes des actions liées à des chemins session.inputSources, les événements select et squeeze, un Gamepad renderer.xr.getController(i), un Object3D qui suit la manette

setAnimationLoop est la raison pour laquelle la leçon 1 le préfère à requestAnimationFrame : dans une session, le requestAnimationFrame de la fenêtre cesse de se déclencher, et le casque cadence les images à son propre rythme, 72, 90 ou 120 Hz. Une boucle écrite avec setAnimationLoop continue de tourner, sans changement.

Chromium headless a un navigator.xr, mais aucun appareil derrière : isSessionSupported('immersive-vr') répond false. IWER 2.4.0, l’Immersive Web Emulation Runtime de Meta, remplace navigator.xr par une implémentation JavaScript dont le casque et les manettes sont déplacés par du code : le même runtime que l’extension de navigateur Immersive Web Emulator. La page l’installe avant de créer le renderer (lignes 25-38) :

// Le casque émulé doit être installé avant le renderer : XRManager cherche XRWebGLBinding dans son constructeur
let device: XRDevice | null = null;
if (params.get('device') !== 'none') {
device = new XRDevice(metaQuest3);
// Chromium a déjà un navigator.xr, sans appareil derrière : IWER n'y touche pas sauf si on le force
device.installRuntime({ forceInstall: true, polyfillLayers: params.has('layers') });
// IWER dessine une seule vue dans la page sauf si la stéréo est activée : les yeux gauche et droit partagent alors le canvas
device.stereoEnabled = true;
device.position.set(-0.2, 1.6, 0.9);
// Regard vers le bas, sur la touche, 40° sous l'horizon
const look = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (-40 * Math.PI) / 180);
device.quaternion.set(look.x, look.y, look.z, look.w);
result.emulated = { device: device.name, immersiveVR: await navigator.xr!.isSessionSupported('immersive-vr') };
}

Deux choses ont chacune coûté une exécution ratée avant d’être trouvées. Sans forceInstall, IWER voit le navigator.xr de Chromium et le laisse en place, et aucune session ne démarre. Sans stereoEnabled, IWER rend une seule vue : le viewport de l’œil droit fait 0 pixel de large.

Dans une session, les unités sont des mètres et, avec local-floor, y = 0 est le sol. La page pose la touche de la leçon 5, réduite au quart, sur une table virtuelle haute de 0.8 m. Une vraie page demande la session depuis un clic, souvent via le VRButton de three.js, puisque les navigateurs exigent un geste de l’utilisateur ; IWER ne l’exige pas, donc la sonde appelle directement requestSession (lignes 131-143).

renderer.xr.getController(i) renvoie un Object3D que three.js déplace à chaque image vers la pose de la i-ième source d’entrée de la session. Son indice n’est pas une main : c’est l’événement connected qui dit laquelle il est. Un événement select, la gâchette, devient un rayon partant de la manette avec Raycaster.setFromXRController, puis passe par fretAt et stringAt de la leçon 5, dans les coordonnées propres de la touche (lignes 85-99) :

for (const index of [0, 1]) {
const controller = renderer.xr.getController(index);
scene.add(controller);
controller.addEventListener('connected', (event) => (controller.userData.handedness = (event.data as XRInputSource).handedness));
controller.addEventListener('select', () => {
raycaster.setFromXRController(controller as THREE.XRTargetRaySpace);
const [hit] = raycaster.intersectObject(board, true);
const hand = controller.userData.handedness;
if (!hit) return void picks.push({ controller: index, hand, hit: null });
const local = board.worldToLocal(hit.point.clone());
const fret = fretAt(local.x);
const string = stringAt(local.z);
picks.push({ controller: index, hand, object: hit.object.name, fret, string, note: fret === null ? null : noteName(string, fret) });
});
}

La sonde pointe ensuite la manette droite d’IWER vers la corde 3, case 5, et presse sa gâchette : updateButtonValue('trigger', 1), puis 0, qu’IWER transforme en événements select de la session (lignes 161-178).

?classic dessine avec le WebGLRenderer de three, le renderer pour lequel les exemples WebXR de three.js ont été écrits :

$ node scripts/probe.mjs 11-xr --query classic
{
"native": {
"navigatorXR": true,
"immersiveVR": false,
"XRGPUBinding": false
},
"emulated": {
"device": "Meta Quest 3",
"immersiveVR": true
},
"rendererBeforeSession": "WebGLRenderer",
"session": {
"mode": "immersive-vr",
"enabledFeatures": [
"local-floor",
"viewer",
"local"
]
},
"presenting": true,
"rendererInSession": "WebGLRenderer",
"frames": [
{
"eyes": 2,
"eyeDistanceMm": 63,
"viewport": [
[0, 0, 400, 450],
[400, 0, 400, 450]
],
"calls": 38,
"triangles": 1560
}
],
"picks": [
{
"controller": 1,
"hand": "right",
"object": "neck",
"fret": 5,
"string": 3,
"note": "C4"
}
],
"inputSources": 2,
"errors": [],
"after": {
"in the session": {
"presenting": true
},
"end the session": {
"presenting": false
}
}
}
  • La session a été accordée avec local-floor, plus viewer et local, que toute session immersive possède.
  • Deux yeux, espacés de 63 mm, la distance interpupillaire par défaut d’IWER, chacun dessiné dans une moitié du canvas de 800 × 450.
  • 38 draw calls et 1 560 triangles : les 19 maillages et 780 triangles de la touche, dessinés une fois par œil. WebGLRenderer rend la scène deux fois ; le multiview, qui dessine les deux yeux en une passe, demande l’extension OVR_multiview2 et un navigateur de casque qui la propose (à vérifier sur un Quest).
  • La gâchette de la manette 1, la main droite, a sélectionné la corde 3, case 5 : C4, la note que la leçon 5 trouvait avec la souris.
  • session.end() a arrêté la présentation.

La capture d’écran, prise pendant la session, montre ce qu’IWER compose dans la page : l’œil gauche, puis le droit, chacun voyant la touche depuis sa propre position.

Deux vues presque identiques côte à côte sur un fond sombre, chacune montrant une petite touche brune avec frettes et cordes, vue d’en haut et de biais, légèrement décalée entre la moitié gauche et la moitié droite.

Ouvrir en plein écran. À essayer : cette page n’affiche rien sans la sonde ; la démo en direct en fin de leçon entre dans la même session émulée avec un bouton, et répond aux exercices 1 et 3.

La page par défaut utilise WebGPURenderer :

$ node scripts/probe.mjs 11-xr
{
...
"rendererBeforeSession": "WebGPURenderer, WebGPU",
"session": {
"mode": "immersive-vr",
"enabledFeatures": [
"local-floor",
"viewer",
"local"
]
},
"errors": [
"Error: THREE.XRManager: WebGPU XR sessions require the \"webgpu\" session feature. Use VRButtonGPU/XRButton with \"webgpu\" enabled or use a WebGL backend."
]
}

Dans r186, le backend WebGPU ne dessine dans une session WebXR que par XRGPUBinding, issu de la proposition de binding WebXR/WebGPU, et seulement dans une session demandée avec la fonctionnalité webgpu (XRManager.js, lignes 739-749, appelé depuis setSession à la ligne 1211). Chromium headless n’a pas de XRGPUBinding, et IWER n’en émule pas. Deux détails du message ne correspondent pas à r186 : il n’y a pas de VRButtonGPU dans examples/jsm/webxr/, et XRButton.js ne demande pas de fonctionnalité webgpu. Quels navigateurs et quels casques offrent XRGPUBinding en septembre 2026 est à vérifier.

Sur une machine sans WebGPU, ou avec --webgl, WebGPURenderer utilise son backend WebGL 2, que le message présente comme l’issue. Avec IWER, la session démarre, mais chaque image lève une exception :

$ node scripts/probe.mjs 11-xr --webgl
...
"rendererInSession": "WebGPURenderer, WebGL 2",
"frames": [
{
"eyes": 2,
"eyeDistanceMm": 63,
"viewport": [
[0, 0, 400, 450],
[400, 0, 400, 450]
],
"calls": 4,
"triangles": 780
}
],
...
"errors": [
"TypeError: Invalid value used as weak map key"
]

Le XRWebGLLayer.framebuffer d’IWER renvoie null (XRWebGLLayer.js, lignes 44-46, dans le paquet publié) : IWER dessine dans le canvas de la page elle-même, dont WebGL représente le framebuffer par null, comme le fait la spécification WebXR pour les sessions inline, qui dessinent dans la page. Le backend WebGL garde cette valeur comme cible XR (WebGLBackend.js, lignes 365-367), et WebGLState.drawBuffers utilise le framebuffer comme clé d’une WeakMap (WebGLState.js, lignes 1149-1164), qui refuse null. WebGLRenderer lie null comme framebuffer par défaut, et fonctionne. Sur un vrai casque, le framebuffer de la couche est un framebuffer opaque, pas null, donc cet échec tient à la combinaison de l’émulateur et du backend WebGL, pas aux casques (à vérifier sur un Quest).

La première version de cette page n’interceptait pas l’exception : les images se sont arrêtées, la sonde les a attendues, et l’exécution est restée bloquée jusqu’à ce qu’on l’arrête à la main, en gardant le verrou GPU de la machine pendant dix minutes. La page intercepte désormais les erreurs de rendu, garde chaque message une seule fois, et renonce à attendre des images au bout de 5 secondes ; probe.mjs s’arrête aussi de lui-même après deux fois son délai.

r186 ajoute WebGLXRFallback.js : il enveloppe renderer.xr.setSession, et quand une session démarre sur le backend WebGPU sans XRGPUBinding, il crée un second renderer avec un backend WebGL, lui transfère la boucle d’animation et les réglages XR, et laisse la page échanger les canvas (lignes 21-69). ?fallback l’installe :

$ node scripts/probe.mjs 11-xr --query fallback
...
"fallback": "switched to WebGPURenderer, WebGL 2",
"presenting": true,
"rendererInSession": "WebGPURenderer, WebGL 2",
...
"picks": [],
"inputSources": 2,
"errors": [
"TypeError: Invalid value used as weak map key"
],

Le basculement a fonctionné, et s’est heurté au même framebuffer null. Il montre autre chose : picks est vide. Les manettes venaient du renderer.xr.getController du premier renderer, et le renderer de repli a son propre XRManager, qui déplace ses propres manettes. L’addon copie la boucle et les réglages, pas les objets que la page a déjà pris dans renderer.xr : une page qui l’utilise doit récupérer à nouveau ses manettes dans onFallback.

check.sh compare les quatre exécutions. Sur les runners Linux et Windows, qui n’ont pas WebGPU, la page par défaut et ?fallback démarrent directement sur le backend WebGL 2, et leurs sorties viennent de expected/l11_probe_webgpu.webgl.txt et expected/l11_probe_fallback.webgl.txt, générés avec chromium-headless-shell.

Ce qu’un navigateur headless ne peut pas vérifier

Section intitulée « Ce qu’un navigateur headless ne peut pas vérifier »
  • La fréquence d’images. Un Quest 3 tourne à 72, 90 ou 120 Hz, et une image manquée se ressent plus qu’elle ne se voit. Le temps CPU par image de la leçon 8, doublé pour deux yeux, est le point de départ ; l’overlay de performance du casque lui-même est le point d’arrivée (à vérifier).
  • Le rendu fovéal et l’échelle du framebuffer. renderer.xr.setFoveation et setFramebufferScaleFactor changent ce que fait le compositeur du casque ; IWER les accepte et dessine les mêmes pixels.
  • Le suivi des mains, qu’IWER émule avec des poses, et le passthrough de l’AR, les hit tests et les ancres, que le cours ne traite pas.
  • Le confort. La touche à 0.8 m de haut, à 0.9 m des yeux, au quart de sa taille, a été choisie sur le papier.

GA n’a aucun code WebXR au commit 05c8eda : ni renderer.xr, ni VRButton, ni navigator.xr. La seule mention est un fichier de skill Claude, .claude/skills/three-best-practices/rules/webxr-setup.md, écrit pour des agents et non utilisé par l’application. Un mode VR pour la touche partirait du WebGPURenderer de ThreeFretboard.tsx, qui, comme cette leçon l’a constaté, ne peut pas entrer dans une session dans r186 sans XRGPUBinding : l’addon de repli ou un WebGLRenderer serait la première étape.

Les exercices 1 et 3 sur une guitare entière : le WebGLRenderer classique, celui que cette leçon a vu rendre les deux yeux avec IWER, un VRButton, et un gestionnaire de select qui ramène le rayon de la manette dans les coordonnées de la guitare, trouve la corde et la case comme la guitare de la leçon 5, joue la note et envoie une impulsion haptique de 40 ms. IWER n’est importé qu’avec ?emulate : un vrai casque n’en télécharge rien. La page ignore resize pendant une session : WebGLRenderer refuse de changer de taille pendant la présentation, et avertit. Sa sonde entre dans une session émulée, vise la corde 3, case 5, avec la manette droite, appuie sur la gâchette et lit string 3, fret 5: C4, avec 2 yeux et aucune erreur. Le source est dans code/threejs/demos/xr.

Deux vues presque identiques côte à côte, l’œil gauche et l’œil droit d’un casque émulé : une guitare électrique entière qui flotte au-dessus d’un sol quadrillé gris, vue de haut, avec un fin rayon de manette orange qui croise ses cordes dans chaque vue.

Page en direct : la lancer ici

Ouvrir en plein écran. À essayer : appuyez sur Enter VR : la manette droite émulée joue alors seule un arpège de do majeur. Avec un casque, ouvrez la page sans ?emulate dans son navigateur, visez le manche et appuyez sur la gâchette ; à vérifier, l’auteur n’a pas de casque.

L’expérience 12 de la leçon 14 place un manche à taille réelle dans un Quest 3 émulé : deux vues à 63 mm l’une de l’autre, et la distance du sillet à la 12e case telle que mesurée. La sensation en main, et le décalage de tête de 25 mm d’IWER, sont à vérifier sur un casque.

  • WebXR donne à la page une session, des espaces de référence en mètres, une caméra par œil et des sources d’entrée ; three.js les enveloppe dans renderer.xr, setAnimationLoop et getController.
  • IWER émule un Quest 3 dans un navigateur headless : sessions, vues stéréo, poses des manettes et gâchettes peuvent être testées en CI.
  • Dans r186, WebGPURenderer n’entre dans une session WebXR sur WebGPU qu’avec XRGPUBinding et la fonctionnalité webgpu ; sinon, il lève une exception.
  • L’addon de repli WebGL échange les renderers, mais pas les manettes que la page détient déjà.
  • Avec IWER, seul le WebGLRenderer classique a rendu : le backend WebGL 2 n’accepte pas un framebuffer de couche null.
  • Mettez un délai strict sur tout ce qui attend des images : une session XR qui cesse d’en produire attend indéfiniment.
  1. Ajoutez un VRButton à la page ?classic, pour que le navigateur d’un vrai casque puisse entrer dans la session. Que ne doit plus faire la page, maintenant que la session n’est plus démarrée par la sonde ?
  2. Dans la page ?fallback, récupérez les manettes du nouveau renderer dans onFallback, et déplacez-y leurs listeners select. Que devrait alors montrer picks, et pourquoi cela échouerait-il encore avec IWER ?
  3. Faites jouer la note par la gâchette : listez ce dont le gestionnaire de select a besoin, et ce que le casque change par rapport à un clic de souris.
Solution 1
import { VRButton } from 'three/addons/webxr/VRButton.js';
document.body.append(VRButton.createButton(renderer));

VRButton vérifie isSessionSupported('immersive-vr'), affiche « ENTER VR » ou « VR NOT SUPPORTED », et, au clic, demande la session avec local-floor, bounded-floor et layers comme fonctionnalités optionnelles (VRButton.js, lignes 68-74), ce qui constitue le geste de l’utilisateur qu’exigent les navigateurs. La page ne doit plus appeler requestSession elle-même, sinon le clic échouerait avec une session déjà en cours : gardez startSession pour la sonde seulement. Le bloc if (probing) de la page le fait déjà. Non exécuté sur un casque (à vérifier).

Solution 2

onFallback(fallback, previous) reçoit le nouveau renderer : appelez-y fallback.xr.getController(0) et (1), ajoutez-les à la scène, et attachez les listeners connected et select, la même boucle qu’avant, transformée en fonction qui prend le renderer. Avec IWER, picks devrait alors contenir la sélection C4, puisque le rayon est calculé sur le CPU à partir de la pose de la manette, que met à jour le XRManager du renderer de repli. Mais chaque image lève toujours une exception sur le framebuffer null avant que la scène soit dessinée, et savoir si les poses des manettes sont mises à jour avant cette exception est à vérifier : dans XRManager, les manettes sont mises à jour dans l’image d’animation XR, avant le callback de la boucle qui lève l’exception (XRManager.js, lignes 1991-1997), donc elles le sont probablement.

Solution 3

Le gestionnaire a besoin de la note (il l’a), d’un son, et d’un moyen de le jouer : la Web Audio API avec un AudioContext créé ou relancé depuis un geste de l’utilisateur, que fournit l’entrée dans la session. Par rapport à un clic : la gâchette est analogique, et inputSource.gamepad.buttons[0].value donne la force avec laquelle elle est pressée, une vélocité toute trouvée ; inputSource.gamepad.hapticActuators peut faire vibrer la manette sur la note ; et le son peut être positionné sur la corde avec un PannerNode, ou le PositionalAudio de three.js, attaché à la touche, pour qu’il vienne de l’endroit où se trouve la corde. Une conception, pas du code exécuté (à vérifier).