Skip to content

WebXR-Grundlagen für VR-Spiele

WebXR bringt VR und AR in den Browser. Spieler erleben dein Spiel im Headset, ohne etwas herunterzuladen.

1) WebXR-Unterstützung prüfen

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 }
}

// Usage
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}

2) Eine VR-Sitzung starten

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)
    
    // Set up rendering
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Get reference space
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Start render loop
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}

3) Die XR-Render-Schleife

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)
  
  // Render for each eye
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Get camera matrices
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Render scene with these matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}

4) Eingabequellen (Controller)

js
xrSession.addEventListener('inputsourceschange', (e) => {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' or '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
    
    // Trigger (usually index 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Grip (usually index 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Thumbstick
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // A/B buttons (index 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}

Nimm nicht an, dass jedes Gerät ein Gamepad hat

Der Code oben liest source.gamepad für Trigger und Thumbsticks. Auf Controller-Headsets wie Meta Quest funktioniert das, auf Apple Vision Pro (WebXR ist in Safari unter visionOS 2 standardmäßig aktiv) oder Samsung Galaxy XR und anderen Android-XR-Geräten passiert dagegen nichts, weil dort Blick und Pinch oder Handtracking die primäre Eingabe sind. Auf diesen Headsets ist source.gamepad null und session.inputSources bleibt leer, bis der Nutzer tatsächlich pincht.

Für die eine Interaktion, die jedes Gerät garantiert, die "primäre Aktion", lauschst du statt auf Tastenabfragen auf die select-Events. WebXR liefert selectstart, select und selectend gleichermaßen für einen Controller-Trigger, einen Hand-Pinch und einen Vision-Pro-Blick-Pinch, ein einziger Handler deckt also alles ab:

js
xrSession.addEventListener('selectstart', (e) => {
  // e.inputSource is the controller, hand, or transient pinch
  // e.frame lets you read its targetRaySpace pose
})
xrSession.addEventListener('select', (e) => {
  // primary action completed (trigger pulled, pinch released on target)
})
xrSession.addEventListener('selectend', (e) => {})

Behalte den Gamepad-Code für Thumbstick-Fortbewegung und Zusatztasten auf Controller-Headsets, sichere ihn aber mit if (source.gamepad) ab und leite deine zentrale Interaktion "Auswählen, Greifen, Auslösen" über diese Events, damit das Spiel auch auf hand- und blickgesteuerten Geräten funktioniert.

5) Controller-Positionen

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) Raycasting fürs Zeigen

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) Fortbewegung per Teleport

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) {
        // Aiming
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Release - teleport
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Intersect with ground plane
    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) Flüssige Fortbewegung

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
    
    // Apply deadzone
    if (Math.abs(moveX) < 0.1) moveX = 0
    if (Math.abs(moveY) < 0.1) moveY = 0
    
    // Get head direction for movement
    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] }
    
    // Normalize to XZ plane
    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) Komfortregeln für VR

js
// Vignette during movement to reduce motion sickness
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity < 0.1) return
  
  // Render dark edges that increase with speed
  renderVignette(intensity)
}

// Snap turning
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 degrees
    player.rotation += angle
    snapTurnCooldown = 0.3 // Prevent rapid turns
  }
}

10) Vollständiges WebXR-Setup

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
    
    // Update game logic
    this.teleport.update(frame, this.session.inputSources)
    
    // Render
    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) {
    // Your rendering code here
  }
  
  onEnd() {
    this.session = null
  }
}

Verwandt

Externe Ressourcen