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

Cuaterniones: qué son esos cuatro números

Por qué hacen falta cuatro parámetros para describir una rotación sin singularidades, qué significan el eje y el medio ángulo, y qué implica que dos cuaterniones opuestos sean la misma rotación.

⏱ 19 min

Un cuaternión unitario no es un objeto misterioso: son un eje de rotación y un ángulo, codificados de una forma concreta que hace que componer rotaciones sea multiplicar. Toda la reputación de dificultad viene de que se suelen presentar por su historia algebraica en lugar de por lo que representan. Presentados por lo que representan, la única parte que exige atención es por qué aparece la mitad del ángulo, y esa mitad tiene una explicación exacta.

🎯 Al terminar esta lección sabrás
  • Explicar por qué ninguna representación de tres parámetros puede evitar las singularidades.
  • Leer las cuatro componentes de un cuaternión unitario como eje y medio ángulo.
  • Justificar la aparición del medio ángulo a partir de cómo se aplica la rotación.
  • Predecir las consecuencias prácticas de que un cuaternión y su opuesto sean la misma rotación.

Por qué cuatro y no tres

El conjunto de todas las orientaciones posibles de un objeto rígido en tres dimensiones tiene tres grados de libertad. Parece razonable describirlo con tres números, y eso es lo que intentan los ángulos de Euler.

El problema es que ese conjunto, visto como espacio geométrico, tiene una forma que no admite un sistema de coordenadas global de tres parámetros que sea suave en todas partes. Es el mismo obstáculo que impide cubrir una esfera con un único mapa plano sin distorsión ni polos: no es una limitación de ingenio, es una propiedad topológica del objeto. Cualquier parametrización con tres números tendrá singularidades en algún sitio, y el gimbal lock es la que aparece con los ángulos de Euler.

La salida es la misma que en cartografía: usar más números de los estrictamente necesarios y añadir una restricción. Un cuaternión tiene cuatro componentes con la condición de que su longitud sea uno, lo que deja exactamente tres grados de libertad, sin ningún punto especial. Es la misma solución que el cuarto anillo que se estudió para las plataformas inerciales del Apolo.

Una matriz de rotación también sirve: nueve números con seis restricciones. Pero nueve números para tres grados de libertad es mucha redundancia, cuesta más componerla, ocupa más y su renormalización —volver a ortogonalizar tras acumular errores— es incómoda. Los cuaterniones dan lo mismo con cuatro números y una normalización trivial.

Las cuatro componentes, leídas

Un cuaternión unitario que representa una rotación de un ángulo alrededor de un eje unitario se compone así: las tres primeras componentes son el eje multiplicado por el seno de la mitad del ángulo, y la cuarta es el coseno de la mitad del ángulo.

De ahí salen lecturas inmediatas que conviene tener en la cabeza para depurar:

La identidad es (0, 0, 0, 1). Ángulo cero: seno cero, coseno uno.

La cuarta componente indica cuánto giro hay. Cerca de uno, poca rotación. Cerca de cero, media vuelta.

Las tres primeras dan la dirección del eje. Normalizadas, son el eje exacto.

Un cuaternión con la cuarta componente negativa describe un giro de más de media vuelta en el sentido positivo del eje, que es lo mismo que un giro corto en sentido contrario.

import * as THREE from 'three';

const eje = new THREE.Vector3(0, 1, 0);
const angulo = THREE.MathUtils.degToRad(90);

const q = new THREE.Quaternion().setFromAxisAngle(eje, angulo);
console.log('componentes:', [q.x, q.y, q.z, q.w].map((n) => +n.toFixed(4)));
// (0, 0.7071, 0, 0.7071): seno y coseno de 45 grados, no de 90

console.log('seno de la mitad:', Math.sin(angulo / 2).toFixed(4));   // 0.7071
console.log('coseno de la mitad:', Math.cos(angulo / 2).toFixed(4)); // 0.7071

// Recuperar el angulo desde el cuaternion.
const anguloRecuperado = 2 * Math.acos(Math.min(1, Math.abs(q.w)));
console.log('angulo recuperado:', THREE.MathUtils.radToDeg(anguloRecuperado).toFixed(2));

// Aplicarlo a un vector: el eje X pasa a ser el eje Z negativo.
const v = new THREE.Vector3(1, 0, 0).applyQuaternion(q);
console.log('vector rotado:', v.toArray().map((n) => +n.toFixed(4)));  // (0, 0, -1)

De dónde sale la mitad

Esta es la parte que casi nunca se explica y que despeja toda la sensación de arbitrariedad.

Un cuaternión no rota un vector multiplicándolo por la izquierda, como hace una matriz. Lo rota con una operación de sándwich: se multiplica el cuaternión por el vector y el resultado por el inverso del cuaternión. El vector queda envuelto entre dos copias de la rotación.

Como la rotación interviene dos veces, cada una aporta la mitad del giro total. Si cada factor codificara el ángulo completo, el sándwich giraría el doble de lo pedido. Codificar la mitad es lo que hace que el resultado sea exacto.

Esa estructura de sándwich no es un capricho: es lo que garantiza que el resultado siga siendo un vector puro, sin componente residual en la cuarta dimensión, y lo que hace que la composición de dos rotaciones sea simplemente el producto de los dos cuaterniones. Dos sándwiches encadenados se colapsan en uno solo cuyo pan es el producto.

El medio ángulo tiene además una consecuencia visible que conviene anticipar: una rotación de trescientos sesenta grados no devuelve el cuaternión al valor inicial, lo devuelve a su opuesto. Hay que dar dos vueltas completas para volver al punto de partida. Es una rareza real de la representación y es el origen del fenómeno siguiente.

