The loop is the spine of the app. Three decisions: how you schedule frames, how you measure time, and whether simulation runs on render time or its own clock.
renderer.setAnimationLoop, not raw rAFrenderer.setAnimationLoop(animate); // start
renderer.setAnimationLoop(null); // stop (teardown!)
Why over requestAnimationFrame(animate) recursion:
setAnimationLoop switches automatically.null) — critical for SPA teardown
(see scale-and-disposal.md).DOMHighResTimeStamp — use it; don't call
performance.now() again inside the frame.let last = 0;
renderer.setAnimationLoop((now) => {
const dt = Math.min((now - last) / 1000, 0.1); // seconds, clamped
last = now;
update(dt);
renderer.render(scene, camera);
});
position += velocity * dt teleports everything through walls.dt-free code
(position.x += 0.01 per frame) runs 2.4× too fast there.THREE.Timer (core since r163) packages this: timer.update() +
timer.getDelta(), and timer.connect(document) auto-handles the
tab-switch spike by listening to visibility itself.Physics and gameplay logic that must behave identically at 60 / 120 / 24 fps run on a fixed step, decoupled from render rate; rendering interpolates between the last two sim states:
const FIXED = 1 / 60;
let acc = 0, last = 0;
renderer.setAnimationLoop((now) => {
acc += Math.min((now - last) / 1000, 0.1);
last = now;
while (acc >= FIXED) {
previousState.copy(currentState); // snapshot for interpolation
simulate(FIXED); // physics.step, AI, gameplay
acc -= FIXED;
}
const alpha = acc / FIXED; // 0..1 — how far into the next tick
mesh.position.lerpVectors(previousState.position, currentState.position, alpha);
mesh.quaternion.slerpQuaternions(previousState.quaternion, currentState.quaternion, alpha);
mixer.update(dtRender); // animation runs on RENDER time — smoother
renderer.render(scene, camera);
});
Notes:
while loop caps itself via the dt clamp — without the clamp, a long
stall queues hundreds of sim ticks (the "spiral of death").world.timestep = FIXED once and call world.step() inside the
while — never pass render dt to a physics step
(physics.md).rAF stops in hidden tabs but not in occluded or backgrounded windows consistently across platforms — pause explicitly:
document.addEventListener('visibilitychange', () => {
paused = document.hidden;
if (!paused) last = performance.now(); // swallow the away-time
});
last on resume is the other half of the dt clamp — otherwise the
first visible frame still sees the whole away period.AudioContext.THREE.Timer.connect(document) implements exactly this for its delta.DOM input events fire asynchronously, between frames — never mutate game state in the handler. Handlers write to a state object; the loop reads it:
const input = { keys: new Set(), pointer: new THREE.Vector2(), pointerDown: false };
addEventListener('keydown', (e) => { if (!e.repeat) input.keys.add(e.code); });
addEventListener('keyup', (e) => input.keys.delete(e.code));
addEventListener('blur', () => input.keys.clear()); // alt-tab = stuck keys otherwise
// inside the fixed tick:
const forward = input.keys.has('KeyW') ? 1 : 0;
e.code (physical key — KeyW works on AZERTY), not e.key.blur clear matters: without it, releasing a key while the window is
unfocused leaves it "held" forever.justPressed by diffing
against the previous tick's set, or consume the key on read.<PointerLockControls> or
three's addon of the same name wrap it.value += (target - value) * 0.1 per frame is the classic bug: the smoothing
speed changes with the frame rate (2.4× stiffer at 144 Hz). The correct,
dt-aware form is exponential decay — built in as THREE.MathUtils.damp:
// λ ≈ 1/seconds-to-close-63%-of-the-gap; bigger = snappier
camera.position.x = THREE.MathUtils.damp(camera.position.x, target.x, 4, dt);
The third-person follow camera in four lines:
const behind = player.localToWorld(new THREE.Vector3(0, 2.5, -5)); // offset in player space
camera.position.lerp(behind, 1 - Math.exp(-4 * dt)); // same math as damp
camera.lookAt(player.position);
Use damping for cameras, UI-ish motion, and audio params; never for physics bodies (the solver owns those — see physics.md).
Fixed timestep is necessary but not sufficient. For replays, lockstep networking, or reproducible tests:
Math.random(). A 10-line mulberry32 is
enough: same seed → same run.At 60 fps the whole frame is 16.6 ms; browsers need ~2 ms, leaving ~14 ms for sim + render. Measure before optimizing:
renderer.info.render; // { calls, triangles, points, lines } — per frame
Draw calls, not triangles, are usually the ceiling — see scale-and-disposal.md for the budget playbook.