Les bases de WebXR pour les jeux en VR
WebXR apporte la VR et l’AR au navigateur. Les joueurs peuvent découvrir votre jeu dans un casque sans rien télécharger.
1) Vérifier la prise en charge de WebXR
async function checkXRSupport() {
if (!navigator.xr) {
return { vr: false, ar: false }
}
const vr = await navigator.xr.isSessionSupported('immersive-vr')
const ar = await navigator.xr.isSessionSupported('immersive-ar')
return { vr, ar }
}
// Utilisation
const support = await checkXRSupport()
if (support.vr) {
showVRButton()
}2) Démarrer une session VR
let xrSession = null
let xrRefSpace = null
async function startVR() {
try {
xrSession = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
optionalFeatures: ['hand-tracking'],
})
xrSession.addEventListener('end', onSessionEnd)
// Configurer le rendu
const gl = canvas.getContext('webgl2', { xrCompatible: true })
await xrSession.updateRenderState({
baseLayer: new XRWebGLLayer(xrSession, gl),
})
// Obtenir l’espace de référence
xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
// Démarrer la boucle de rendu
xrSession.requestAnimationFrame(onXRFrame)
} catch (err) {
console.error('Failed to start VR:', err)
}
}
function onSessionEnd() {
xrSession = null
xrRefSpace = null
}3) La boucle de rendu XR
function onXRFrame(time, frame) {
const session = frame.session
session.requestAnimationFrame(onXRFrame)
const pose = frame.getViewerPose(xrRefSpace)
if (!pose) return
const glLayer = session.renderState.baseLayer
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
// Effectuer le rendu pour chaque œil
for (const view of pose.views) {
const viewport = glLayer.getViewport(view)
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
// Obtenir les matrices de la caméra
const viewMatrix = view.transform.inverse.matrix
const projectionMatrix = view.projectionMatrix
// Effectuer le rendu de la scène avec ces matrices
renderScene(viewMatrix, projectionMatrix)
}
}4) Sources d’entrée (contrôleurs)
xrSession.addEventListener('inputsourceschange', (e) => {
for (const source of e.added) {
console.log('Controller added:', source.handedness) // 'left' ou 'right'
}
for (const source of e.removed) {
console.log('Controller removed:', source.handedness)
}
})
function processInput(frame) {
for (const source of xrSession.inputSources) {
if (!source.gamepad) continue
const gamepad = source.gamepad
// Gâchette (généralement à l’index 0)
const triggerPressed = gamepad.buttons[0]?.pressed
const triggerValue = gamepad.buttons[0]?.value || 0
// Poignée (généralement à l’index 1)
const gripPressed = gamepad.buttons[1]?.pressed
// Stick analogique
const thumbstickX = gamepad.axes[2] || 0
const thumbstickY = gamepad.axes[3] || 0
// Boutons A/B (index 4, 5)
const buttonA = gamepad.buttons[4]?.pressed
const buttonB = gamepad.buttons[5]?.pressed
}
}Ne supposez pas que chaque appareil possède une manette
Le code ci-dessus lit source.gamepad pour les gâchettes et les sticks analogiques. Cela fonctionne sur les casques avec contrôleurs comme le Meta Quest, mais ne produit aucun effet sur l’Apple Vision Pro (WebXR est activé par défaut dans Safari sous visionOS 2), le Samsung Galaxy XR et les autres appareils Android XR, où le regard et le pincement ou le suivi des mains constituent les principales méthodes d’entrée. Sur ces casques, source.gamepad vaut null et session.inputSources reste vide jusqu’à ce que l’utilisateur effectue réellement un pincement.
Pour la seule interaction garantie par tous les appareils, l’« action principale », écoutez les événements select au lieu d’interroger les boutons. WebXR émet selectstart, select et selectend aussi bien pour la gâchette d’un contrôleur, un pincement de la main ou une interaction par regard et pincement sur Vision Pro. Un seul gestionnaire suffit donc à les prendre tous en charge :
xrSession.addEventListener('selectstart', (e) => {
// e.inputSource est le contrôleur, la main ou le pincement transitoire
// e.frame permet de lire la pose de son targetRaySpace
})
xrSession.addEventListener('select', (e) => {
// action principale terminée (gâchette pressée, pincement relâché sur la cible)
})
xrSession.addEventListener('selectend', (e) => {})Conservez le code de la manette pour les déplacements au stick analogique et les boutons supplémentaires sur les casques avec contrôleurs, mais conditionnez-le avec if (source.gamepad) et faites passer votre interaction principale de « sélection/saisie/activation » par ces événements afin que le jeu fonctionne toujours sur les appareils pilotés par les mains et le regard.
5) Positions des contrôleurs
function getControllerPose(frame, source) {
if (!source.gripSpace) return null
const pose = frame.getPose(source.gripSpace, xrRefSpace)
if (!pose) return null
return {
position: pose.transform.position,
orientation: pose.transform.orientation,
matrix: pose.transform.matrix,
}
}
function renderControllers(frame) {
for (const source of xrSession.inputSources) {
const pose = getControllerPose(frame, source)
if (pose) {
renderControllerModel(pose, source.handedness)
}
}
}6) Lancer de rayons pour le pointage
function getControllerRay(frame, source) {
if (!source.targetRaySpace) return null
const pose = frame.getPose(source.targetRaySpace, xrRefSpace)
if (!pose) return null
const origin = pose.transform.position
const direction = {
x: -pose.transform.matrix[8],
y: -pose.transform.matrix[9],
z: -pose.transform.matrix[10],
}
return { origin, direction }
}
function checkRayIntersection(ray, objects) {
let closest = null
let closestDist = Infinity
for (const obj of objects) {
const dist = rayIntersectBox(ray, obj.boundingBox)
if (dist !== null && dist < closestDist) {
closest = obj
closestDist = dist
}
}
return closest
}7) Déplacement par téléportation
class TeleportSystem {
constructor() {
this.targetPosition = null
this.isAiming = false
}
update(frame, inputSources) {
for (const source of inputSources) {
if (source.handedness !== 'left') continue
const gamepad = source.gamepad
const thumbstickY = gamepad?.axes[3] || 0
if (thumbstickY < -0.5) {
// Visée
this.isAiming = true
const ray = getControllerRay(frame, source)
this.targetPosition = this.findTeleportTarget(ray)
} else if (this.isAiming) {
// Relâchement : téléportation
if (this.targetPosition) {
player.position.x = this.targetPosition.x
player.position.z = this.targetPosition.z
}
this.isAiming = false
this.targetPosition = null
}
}
}
findTeleportTarget(ray) {
// Calculer l’intersection avec le plan du sol
if (ray.direction.y >= 0) return null
const t = -ray.origin.y / ray.direction.y
if (t < 0 || t > 10) return null
return {
x: ray.origin.x + ray.direction.x * t,
y: 0,
z: ray.origin.z + ray.direction.z * t,
}
}
render() {
if (this.isAiming && this.targetPosition) {
renderTeleportMarker(this.targetPosition)
}
}
}8) Déplacement fluide
function updateSmoothLocomotion(frame, inputSources, dt) {
for (const source of inputSources) {
if (source.handedness !== 'left') continue
const gamepad = source.gamepad
if (!gamepad) continue
const moveX = gamepad.axes[2] || 0
const moveY = gamepad.axes[3] || 0
// Appliquer une zone morte
if (Math.abs(moveX) < 0.1) moveX = 0
if (Math.abs(moveY) < 0.1) moveY = 0
// Obtenir la direction de la tête pour le déplacement
const pose = frame.getViewerPose(xrRefSpace)
if (!pose) continue
const headMatrix = pose.transform.matrix
const forward = { x: -headMatrix[8], z: -headMatrix[10] }
const right = { x: headMatrix[0], z: headMatrix[2] }
// Normaliser sur le plan XZ
const len = Math.sqrt(forward.x ** 2 + forward.z ** 2)
forward.x /= len
forward.z /= len
const speed = 3 * dt
player.position.x += (forward.x * -moveY + right.x * moveX) * speed
player.position.z += (forward.z * -moveY + right.z * moveX) * speed
}
}9) Recommandations pour le confort en VR
// Ajouter une vignette pendant le déplacement pour réduire le mal des transports
function renderComfortVignette(movementSpeed) {
const intensity = Math.min(movementSpeed / 5, 0.5)
if (intensity < 0.1) return
// Afficher des bords sombres dont l’intensité augmente avec la vitesse
renderVignette(intensity)
}
// Rotation par paliers
let snapTurnCooldown = 0
function handleSnapTurn(gamepad, dt) {
snapTurnCooldown -= dt
const thumbstickX = gamepad.axes[2] || 0
if (Math.abs(thumbstickX) > 0.7 && snapTurnCooldown <= 0) {
const angle = Math.sign(thumbstickX) * (Math.PI / 4) // 45 degrés
player.rotation += angle
snapTurnCooldown = 0.3 // Empêcher les rotations trop rapides
}
}10) Configuration WebXR complète
class VRGame {
constructor(canvas) {
this.canvas = canvas
this.gl = canvas.getContext('webgl2', { xrCompatible: true })
this.session = null
this.refSpace = null
this.teleport = new TeleportSystem()
}
async checkSupport() {
if (!navigator.xr) return false
return await navigator.xr.isSessionSupported('immersive-vr')
}
async start() {
this.session = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
})
await this.session.updateRenderState({
baseLayer: new XRWebGLLayer(this.session, this.gl),
})
this.refSpace = await this.session.requestReferenceSpace('local-floor')
this.session.addEventListener('end', () => this.onEnd())
this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
}
onFrame(time, frame) {
this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
const dt = (time - this.lastTime) / 1000
this.lastTime = time
// Mettre à jour la logique du jeu
this.teleport.update(frame, this.session.inputSources)
// Effectuer le rendu
const pose = frame.getViewerPose(this.refSpace)
if (!pose) return
const glLayer = this.session.renderState.baseLayer
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, glLayer.framebuffer)
this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT)
for (const view of pose.views) {
const vp = glLayer.getViewport(view)
this.gl.viewport(vp.x, vp.y, vp.width, vp.height)
this.render(view.transform.inverse.matrix, view.projectionMatrix)
}
this.teleport.render()
}
render(viewMatrix, projectionMatrix) {
// Votre code de rendu ici
}
onEnd() {
this.session = null
}
}À lire aussi
- Principes fondamentaux de WebGL
- Premiers pas avec WebGPU
- Gestion des entrées de jeu
- Stack technique des jeux web en 2026 — la place de WebXR dans l’écosystème technologique au sens large
- Three.js + USDC dans le navigateur — charger des ressources 3D pour les scènes XR
Ressources externes
- MDN : API WebXR Device — référence complète de l’API
- Immersive Web — exemples WebXR, outils et tableaux de compatibilité des navigateurs
- Guide WebXR de Three.js — créer du contenu VR avec Three.js