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.
camera.position.lerp( destino, 0.1 ) dentro del bucle de render es probablemente la línea más copiada de la historia de Three.js, y es incorrecta. No un poco incorrecta: produce comportamientos que difieren en cinco órdenes de magnitud entre un monitor de 30 Hz y uno de 144 Hz. El arreglo cabe en una línea y se deriva en tres pasos de cálculo, pero hay que entender por qué funciona para saber cómo elegir su parámetro.
- Demostrar por qué un factor fijo por frame depende de la frecuencia de refresco.
- Derivar la corrección exponencial y comprobar su propiedad de composición.
- Parametrizar el amortiguamiento por lambda, por vida media o por factor equivalente a 60 fps.
- Reconocer los casos en que el amortiguamiento exponencial no es la herramienta adecuada.
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.
Una exponencial nunca llega: siempre queda una fracción. En la práctica eso significa que la cámara sigue moviéndose micrométricamente para siempre, lo que impide que se duerma cualquier optimización basada en “no ha cambiado nada”. Añade un corte: if ( Math.abs( destino - x ) < 0.0001 ) x = destino;. Es una línea y elimina una clase entera de artefactos, incluido el temblor de precisión de coma flotante cuando la distancia se hace muy pequeña.
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—.
Si abres el código de OrbitControls en r184 y buscas el amortiguamiento, encuentras esto dentro de update(): this._sphericalDelta.theta *= ( 1 - this.dampingFactor ), y lo mismo para phi y para el desplazamiento lateral. Es exactamente el factor fijo por frame que acabamos de desmontar. Con dampingFactor en su valor por defecto de 0,05, la inercia de la órbita dura unas cuatro veces más en un monitor de 30 Hz que en uno de 120: el mismo gesto de arrastrar y soltar produce un giro por inercia notablemente distinto según el equipo. Y esto convive, en el mismo método, con un autoRotate que sí acepta un deltaTime opcional y que está documentado diciendo que si quieres velocidad independiente del framerate tienes que pasárselo. La misma función, dos criterios. No es descuido: es una decisión deliberada de compatibilidad. update() se llama sin argumentos en miles de proyectos, cambiar la semántica del amortiguamiento alteraría la sensación de todos ellos, y la alternativa —una API nueva— fragmentaría la clase. Es el coste normal de una biblioteca con quince años de código en producción. Lo que hay que sacar de aquí es doble. Primero, práctico: si el amortiguamiento consistente de OrbitControls te importa de verdad, o llamas a update() a frecuencia fija desde tu bucle de física, o te pasas a camera-controls, que resuelve esto con un smoothTime en segundos y un muelle real. Y segundo, general y más valioso: cuando leas código de una biblioteca madura y encuentres algo que sabes que está mal, la pregunta útil no es “por qué no lo arreglan” sino “qué compromiso están manteniendo”. Casi siempre hay uno, casi siempre es compatibilidad, y saber identificarlo es lo que te permite decidir si te afecta en lugar de asumir que es un descuido.
- Escribe la función
restantedel ejemplo y compruébalo condampy conlerpde factor fijo. - Simula un frame de 200 ms con
lerp( destino, 0.1 * dt * 60 )y comprueba que sobrepasa. - Convierte un
lerp( destino, 0.08 )existente adampconservando la sensación, y verifica la equivalencia a 60 Hz. - Reescribe el mismo movimiento parametrizado por vida media y busca el valor que se siente igual.
- Implementa
smoothDampy compáralo con la exponencial en un panel que entra desde fuera de pantalla.