Fundamentos de WebXR para juegos de RV
WebXR lleva la RV y la RA al navegador. Los jugadores pueden disfrutar de tu juego con un visor sin descargar nada.
1) Comprobar la compatibilidad con 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 }
}
// Uso
const support = await checkXRSupport()
if (support.vr) {
showVRButton()
}2) Iniciar una sesión 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)
// Configurar el renderizado
const gl = canvas.getContext('webgl2', { xrCompatible: true })
await xrSession.updateRenderState({
baseLayer: new XRWebGLLayer(xrSession, gl),
})
// Obtener el espacio de referencia
xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
// Iniciar el bucle de renderizado
xrSession.requestAnimationFrame(onXRFrame)
} catch (err) {
console.error('Failed to start VR:', err)
}
}
function onSessionEnd() {
xrSession = null
xrRefSpace = null
}3) El bucle de renderizado de 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)
// Renderizar para cada ojo
for (const view of pose.views) {
const viewport = glLayer.getViewport(view)
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
// Obtener las matrices de la cámara
const viewMatrix = view.transform.inverse.matrix
const projectionMatrix = view.projectionMatrix
// Renderizar la escena con estas matrices
renderScene(viewMatrix, projectionMatrix)
}
}4) Fuentes de entrada (controladores)
xrSession.addEventListener('inputsourceschange', (e) => {
for (const source of e.added) {
console.log('Controller added:', source.handedness) // 'left' o '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
// Gatillo (normalmente, índice 0)
const triggerPressed = gamepad.buttons[0]?.pressed
const triggerValue = gamepad.buttons[0]?.value || 0
// Agarre (normalmente, índice 1)
const gripPressed = gamepad.buttons[1]?.pressed
// Joystick
const thumbstickX = gamepad.axes[2] || 0
const thumbstickY = gamepad.axes[3] || 0
// Botones A/B (índices 4 y 5)
const buttonA = gamepad.buttons[4]?.pressed
const buttonB = gamepad.buttons[5]?.pressed
}
}No des por sentado que todos los dispositivos tienen un gamepad
El código anterior lee source.gamepad para los gatillos y joysticks. Esto funciona en visores con controladores como Meta Quest, pero no hace nada en Apple Vision Pro (WebXR está activado de forma predeterminada en Safari de visionOS 2), Samsung Galaxy XR y otros dispositivos Android XR, donde la entrada principal se basa en la mirada y el gesto de pellizco o en el seguimiento de manos. En esos visores, source.gamepad es null y session.inputSources permanece vacío hasta que el usuario hace realmente el gesto de pellizco.
Para la única interacción que garantizan todos los dispositivos, la «acción principal», escucha los eventos select en lugar de consultar continuamente los botones. WebXR emite selectstart, select y selectend de la misma forma para el gatillo de un controlador, un pellizco con la mano y un gesto de mirada y pellizco de Vision Pro, por lo que un único controlador de eventos sirve para todos ellos:
xrSession.addEventListener('selectstart', (e) => {
// e.inputSource es el controlador, la mano o el pellizco transitorio
// e.frame permite leer la pose de su targetRaySpace
})
xrSession.addEventListener('select', (e) => {
// acción principal completada (gatillo presionado, pellizco soltado sobre el objetivo)
})
xrSession.addEventListener('selectend', (e) => {})Conserva el código del gamepad para el desplazamiento con joystick y los botones adicionales en visores con controladores, pero protégelo con if (source.gamepad) y canaliza la interacción principal de «seleccionar/agarrar/activar» a través de estos eventos para que el juego siga funcionando en dispositivos controlados mediante las manos y la mirada.
5) Posiciones de los controladores
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) Lanzamiento de rayos para apuntar
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) Desplazamiento 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) {
// Apuntar
this.isAiming = true
const ray = getControllerRay(frame, source)
this.targetPosition = this.findTeleportTarget(ray)
} else if (this.isAiming) {
// Soltar: teletransportarse
if (this.targetPosition) {
player.position.x = this.targetPosition.x
player.position.z = this.targetPosition.z
}
this.isAiming = false
this.targetPosition = null
}
}
}
findTeleportTarget(ray) {
// Intersecar con el plano del suelo
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) Desplazamiento fluido
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
// Aplicar la zona muerta
if (Math.abs(moveX) < 0.1) moveX = 0
if (Math.abs(moveY) < 0.1) moveY = 0
// Obtener la dirección de la cabeza para el movimiento
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] }
// Normalizar al plano 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) Pautas de comodidad para RV
// Viñeta durante el movimiento para reducir el mareo por movimiento
function renderComfortVignette(movementSpeed) {
const intensity = Math.min(movementSpeed / 5, 0.5)
if (intensity < 0.1) return
// Renderizar bordes oscuros que aumentan con la velocidad
renderVignette(intensity)
}
// Giro por intervalos
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 grados
player.rotation += angle
snapTurnCooldown = 0.3 // Evitar giros rápidos
}
}10) Configuración completa de 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
// Actualizar la lógica del juego
this.teleport.update(frame, this.session.inputSources)
// Renderizar
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) {
// Tu código de renderizado va aquí
}
onEnd() {
this.session = null
}
}Contenido relacionado
- Fundamentos de WebGL
- Primeros pasos con WebGPU
- Gestión de entradas en juegos
- Tecnologías para juegos web en 2026 — dónde encaja WebXR en el panorama tecnológico general
- Three.js + USDC en el navegador — carga de recursos 3D para escenas XR
Recursos externos
- MDN: API de dispositivos WebXR — referencia completa de la API
- Immersive Web — ejemplos, herramientas y tablas de compatibilidad con navegadores para WebXR
- Guía de WebXR de Three.js — contenido de RV con Three.js