Saltar al contenido

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.

Por qué el paso variable rompe la simulación

La versión ingenua tiene una línea y tres problemas:

JS
// 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:

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

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

JS
const MAX_DELTA = 0.25;   // como maximo, cuatro pasos de retrasoif ( 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.

JS
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. Descártala.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

JS
const PASO = 1 / 60;        // debe coincidir con mundo.timestepconst MAX_DELTA = 0.25;     // segundosconst 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.