Saltar al contenido

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.

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.

JS
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.

JS
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.