El modo XR de Three.js y las sesiones inmersivas
Qué cambia en el renderer al activar renderer.xr, quién programa los fotogramas dentro de una sesión, y en qué se diferencian de verdad una sesión de VR y una de AR.
Activar WebXR en Three.js son dos líneas, y esa facilidad esconde un cambio profundo: dentro de una sesión inmersiva ya no controlas la cámara, ni la resolución, ni el momento en que se dibuja un fotograma. El dispositivo toma esas decisiones y tú recibes, cada fotograma, la lista de vistas que hay que renderizar y las matrices que las describen. Entender ese traspaso de control es la diferencia entre una escena que funciona en el visor y una que aparece torcida, con la escala equivocada o dibujada una sola vez para los dos ojos.
- Describir qué asume el gestor de XR cuando
renderer.xr.enabledpasa atrue. - Explicar por qué
setAnimationLoopes obligatorio y de dónde vienen los fotogramas en sesión. - Detectar el soporte de cada modo antes de ofrecer un botón que no va a funcionar.
- Diferenciar una sesión de VR de una de AR en lo que cambia de verdad para tu código.
Las dos líneas y lo que implican
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
renderer.setAnimationLoop(frame);
document.body.appendChild(renderer.domElement);
function frame(tiempo, marcoXR) {
// marcoXR es undefined fuera de sesion, y un XRFrame dentro
renderer.render(scene, camera);
}
Con renderer.xr.enabled = true, el renderer deja de renderizar directamente al canvas cuando hay sesión activa y pasa a renderizar a la capa que el dispositivo le proporciona. La cámara que le pasas a render deja de usarse tal cual: Three construye internamente una ArrayCamera con una cámara por ojo, copia la posición y la orientación del visor a partir de la pose del fotograma, y renderiza la escena una vez por vista con el viewport correspondiente.
Ese es el detalle que hay que interiorizar: tu cámara se convierte en el punto de referencia, no en la cámara. Las matrices de proyección y de vista de cada ojo las fija el dispositivo, incluido el campo de visión, que es una propiedad física de las lentes y no algo que tú elijas. Cambiar camera.fov dentro de una sesión no hace absolutamente nada.
Lo que sí puedes hacer con tu cámara es moverla, y esa es la forma de desplazar al usuario por el mundo. La convención habitual es meter la cámara dentro de un Group que representa el suelo bajo los pies del jugador, y mover el grupo:
const jugador = new THREE.Group();
jugador.add(camera);
scene.add(jugador);
// Teleporte: mueve el grupo, no la camara
function teleportar(destino) {
jugador.position.copy(destino);
}
Mover la cámara directamente entra en conflicto con el seguimiento del visor, que la reescribe en cada fotograma. Mover el grupo padre compone con el seguimiento y funciona siempre. Es el patrón estándar y merece la pena adoptarlo desde el primer prototipo.
Dentro de una sesión, renderer.setSize no afecta al render inmersivo: el tamaño del buffer lo determina el dispositivo. Si tu manejador de resize llama a setSize mientras la sesión está activa, no rompes nada, pero tampoco consigues nada. Y camera.aspect tampoco importa: cada ojo tiene el suyo. Ambas cosas vuelven a importar al salir de la sesión, así que el manejador debe seguir ahí.
Quién programa los fotogramas
Fuera de sesión, los fotogramas los programa la ventana con requestAnimationFrame, a la frecuencia de la pantalla. Dentro de sesión, los programa el dispositivo XR a través de session.requestAnimationFrame, a la frecuencia del visor, que suele ser setenta y dos, noventa o ciento veinte hercios. Son dos fuentes distintas y no coexisten: mientras hay sesión, la ventana puede no emitir fotogramas en absoluto.
renderer.setAnimationLoop existe precisamente para abstraer ese cambio. Escucha los eventos de inicio y fin de sesión y conmuta la fuente. Un bucle escrito con requestAnimationFrame a mano deja de ejecutarse al entrar en el visor, y el síntoma es una pantalla negra dentro del casco mientras la vista de espejo del escritorio sigue funcionando.
El segundo argumento del callback es el XRFrame de ese fotograma, y es el objeto que da acceso a todo lo que cambia por fotograma: poses, resultados de hit test, estado de las articulaciones de las manos. Fuera de sesión vale undefined, así que sirve como comprobación idiomática.
function frame(tiempo, marcoXR) {
if (marcoXR) {
// Solo tiene sentido dentro de una sesion
const espacio = renderer.xr.getReferenceSpace();
const pose = marcoXR.getViewerPose(espacio);
if (pose) {
// pose.views tiene una entrada por ojo
}
}
actualizar();
renderer.render(scene, camera);
}
Three ya consume la pose del visor por su cuenta para colocar las cámaras. Acceder a ella manualmente solo hace falta para casos concretos, como sincronizar audio espacial o registrar la trayectoria de la cabeza.
Los eventos de sesión se escuchan en el gestor de XR, no en el renderer:
renderer.xr.addEventListener('sessionstart', () => {
// Ajustes solo para el visor: menos post-proceso, sombras mas baratas
composer.enabled = false;
renderer.shadowMap.autoUpdate = false;
});
renderer.xr.addEventListener('sessionend', () => {
composer.enabled = true;
renderer.shadowMap.autoUpdate = true;
});
Este par de manejadores es el sitio natural para el cambio de presupuesto que exige el visor, y del que trata el rendimiento en visores.
Detectar el soporte antes de prometer nada
Ofrecer un botón de “entrar en VR” en un dispositivo que no puede es una mala experiencia evitable. La API expone una consulta asíncrona por modo.
const MODOS = {
vr: 'immersive-vr',
ar: 'immersive-ar',
inline: 'inline',
};
async function soporta(modo) {
if (!('xr' in navigator)) return false;
try {
return await navigator.xr.isSessionSupported(MODOS[modo]);
} catch {
// Algunos navegadores lanzan en contextos no seguros o en iframes
// sin el permiso xr-spatial-tracking
return false;
}
}
const [hayVR, hayAR] = await Promise.all([soporta('vr'), soporta('ar')]);
El try no es paranoia. isSessionSupported rechaza en contextos no seguros y en iframes que no declaran el permiso correspondiente, y una promesa rechazada sin capturar rompe el arranque de la página entera. Además, 'xr' in navigator es false en todos los navegadores de escritorio sin extensión, que es la mayoría de tus visitas.
Hay dos requisitos de contexto que conviene tener claros. El primero: WebXR exige HTTPS, salvo en localhost. El segundo: entrar en una sesión inmersiva exige un gesto del usuario, un clic real. No se puede entrar automáticamente al cargar la página, y es una restricción deliberada.
isSessionSupported responde si el modo existe, no si va a funcionar bien. En un móvil Android con ARCore, immersive-ar responde true en muchísimos dispositivos cuyo rendimiento hace la experiencia inservible, porque la cámara y el seguimiento ya consumen una parte del presupuesto antes de que dibujes nada. Y en modo AR el navegador compone tu render sobre el vídeo de la cámara en cada fotograma, lo que añade un coste que no ves en ningún contador. La detección de capacidad tiene que ser doble: primero el modo, y después una medición de los primeros segundos con degradación automática, exactamente el mismo patrón que en detectar la capacidad del dispositivo.
VR y AR: qué cambia de verdad
Desde el punto de vista de la API son dos valores de un mismo parámetro, pero para tu escena las diferencias son sustanciales.
El fondo. En VR renderizas un mundo completo y scene.background se ve. En AR el fondo tiene que ser transparente para que se vea el mundo real: el renderer necesita alpha: true y la escena no debe tener fondo. Es el error número uno de las primeras pruebas de AR: una escena con cielo que tapa la realidad.
// AR: el canvas tiene que dejar pasar la camara del dispositivo
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
scene.background = null;
La escala. En VR puedes elegir la escala del mundo. En AR el mundo real impone la suya: un metro en tu escena es un metro en el salón del usuario. Modelos exportados en centímetros aparecen como rascacielos, y es el segundo error más frecuente.
La iluminación. En VR iluminas como quieras. En AR, si quieres que el objeto parezca estar ahí, tiene que responder a la luz real. La especificación tiene una funcionalidad de estimación de iluminación (light-estimation) que expone la intensidad y el color de la luz ambiental, y Three trae XREstimatedLight en los addons para consumirla.
El modo de composición. renderer.xr.getEnvironmentBlendMode() devuelve cómo el dispositivo mezcla tu render con el mundo: opaque en VR, additive en visores de guía de ondas donde el negro es transparente, y alpha-blend en visores de vídeo pasante. En un dispositivo aditivo, dibujar sombras oscuras no funciona: lo oscuro es invisible por definición. Consultarlo permite adaptar la paleta.
Los grados de libertad. No todos los dispositivos rastrean la posición, solo la orientación. Eso condiciona el espacio de referencia que puedes pedir, y de eso trata espacios de referencia y hit testing.
La consecuencia práctica es que una experiencia bien hecha para VR rara vez funciona tal cual en AR, y al revés. Compartir la escena es fácil; compartir el diseño no lo es. Lo que sí conviene compartir es toda la capa de abajo: la misma factoría, el mismo bucle, el mismo dispose.
Monta una escena mínima con un cubo a un metro de altura, actívale el modo XR y pruébala en los dos modos si tienes hardware, o con el emulador de WebXR de las herramientas de desarrollo si no lo tienes. Registra en consola el valor de renderer.xr.getEnvironmentBlendMode() y el número de vistas del XRViewerPose en cada modo. Después mete la cámara en un grupo y comprueba que mover el grupo desplaza el punto de vista sin pelear con el seguimiento de cabeza.