Interpolar orientaciones: slerp frente a lerp
Qué diferencia hay entre interpolar linealmente y esféricamente entre dos cuaterniones, cuándo se nota, y cómo hacer que un suavizado sea independiente de la tasa de fotogramas.
Interpolar entre dos orientaciones es la operación por la que la industria de la animación adoptó los cuaterniones. La interpolación esférica recorre el camino más corto entre dos orientaciones a velocidad angular constante, que es exactamente lo que la intuición espera de un giro y lo que ninguna otra representación da gratis. La interpolación lineal normalizada recorre el mismo camino pero a velocidad variable, y saber cuándo esa diferencia importa evita tanto un artefacto visible como una optimización inútil.
- Explicar por qué la interpolación lineal normalizada recorre el arco correcto a velocidad incorrecta.
- Medir la diferencia entre ambas y decidir cuándo es perceptible.
- Distinguir interpolar cuaterniones de interpolar ángulos de Euler.
- Escribir un suavizado de orientación independiente de la tasa de fotogramas.
Los dos caminos entre dos orientaciones
Piensa en los cuaterniones unitarios como puntos sobre la superficie de una esfera en cuatro dimensiones. Dos orientaciones son dos puntos, y el camino natural entre ellos es el arco de círculo máximo que los une: la ruta más corta sobre la superficie.
La interpolación esférica recorre ese arco a velocidad constante. Si el arco corresponde a un giro de noventa grados, en la mitad del recorrido habrás girado exactamente cuarenta y cinco. Es lo que hace slerp.
La interpolación lineal normalizada interpola los cuatro números como si fueran coordenadas cualesquiera —es decir, recorre la cuerda recta que atraviesa la esfera— y después proyecta el resultado de vuelta a la superficie normalizando. El punto final cae sobre el mismo arco, así que el camino es el mismo, pero el reparto es desigual: la proyección estira los tramos centrales y comprime los extremos.
El efecto neto es una velocidad angular que empieza lenta, se acelera en la mitad y vuelve a frenar. Un suavizado involuntario. En un giro pequeño es imperceptible; en un giro grande se nota como una aceleración que nadie ha pedido.
import * as THREE from 'three';
const inicio = new THREE.Quaternion();
const fin = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
THREE.MathUtils.degToRad(160)
);
const _tmp = new THREE.Quaternion();
function nlerp(a, b, t, destino) {
return destino.set(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
a.z + (b.z - a.z) * t,
a.w + (b.w - a.w) * t
).normalize();
}
console.log(' t slerp nlerp');
for (let i = 0; i <= 4; i++) {
const t = i / 4;
const conSlerp = new THREE.Quaternion().slerpQuaternions(inicio, fin, t);
const conNlerp = nlerp(inicio, fin, t, _tmp);
const gradosSlerp = THREE.MathUtils.radToDeg(inicio.angleTo(conSlerp));
const gradosNlerp = THREE.MathUtils.radToDeg(inicio.angleTo(conNlerp));
console.log(`${t.toFixed(2)} ${gradosSlerp.toFixed(1)} ${gradosNlerp.toFixed(1)}`);
}
La columna de la interpolación esférica avanza en pasos iguales: cuarenta grados por cada cuarto de recorrido. La lineal se queda claramente por detrás en el primer cuarto, alcanza el punto medio exacto en la mitad —por simetría, siempre coincide ahí— y va por delante en el tercer cuarto. Con un giro de ciento sesenta grados, la diferencia en el primer cuarto ronda los cinco grados, que en un movimiento de cámara es perfectamente visible.
Con un giro de veinte grados, la misma prueba da diferencias de centésimas de grado. Esa es la regla práctica: el error de la interpolación lineal crece con el ángulo y es despreciable por debajo de unos treinta grados.
Cuándo usar cada una
Interpolación esférica cuando el giro es grande y el movimiento se ve: transiciones de cámara, orientación de un objeto que apunta a un destino lejano, animación cinemática, cualquier cosa donde la velocidad de giro forme parte de la intención.
Interpolación lineal normalizada cuando hay muchísimas mezclas por fotograma y los ángulos son pequeños: mezcla de huesos en un esqueleto, donde cada hueso interpola unos pocos grados entre capas de animación y hay cientos por personaje. Ahí el coste importa y el error no se ve.
En cuanto al coste real conviene no exagerar: la interpolación esférica necesita un arcocoseno, un seno y dos divisiones, mientras que la lineal necesita una raíz cuadrada para normalizar. La diferencia existe pero es de un factor pequeño, no de un orden de magnitud, y en la práctica solo se nota cuando se ejecuta decenas de miles de veces por fotograma. Para interpolar la orientación de una cámara, elegir la lineal por rendimiento es optimizar lo que no cuesta.
Y una advertencia que hay que separar con claridad: interpolar ángulos de Euler no es ninguna de las dos cosas y es mucho peor. Interpolar tres ángulos por separado no recorre el arco corto, ni siquiera recorre un arco: produce una trayectoria que se curva de forma impredecible, que puede dar vueltas enteras si las ternas de origen y destino no son las canónicas, y que se comporta de forma errática cerca de la singularidad del orden. Si tu sistema de animación tiene ángulos de Euler en los fotogramas clave, conviértelos a cuaterniones antes de interpolar.
El idioma más extendido para suavizar una orientación es una línea que aparece en miles de proyectos: interpolar un poco hacia el objetivo en cada fotograma, con un factor constante pequeño. Funciona, se ve bien, y está mal, porque el resultado depende de cuántas veces por segundo se ejecute. A sesenta fotogramas por segundo la orientación converge de una manera; a ciento veinte, el doble de veces con el mismo factor, converge el doble de rápido; en un móvil que baja a treinta, va la mitad de lento. El mismo código produce tres sensaciones distintas de peso e inercia según el dispositivo, y es una de las causas más comunes de que una animación se sienta bien en el portátil del desarrollador y mal en producción. La raíz del problema es que aplicar repetidamente un factor de interpolación es una progresión geométrica, y por tanto un decaimiento exponencial disfrazado. Si se acepta eso, la corrección sale sola: el factor que hay que pasar a la interpolación no es una constante, es 1 - Math.exp(-lambda * dt), donde lambda es la velocidad de convergencia en unidades por segundo y dt el tiempo transcurrido. Con esa fórmula, dos fotogramas de dieciséis milisegundos producen exactamente el mismo resultado que uno de treinta y dos, que es la definición de independencia de la tasa de fotogramas. Three.js incluye esa fórmula para valores escalares en MathUtils.damp, pero no hay equivalente para cuaterniones, así que hay que escribirla. Es una línea, y convierte un suavizado que se siente distinto en cada máquina en uno que se siente igual en todas.
import * as THREE from 'three';
const _objetivo = new THREE.Quaternion();
/**
* Suavizado de orientacion independiente de la tasa de fotogramas.
* lambda controla la rapidez: valores altos convergen antes.
*/
export function amortiguarOrientacion(objeto, orientacionDeseada, lambda, dt) {
const t = 1 - Math.exp(-lambda * dt);
objeto.quaternion.slerp(orientacionDeseada, t);
}
/** Giro con velocidad angular maxima, util para torretas y vehiculos. */
export function girarConLimite(objeto, orientacionDeseada, radianesPorSegundo, dt) {
objeto.quaternion.rotateTowards(orientacionDeseada, radianesPorSegundo * dt);
}
// Comprobacion: dos pasos de 16 ms equivalen a uno de 32 ms.
const a = new THREE.Quaternion();
const b = new THREE.Quaternion();
_objetivo.setFromAxisAngle(new THREE.Vector3(0, 1, 0), 1.5);
a.slerp(_objetivo, 1 - Math.exp(-6 * 0.016));
a.slerp(_objetivo, 1 - Math.exp(-6 * 0.016));
b.slerp(_objetivo, 1 - Math.exp(-6 * 0.032));
console.log('diferencia entre ambos caminos:', a.angleTo(b).toFixed(9)); // ~0
rotateTowards merece un apunte aparte porque resuelve un problema distinto. La interpolación amortiguada nunca llega del todo al objetivo —se acerca asintóticamente— y su velocidad depende de lo lejos que esté. rotateTowards gira una cantidad fija de radianes hacia el destino y se detiene exactamente al llegar. Es lo que quieres para cualquier cosa con una velocidad de giro física: una torreta que tarda dos segundos en dar media vuelta, un vehículo con radio de giro, un personaje que no puede cambiar de dirección instantáneamente.
El detalle numérico de la interpolación esférica
Dos cosas hace la implementación de Three.js que conviene conocer porque explican comportamientos que de otro modo parecen mágicos.
Elige el camino corto. Antes de interpolar, comprueba el signo del producto escalar entre los dos cuaterniones. Si es negativo, significa que los dos representantes están en hemisferios opuestos y que interpolar directamente recorrería el arco largo; entonces invierte el signo del destino, que como sabemos representa la misma orientación, y con eso el arco pasa a ser el corto. Sin esa comprobación, la mitad de las interpolaciones darían una vuelta casi completa en el sentido equivocado.
Cae a interpolación lineal cuando el arco es diminuto. La fórmula de la interpolación esférica divide por el seno del ángulo entre los dos cuaterniones. Cuando ese ángulo tiende a cero, el divisor tiende a cero y la operación se vuelve inestable. La implementación detecta ese caso comparando contra el épsilon de la máquina y usa interpolación lineal, que en un arco minúsculo es indistinguible y perfectamente estable.
Los dos detalles son ejemplos de algo que conviene generalizar: casi todas las funciones matemáticas de una biblioteca madura tienen un caso especial cerca de una singularidad. Cuando escribas la tuya —y en gráficos acabas escribiendo alguna— la pregunta que hay que hacerse siempre es dónde se anula el denominador.
Implementa una cámara que siga a un objetivo con amortiguarOrientacion y añade un control para variar lambda entre uno y veinte. Después limita artificialmente la tasa de fotogramas a treinta y comprueba que la sensación no cambia. Repite el experimento sustituyendo la fórmula exponencial por un factor fijo de 0.1 y observa la diferencia: es exactamente el bug que tiene la mitad del código de cámaras que circula por internet.