Three.js From Zero · Article s8-03
S8-03 Game Loop Architectures
Game Loop Architectures
Fixed timestep for physics. Variable rendering for smooth display. The "fix your timestep" article by Glenn Fiedler — codified here in Three.js.
1. The naïve loop is wrong
function tick() {
const dt = (now - last) / 1000;
physics.step(dt); // Bug: variable dt makes physics non-deterministic
render();
requestAnimationFrame(tick);
}
If the browser drops a frame, dt spikes. Physics blows up (fast collisions tunnel through walls). Replays desync.
2. The fix: accumulator pattern
const FIXED_DT = 1 / 60; // physics at 60Hz
let acc = 0, last = performance.now();
function tick(now) {
const frameTime = Math.min((now - last) / 1000, 0.25); // clamp to avoid spiral of death
last = now;
acc += frameTime;
while (acc >= FIXED_DT) {
physics.step(FIXED_DT); // always exactly 1/60
acc -= FIXED_DT;
}
const alpha = acc / FIXED_DT; // 0..1 interpolation
render(alpha);
requestAnimationFrame(tick);
}
3. Interpolation for smooth render
Between physics steps, render interpolates:
function render(alpha) {
for (const obj of renderables) {
obj.position.lerpVectors(obj.prevPosition, obj.currentPosition, alpha);
}
renderer.render(scene, camera);
}
120Hz display + 60Hz physics = perfectly smooth. Physics stays stable.
4. Three.js + Rapier example
const world = new RAPIER.World(gravity);
const FIXED_DT = 1 / 60;
world.timestep = FIXED_DT;
let acc = 0, last = performance.now();
function tick(now) {
const dt = Math.min((now - last) / 1000, 0.25);
last = now;
acc += dt;
while (acc >= FIXED_DT) {
// Save positions BEFORE step
for (const [mesh, body] of pairs) {
mesh.userData.prevPos.copy(mesh.position);
}
world.step();
// Copy body positions to mesh
for (const [mesh, body] of pairs) {
mesh.position.copy(body.translation());
}
acc -= FIXED_DT;
}
const alpha = acc / FIXED_DT;
for (const [mesh, body] of pairs) {
mesh.position.lerpVectors(mesh.userData.prevPos, body.translation(), alpha);
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
5. Live demo — naive vs fixed timestep
Toggle between naive (variable) and fixed-step. Throttle CPU via stalls. Watch physics stability.
6. Spiral of death
If one physics step takes > 1/60s, accumulator grows faster than drained. Next frame accumulates more. Spiral.
Fix: clamp frameTime = min(frameTime, 0.25). Accept some slow-motion during spikes instead of lockup.
7. Variable render / fixed update (Unity pattern)
Update(): variable, per-frame. Input, camera, visuals.FixedUpdate(): fixed, per-physics-step. Physics, network send.LateUpdate(): variable, after Update. Camera follow, final adjustments.
8. Deterministic replays
With fixed timestep + seeded RNG + deterministic physics: same inputs → same simulation → replay works. Essential for multiplayer reconciliation, ghosts, esports replays.
9. Takeaways
- Never give physics a variable dt. Always fixed.
- Accumulator pattern: drain in fixed chunks, render between.
- Interpolate between physics states for smooth rendering.
- Clamp frame time to avoid spiral of death.
- Deterministic replays need fixed timestep.