wandres.dev
MATEMÁTICAS DEL 3D III · Rotaciones y cuaterniones

Rotaciones en Three.js: qué método usar en cada caso

La API completa de rotación de Object3D y Quaternion, la diferencia entre rotar en espacio local y en espacio de mundo, y una tabla de decisión para elegir representación.

⏱ 18 min

Con la teoría de las tres lecciones anteriores, la API de rotación de Three.js deja de ser una lista de métodos parecidos y pasa a ser un conjunto de herramientas con propósitos distintos. Esta lección las ordena por el problema que resuelven, señala las dos que casi todo el mundo confunde, y cierra con la decisión práctica de qué representación usar en cada situación.

🎯 Al terminar esta lección sabrás
  • Elegir entre rotation, quaternion, lookAt y setFromUnitVectors según el problema.
  • Distinguir rotar sobre un eje local de rotar sobre un eje del mundo.
  • Orientar un objeto para que apunte hacia otro sin usar ángulos.
  • Reconocer las limitaciones documentadas de lookAt y rotateOnWorldAxis.

El estado y las cuatro formas de escribirlo

La orientación de un Object3D es un único estado con dos vistas sincronizadas: rotation, que es un Euler, y quaternion. Escribir en cualquiera de las dos actualiza la otra inmediatamente. Sobre ese estado hay cuatro formas de escribir, y cada una responde a una pregunta distinta.

Ángulos directos. objeto.rotation.y = valor cuando la rotación es de un solo eje o cuando el valor viene de un control de usuario en grados. Es legible y no tiene ningún inconveniente en ese contexto.

Eje y ángulo. setRotationFromAxisAngle cuando conoces el eje de giro y la cantidad. Es la forma natural de expresar «gira treinta grados alrededor de esta arista», y no tiene singularidades.

Rotación acumulativa. rotateX, rotateY, rotateZ, rotateOnAxis y rotateOnWorldAxis añaden un giro al que ya había, en lugar de sustituirlo. Son el equivalente a multiplicar por la izquierda o por la derecha, y sirven para controles incrementales.

Orientación calculada. lookAt, setFromUnitVectors y setRotationFromMatrix cuando la orientación se deduce de una condición geométrica en lugar de un ángulo.

import * as THREE from 'three';

const objeto = new THREE.Object3D();

// 1. Angulo directo: una sola perilla, sin ambiguedad.
objeto.rotation.y = THREE.MathUtils.degToRad(30);

// 2. Eje y angulo: sustituye la orientacion completa.
objeto.setRotationFromAxisAngle(new THREE.Vector3(1, 1, 0).normalize(), Math.PI / 3);

// 3. Acumulativa: se anade a lo que ya hubiera.
objeto.rotateOnAxis(new THREE.Vector3(0, 1, 0), 0.1);

// 4. Calculada: la orientacion minima que lleva una direccion a otra.
const desde = new THREE.Vector3(0, 0, 1);
const hasta = new THREE.Vector3(1, 1, 0).normalize();
objeto.quaternion.setFromUnitVectors(desde, hasta);

const comprobacion = desde.clone().applyQuaternion(objeto.quaternion);
console.log('coincide con el destino:', comprobacion.distanceTo(hasta) < 1e-6);

setFromUnitVectors es la más infravalorada de las cuatro. Calcula la rotación mínima que lleva una dirección unitaria a otra, sin ningún giro sobrante alrededor del eje resultante. Es la herramienta correcta para alinear un objeto con una normal de superficie, para orientar una flecha, para pegar un adhesivo a una pared o para colocar el pie de un personaje sobre un terreno inclinado. Hacer lo mismo con lookAt introduce un giro arbitrario alrededor del eje de vista que casi nunca es el que quieres.

Local frente a mundo: la confusión de siempre

rotateOnAxis interpreta el eje en el sistema de coordenadas del propio objeto. Si el objeto ya está girado, el eje gira con él. Es lo que quieres para un avión que hace un alabeo: el eje de alabeo es el eje longitudinal del avión, no un eje fijo del mundo.

rotateOnWorldAxis interpreta el eje en el sistema del mundo, sin importar cómo esté orientado el objeto. Es lo que quieres para un control orbital donde arrastrar horizontalmente siempre gira alrededor del eje vertical del mundo, esté el objeto como esté.

La diferencia se ve mejor con un ejemplo que con una definición.

import * as THREE from 'three';

const local = new THREE.Object3D();
const mundo = new THREE.Object3D();

// Ambos parten inclinados 90 grados sobre X.
local.rotation.x = Math.PI / 2;
mundo.rotation.x = Math.PI / 2;

const ejeY = new THREE.Vector3(0, 1, 0);
local.rotateOnAxis(ejeY, Math.PI / 2);        // el eje Y del propio objeto
mundo.rotateOnWorldAxis(ejeY, Math.PI / 2);   // el eje Y del mundo

const sonda = new THREE.Vector3(0, 0, 1);
console.log('local:', sonda.clone().applyQuaternion(local.quaternion)
  .toArray().map((n) => +n.toFixed(3)));
console.log('mundo:', sonda.clone().applyQuaternion(mundo.quaternion)
  .toArray().map((n) => +n.toFixed(3)));

