El delta time: animar en segundos, no en fotogramas
Escribir movimiento que se comporta igual a 60, 120 y 30 hercios: unidades por segundo, acotado del delta, y el paso fijo con acumulador para la simulación.
Sumar tres píxeles por fotograma parece razonable hasta que alguien abre la página en un portátil de 120 hercios y todo va al doble de velocidad, o en un móvil que baja a 30 y todo va a la mitad. El fotograma no es una unidad de tiempo: es una unidad de oportunidad de dibujo, y su duración depende de la pantalla, de la carga y de decisiones del sistema operativo que no controlas. La única magnitud estable es el tiempo real transcurrido, y trabajar con ella cambia cómo se escribe cada línea de la fase de actualización.
- Expresar toda velocidad y aceleración en unidades por segundo.
- Acotar el delta y justificar el valor del límite.
- Implementar el paso fijo con acumulador y evitar la espiral de la muerte.
- Corregir el suavizado exponencial para que no dependa de la frecuencia.
Unidades por segundo
El cambio mecánico es trivial: donde había una constante por fotograma, hay una constante por segundo multiplicada por el delta.
// Dependiente de la frecuencia: 3 px por fotograma
p.x += 3;
// Independiente: 180 px por segundo
p.x += 180 * (dt / 1000);
Conviene convertir el delta a segundos una sola vez al principio de actualizar y trabajar siempre en esas unidades, porque mezclar milisegundos y segundos en la misma función es una fuente inagotable de factores de mil perdidos.
function actualizar(mundo, dtMs) {
const dt = dtMs / 1000; // a partir de aqui, segundos
for (const c of mundo.cuerpos) {
c.vy += 980 * dt; // gravedad en px por segundo al cuadrado
c.x += c.vx * dt;
c.y += c.vy * dt;
}
mundo.angulo += Math.PI * 0.5 * dt; // media vuelta por segundo
}
El beneficio secundario es que las constantes pasan a ser legibles. 980 es una gravedad que se entiende; 0.27 por fotograma no significa nada para nadie, incluido tú dentro de tres meses.
Hay un tipo de valor que no se multiplica por el delta y que se confunde a menudo: las cantidades que ya son estados, no ritmos. Una posición objetivo, un ángulo absoluto, un color destino. Multiplicar por el delta algo que no es una velocidad produce animaciones que se ralentizan solas.
Acotar el delta
Entre dos fotogramas puede pasar cualquier cosa: el usuario cambia de pestaña, el portátil suspende, el navegador decide que tu pestaña no es visible. Al volver, el delta puede valer treinta segundos, y ese número entra en tu física.
Con un delta de treinta segundos, un cuerpo que cae a 980 píxeles por segundo al cuadrado aparece a novecientos mil píxeles de distancia, las colisiones se saltan todos los obstáculos y la escena se rompe de forma irreparable.
const DT_MAXIMO = 50; // milisegundos: unos tres fotogramas a 60 Hz
const dt = Math.min(DT_MAXIMO, ahora - anterior);
El valor de cincuenta no es mágico pero tampoco arbitrario. Tiene que ser mayor que la duración de un fotograma lento real —para no ralentizar la animación en un dispositivo que va a 30 hercios— y menor que la distancia a la que tu simulación empieza a comportarse mal. Entre 50 y 100 milisegundos funciona para casi todo; en una simulación con colisiones rápidas, más bajo.
Y hay que decidir qué significa acotar: el tiempo perdido se descarta. Al volver de una pestaña oculta, el mundo no ha avanzado los treinta segundos, ha avanzado cincuenta milisegundos. Si tu aplicación necesita que el mundo sí avance —un reloj, una simulación con estado real— la solución no es un delta grande, es recalcular el estado analíticamente a partir del tiempo transcurrido.
El primer fotograma merece una nota aparte. Si inicializas anterior con cero, el primer delta vale varios miles de milisegundos:
let anterior = null;
function marco(ahora) {
if (anterior === null) anterior = ahora; // primer delta: cero
const dt = Math.min(DT_MAXIMO, ahora - anterior);
anterior = ahora;
// ...
}
Hay un patrón que aparece en todas las bases de código con animación y que es incorrecto de una manera que el acotado del delta no arregla: el seguimiento suave hacia un objetivo. La forma canónica es x += (objetivo - x) * 0.1, que produce un movimiento precioso y que depende por completo de la frecuencia de fotogramas: a 120 hercios llega al doble de rápido que a 60. La corrección que todo el mundo intenta primero es multiplicar por el delta, x += (objetivo - x) * 0.1 * dt, y está mal, aunque el resultado se parece lo bastante como para colar en una revisión. Está mal porque el proceso subyacente es exponencial y no lineal: la fracción que recorres en dos pasos de medio delta no es la misma que en un paso entero, y con deltas grandes el factor puede pasar de uno, lo que hace que el valor sobrepase el objetivo y oscile. La fórmula correcta sale de plantear el decaimiento como lo que es, una exponencial: si en un segundo quieres que quede un diez por ciento de la distancia, el factor por fotograma es 1 - Math.pow(0.1, dt) con el delta en segundos. Escrito como función reutilizable, function suavizar(actual, objetivo, restantePorSegundo, dt) { return objetivo + (actual - objetivo) * Math.pow(restantePorSegundo, dt); }. Esa versión da exactamente el mismo movimiento a cualquier frecuencia, no sobrepasa nunca y se comporta bien con deltas grandes. La misma trampa vive en cualquier decaimiento: el rozamiento v *= 0.98 por fotograma es v *= Math.pow(0.98, dt * 60) si quieres conservar el valor calibrado a sesenta hercios, y en general toda multiplicación repetida por una constante menor que uno tiene que pasar por una potencia, no por un producto. La forma de detectarlo en código ajeno es infalible: cualquier línea que multiplique un valor por una constante entre cero y uno en cada fotograma es un decaimiento exponencial mal escrito, y hay una potencia esperando a ocupar su sitio.
El paso fijo
Hay simulaciones que no toleran un delta variable por mucho que lo acotes: colisiones, muelles, cualquier integrador numérico con realimentación. Con paso variable, el mismo escenario da resultados distintos en cada máquina y a veces se vuelve inestable.
La solución clásica es desacoplar la frecuencia de simulación de la de dibujo: se acumula el tiempo real y se ejecutan tantos pasos fijos como quepan.
const PASO = 1 / 120; // segundos por paso de simulacion
const MAX_PASOS = 5; // techo por fotograma
let acumulador = 0;
function marco(ahora) {
const dt = Math.min(50, ahora - anterior) / 1000;
anterior = ahora;
acumulador += dt;
let pasos = 0;
while (acumulador >= PASO && pasos < MAX_PASOS) {
simular(mundo, PASO); // siempre el mismo delta
acumulador -= PASO;
pasos++;
}
if (pasos === MAX_PASOS) acumulador = 0; // descartar el retraso acumulado
const alfa = acumulador / PASO; // resto para interpolar al dibujar
dibujar(ctx, mundo, alfa);
requestAnimationFrame(marco);
}
Dos detalles cargan con todo el peso.
El techo de pasos evita la espiral de la muerte. Sin él, si un fotograma tarda mucho, el bucle intenta recuperar el tiempo perdido ejecutando muchos pasos, lo que hace que el fotograma tarde aún más, lo que acumula más tiempo pendiente. El sistema entra en una realimentación de la que no sale y la pestaña se congela. Con el techo, la simulación simplemente va a cámara lenta durante un momento y se recupera.
El resto del acumulador es información, no basura. Cuando el bucle termina, sobra una fracción de paso: el estado del mundo corresponde a un instante ligeramente anterior al momento de dibujar. Ese alfa entre cero y uno es justo lo que hace falta para interpolar entre el estado previo y el actual y eliminar el temblor que produciría dibujar siempre el último paso completo.
La contrapartida honesta: el paso fijo obliga a guardar dos estados por objeto —el anterior y el actual— y complica el código. Solo compensa cuando la simulación lo exige. Para mover partículas, animar una interfaz o desplazar una cámara, el paso variable con delta acotado es correcto y mucho más simple.
Medir de verdad la frecuencia
Nada de lo anterior sirve si asumes sesenta hercios en tu cabeza mientras el usuario está en otra frecuencia. Medirlo son cinco líneas y conviene tenerlas a mano durante el desarrollo:
const muestras = [];
function medir(dt) {
muestras.push(dt);
if (muestras.length < 120) return null;
const media = muestras.reduce((a, b) => a + b, 0) / muestras.length;
const peor = Math.max(...muestras);
muestras.length = 0;
return { fps: Math.round(1000 / media), peorMs: Math.round(peor) };
}
La media dice a qué frecuencia va la pantalla; el peor caso dice si tu animación tiene saltos, y es el número que importa. Una media de 59 con un peor caso de 90 milisegundos es una animación que se ve mal, y la media sola no lo revela.