Damping independiente del framerate: la fórmula correcta
Por qué interpolar con un factor fijo por frame da resultados distintos a 60 y a 144 Hz, la derivación de la corrección exponencial, y las tres formas de parametrizarla sin que el ajuste sea a ciegas.
El factor fijo depende del monitor
La operación ingenua es esta:
// La linea de siempre. Y esta mal.camera.position.lerp( destino, 0.1 );Cada frame, la posición avanza un 10 % de lo que le falta. Lo que queda por recorrer después de un frame es el 90 % de lo que quedaba antes. Después de n frames, la fracción restante es 0.9 elevado a n.
Ahí está el problema completo: n es el número de frames, no el tiempo transcurrido. Un segundo a 30 Hz son 30 aplicaciones; a 60 Hz, 60; a 144 Hz, 144. Las tres dan resultados distintos.
| Frecuencia | Aplicaciones en 1 s | Fracción restante | Qué se ve |
|---|---|---|---|
| 30 Hz | 30 | 0,9 elevado a 30 = 0,0424 | queda un 4,2 % de camino |
| 60 Hz | 60 | 0,9 elevado a 60 = 0,00179 | queda un 0,18 % |
| 144 Hz | 144 | 0,9 elevado a 144 ≈ 0,00000026 | ha llegado |
En un monitor de 144 Hz la cámara alcanza el destino prácticamente al instante; en uno de 30 se arrastra. Peor: no es solo cuestión de velocidad, es que la curva entera tiene otra forma, así que el ajuste que hiciste a ojo en tu monitor de 60 Hz se siente mal en cualquier otro. Y como los frames nunca duran exactamente lo mismo, incluso en un solo equipo el movimiento cambia de carácter cuando la escena se complica.
El intento habitual de arreglarlo multiplicando por el delta empeora las cosas de forma sutil:
// TAMPOCO. Mejor que nada, pero sigue estando mal.camera.position.lerp( destino, 0.1 * dt * 60 );Ahora el factor escala con el tiempo, lo que corrige el primer orden. Pero sigue siendo una aproximación lineal de un proceso exponencial, y se rompe en los extremos: con un delta grande —un tirón de 200 ms— el factor supera uno y la interpolación sobrepasa el destino, produciendo un salto hacia atrás. Con factores altos y deltas moderados, oscila.
La corrección exponencial
Lo que queremos describir es un proceso continuo: la velocidad de acercamiento es proporcional a lo que falta. Eso es una ecuación diferencial de primer orden.
Si x es la posición actual, d el destino y k la constante de velocidad:
dx/dt = k * ( d - x )Su solución es la exponencial decreciente:
x( t ) = d + ( x0 - d ) * e^( -k * t )Es decir: la fracción que queda por recorrer después de un tiempo t es e elevado a -k*t, y solo depende de t. No de en cuántos trozos hayas dividido ese tiempo.
Traducido a un paso de duración dt, el factor de interpolación que hay que usar es:
const factor = 1 - Math.exp( - lambda * dt );x = x + ( destino - x ) * factor;Y eso es exactamente lo que hace MathUtils.damp de Three.js:
import { MathUtils } from 'three';// damp( x, y, lambda, dt ) === lerp( x, y, 1 - Math.exp( - lambda * dt ) )camera.position.x = MathUtils.damp( camera.position.x, destino.x, 6, dt );La demostración de que es correcto es la propiedad de composición. Aplicar el amortiguamiento con un paso dt1 y después con dt2 deja una fracción restante de
e^( -k * dt1 ) * e^( -k * dt2 ) = e^( -k * ( dt1 + dt2 ) )que es exactamente la misma que aplicarlo una sola vez con dt1 + dt2. Da igual cómo trocees el tiempo: el resultado tras un segundo es el mismo tanto si has dado 30 pasos como 144 o uno solo. El factor fijo no tiene esa propiedad, porque (1-t) elevado a 2 no tiene nada que ver con (1-t) aplicado a un intervalo doble.
Compruébalo numéricamente si te queda alguna duda:
function restante( lambda, dt, pasos ) { let x = 1; for ( let i = 0; i < pasos; i ++ ) { x = MathUtils.damp( x, 0, lambda, dt ); } return x;}console.log( restante( 6, 1 / 30, 30 ) ); // 0.002478...console.log( restante( 6, 1 / 60, 60 ) ); // 0.002478...console.log( restante( 6, 1 / 144, 144 ) ); // 0.002478...Los tres imprimen el mismo valor, que es Math.exp( -6 ). Con el lerp de factor fijo, los tres imprimen números que se diferencian en cinco órdenes de magnitud.
Como Three.js no trae una versión vectorial, se escribe una vez:
function dampVector( actual, destino, lambda, dt ) { actual.x = MathUtils.damp( actual.x, destino.x, lambda, dt ); actual.y = MathUtils.damp( actual.y, destino.y, lambda, dt ); actual.z = MathUtils.damp( actual.z, destino.z, lambda, dt ); return actual;}function dampQuaternion( actual, destino, lambda, dt ) { return actual.slerp( destino, 1 - Math.exp( - lambda * dt ) );}Para el cuaternión, el mismo factor corregido pasado a slerp funciona: slerp recorre el arco a velocidad angular constante, así que la fracción de arco que queda se compone igual que la fracción de distancia.
Tres formas de elegir el parámetro
El problema práctico de lambda es que no significa nada intuitivo. Hay tres parametrizaciones equivalentes y conviene tener las tres a mano.
Por lambda directamente. Cuanto mayor, más rápido converge. Como referencia: lambda = 1 deja un 37 % del camino tras un segundo; lambda = 5 deja un 0,7 %; lambda = 10 deja un 0,005 %. Para cámaras, el rango útil está entre 3 y 12.
Por vida media. Mucho más intuitivo: “quiero que recorra la mitad de la distancia en 0,15 segundos”. La conversión sale de resolver e^(-k*h) = 0.5:
// lambda a partir de la vida media, en segundos.const lambda = Math.LN2 / vidaMedia;// O directamente, sin pasar por lambda:function dampPorVidaMedia( x, destino, vidaMedia, dt ) { const factor = 1 - Math.pow( 2, - dt / vidaMedia ); return x + ( destino - x ) * factor;}Esta versión con Math.pow( 2, ... ) es idéntica matemáticamente a la exponencial y bastante más fácil de comunicar a un diseñador: el parámetro es un tiempo en segundos, con unidades y con significado.
Por equivalencia con el factor de 60 fps. Si tienes código antiguo con lerp( destino, 0.1 ) ajustado a ojo y quieres conservar exactamente esa sensación:
// El factor 0.1 por frame a 60 fps equivale a:const lambda = - 60 * Math.log( 1 - 0.1 ); // 6.3216...// O sin convertir, la forma directa:function dampComoSesenta( x, destino, factor60, dt ) { const f = 1 - Math.pow( 1 - factor60, dt * 60 ); return x + ( destino - x ) * f;}Math.pow( 1 - t, dt * 60 ) es la generalización continua del (1-t) elevado al número de frames: cuando dt vale exactamente un sesentavo, el exponente es uno y recuperas el comportamiento original. Es la ruta de migración menos dolorosa para una base de código existente.
Lo que el amortiguamiento exponencial no hace
La exponencial se acerca al destino y nunca lo sobrepasa. Eso es una virtud en una cámara —nadie quiere que la cámara se pase y vuelva— y una limitación cuando quieres una sensación de muelle, de rebote, de peso.
Para eso hace falta un sistema de segundo orden. El estándar de la industria es el muelle críticamente amortiguado, publicado en Game Programming Gems 4 y popularizado como SmoothDamp:
// Devuelve la nueva posicion. velocidad es un objeto mutable { valor }.function smoothDamp( actual, destino, velocidad, tiempoSuavizado, dt, velMax = Infinity ) { tiempoSuavizado = Math.max( 0.0001, tiempoSuavizado ); const omega = 2 / tiempoSuavizado; const x = omega * dt; // Aproximacion racional de e^(-x), estable y sin llamar a Math.exp. const exp = 1 / ( 1 + x + 0.48 * x * x + 0.235 * x * x * x ); let cambio = actual - destino; const cambioMax = velMax * tiempoSuavizado; cambio = Math.max( - cambioMax, Math.min( cambio, cambioMax ) ); const objetivo = actual - cambio; const temp = ( velocidad.valor + omega * cambio ) * dt; velocidad.valor = ( velocidad.valor - omega * temp ) * exp; let salida = objetivo + ( cambio + temp ) * exp; // Evitar sobrepasar el destino. if ( ( destino - actual > 0 ) === ( salida > destino ) ) { salida = destino; velocidad.valor = ( salida - destino ) / dt; } return salida;}Mantiene una velocidad entre llamadas, es independiente del framerate por construcción, y su parámetro tiempoSuavizado es el tiempo aproximado en llegar al destino: otra vez, un número con significado. La diferencia perceptible frente al amortiguamiento exponencial es que arranca suave en lugar de arrancar a velocidad máxima, lo que se siente como masa.
La regla para elegir: exponencial cuando quieras seguir algo que se mueve continuamente —una cámara persiguiendo a un personaje— y muelle cuando quieras llegar a un destino fijo con sensación física —un panel que aparece, un objeto que vuelve a su sitio—.