Los dos resultados son distintos porque el eje Y local del primer objeto, tras la inclinación inicial, ya no coincide con el eje Y del mundo.

Hay una limitación documentada de rotateOnWorldAxis que conviene tener presente: asume que el objeto no tiene ancestros rotados. La implementación aplica la rotación por la izquierda al cuaternión local, lo que solo equivale a un giro en el mundo si la cadena de padres no aporta rotación. En una jerarquía con grupos girados, el resultado no será el que esperas y no habrá ningún aviso.

lookAt y sus dos letras pequeñas

lookAt orienta un objeto para que su eje Z negativo apunte hacia un punto del mundo. Ese detalle de convención viene de que las cámaras miran hacia su Z negativo; para un objeto que no sea una cámara, significa que la cara que acabará mirando al objetivo es la que en el modelo apunta hacia atrás.

La rotación alrededor del eje de vista se resuelve con el vector up del objeto, que por defecto es el eje Y del mundo. De ahí sale el caso degenerado que ya conoces: cuando la dirección de vista es paralela a up, el producto vectorial que construye la base se anula y la orientación se vuelve inestable.

Las dos letras pequeñas de la documentación son estas: lookAt no funciona correctamente con ancestros de escala no uniforme, por la razón que se explicó en la lección de composición —la descomposición de una matriz con cizallamiento no es fiable—, y el método actúa sobre la orientación en espacio de mundo, así que en un objeto con padre rotado el resultado se calcula respecto al mundo y después se convierte.

import * as THREE from 'three';

const scene = new THREE.Scene();

const torreta = new THREE.Object3D();
scene.add(torreta);

const objetivo = new THREE.Vector3(5, 0, 5);

// Opcion A: lookAt. Rapida, pero fija tambien el giro sobre el eje de vista.
torreta.lookAt(objetivo);

// Opcion B: la rotacion minima desde la direccion actual. No introduce alabeo.
const _actual = new THREE.Vector3();
const _deseada = new THREE.Vector3();
const _giro = new THREE.Quaternion();

function apuntarSinAlabeo(objeto, punto) {
  objeto.getWorldDirection(_actual);                    // eje Z positivo del objeto
  _deseada.subVectors(punto, objeto.position).normalize();
  _giro.setFromUnitVectors(_actual, _deseada);
  objeto.quaternion.premultiply(_giro);                 // el giro se aplica despues
}

apuntarSinAlabeo(torreta, objetivo);

premultiply en la última línea no es casual: el giro corrector está expresado en el mundo, así que tiene que aplicarse después de la orientación actual del objeto, lo que en la convención de composición significa colocarlo a la izquierda.

Acumular ángulos leyendo la propiedad rotation es el bug que aparece a los tres meses

Hay un patrón que parece razonable y que falla de una forma muy concreta: leer objeto.rotation.x, sumarle el desplazamiento del ratón, recortar el resultado y volver a escribirlo. Funciona perfectamente durante el desarrollo y falla el día que alguien mira muy hacia arriba. La causa es la ida y vuelta por el cuaternión: al escribir en rotation, Three.js recalcula el cuaternión; al leer, recalcula el Euler desde el cuaternión y devuelve la forma canónica, que mantiene el ángulo central en el rango de menos noventa a noventa grados. Si tu valor acumulado sale de ese rango, lo que lees no es lo que escribiste: es una terna equivalente con números distintos, y a partir de ahí tu acumulador está corrupto. El síntoma es característico y confuso: el control funciona bien hasta cierta inclinación y entonces la cámara da un giro completo o se queda pegada a un tope invisible. Nada de esto ocurre si los ángulos son tuyos. Mantén pitch y yaw como variables propias de tu módulo, acumúlalas y recórtalas ahí, y escribe en el objeto sin leer nunca de vuelta. Es la diferencia entre tratar el Object3D como el estado de tu aplicación y tratarlo como lo que es, la representación visual de un estado que vive en otro sitio. Este consejo vale mucho más allá de las rotaciones: cada vez que el estado autoritativo está dentro de un objeto de la biblioteca en lugar de en tu código, aparecen bugs de este tipo.

La tabla de decisión

Situación Representación Método
Un solo eje de giro Euler objeto.rotation.y
Control de usuario en grados Euler con ángulos propios Escribir en rotation, sin leer
Cámara en primera persona Euler con orden YXZ y ángulos propios Recortar la elevación
Giro alrededor de un eje concreto Eje y ángulo setRotationFromAxisAngle
Alinear una dirección con otra Cuaternión setFromUnitVectors
Apuntar hacia un punto Cuaternión lookAt o el giro mínimo
Interpolar entre dos orientaciones Cuaternión slerp
Rotación libre en tres ejes acumulada Cuaternión multiply o premultiply
Orientación de física o de un motor externo Cuaternión Copiar directamente

La regla que resume toda la tabla: usa ángulos donde haya una persona mirando y cuaterniones donde haya matemáticas encadenadas. Los ángulos son un formato de presentación; el cuaternión es el estado.

Con esto se cierra el bloque de matemáticas del track. Lo que sigue es el primer proyecto ejecutable, donde por fin aparece algo en pantalla, y donde toda la teoría de estos tres niveles se convierte en cuatro líneas que ahora sabes leer.