Saltar al contenido

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.

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.

JS
// Dependiente de la frecuencia: 3 px por fotogramap.x += 3;// Independiente: 180 px por segundop.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.

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

JS
const DT_MAXIMO = 50;                          // milisegundos: unos tres fotogramas a 60 Hzconst 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:

JS
let anterior = null;function marco(ahora) {  if (anterior === null) anterior = ahora;     // primer delta: cero  const dt = Math.min(DT_MAXIMO, ahora - anterior);  anterior = ahora;  // ...}

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.

JS
const PASO = 1 / 120;                   // segundos por paso de simulacionconst MAX_PASOS = 5;                    // techo por fotogramalet 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:

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