Controladores, modelos de mando y seguimiento de manos
Los tres espacios que Three expone por cada entrada, los eventos de selección, cómo cargar el modelo real del mando del usuario y cómo apuntar con un rayo que funcione en los dos casos.
Three.js expone cada dispositivo de entrada de XR como tres objetos distintos del grafo de escena, y elegir el equivocado produce un mando que aparece girado noventa grados o un rayo que sale del sitio incorrecto. No es un capricho de la API: responden a tres preguntas físicamente diferentes. Dónde apunta, dónde está la mano que lo sujeta, y dónde están los dedos. Entendida la distinción, todo lo demás (modelos, eventos, rayos) encaja sin sorpresas.
- Distinguir el espacio del rayo, el espacio del agarre y el espacio de la mano, y saber qué colgar de cada uno.
- Gestionar el ciclo de conexión y desconexión de un controlador sin dejar objetos huérfanos.
- Cargar el modelo real del mando del usuario con la factoría de los addons.
- Lanzar rayos desde un controlador con la API que ya lo hace bien.
Los tres espacios
const controlador = renderer.xr.getController(0); // espacio del rayo
const agarre = renderer.xr.getControllerGrip(0); // espacio del mando
const mano = renderer.xr.getHand(0); // articulaciones
El espacio del rayo apunta a donde el usuario señala. Su eje Z negativo es la dirección de apuntado, por convención igual que una cámara. Aquí es donde se cuelga la línea de puntero, el retículo y cualquier cosa que represente “hacia dónde miro”. En un móvil con AR, este espacio corresponde al centro de la pantalla, no a un mando físico.
El espacio del agarre está donde está la mano que sujeta el mando, con la orientación del objeto empuñado. Aquí se cuelga el modelo del mando y cualquier objeto que el usuario “sostenga”: una espada, una linterna, una herramienta. Colgar el modelo del mando del espacio del rayo es el error clásico y produce un mando inclinado, porque el rayo suele salir con un ángulo respecto al agarre.
El espacio de la mano solo tiene contenido cuando el dispositivo hace seguimiento de manos y la funcionalidad está concedida. Contiene un objeto por articulación, veinticinco por mano, cada uno con su pose y su radio.
Los tres objetos existen siempre, incluso antes de que haya un dispositivo conectado. Se pueden crear al inicializar la escena y añadir al grafo sin condiciones; permanecerán quietos en el origen hasta que llegue una entrada.
import * as THREE from 'three';
const jugador = new THREE.Group();
scene.add(jugador);
for (let i = 0; i < 2; i++) {
const controlador = renderer.xr.getController(i);
const agarre = renderer.xr.getControllerGrip(i);
const mano = renderer.xr.getHand(i);
// Todos van dentro del grupo del jugador para que el teleporte
// los mueva junto con la camara
jugador.add(controlador, agarre, mano);
}
Meterlos dentro del mismo grupo que la cámara es imprescindible. Si la cámara está en un grupo que se desplaza y los controladores cuelgan de la escena, al teleportarse las manos se quedan atrás.
Conexión, desconexión y el modelo del puntero
Un controlador puede conectarse y desconectarse en cualquier momento: el usuario suelta un mando, se agota la batería, cambia de mandos a manos. Los eventos connected y disconnected traen la información del dispositivo.
function prepararControlador(indice) {
const controlador = renderer.xr.getController(indice);
controlador.addEventListener('connected', (evento) => {
const datos = evento.data; // XRInputSource
controlador.userData.modoRayo = datos.targetRayMode;
controlador.userData.mano = datos.handedness; // 'left' | 'right' | 'none'
controlador.add(construirPuntero(datos.targetRayMode));
});
controlador.addEventListener('disconnected', () => {
const hijo = controlador.children[0];
if (hijo) {
controlador.remove(hijo);
hijo.geometry?.dispose();
hijo.material?.dispose();
}
controlador.userData.modoRayo = null;
});
return controlador;
}
targetRayMode tiene tres valores y cada uno pide un puntero distinto. tracked-pointer es un mando con seguimiento posicional: la representación correcta es una línea que sale del mando. gaze es un dispositivo que solo apunta con la mirada: la representación correcta es un retículo en el centro, porque una línea desde el ojo no se ve. screen es un toque en la pantalla de un móvil en AR: no hay que dibujar nada, porque el usuario ya está tocando el sitio.
import * as THREE from 'three';
function construirPuntero(modo) {
if (modo === 'tracked-pointer') {
const g = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -1),
]);
const m = new THREE.LineBasicMaterial({
vertexColors: false,
color: 0x88aaff,
transparent: true,
opacity: 0.7,
});
const linea = new THREE.Line(g, m);
linea.name = 'puntero';
linea.scale.z = 5;
return linea;
}
if (modo === 'gaze') {
const g = new THREE.RingGeometry(0.02, 0.04, 32).translate(0, 0, -1);
const m = new THREE.MeshBasicMaterial({ opacity: 0.5, transparent: true });
const anillo = new THREE.Mesh(g, m);
anillo.name = 'puntero';
return anillo;
}
return new THREE.Group(); // modo 'screen': nada que dibujar
}
La liberación en disconnected no es un adorno. Un usuario que conecta y desconecta un mando veinte veces durante una sesión larga acumula veinte geometrías y veinte materiales si no se limpian, con la agravante de que en un visor el presupuesto de memoria es mucho más ajustado que en escritorio.
Los eventos de selección
Tres pares de eventos cubren toda la entrada de botones:
| Evento | Se dispara con |
|---|---|
selectstart / selectend |
El gatillo principal, o un toque en pantalla en AR |
select |
Al soltar, si la selección fue válida |
squeezestart / squeezeend |
El botón de agarre lateral |
squeeze |
Al soltar el agarre |
select es el que corresponde a “clic”: se dispara una vez, al final. selectstart y selectend son los que sirven para arrastrar, para mantener pulsado, o para cualquier acción con duración.
controlador.addEventListener('selectstart', (evento) => {
const c = evento.target;
const impactos = intersecar(c);
if (impactos.length === 0) return;
const objeto = impactos[0].object;
// attach conserva la transformacion en el mundo al cambiar de padre
c.attach(objeto);
c.userData.agarrado = objeto;
});
controlador.addEventListener('selectend', (evento) => {
const c = evento.target;
if (!c.userData.agarrado) return;
grupoMundo.attach(c.userData.agarrado);
c.userData.agarrado = null;
});
attach frente a add es la clave de todo agarre. add cambia el padre y mantiene la transformación local, con lo que el objeto salta a la posición relativa al mando. attach recalcula la transformación local para que la posición en el mundo no cambie, que es lo que hace que el objeto se quede exactamente donde estaba al cogerlo.
Los botones que no son el gatillo ni el agarre (los de la cara, los joysticks) no tienen eventos: se leen por sondeo del gamepad asociado a la fuente de entrada.
function leerEjes(controlador) {
const fuente = controlador.userData.fuente;
const gp = fuente?.gamepad;
if (!gp) return { x: 0, y: 0 };
// El layout estandar de xr-standard: ejes 2 y 3 son el joystick principal
return { x: gp.axes[2] ?? 0, y: gp.axes[3] ?? 0 };
}
Guardar la XRInputSource en userData durante el evento connected es lo que hace posible esta lectura, porque Three no la expone de otra forma.
El modelo real del mando
Dibujar un mando genérico funciona, pero el usuario reconoce el suyo y eso ayuda muchísimo a la orientación espacial. La especificación de perfiles de entrada publica modelos glTF de casi todos los mandos del mercado, y los addons traen la factoría que los descarga y anima los botones.
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';
const fabricaMandos = new XRControllerModelFactory();
const fabricaManos = new XRHandModelFactory();
for (let i = 0; i < 2; i++) {
const agarre = renderer.xr.getControllerGrip(i);
agarre.add(fabricaMandos.createControllerModel(agarre));
jugador.add(agarre);
const mano = renderer.xr.getHand(i);
mano.add(fabricaManos.createHandModel(mano));
jugador.add(mano);
}
createControllerModel devuelve un objeto vacío que se rellena solo cuando el controlador se conecta y se identifica: la factoría consulta el perfil, descarga el glTF y lo monta. Por eso se llama antes de que haya nada conectado y funciona igualmente.
Merece la pena saber de dónde salen esos modelos: se descargan de un CDN público de perfiles de entrada. En una aplicación de producción sin conexión garantizada, o con políticas de seguridad de contenido estrictas, conviene servir los perfiles desde tu propio dominio. La factoría acepta la ruta base en el constructor.
XRHandModelFactory tiene un método setPath para indicar dónde están los modelos de mano, y su createHandModel admite un segundo argumento con el tipo de representación.
El modelo del mando descarga un glTF por CDN en el momento de la conexión, que ocurre justo cuando el usuario acaba de entrar en el visor. Si la conexión es lenta, el usuario pasa varios segundos sin manos visibles dentro de un entorno inmersivo, y eso desorienta bastante más de lo que parece desde fuera. La solución que usan las experiencias cuidadas es tener siempre una representación provisional (un par de esferas pequeñas, unos ejes) colgada del agarre desde el primer fotograma, y retirarla cuando el modelo real aparece. Nunca dejes las manos invisibles: en un entorno inmersivo, no ver las propias manos rompe la presencia de forma inmediata.
Rayos desde el controlador
El error habitual al lanzar un rayo desde un mando es construirlo a mano con la posición y la dirección extraídas de la matriz. Funciona, pero hay una API que ya lo hace y que además maneja bien los casos raros.
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
function intersecar(controlador) {
controlador.updateMatrixWorld();
raycaster.setFromXRController(controlador);
return raycaster.intersectObjects(interactivos, false);
}
setFromXRController toma la matriz del mundo del espacio del rayo y configura el origen y la dirección correctamente, incluyendo el signo del eje Z. La llamada a updateMatrixWorld antes es necesaria si consultas fuera del bucle de render, porque la matriz se actualiza al renderizar.
El resaltado de lo que está bajo el rayo tiene que hacerse con la misma guarda de cambio que en escritorio, y con un cuidado adicional: en modo screen (móvil en AR) no hay puntero continuo, así que resaltar no tiene sentido y confunde.
let resaltado = null;
function actualizarResaltado(controlador) {
if (controlador.userData.modoRayo === 'screen') return;
if (controlador.userData.agarrado) return;
const impactos = intersecar(controlador);
const actual = impactos.length > 0 ? impactos[0].object : null;
if (actual === resaltado) {
// Aunque no cambie el objeto, la longitud del rayo si cambia
const puntero = controlador.getObjectByName('puntero');
if (puntero && impactos.length > 0) puntero.scale.z = impactos[0].distance;
return;
}
if (resaltado) resaltado.material.emissive.setHex(resaltado.userData.hexBase);
resaltado = actual;
if (resaltado) {
resaltado.userData.hexBase = resaltado.material.emissive.getHex();
resaltado.material.emissive.setHex(0x224466);
}
}
Ajustar puntero.scale.z a la distancia del impacto es un detalle pequeño con un efecto enorme en la usabilidad: el rayo termina en el objeto en lugar de atravesarlo, y eso da al usuario una percepción de profundidad que un rayo de longitud fija no da.
Monta una escena con dos controladores, modelos reales de mando, punteros adaptados al targetRayMode y agarre con attach. Después desconecta un mando durante la sesión (apágalo o suéltalo hasta que entre en reposo) y comprueba que el puntero desaparece y que el objeto agarrado vuelve al mundo en lugar de quedarse colgado de un controlador sin dispositivo. Ese caso es el que separa una demo de una experiencia usable.