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.
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 objetomundo.rotateOnWorldAxis(ejeY, Math.PI / 2); // el eje Y del mundoconst 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.
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.