wandres.dev
WEBXR · Realidad virtual y aumentada

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.

⏱ 20 min

En una escena normal el origen de coordenadas es un punto arbitrario que tú decides. En XR el origen es una afirmación sobre el mundo físico: dónde está el suelo, dónde estaba el usuario al empezar, y qué punto se considera fijo mientras el sistema de seguimiento corrige su propia deriva. Los tipos de espacio de referencia son las distintas respuestas a esa pregunta, y elegir mal produce experiencias donde el objeto está enterrado en el suelo, flotando a la altura de la cabeza, o desplazándose lentamente durante la sesión.

🎯 Al terminar esta lección sabrás
  • Describir los cinco tipos de espacio de referencia y qué garantiza cada uno.
  • Elegir el espacio adecuado según si el usuario está sentado, de pie o recorriendo un área.
  • Implementar el ciclo completo de hit test en AR, desde la petición hasta la pose.
  • Entender por qué el origen se recoloca y qué hacer para que no se note.

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 despues
renderer.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
⚠️
Cuidado

Pedir un espacio que el dispositivo no soporta hace que la petición de sesión falle si estaba en requiredFeatures. local y viewer están garantizados por la especificación; local-floor está en casi todo pero no en todo; bounded-floor y unbounded son minoritarios. Si tu experiencia necesita el suelo, pide local-floor como requerida y asume que excluyes algunos dispositivos, o pide local y ofrece un ajuste manual de altura.

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 nosotros
reticula.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 centro

function 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.

Nivel dios

La pose de un hit test no es estable: el sistema refina su reconstrucción del entorno fotograma a fotograma, y un objeto colocado en una pose y dejado ahí va a desplazarse unos centímetros durante los primeros segundos. Para colocación definitiva existen los anclajes: hitTestResult.createAnchor() devuelve un objeto que el sistema se compromete a mantener en el mismo punto físico, corrigiendo su transformación a medida que mejora el mapa. Cuesta una funcionalidad opcional más y un poco de código para actualizar la transformación cada fotograma desde frame.trackedAnchors, y es la diferencia entre un mueble que se queda donde lo pusiste y uno que va derivando. Casi ningún tutorial lo menciona porque en una demo de treinta segundos la deriva no se ve.

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.

⚔️ Reto práctico

Implementa el ciclo completo de hit test con retícula y colocación. Después coloca cinco objetos seguidos y déjalos quietos treinta segundos mientras mueves el dispositivo por la habitación. Anota cuánto se han desplazado respecto a los puntos físicos donde los pusiste. Repite creando un anclaje con createAnchor en cada colocación y actualizando la transformación desde frame.trackedAnchors, y compara: la diferencia es la razón por la que existen los anclajes.