WebXR basics for VR games
WebXR brings VR and AR to the browser. Players can experience your game in a headset without downloading anything.
1) Check WebXR support
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) Starting a VR session
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) The XR render loop
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) Input sources (controllers)
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
}
}Don't assume every device has a gamepad
The code above reads source.gamepad for triggers and thumbsticks. That works on controller headsets like Meta Quest, but it does nothing on Apple Vision Pro (WebXR is on by default in visionOS 2 Safari) or Samsung Galaxy XR and other Android XR devices, where the primary input is gaze-and-pinch or hand tracking. On those headsets source.gamepad is null and session.inputSources stays empty until the user actually pinches.
For the one interaction every device guarantees, the "primary action," listen for the select events instead of polling buttons. WebXR delivers selectstart, select, and selectend for a controller trigger, a hand pinch, and a Vision Pro gaze-and-pinch alike, so a single handler covers all of them:
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) => {})Keep the gamepad code for thumbstick locomotion and extra buttons on controller headsets, but gate it behind if (source.gamepad) and route your core "select/grab/activate" interaction through these events so the game still works on hand- and gaze-driven devices.
5) Controller positions
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) Ray casting for pointing
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) Teleportation locomotion
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) Smooth locomotion
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) VR comfort guidelines
// 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) Complete 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
}
}Related
- WebGL fundamentals
- WebGPU getting started
- Game input handling
- Web Games Tech Stack in 2026 — where WebXR sits in the broader tech landscape
- Three.js + USDC in the Browser — loading 3D assets for XR scenes
External Resources
- MDN: WebXR Device API — full API reference
- Immersive Web — WebXR samples, tools, and browser support tables
- Three.js WebXR guide — VR content with Three.js