Dos cuaterniones para cada rotación

Si un cuaternión representa una rotación, su opuesto —el que tiene las cuatro componentes con el signo cambiado— representa exactamente la misma rotación. Se comprueba de inmediato en la operación de sándwich: los dos signos negativos se cancelan.

Geométricamente, uno describe el giro corto en un sentido y el otro el giro largo en el contrario, que llevan al mismo sitio. Esta propiedad se llama doble recubrimiento y es la única fuente real de trampas al trabajar con cuaterniones.

import * as THREE from 'three';

const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), 1.2);
const opuesto = new THREE.Quaternion(-q.x, -q.y, -q.z, -q.w);

const v1 = new THREE.Vector3(1, 0, 0).applyQuaternion(q);
const v2 = new THREE.Vector3(1, 0, 0).applyQuaternion(opuesto);
console.log('misma rotacion:', v1.distanceTo(v2) < 1e-9);        // true

console.log('componentes iguales:', q.equals(opuesto));           // false
console.log('angulo entre ambos:', q.angleTo(opuesto).toFixed(9)); // 0

// Y de aqui la regla: nunca compares cuaterniones componente a componente.
const casiIgual = q.clone().slerp(opuesto, 0.5);
console.log('interpolar hacia el opuesto no mueve nada:',
  casiIgual.angleTo(q).toFixed(9));

angleTo devuelve cero porque su implementación toma el valor absoluto del producto escalar antes de aplicar el arcocoseno, precisamente para tratar los dos representantes como el mismo. Esa línea de código, que parece un detalle, es la que hace que la biblioteca se comporte de forma sensata.

El doble recubrimiento no molesta hasta que guardas o promedias cuaterniones, y entonces molesta mucho

Three.js hace lo correcto en las operaciones que expone: la interpolación esférica comprueba el signo del producto escalar y toma el camino corto, y la comparación angular usa el valor absoluto. Con la API de la biblioteca, el doble recubrimiento es invisible. Deja de serlo en cuanto tu código toca cuaterniones por su cuenta, y hay tres situaciones frecuentes donde eso pasa. La primera es guardar orientaciones: si serializas cuaterniones a un archivo o los envías por red y en algún punto se normaliza el signo de forma distinta, dos orientaciones idénticas se comparan como distintas y un sistema de detección de cambios dispara actualizaciones fantasma cada fotograma. La segunda es promediar: la media aritmética de un cuaternión y su opuesto es el cuaternión cero, que no representa ninguna rotación y que al normalizarse produce valores no numéricos; cualquier sistema de mezcla de animaciones que sume cuaterniones con pesos tiene que alinear los signos antes de sumar, y las bibliotecas serias lo hacen respecto al primero de la lista. La tercera es interpolar una lista de fotogramas clave: si dos claves consecutivas tienen signos opuestos, la interpolación entre ellas recorre trescientos sesenta grados menos el ángulo real, es decir, casi una vuelta completa en el sentido equivocado, y el personaje da un giro espectacular en medio de una animación por lo demás correcta. El remedio, cuando manejas cuaterniones a mano, es una convención sencilla y sistemática: antes de guardar o de mezclar, si el producto escalar con la referencia es negativo, invierte las cuatro componentes. Dos líneas que ahorran una clase entera de bugs que parecen sobrenaturales.

Componer, invertir y mantener la salud numérica

Componer dos rotaciones es multiplicar sus cuaterniones, con la misma convención de orden que las matrices: el factor de la derecha actúa primero. multiply añade por la derecha y premultiply por la izquierda, igual que en Matrix4.

Invertir una rotación representada por un cuaternión unitario es cambiar el signo de las tres primeras componentes: la operación se llama conjugado y es prácticamente gratis. invert en Three.js hace exactamente eso, asumiendo que el cuaternión es unitario.

Y queda la higiene numérica. Cada multiplicación introduce un error de redondeo minúsculo, y multiplicar miles de veces —lo que ocurre si acumulas una rotación por fotograma— hace que la longitud se aleje poco a poco de uno. Un cuaternión no unitario deja de representar una rotación pura y empieza a introducir una escala. La cura es normalizar cada cierto tiempo; el coste es despreciable y el problema es real en simulaciones largas.

import * as THREE from 'three';

const giroY = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
const giroX = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);

// Primero X y despues Y: el de la derecha actua antes.
const compuesta = new THREE.Quaternion().multiplyQuaternions(giroY, giroX);
console.log('resultado:', new THREE.Vector3(0, 0, 1).applyQuaternion(compuesta)
  .toArray().map((n) => +n.toFixed(4)));

// El orden contrario da otra cosa: la composicion no es conmutativa.
const alReves = new THREE.Quaternion().multiplyQuaternions(giroX, giroY);
console.log('angulo entre ambas composiciones:',
  THREE.MathUtils.radToDeg(compuesta.angleTo(alReves)).toFixed(2));

// Deriva numerica al acumular muchas rotaciones pequenas.
const acumulado = new THREE.Quaternion();
const paso = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0.577, 0.577, 0.577), 0.001);
for (let i = 0; i < 200000; i++) acumulado.multiply(paso);
console.log('longitud tras 200000 productos:', acumulado.length().toFixed(12));
acumulado.normalize();
console.log('tras normalizar:', acumulado.length().toFixed(12));

Con esto, la representación está completa: cuatro números, sin singularidades, composición barata, inversa gratis. Lo que falta es la operación por la que se adoptaron los cuaterniones en la industria de la animación antes que por ninguna otra razón: interpolar entre dos orientaciones de forma que el resultado tenga sentido.