Framerate variable, el primer frame y el paso fijo
Cuándo el delta time no basta: acotar el delta, el bucle con acumulador que simula a paso constante, y la espiral de la muerte que hay que evitar.
Aplicar el delta time arregla la dependencia de la tasa de refresco, pero no arregla el hecho de que el delta varía. Un frame de 16 milisegundos seguido de uno de 60 y otro de 17 es lo normal en cualquier página real. Para un desplazamiento a velocidad constante da igual; para cualquier simulación con fuerzas, colisiones o umbrales, un delta grande no equivale a varios pequeños y el resultado deja de ser reproducible. La solución tiene nombre y es antigua: desacoplar el ritmo de la simulación del ritmo del dibujado.
- Explicar por qué un delta grande no equivale a varios pequeños en una simulación.
- Acotar el delta y manejar el primer frame y la vuelta desde segundo plano.
- Implementar un bucle con acumulador y paso fijo.
- Evitar la espiral de la muerte con un tope de pasos por frame.
Por qué un delta grande no es varios pequeños
Con velocidad constante, integrar con un paso de 100 milisegundos o con seis pasos de 16.7 da exactamente lo mismo: la posición es lineal en el tiempo. En cuanto hay una aceleración, deja de serlo. Integrar con pasos grandes usando el método más simple —sumar la velocidad por el delta y después actualizar la velocidad— introduce un error que crece con el tamaño del paso, y una simulación de un muelle puede pasar de amortiguarse a oscilar cada vez más solo por haberse ejecutado con frames largos.
El caso más visible es el atravesamiento. Un objeto que se mueve a 600 píxeles por segundo recorre 10 píxeles en un frame de 16 milisegundos y 120 en uno de 200. Si hay una pared de 20 píxeles de grosor, en el primer caso la colisión se detecta y en el segundo el objeto aparece al otro lado sin haberla tocado. La detección de colisiones por solapamiento es correcta solo si el paso es lo bastante pequeño.
Y hay un tercer efecto, más sutil pero igual de real: la reproducibilidad. Si la simulación depende del delta, dos ejecuciones del mismo escenario en dos máquinas distintas divergen. Eso descarta cualquier prueba automatizada del comportamiento y hace imposible reproducir un bug que alguien reportó.
Acotar el delta
La primera línea de defensa es barata y hay que ponerla siempre:
const DT_MAX = 1 / 20; // 50 ms; nunca simulamos un paso mayor
function frame(ts) {
if (anterior === null) anterior = ts;
const dt = Math.min((ts - anterior) / 1000, DT_MAX);
anterior = ts;
// ...
}
Con esto, un frame largo se simula como si hubiera durado 50 milisegundos. La simulación va más lenta que el reloj durante ese hueco, que es preferible a explotar. Y al volver de segundo plano, donde el delta puede valer minutos, el tope evita que un solo paso destruya el estado.
Merece la pena además reajustar el reloj explícitamente al recuperar la visibilidad, en vez de confiar solo en el tope:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') anterior = null; // el proximo frame tendra dt 0
});
Poner anterior a null hace que el siguiente frame se trate como el primero, con delta cero. La simulación se congela durante el tiempo en segundo plano y continúa exactamente donde estaba, que es casi siempre lo que quiere el usuario: al volver a la pestaña espera encontrarse la escena como la dejó, no adelantada dos minutos.
El bucle con acumulador
Para una simulación de verdad, el tope no basta: hace falta que el paso sea siempre el mismo. La técnica consiste en acumular el tiempo real y consumirlo en trozos de tamaño fijo.
flowchart TB A[llega un frame con su marca de tiempo] --> B[dt igual a la marca menos la anterior] B --> C[acotar dt a un maximo] C --> D[sumar dt al acumulador] D -->|el acumulador llega al paso| E[simular un paso fijo] E --> D D -->|no llega al paso| F[dibujar interpolando el resto] style B fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style E fill:#cba6f7,color:#11111b style F fill:#a6e3a1,color:#11111b
const PASO = 1 / 120; // segundos por paso de simulacion
const DT_MAX = 0.25; // tope del delta real
const MAX_PASOS = 8; // tope de pasos por frame
let anterior = null;
let acumulador = 0;
let estado = { x: 0, v: 0 };
let estadoPrevio = { ...estado };
function simular(s, dt) {
const gravedad = 900;
const v = s.v + gravedad * dt;
const x = s.x + v * dt;
return x > 400 ? { x: 400, v: -v * 0.7 } : { x, v };
}
function frame(ts) {
if (anterior === null) anterior = ts;
const dt = Math.min((ts - anterior) / 1000, DT_MAX);
anterior = ts;
acumulador += dt;
let pasos = 0;
while (acumulador >= PASO && pasos < MAX_PASOS) {
estadoPrevio = estado;
estado = simular(estado, PASO);
acumulador -= PASO;
pasos++;
}
if (pasos === MAX_PASOS) acumulador = 0; // descartamos el retraso irrecuperable
const alfa = acumulador / PASO;
const x = estadoPrevio.x + (estado.x - estadoPrevio.x) * alfa;
pelota.style.transform = `translateY(${x.toFixed(2)}px)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Tres piezas y cada una resuelve algo distinto.
El acumulador guarda el tiempo real que aún no se ha simulado. El bucle while consume pasos completos mientras haya de sobra, y lo que sobra se queda para el frame siguiente. Así la simulación avanza siempre en trozos de 1/120 de segundo, sea cual sea el ritmo de los frames.
La interpolación con alfa resuelve el problema que crea el acumulador. Como el estado simulado va por delante o por detrás del instante que hay que dibujar, dibujar el estado tal cual produce un temblor de subpíxel. Interpolando entre el estado anterior y el actual con la fracción sobrante se dibuja el instante correcto. Es la diferencia entre un movimiento fluido y uno que vibra ligeramente sin motivo aparente.
El tope de pasos evita el desastre que viene ahora.
La espiral de la muerte
Si simular un paso cuesta más tiempo del que representa, el acumulador crece más rápido de lo que se vacía. El frame siguiente tiene más tiempo que consumir, así que ejecuta más pasos, así que tarda más, así que acumula todavía más. La página se cuelga.
Se llama espiral de la muerte y no es teórica: aparece en cuanto una simulación crece hasta rozar el presupuesto y un pico de carga la empuja al otro lado. Sin un tope, no hay recuperación posible.
El tope de pasos por frame corta la realimentación. Al alcanzarlo, aceptas que la simulación va con retraso y descartas el retraso poniendo el acumulador a cero. La simulación se ralentiza respecto al reloj —lo que se llama cámara lenta— pero la página sigue respondiendo, y en cuanto la carga baja se recupera sola.
La alternativa de no descartar y arrastrar el retraso solo sirve si estás seguro de que el pico es puntual. Para una interfaz web, descartar es lo correcto: nadie está comparando la simulación con un cronómetro externo.
Elegir el tamaño del paso fijo tiene una restricción que no aparece en ningún tutorial: el paso debe ser menor o igual que el frame más corto que vayas a dibujar. Si simulas a 1/60 y el monitor va a 120Hz, la mitad de los frames no ejecutan ningún paso —el acumulador todavía no llega— y se limitan a redibujar la interpolación. Eso funciona y es exactamente para lo que está el alfa, pero significa que la resolución temporal de tu simulación es la mitad de la del dibujado, y en un movimiento rápido se percibe como una ligera falta de nitidez en la trayectoria. Simular a 1/120 en un monitor de 60Hz tiene el problema opuesto y menos grave: dos pasos por frame, el doble de coste de simulación, pero trayectorias más precisas y colisiones más fiables. Como los monitores de 120Hz ya son comunes y el coste de simular es casi siempre despreciable frente al de dibujar, el valor por defecto sensato en 2026 es 1/120, no 1/60. Y si tu simulación es cara, la respuesta no es bajar la frecuencia del paso: es simular menos cosas, porque bajar la frecuencia degrada la calidad de forma no lineal en cuanto hay colisiones de por medio.
Cuándo no hace falta nada de esto
El paso fijo es maquinaria pesada y la mayoría de los bucles de interfaz no la necesitan. Si tu movimiento es función del tiempo —una rotación, una oscilación, un desplazamiento constante—, no hay estado que integrar y ni el delta ni el acumulador tienen sentido: calculas la posición a partir del tiempo absoluto y ya está.
Si tu movimiento es un suavizado exponencial hacia un objetivo, la fórmula con Math.exp de la lección anterior ya es exacta para cualquier delta, y con un tope al delta no necesitas más.
El paso fijo empieza a hacer falta cuando hay acumulación de fuerzas, colisiones o umbrales de estado: cosas que ocurren o no ocurren dependiendo de dónde caigan exactamente los pasos. Ese es el criterio, y es una lista más corta de lo que parece.
Implementa la pelota que rebota con el bucle de arriba y quítale la interpolación con alfa, dibujando estado.x directamente. Obsérvala con atención en un monitor de 60Hz con PASO = 1/120: verás una vibración de un píxel. Vuelve a poner el alfa y comprueba que desaparece. Después baja MAX_PASOS a 1 y provoca carga en el hilo principal para ver la cámara lenta en acción.