El delta time y el error que todo el mundo comete
Sumar una constante por frame hace que la animación corra al doble en un monitor de 120Hz. La fórmula correcta, por qué es correcta, y cómo se detecta el error sin tener dos pantallas.
Es el primer bucle que escribe todo el mundo y está mal: x += 2 en cada frame. Funciona en el monitor de quien lo escribió, se ve bien en la revisión, y en el portátil del cliente con pantalla de 120Hz el elemento cruza la pantalla al doble de velocidad. El error no es de precisión ni de redondeo: es conceptual. Una constante por frame define una velocidad en píxeles por frame, y la interfaz que estás construyendo necesita una velocidad en píxeles por segundo.
- Explicar por qué un incremento fijo por frame depende de la tasa de refresco.
- Escribir la fórmula del delta time y aplicarla a posición y a interpolación.
- Corregir un suavizado exponencial, que no se arregla multiplicando por el delta.
- Detectar el error sin disponer de un monitor de alta frecuencia.
El error
let x = 0;
function frame() {
x += 2; // 2 pixeles por FRAME
caja.style.transform = `translateX(${x}px)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
A 60 frames por segundo eso son 120 píxeles por segundo. A 120 frames por segundo son 240. A 144, son 288. El mismo código produce tres animaciones distintas en tres pantallas, y ninguna de las tres es “la buena”: la velocidad no está definida en el código, está definida por el hardware.
El problema es peor de lo que parece porque no se manifiesta como un fallo. No hay error en consola, no hay salto, no hay parpadeo. El movimiento es perfectamente fluido; simplemente va a otra velocidad. Quien lo pruebe en un monitor de 60Hz no verá nada raro nunca.
Y afecta a todo lo que dependa de la cuenta: un contador que sube, una rotación, un desplazamiento de fondo, un temporizador de partículas. Todo lo que se exprese como “cuánto cambia cada vez que me llaman” hereda la tasa de refresco como unidad.
La fórmula
La corrección es expresar la velocidad en unidades por segundo y multiplicar por el tiempo transcurrido desde el frame anterior:
const VELOCIDAD = 120; // pixeles por segundo, una decision de diseno
let x = 0;
let anterior = null;
function frame(ts) {
if (anterior === null) anterior = ts; // el primer frame no tiene delta
const dt = (ts - anterior) / 1000; // segundos
anterior = ts;
x += VELOCIDAD * dt;
caja.style.transform = `translateX(${x}px)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Ahora VELOCIDAD significa algo: 120 píxeles por segundo, en cualquier pantalla. A 60Hz el delta vale aproximadamente 0.0167 y el incremento es 2 píxeles; a 120Hz vale 0.0083 y el incremento es 1. El resultado por unidad de tiempo es idéntico.
Tres detalles de la implementación que importan:
El primer frame no tiene anterior. Inicializar anterior a ts en la primera llamada da un delta de cero, que es correcto: aún no ha pasado tiempo. La alternativa habitual, dejar anterior sin inicializar o a cero, produce un delta igual al tiempo desde que se cargó la página, o NaN, y en ambos casos el primer frame teletransporta el elemento.
La división entre mil convierte a segundos. Es opcional: puedes trabajar en milisegundos y expresar la velocidad en píxeles por milisegundo. Lo que no es opcional es ser coherente, y trabajar en segundos hace que las constantes sean números legibles.
El argumento ts, y no performance.now(). Todos los callbacks del mismo frame reciben el mismo ts, mientras que performance.now() devuelve la hora en que se ejecuta cada uno. Usar el segundo introduce en el delta el tiempo que tardó el callback anterior, que es ruido.
Interpolar hacia un destino también necesita delta
El caso que más se resiste es el suavizado exponencial, ese x += (objetivo - x) * 0.12 que aparece en todos los efectos de cursor y de paralaje:
x += (objetivo - x) * 0.12; // depende de la tasa de refresco igual que el anterior
A 120Hz se ejecuta el doble de veces, así que converge el doble de rápido y el suavizado se nota la mitad. Es exactamente el mismo bug con otra cara.
Y aquí viene la trampa: no se arregla multiplicando el factor por el delta. x += (objetivo - x) * 0.12 * dt mejora la cosa pero sigue estando mal, porque la convergencia exponencial no es lineal en el tiempo. La corrección exacta usa la exponencial:
const SUAVIZADO = 8; // mayor es mas rapido; se lee como uno partido por segundos
function frame(ts) {
if (anterior === null) anterior = ts;
const dt = (ts - anterior) / 1000;
anterior = ts;
const factor = 1 - Math.exp(-SUAVIZADO * dt);
x += (objetivo - x) * factor;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
requestAnimationFrame(frame);
}
La expresión 1 - Math.exp(-k * dt) es la fracción del camino que hay que recorrer en un intervalo dt para que la convergencia sea la misma sea cual sea el tamaño del intervalo. Con un delta el doble de grande, el factor no es el doble: es el que corresponde a aplicar el paso pequeño dos veces. Esa es la diferencia entre un suavizado que se siente igual a 60 y a 144 y uno que no.
Con deltas pequeños, esa expresión es casi igual a k * dt, y por eso la versión lineal parece funcionar. Se rompe cuando el delta crece: en un frame largo de 200 milisegundos, la versión lineal puede dar un factor mayor que 1 y sobrepasar el destino, produciendo una oscilación que no debería existir. La versión exponencial nunca pasa de 1 por construcción.
Hay una tercera vía que casi nadie considera y que resuelve el problema por eliminación: no lleves la cuenta. Si lo que animas es una función del tiempo absoluto y no del frame anterior, el delta desaparece del código. En vez de x += velocidad * dt, escribe x = velocidad * (ts - inicio) / 1000. Suena a lo mismo y no lo es: la versión acumulativa arrastra el error de todos los frames anteriores y depende de haberlos recibido todos, así que un frame perdido en segundo plano la descuadra para siempre; la versión absoluta es correcta en cualquier frame aislado, sin memoria, y se recupera sola de cualquier interrupción. La regla que separa los dos mundos es si la trayectoria se puede escribir como función del tiempo. Un desplazamiento a velocidad constante, una rotación, una oscilación senoidal, una trayectoria parabólica: todas son funciones del tiempo y ninguna necesita delta ni estado. Un muelle que persigue un objetivo móvil, una colisión, un sistema con fricción variable: esas sí necesitan integración paso a paso. La mayoría de los bucles que se escriben en interfaces web pertenecen al primer grupo y están escritos como si fueran del segundo. Y si tu trayectoria es función del tiempo, hay una pregunta incómoda que hacerse: probablemente ni siquiera necesitabas un bucle.
Detectarlo sin un monitor de 120Hz
No hace falta hardware para reproducir el bug. Basta con simular una tasa distinta llamando al callback a otro ritmo:
// Fuerza aproximadamente 30 frames por segundo saltandose uno de cada dos
let salta = false;
function frameDePrueba(ts) {
salta = !salta;
if (!salta) frame(ts);
requestAnimationFrame(frameDePrueba);
}
Si la animación tarda el doble con esto activado, depende de la tasa de refresco. Si tarda lo mismo, el delta está bien aplicado. Es una comprobación de treinta segundos que debería formar parte de cualquier revisión de código con un bucle dentro.
La otra vía es la limitación de rendimiento de las herramientas de desarrollo: reducir la velocidad de la CPU a un cuarto baja la tasa de frames real y expone el mismo problema, con la ventaja de que también te enseña si el bucle es demasiado caro.
Escribe dos elementos que crucen la pantalla, uno con x += 2 y otro con la fórmula del delta, y ajusta las constantes para que a 60Hz vayan igual. Aplica después el filtro de saltar frames alternos y comprueba que uno tarda el doble y el otro no. Repite el ejercicio con el suavizado del cursor en sus tres versiones: constante, lineal con delta y exponencial.