El bucle de tiempo fijo con acumulador
Por qué la física no puede ir al ritmo del render, cómo se construye el acumulador completo con recorte de delta y límite de pasos, y qué falla exactamente en cada versión incompleta.
Casi todo el mundo escribe mundo.step() dentro del bucle de render la primera vez, y casi todo el mundo descubre semanas después que su juego se comporta distinto en un monitor de 144 Hz que en uno de 60. No es un detalle de pulido: es un fallo estructural que hace que la simulación no sea reproducible, que la dificultad dependa del hardware, y que un tirón de medio segundo atraviese las paredes. El patrón que lo resuelve tiene cuarenta años, se llama acumulador, y hay que escribirlo entero para que funcione.
- Explicar por qué un paso de simulación variable degrada el solver.
- Implementar el bucle con acumulador completo, con recorte de delta y límite de pasos.
- Reconocer la espiral de la muerte y saber por qué el límite de pasos la evita.
- Justificar cada constante del patrón en función del comportamiento que produce.
Por qué el paso variable rompe la simulación
La versión ingenua tiene una línea y tres problemas:
// MAL: la fisica avanza al ritmo del monitor.
renderer.setAnimationLoop( () => {
mundo.step();
sincronizar();
renderer.render( scene, camera );
} );
mundo.step() avanza siempre mundo.timestep segundos, que por defecto es un sesentavo. A 60 Hz eso coincide con el tiempo real. A 144 Hz se llama 144 veces por segundo y la simulación avanza 2,4 segundos por cada segundo real: todo va en cámara rápida. A 30 Hz, la mitad de velocidad. La simulación queda atada a la frecuencia de refresco, que es una propiedad del monitor del usuario.
El arreglo obvio es pasar el delta al motor:
// TAMBIEN MAL, y peor de lo que parece.
renderer.setAnimationLoop( ( tiempo ) => {
reloj.update( tiempo );
mundo.timestep = reloj.getDelta();
mundo.step();
} );
Ahora la velocidad es correcta y la simulación es peor. Hay tres razones y las tres son técnicas.
El solver pierde su historia. Rapier —como cualquier motor moderno— guarda los impulsos de contacto de un paso para usarlos como punto de partida del siguiente. Esos impulsos están escalados por el paso de tiempo. Si el paso cambia entre iteraciones, la estimación inicial deja de ser válida y el solver converge peor. Una pila de cajas que se mantiene estable con paso fijo empieza a temblar con paso variable.
La integración numérica cambia de comportamiento. Los integradores semiimplícitos que usan estos motores son estables dentro de un rango de pasos y se vuelven inestables fuera de él. Un delta grande —un tirón, una pestaña en segundo plano, un cambio de escritorio— produce un paso enorme que dispara velocidades absurdas, mete cuerpos dentro de paredes y hace explotar la escena.
Se pierde el determinismo. Con paso fijo, la misma secuencia de entradas produce la misma simulación en cualquier máquina. Con paso variable, cada ejecución es distinta porque los deltas nunca se repiten. Eso mata la repetición de partidas, el juego en red con predicción, los tests automatizados, y la posibilidad de reproducir un bug.
El acumulador
La idea es desacoplar los dos relojes. El render corre a la frecuencia que quiera; la física avanza en pasos fijos, tantos como quepan en el tiempo transcurrido, y el resto se guarda para el frame siguiente.
flowchart TB
A[Frame de render] --> B[delta igual a ahora menos anterior]
B --> C[Recortar delta a un maximo]
C --> D[acumulador mas delta]
D --> E{acumulador mayor o igual que paso}
E -- Si --> F[Copiar estado actual a estado previo]
F --> G[world step avanza un paso fijo]
G --> H[acumulador menos paso]
H --> E
E -- No --> I[alfa igual acumulador dividido por paso]
I --> J[Interpolar previo hacia actual con alfa]
J --> K[Renderizar]
K --> A
style A fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
style E fill:#cba6f7,color:#11111b
style G fill:#fab387,color:#11111b
style J fill:#a6e3a1,color:#11111b
style K fill:#a6e3a1,color:#11111bEl acumulador es una deuda de tiempo. Cada frame le sumas el tiempo real transcurrido y le restas un paso por cada step() que ejecutas. Lo que queda dentro es siempre menor que un paso: es el sobrante que todavía no da para simular y que se arrastra al frame siguiente.
A 144 Hz, cada frame aporta unos 6,9 ms al acumulador. Como el paso son 16,7 ms, la mayoría de los frames no ejecutan ningún step() y uno de cada dos o tres ejecuta uno. A 30 Hz, cada frame aporta 33 ms y ejecuta dos pasos. En los dos casos, la física avanza exactamente 60 pasos por segundo real.
Las tres defensas
El bucle con solo el while está incompleto y falla de formas espectaculares. Faltan tres protecciones.
Recortar el delta. Si el usuario cambia de pestaña, minimiza la ventana o el sistema se congela un segundo, el siguiente frame trae un delta de varios segundos. Sin recorte, el acumulador recibe ese valor y el while ejecuta cientos de pasos de golpe, congelando el navegador durante el proceso.
const MAX_DELTA = 0.25; // como maximo, cuatro pasos de retraso
if ( delta > MAX_DELTA ) delta = MAX_DELTA;
Ese recorte significa aceptar que el tiempo perdido se pierde: la simulación no intenta recuperar el segundo que estuvo congelada. Es la decisión correcta. Alternativamente puedes escuchar visibilitychange y reiniciar el reloj al volver, que evita el salto de raíz.
Limitar los pasos por frame. El recorte de delta protege contra pausas puntuales. El límite de pasos protege contra algo peor: la espiral de la muerte. Ocurre cuando la simulación tarda más en ejecutar un paso de lo que ese paso representa. Si un step() cuesta 20 ms de CPU pero solo avanza 16,7 ms de simulación, cada frame acumulas más deuda de la que puedes pagar. El while ejecuta cada vez más pasos, el frame tarda cada vez más, y en pocos segundos la aplicación está congelada sin posibilidad de recuperarse.
const MAX_PASOS = 5;
let pasos = 0;
while ( acumulador >= PASO && pasos < MAX_PASOS ) {
mundo.step();
acumulador -= PASO;
pasos ++;
}
// Si hemos tocado el limite, la deuda es incobrable. Descartala.
if ( pasos === MAX_PASOS ) acumulador = 0;
Ese acumulador = 0 es la parte que casi todas las implementaciones olvidan y es la que de verdad rompe la espiral. Sin él, el acumulador sigue creciendo frame tras frame, el while sigue tocando el techo, y la simulación se retrasa cada vez más respecto al tiempo real sin recuperarse nunca: el juego va a cámara lenta permanente. Descartar la deuda significa que la simulación salta hacia adelante en el tiempo, lo que es visible pero es infinitamente mejor que una degradación irreversible.
Guardar el estado previo. El acumulador deja un residuo entre cero y un paso. Renderizar el último estado simulado significa mostrar un instante que está hasta 16,7 ms por detrás del presente, y como ese desfase varía frame a frame, se percibe como micro-tirones. La solución es interpolar entre el estado anterior y el actual con alfa = acumulador / PASO, y es el tema de la lección siguiente. Lo que hay que reservar aquí es el sitio donde se guarda el estado previo: justo antes de cada step(), no una vez por frame.
El bucle completo
const PASO = 1 / 60; // debe coincidir con mundo.timestep
const MAX_DELTA = 0.25; // segundos
const MAX_PASOS = 5;
mundo.timestep = PASO;
let acumulador = 0;
let anterior = performance.now() / 1000;
function bucle() {
const ahora = performance.now() / 1000;
let delta = ahora - anterior;
anterior = ahora;
// Defensa 1: pausas largas no se recuperan.
if ( delta > MAX_DELTA ) delta = MAX_DELTA;
acumulador += delta;
// Defensa 2: limite de pasos por frame.
let pasos = 0;
while ( acumulador >= PASO && pasos < MAX_PASOS ) {
guardarEstadoPrevio(); // antes de cada paso, no una vez por frame
mundo.step();
acumulador -= PASO;
pasos ++;
}
if ( pasos === MAX_PASOS ) acumulador = 0;
// Defensa 3: interpolar el residuo.
const alfa = acumulador / PASO;
sincronizarEscena( alfa );
renderer.render( scene, camera );
}
renderer.setAnimationLoop( bucle );
// Evitar el salto al volver de una pestaña en segundo plano.
document.addEventListener( 'visibilitychange', () => {
if ( ! document.hidden ) {
anterior = performance.now() / 1000;
acumulador = 0;
}
} );
Una nota sobre el reloj: performance.now() es monótono y no salta si el sistema ajusta la hora, a diferencia de Date.now(). El argumento que setAnimationLoop pasa al callback es el mismo tipo de marca de tiempo y también sirve; lo que no sirve es Date.now().
Toda la lógica del juego que dependa del tiempo —temporizadores, contadores, disparadores— debería vivir dentro del while, junto al step(), no en el cuerpo del frame. Solo así comparte el determinismo de la física. Lo que va fuera es lo puramente visual: interpolación, cámara, animaciones de interfaz.
Merece la pena entender qué se gana de verdad con todo este aparato, porque la respuesta no es “que vaya a la misma velocidad en todos los monitores” —eso lo consigue también el paso variable— sino algo más profundo: el paso fijo convierte la simulación en una función determinista del estado inicial y la secuencia de entradas. Con paso fijo, si guardas el estado del mundo y la lista de acciones del jugador, puedes reproducir la partida entera, bit a bit, en cualquier máquina. Esa propiedad es la que hace posible el juego en red con predicción del lado del cliente: el cliente simula localmente los pasos 100 a 110, el servidor confirma el 100, y si difieren, el cliente rebobina al 100 y resimula los diez pasos con las entradas corregidas. Sin paso fijo eso es literalmente imposible, porque no existen “los diez pasos”: existen diez intervalos irrepetibles. Lo mismo vale para las repeticiones —guardar entradas en lugar de posiciones ocupa mil veces menos— y para los tests: un test de física con paso variable no es un test, es una observación. Y hay un corolario que cambia cómo estructuras el código. Una vez que aceptas que la física vive en su propio reloj, se hace evidente que el render no es el bucle principal, es un observador. El bucle principal es la simulación, que avanza a 60 pasos por segundo pase lo que pase; el render es un proceso separado que, cuando le toca, mira el estado de la simulación y dibuja una interpolación de él. Esa inversión mental es la que hace que después te resulten naturales cosas que de otro modo parecen complicadas: simular a 120 pasos por segundo y renderizar a 60 para un juego de precisión, simular a 30 y renderizar a 144 para ahorrar CPU en móvil, o mover la simulación entera a un worker y dejar el hilo principal solo para dibujar. Ninguna de las tres es posible mientras step() viva dentro del callback de render.
- Monta una torre de diez cajas con
mundo.timestep = reloj.getDelta()y observa el temblor. - Cámbialo a paso fijo con acumulador y comprueba que la torre se queda quieta.
- Quita el recorte de delta y cambia de pestaña treinta segundos; anota qué ocurre al volver.
- Quita el
acumulador = 0tras tocar el límite y fuerza una carga que haga questep()cueste 25 ms; describe la degradación. - Instrumenta el bucle para imprimir cuántos pasos se ejecutan por frame y comprueba el patrón a 60 Hz y a 144 Hz.