Skip to content

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

js
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

js
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

js
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)

js
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 :

js
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

js
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

js
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

js
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

js
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

js
// 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

js
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

Ressources externes