Los botones de entrada: VRButton, ARButton y XRButton
Qué hacen exactamente los tres botones de los addons, cómo se declaran las funcionalidades requeridas y opcionales de una sesión, y por qué una funcionalidad mal declarada rompe la entrada.
Los botones de entrada de los addons parecen un detalle cosmético y son en realidad el sitio donde se negocia el contrato de la sesión. Ahí se declara qué funcionalidades necesita tu experiencia, y esa declaración es vinculante: una funcionalidad pedida como requerida que el dispositivo no tenga hace que la sesión no arranque, y una que se te olvide declarar hace que la API correspondiente devuelva nada sin explicar por qué. La mitad de los “no me funciona el hit testing” del mundo son una lista de funcionalidades incompleta.
- Distinguir qué hace cada uno de los tres botones y cuál usar en cada caso.
- Declarar funcionalidades requeridas y opcionales con criterio y comprobar cuáles se concedieron.
- Explicar por qué la entrada necesita un gesto del usuario y qué implica para el diseño.
- Construir un botón propio cuando el de los addons no encaja con el diseño.
Los tres botones y el criterio para elegir
Los addons traen tres factorías, y todas comparten la misma forma: un método estático que recibe el renderer y devuelve un elemento del DOM ya listo para añadir a la página.
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { ARButton } from 'three/addons/webxr/ARButton.js';
import { XRButton } from 'three/addons/webxr/XRButton.js';
// Solo VR
document.body.appendChild(VRButton.createButton(renderer));
// Solo AR, con una funcionalidad requerida
document.body.appendChild(
ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] }),
);
// El que sirve para los dos: prueba AR y cae a VR
document.body.appendChild(XRButton.createButton(renderer));
VRButton pide immersive-vr. ARButton pide immersive-ar y hace dos cosas más por su cuenta que conviene conocer: fija el tipo de espacio de referencia a local antes de arrancar la sesión, y crea un elemento de superposición para el botón de salida si no le das uno, añadiendo dom-overlay a las funcionalidades opcionales. XRButton decide entre los dos modos según lo que soporte el dispositivo, y es la elección razonable cuando la experiencia funciona en ambos.
Los tres devuelven un elemento distinto si no hay soporte: un botón deshabilitado con el texto correspondiente, o un enlace a información sobre WebXR si el navegador no tiene navigator.xr en absoluto. Y comprueban el contexto seguro: en HTTP, el enlace apunta a la versión HTTPS de la misma URL.
Los tres también aprovechan navigator.xr.offerSession cuando existe, que permite al navegador ofrecer la entrada a la sesión desde su propia interfaz sin que el usuario tenga que encontrar tu botón. Es una mejora silenciosa que llega gratis por usar los botones de los addons.
Funcionalidades requeridas y opcionales
El segundo argumento es el descriptor de inicialización de la sesión, y es donde vive el contrato.
document.body.appendChild(
ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay', 'light-estimation', 'anchors'],
domOverlay: { root: document.querySelector('#interfaz') },
}),
);
requiredFeatures es una lista de funcionalidades sin las cuales la experiencia no tiene sentido. Si el dispositivo no soporta alguna, la petición de sesión falla y el usuario ve un error en lugar de entrar. Es lo correcto cuando la funcionalidad es estructural: una aplicación de colocar muebles sin hit-test no es una aplicación degradada, es una aplicación rota.
optionalFeatures es la lista de lo que mejora la experiencia pero no la define. El dispositivo concede lo que puede e ignora el resto, y la sesión arranca igual.
La consecuencia práctica es que después de entrar hay que comprobar qué se concedió, y esa comprobación se salta casi todo el mundo.
renderer.xr.addEventListener('sessionstart', () => {
const sesion = renderer.xr.getSession();
const concedidas = sesion.enabledFeatures ?? [];
const hayLuz = concedidas.includes('light-estimation');
const hayAnclas = concedidas.includes('anchors');
if (!hayLuz) {
// Usa una iluminacion fija razonable en lugar de la estimada
luzAmbiente.intensity = 1.2;
}
});
session.enabledFeatures es un array con las funcionalidades realmente activas. No está en todos los navegadores, de ahí el valor por defecto. Consultarlo antes de usar cualquier API opcional evita el patrón de “esto funciona en mi visor y no en el tuyo”.
| Funcionalidad | Para qué sirve | Suele ser |
|---|---|---|
local-floor |
Espacio de referencia con el suelo a altura cero | Requerida en VR de pie |
bounded-floor |
Añade el polígono del área segura | Opcional |
hit-test |
Rayos contra la geometría detectada del mundo | Requerida en AR de colocación |
dom-overlay |
Superponer HTML sobre la vista de AR | Opcional |
light-estimation |
Intensidad y color de la luz real | Opcional |
anchors |
Puntos fijos que el sistema mantiene estables | Opcional |
plane-detection |
Planos detectados con su polígono | Opcional |
depth-sensing |
Mapa de profundidad del entorno | Opcional |
hand-tracking |
Articulaciones de las manos | Opcional |
unbounded |
Espacio sin límites para grandes recorridos | Requerida si aplica |
Declarar de más en requiredFeatures es la causa más común de un botón que no entra en dispositivos perfectamente capaces. hand-tracking como requerida excluye todos los mandos; depth-sensing como requerida excluye la mayoría de los visores. La regla es simple: si tu experiencia puede hacer algo razonable sin ella, va en optionalFeatures y se comprueba después.
El gesto del usuario y lo que implica
navigator.xr.requestSession solo se puede llamar durante la activación de un gesto del usuario: un clic, un toque, una pulsación de tecla. No vale un setTimeout, no vale el load de la página, y no vale una promesa que se resuelve después del clic si en medio ha pasado demasiado tiempo.
Esa última parte es la trampa. Un patrón aparentemente razonable como “al pulsar, carga el modelo y luego entra en la sesión” pierde la activación mientras el modelo descarga, y la petición falla con un error de seguridad.
// MAL: la activacion del gesto se pierde durante el await
boton.addEventListener('click', async () => {
await cargarModelo(); // puede tardar segundos
const s = await navigator.xr.requestSession('immersive-vr'); // falla
});
// BIEN: pide la sesion primero y carga dentro
boton.addEventListener('click', async () => {
const s = await navigator.xr.requestSession('immersive-vr');
await renderer.xr.setSession(s);
cargarModelo(); // ya dentro, con una escena de espera visible
});
El diseño que se deriva de esto: carga todo antes de mostrar el botón. La secuencia correcta es pantalla de carga, luego botón habilitado, luego entrada instantánea. Un botón que aparece al principio y tarda cinco segundos en meterte es peor experiencia y además es frágil.
Si de verdad necesitas cargar dentro de la sesión, entra primero a una escena mínima que ya esté lista (una sala vacía, un fondo neutro con un indicador) y carga el resto en segundo plano. Dejar al usuario dentro de un visor mirando negro mientras algo descarga es la forma más rápida de que se lo quite.
Un botón propio
Los botones de los addons tienen estilos en línea y un aspecto muy reconocible que rara vez encaja con un diseño. Escribir el propio es directo y no se pierde nada, porque toda la lógica útil son diez líneas.
import * as THREE from 'three';
/**
* Crea un boton de entrada a XR con estilo propio.
* @param {THREE.WebGLRenderer} renderer
* @param {'immersive-vr'|'immersive-ar'} modo
* @param {XRSessionInit} init
* @param {HTMLButtonElement} boton elemento ya estilado por ti
*/
export async function conectarBoton(renderer, modo, init, boton) {
if (!('xr' in navigator)) {
boton.disabled = true;
boton.textContent = 'Este navegador no soporta WebXR';
return;
}
let soportado = false;
try {
soportado = await navigator.xr.isSessionSupported(modo);
} catch {
soportado = false;
}
if (!soportado) {
boton.disabled = true;
boton.textContent = 'Tu dispositivo no soporta este modo';
return;
}
let sesionActual = null;
boton.disabled = false;
boton.textContent = 'Entrar';
async function alTerminar() {
sesionActual?.removeEventListener('end', alTerminar);
sesionActual = null;
boton.textContent = 'Entrar';
}
boton.addEventListener('click', async () => {
if (sesionActual) {
sesionActual.end();
return;
}
try {
const sesion = await navigator.xr.requestSession(modo, init);
sesion.addEventListener('end', alTerminar);
if (modo === 'immersive-ar') renderer.xr.setReferenceSpaceType('local');
await renderer.xr.setSession(sesion);
sesionActual = sesion;
boton.textContent = 'Salir';
} catch (e) {
boton.disabled = true;
boton.textContent = 'No se pudo iniciar la sesion';
console.warn(e);
}
});
}
El orden de las tres líneas centrales importa. setReferenceSpaceType tiene que llamarse antes de setSession, porque el gestor solicita el espacio de referencia al recibir la sesión y después ya es tarde. Y setSession devuelve una promesa que hay que esperar: es asíncrona porque el gestor tiene que crear la capa de render y configurar el estado del render de la sesión.
Un detalle que solo se aprende sufriéndolo: al salir de una sesión, el bucle vuelve a la fuente de la ventana pero el tamaño del canvas puede haber quedado con los valores del visor, y la escena aparece deformada o del tamaño equivocado en el escritorio. Three no reajusta automáticamente porque no sabe cuál era tu tamaño. La solución es llamar a tu propia función de ajuste desde el manejador de sessionend, la misma que usa el ResizeObserver. Es una línea y evita el bug más visible de todo el ciclo de entrada y salida, que además solo se ve al probar el flujo completo y no al probar solo la entrada.
La superposición de HTML en AR
dom-overlay merece un apartado porque es la única forma razonable de tener interfaz en AR sobre móvil. Con esa funcionalidad concedida, el elemento que declares como raíz se dibuja encima de la vista de AR, con eventos de puntero funcionando con normalidad.
const interfaz = document.querySelector('#interfaz-ar');
document.body.appendChild(
ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay'],
domOverlay: { root: interfaz },
}),
);
Dos avisos. El primero: solo puede haber un elemento raíz, y el navegador lo muestra a pantalla completa; la maquetación interna es cosa tuya. El segundo: en modo AR con superposición, un toque en la pantalla que caiga sobre un elemento de la interfaz no genera un evento select de XR, y un toque que caiga sobre el fondo sí. Eso es exactamente lo que quieres, pero implica que un elemento invisible que ocupe toda la pantalla se come todos los toques y deja la escena inerte. Es un fallo real y frecuente, y se diagnostica mirando el pointer-events de los contenedores de la superposición.
Monta una experiencia de AR con hit-test como requerida y light-estimation y anchors como opcionales. Al iniciar la sesión, vuelca session.enabledFeatures en la superposición de HTML. Pruébalo en dos dispositivos distintos si puedes, o compáralo con el emulador: la lista casi nunca coincide con lo que pediste, y ese hueco entre lo pedido y lo concedido es exactamente el que tu código tiene que cubrir.