Cambiar de espacio en Three.js sin equivocarse
La API real para convertir entre espacios, la conversión a píxeles del DOM, y la trampa de proyectar un punto que está detrás de la cámara.
La teoría de los cinco espacios se convierte en código a través de una docena de métodos, y casi todos los errores prácticos vienen de tres cosas: no actualizar las matrices antes de leerlas, olvidar que el eje vertical se invierte al llegar a píxeles, y proyectar puntos que están detrás de la cámara. Las tres tienen síntomas raros y ninguna produce un error en consola.
- Convertir posiciones y direcciones entre espacio local y espacio de mundo con la API de
Object3D. - Proyectar un punto de la escena a coordenadas de píxel del DOM.
- Detectar y descartar los puntos que quedan detrás de la cámara antes de proyectar.
- Convertir la posición del ratón a coordenadas normalizadas correctamente.
De local a mundo y de vuelta
Object3D expone los métodos que cubren el noventa por ciento de los casos, y conviene conocer la diferencia entre los que devuelven un valor y los que escriben en un objetivo.
Los que escriben en un objetivo son getWorldPosition, getWorldQuaternion, getWorldScale y getWorldDirection. Reciben un objeto donde depositar el resultado, precisamente para no reservar memoria en cada llamada. Los cuatro descomponen la matriz de mundo del objeto, con la particularidad de que getWorldDirection devuelve la dirección del eje Z positivo del objeto en el mundo; para una cámara, que mira hacia su Z negativo, el método ya tiene en cuenta la convención y devuelve la dirección de vista.
Los que transforman un vector que les pasas son localToWorld y worldToLocal. Modifican el vector recibido, así que si el original te sirve, clónalo antes. Ambos tratan el vector como un punto: aplican la traslación. Para transformar una dirección hay que usar otra vía, que es aplicar solo la parte rotacional.
import * as THREE from 'three';
const scene = new THREE.Scene();
const brazo = new THREE.Group();
brazo.position.set(3, 1, 0);
brazo.rotation.y = Math.PI / 2;
scene.add(brazo);
const mano = new THREE.Object3D();
mano.position.set(0, 0, 2); // dos unidades por delante del brazo
brazo.add(mano);
scene.updateMatrixWorld(true);
// Posicion global de la mano, sin componer matrices a mano.
const posicionGlobal = new THREE.Vector3();
mano.getWorldPosition(posicionGlobal);
console.log('mano en el mundo:', posicionGlobal.toArray()); // (5, 1, 0)
// Un punto local de la mano llevado al mundo.
const puntoLocal = new THREE.Vector3(0, 0.5, 0);
const puntoGlobal = mano.localToWorld(puntoLocal.clone());
console.log('punto en el mundo:', puntoGlobal.toArray());
// Y el camino inverso: donde cae un punto del mundo dentro de la mano.
const enLocal = mano.worldToLocal(new THREE.Vector3(5, 1, 0));
console.log('el origen de la mano en su propio espacio:', enLocal.toArray());
// Una direccion, no un punto: no debe recibir la traslacion.
const direccionLocal = new THREE.Vector3(0, 0, 1);
const direccionGlobal = direccionLocal
.clone()
.transformDirection(mano.matrixWorld);
console.log('hacia donde apunta la mano:', direccionGlobal.toArray());
transformDirection aplica la submatriz de rotación y normaliza el resultado, que es exactamente lo que necesita una dirección. Usar applyMatrix4 con una dirección es el error equivalente a poner un uno donde iba un cero en la cuarta componente, y el síntoma es que la dirección se desplaza cuando el objeto se mueve.
De la escena a un píxel del DOM
Superponer una etiqueta de HTML sobre un objeto 3D es un requisito tan frecuente que conviene resolverlo bien de una vez. El camino es: posición de mundo, proyección a coordenadas normalizadas, conversión a píxeles.
Vector3.project(camera) hace de golpe los pasos de mundo a normalizado: multiplica por la matriz de vista, por la de proyección y divide por la cuarta componente. unproject hace el camino contrario. Después, la conversión a píxeles es una interpolación con la inversión del eje vertical.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11111b);
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 1.5, 6);
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
const luz = new THREE.DirectionalLight(0xffffff, 2.5);
luz.position.set(3, 4, 2);
scene.add(luz);
const objetivo = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.6, 1),
new THREE.MeshStandardMaterial({ color: 0xa6e3a1, flatShading: true })
);
scene.add(objetivo);
const etiqueta = document.createElement('div');
etiqueta.textContent = 'objetivo';
etiqueta.style.cssText =
'position:fixed;padding:4px 8px;background:#181825;color:#cdd6f4;' +
'border-radius:6px;font:14px system-ui;pointer-events:none;transform:translate(-50%,-50%)';
document.body.appendChild(etiqueta);
const _mundo = new THREE.Vector3();
const _vista = new THREE.Vector3();
function colocarEtiqueta() {
objetivo.getWorldPosition(_mundo);
// Comprobacion imprescindible: en espacio de vista, lo visible tiene Z negativa.
_vista.copy(_mundo).applyMatrix4(camera.matrixWorldInverse);
if (_vista.z >= 0) {
etiqueta.style.display = 'none';
return;
}
etiqueta.style.display = 'block';
const ndc = _mundo.clone().project(camera);
const x = (ndc.x * 0.5 + 0.5) * renderer.domElement.clientWidth;
const y = (1 - (ndc.y * 0.5 + 0.5)) * renderer.domElement.clientHeight;
etiqueta.style.left = `${x}px`;
etiqueta.style.top = `${y}px`;
}
renderer.setAnimationLoop((tiempo) => {
const t = tiempo / 1000;
objetivo.position.set(Math.sin(t) * 3, Math.sin(t * 1.7) * 0.8, Math.cos(t) * 3);
renderer.render(scene, camera);
colocarEtiqueta();
});
Fíjate en dos detalles del orden. La etiqueta se coloca después de render, porque render es quien actualiza las matrices de mundo de toda la escena; hacerlo antes usaría las del fotograma anterior y la etiqueta iría un fotograma por detrás del objeto, que es un desfase perfectamente visible. Y el tamaño se toma de clientWidth del canvas, no de window.innerWidth, porque son cosas distintas en cuanto el canvas no ocupa toda la ventana.
Este es el error que todo el mundo comete una vez y que cuesta horas encontrar, porque el resultado no es un valor no numérico ni un aviso: son números perfectamente normales, dentro de rango, que colocan la etiqueta en el sitio equivocado. La causa es aritmética pura. project divide por la cuarta componente, que para un punto detrás de la cámara es negativa; dividir por un negativo invierte el signo de las dos primeras coordenadas, así que un objeto que está detrás y a la izquierda aparece proyectado delante y a la derecha. El síntoma es característico y desconcertante: las etiquetas de los objetos que tienes a tu espalda aparecen en pantalla, espejadas, moviéndose al revés que la cámara. Y como el punto proyectado suele caer dentro del rango visible, ningún recorte lo filtra. La comprobación correcta no se puede hacer sobre las coordenadas normalizadas, porque ahí la información del signo ya se ha perdido: hay que hacerla antes, en espacio de vista, comprobando que la coordenada Z sea negativa, o quedándose con la cuarta componente antes de dividir. Es una línea de código y no aparece en ningún tutorial de etiquetas superpuestas. Si alguna vez has visto una interfaz 3D con marcadores fantasma que aparecen al girar, ya sabes exactamente qué línea falta.
Del ratón a la escena
El camino contrario empieza en un evento del DOM y tiene su propia trampa: las coordenadas del evento son relativas a la ventana, y lo que necesitas son coordenadas relativas al canvas, que puede estar desplazado y tener un tamaño distinto.
import * as THREE from 'three';
const puntero = new THREE.Vector2();
export function actualizarPuntero(evento, canvas) {
const caja = canvas.getBoundingClientRect();
// De pixeles del canvas a coordenadas normalizadas, con la Y invertida.
puntero.x = ((evento.clientX - caja.left) / caja.width) * 2 - 1;
puntero.y = -((evento.clientY - caja.top) / caja.height) * 2 + 1;
return puntero;
}
/** Punto del mundo bajo el raton, sobre un plano horizontal a una altura dada. */
const _origen = new THREE.Vector3();
const _direccion = new THREE.Vector3();
export function puntoSobrePlano(puntero, camara, altura = 0) {
_origen.set(puntero.x, puntero.y, -1).unproject(camara);
_direccion.set(puntero.x, puntero.y, 1).unproject(camara).sub(_origen).normalize();
// Interseccion recta plano, resuelta a mano: dos restas y una division.
const t = (altura - _origen.y) / _direccion.y;
if (!isFinite(t) || t < 0) return null;
return _origen.clone().addScaledVector(_direccion, t);
}
El truco de unproject con la tercera coordenada en menos uno y en uno merece explicación: son los dos extremos del volumen visible en profundidad, así que al desproyectarlos obtienes dos puntos del mundo que definen la recta que pasa por el píxel. Esa recta es el rayo que sale del ojo a través del cursor, y con ella se resuelve cualquier consulta de qué hay bajo el ratón. Three.js tiene una clase dedicada a esto que se estudia mucho más adelante en el track; conocer la construcción a mano sirve para entender qué hace y para los casos en que no necesitas toda su maquinaria.
El error que precede a todos los demás
Cierro con la regla que evita la mitad de los problemas de esta lección: ninguna consulta de espacio de mundo es válida si las matrices no están actualizadas.
Three.js actualiza las matrices de mundo de toda la escena dentro de render. Si consultas una posición global antes del primer render, o después de mover un objeto pero antes de dibujar, obtienes el valor anterior. El síntoma es un desfase de un fotograma que en objetos rápidos se ve como un temblor y en objetos lentos no se ve en absoluto hasta que alguien lo nota en una captura.
La solución inmediata es llamar a objeto.updateMatrixWorld(true) sobre el objeto concreto, o a scene.updateMatrixWorld(true) sobre toda la escena, justo antes de leer. La solución estructural es ordenar el bucle de forma que las lecturas ocurran siempre después del render, como en el ejemplo de la etiqueta. Cuándo se actualizan exactamente esas matrices, y cómo evitar recalcularlas de más, es el contenido del nivel del grafo de escena.