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
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
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
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)
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:
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
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
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
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
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
// 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
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
- WebGL-Grundlagen
- WebGPU-Einstieg
- Eingaben in Spielen
- Tech-Stack für Web-Spiele 2026 — wo WebXR in der Werkzeuglandschaft sitzt
- Three.js und USDC im Browser — 3D-Assets für XR-Szenen laden
Externe Ressourcen
- MDN: WebXR Device API — vollständige API-Referenz
- Immersive Web — WebXR-Beispiele, Werkzeuge und Tabellen zur Browser-Unterstützung
- Three.js WebXR guide — VR-Inhalte mit Three.js