Noções básicas de WebXR para jogos de RV
O WebXR leva RV e RA ao navegador. Os jogadores podem aproveitar seu jogo em um headset sem precisar baixar nada.
1) Verifique o suporte a 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 }
}
// Usage
const support = await checkXRSupport()
if (support.vr) {
showVRButton()
}2) Iniciando uma sessão de RV
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) O loop de renderização 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)
// 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) Fontes de entrada (controles)
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
}
}Não presuma que todo dispositivo tenha um gamepad
O código acima lê source.gamepad para gatilhos e direcionais analógicos. Isso funciona em headsets com controles, como o Meta Quest, mas não faz nada no Apple Vision Pro (o WebXR vem ativado por padrão no Safari do visionOS 2), no Samsung Galaxy XR e em outros dispositivos Android XR, nos quais a principal forma de entrada é olhar e pinçar ou o rastreamento das mãos. Nesses headsets, source.gamepad é nulo, e session.inputSources permanece vazio até o usuário realmente fazer o gesto de pinça.
Para a única interação garantida por todos os dispositivos, a “ação principal”, escute os eventos select em vez de verificar continuamente os botões. O WebXR fornece selectstart, select e selectend tanto para o gatilho de um controle quanto para uma pinça com a mão ou o gesto de olhar e pinçar do Vision Pro. Assim, um único manipulador atende a todos eles:
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) => {})Mantenha o código do gamepad para locomoção com o direcional analógico e botões extras em headsets com controles, mas condicione sua execução a if (source.gamepad) e encaminhe a interação principal de “selecionar/agarrar/ativar” por esses eventos. Assim, o jogo continuará funcionando em dispositivos controlados pelas mãos e pelo olhar.
5) Posições dos controles
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 para apontar
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) Locomoção por teletransporte
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) Locomoção suave
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) Diretrizes de conforto em RV
// 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) Configuração completa do WebXR
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
}
}Conteúdo relacionado
- Fundamentos de WebGL
- Primeiros passos com WebGPU
- Gerenciamento de entrada em jogos
- Stack tecnológico para jogos web em 2026 — onde o WebXR se encaixa no panorama tecnológico mais amplo
- Three.js + USDC no navegador — como carregar recursos 3D para cenas de XR
Recursos externos
- MDN: API WebXR Device — referência completa da API
- Immersive Web — exemplos de WebXR, ferramentas e tabelas de compatibilidade com navegadores
- Guia de WebXR do Three.js — conteúdo de RV com Three.js