Espacios de referencia y hit testing en AR
Qué significa cada tipo de espacio de referencia, por qué el origen se mueve, y el ciclo completo de un hit test contra la geometría real del entorno.
Los cinco espacios
Un espacio de referencia define un sistema de coordenadas y una promesa sobre su estabilidad. La API los pide por nombre y el dispositivo concede lo que puede.
viewer tiene su origen en la cabeza del usuario y se mueve con ella. No sirve para colocar nada en el mundo, pero es imprescindible para una cosa: lanzar rayos desde el punto de vista, que es exactamente lo que necesita el hit test.
local fija el origen donde estaba el usuario al empezar la sesión, con la orientación que tenía. No garantiza que el suelo esté a altura cero: la altura cero es la altura a la que estaba el visor. Es el espacio que ARButton establece por defecto.
local-floor es como local pero con el plano Y igual a cero coincidiendo con el suelo real, medido por el sistema o configurado por el usuario. Es el que quieres en cualquier experiencia de pie, porque permite colocar objetos a alturas que tienen sentido físico.
bounded-floor añade a lo anterior el polígono del área segura configurada por el usuario, accesible en referenceSpace.boundsGeometry. Sirve para saber hasta dónde puede caminar sin chocar con una pared, y para dibujar el límite dentro de la experiencia.
unbounded está pensado para recorridos largos donde el sistema puede reajustar el origen: el seguimiento prioriza la estabilidad local sobre la coherencia global. Se usa poco y solo lo soportan algunos dispositivos.
// Antes de setSession, nunca despuesrenderer.xr.setReferenceSpaceType('local-floor');La llamada tiene que ocurrir antes de que el gestor reciba la sesión, porque es en ese momento cuando solicita el espacio. Después no tiene efecto, y el síntoma es una escena que ignora el ajuste sin dar ningún error.
| Situación | Espacio | Por qué |
|---|---|---|
| Experiencia sentada, sin caminar | local |
El origen en la cabeza es lo natural |
| De pie, objetos en el suelo | local-floor |
Altura cero es el suelo de verdad |
| Área delimitada, el usuario camina | bounded-floor |
Además conoce los límites |
| AR de colocación sobre superficies | local con hit test |
El hit test da la altura real |
| Recorrido largo por un edificio | unbounded |
Tolera el reajuste del origen |
El origen que se mueve
Hay un detalle que sorprende la primera vez: el sistema de seguimiento recoloca el origen cuando gana información. Un visor que arranca con seguimiento inercial y luego reconoce el entorno con las cámaras corrige su estimación, y esa corrección se manifiesta como un salto del origen. En AR sobre móvil ocurre constantemente durante los primeros segundos.
La especificación expone el evento reset en el espacio de referencia:
renderer.xr.addEventListener('sessionstart', () => { const espacio = renderer.xr.getReferenceSpace(); espacio.addEventListener('reset', () => { // Todo lo colocado en coordenadas del mundo ha quedado desplazado. // Lo que este anclado a superficies detectadas sigue bien. recolocarInterfazFlotante(); });});La consecuencia de diseño es importante: no coloques nada importante en coordenadas absolutas y lo dejes ahí. Un panel de interfaz colocado a dos metros del origen al empezar puede acabar dentro de una pared. Las tres estrategias que funcionan son colocar relativo al usuario y recolocar bajo demanda, anclar a geometría detectada con anchors, o recolocar en el evento reset.
Para la interfaz flotante, el patrón más usado es el de “seguir con retraso”: el panel se coloca delante del usuario, pero solo se mueve cuando el ángulo entre su posición y la mirada supera un umbral, y entonces se desplaza con suavidad.
import * as THREE from 'three';const objetivo = new THREE.Vector3();const direccion = new THREE.Vector3();function seguirConRetraso(panel, camaraXR, dt) { camaraXR.getWorldDirection(direccion); objetivo.copy(camaraXR.position).addScaledVector(direccion, 1.5); objetivo.y = camaraXR.position.y - 0.2; const desviacion = panel.position.distanceTo(objetivo); if (desviacion > 0.6) panel.userData.siguiendo = true; if (desviacion < 0.05) panel.userData.siguiendo = false; if (panel.userData.siguiendo) { // Amortiguado independiente del framerate const k = 1 - Math.exp(-4 * dt); panel.position.lerp(objetivo, k); panel.lookAt(camaraXR.position); }}La cámara que hay que usar aquí es la que Three construye para XR, accesible con renderer.xr.getCamera(). La cámara que tú creaste tiene la posición del grupo, no la de la cabeza.
El factor 1 - Math.exp(-k * dt) es la forma correcta de amortiguar sin depender del framerate. Un lerp con factor fijo se comporta distinto a setenta y dos hercios que a ciento veinte, y en un visor esa diferencia es perceptible.
Hit testing: el ciclo completo
El hit test lanza un rayo contra la geometría que el sistema ha reconstruido del entorno real (suelos, mesas, paredes) y devuelve la pose de los puntos de impacto. Es la base de cualquier aplicación de colocación en AR.
El ciclo tiene tres fases y hay que ejecutarlas en orden, una sola vez por sesión.
import * as THREE from 'three';import { ARButton } from 'three/addons/webxr/ARButton.js';let fuenteHitTest = null;let hitTestPedido = false;const reticula = new THREE.Mesh( new THREE.RingGeometry(0.08, 0.1, 32).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x88ccff }),);reticula.matrixAutoUpdate = false; // la matriz la escribimos nosotrosreticula.visible = false;scene.add(reticula);document.body.appendChild( ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] }),);function frame(tiempo, marco) { if (marco) { const espacio = renderer.xr.getReferenceSpace(); const sesion = renderer.xr.getSession(); // Fase 1: pedir la fuente, una sola vez por sesion if (!hitTestPedido) { hitTestPedido = true; sesion.requestReferenceSpace('viewer').then((espacioVisor) => { sesion.requestHitTestSource({ space: espacioVisor }).then((fuente) => { fuenteHitTest = fuente; }); }); sesion.addEventListener('end', () => { hitTestPedido = false; fuenteHitTest = null; }); } // Fase 2: consultar los resultados de este fotograma if (fuenteHitTest) { const resultados = marco.getHitTestResults(fuenteHitTest); if (resultados.length > 0) { // Fase 3: convertir la pose a una matriz del grafo de escena const pose = resultados[0].getPose(espacio); reticula.visible = true; reticula.matrix.fromArray(pose.transform.matrix); } else { reticula.visible = false; } } } renderer.render(scene, camera);}renderer.setAnimationLoop(frame);Cuatro cosas que este código hace bien y que conviene copiar.
requestReferenceSpace('viewer') obtiene el espacio de la cabeza, que es desde donde se lanza el rayo. El resultado se pasa como space a requestHitTestSource. Confundir este espacio con el espacio del mundo es el error que hace que el hit test devuelva siempre cero resultados.
La petición ocurre una vez y detrás de una bandera. requestHitTestSource es asíncrona y cara; llamarla cada fotograma satura el sistema y agota la memoria.
matrixAutoUpdate = false en la retícula es imprescindible. La pose viene como una matriz completa que hay que escribir directamente, y si Three recalcula la matriz a partir de posición, rotación y escala en el siguiente fotograma, la sobrescribe y la retícula vuelve al origen.
Y la bandera se resetea al terminar la sesión, porque la fuente de hit test pertenece a esa sesión y no vale para la siguiente.
Colocar un objeto en el punto es entonces trivial:
const geometria = new THREE.CylinderGeometry(0.05, 0.05, 0.2, 32) .translate(0, 0.1, 0); // el pivote en la base, no en el centrofunction alSeleccionar() { if (!reticula.visible) return; const objeto = new THREE.Mesh( geometria, new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff }), ); // decompose extrae posicion, rotacion y escala de la matriz de la retícula reticula.matrix.decompose(objeto.position, objeto.quaternion, objeto.scale); scene.add(objeto);}renderer.xr.getController(0).addEventListener('select', alSeleccionar);El translate(0, 0.1, 0) sobre la geometría desplaza el pivote a la base del cilindro. Sin eso, el objeto aparece medio enterrado, porque la pose del hit test está en la superficie y el pivote por defecto de un cilindro está en su centro. Es el ajuste más olvidado de todo AR.
Detección de planos como alternativa
Cuando lo que necesitas no es “dónde impacta este rayo” sino “dónde están las superficies”, la funcionalidad es plane-detection. El sistema expone los planos que ha reconocido, cada uno con su polígono, y Three emite eventos en el gestor de XR cuando aparecen, cambian o desaparecen.
renderer.xr.addEventListener('planesdetected', (evento) => { const planos = evento.data; // XRPlaneSet for (const plano of planos) { // plano.polygon: puntos del contorno en el espacio del plano // plano.orientation: 'horizontal' o 'vertical' // plano.planeSpace: el espacio para obtener su pose }});La diferencia de uso es clara. Hit test responde a “el usuario apunta aquí, dame el punto”; detección de planos responde a “dame el mapa de superficies para que yo decida”. La primera es interactiva y barata; la segunda permite cosas que la primera no, como que un objeto virtual quede oculto detrás de una mesa real o que una pelota rebote contra el suelo verdadero.
Las dos se pueden usar juntas, y de hecho la combinación es lo que hace que una experiencia de AR parezca convincente: hit test para colocar, planos para la física y la oclusión.