· 2 min read
Never trust the delta your frame loop hands you
An unclamped frame delta made a WebGL hero teleport instead of resume, and pushed an interpolation past its target into negative values.

The hero on this site is an oscilloscope trace: one continuous line, drawn in a shader, that reacts to the cursor. It looked broken intermittently. Not every load, and never while I was watching for it.
Three bugs, one root cause. All of them came from trusting the elapsed time the frame loop reports.
useFrame((state, delta) => {
u.uTime.value += delta;
u.uProbeEnergy.value = Math.min(1, lerp(u.uProbeEnergy.value, 0, delta * 1.6) + moved * 2.2);
probe.x = lerp(probe.x, target.x, 0.12);
});
The delta is not 16ms
It is 16ms when everything is healthy. It is whatever actually elapsed otherwise, and plenty of ordinary things stop a loop: a tab returning to the foreground, a long task during hydration, a phone throttling under thermal pressure, a debugger pause.
Time teleports. uTime advances by the whole gap, so a waveform built from sin(t) does not resume where it left off. It jumps to an unrelated phase. To a viewer that is not a pause, it is a glitch.
The interpolation overshoots. This is the interesting one. lerp(a, b, t) returns a + (b - a) * t, and nothing clamps t. Here t is delta * 1.6, so once delta passes 0.625 seconds the factor exceeds 1 and the result travels past the target.
The target was zero, so the energy went negative. That value is a brightness in the fragment shader. A negative brightness under additive blending is not a dimmer line — it is a different equation, and what you see is a line that flickers or disappears.
Nothing is framerate independent. 0.12 per frame is twice the speed on a 120Hz phone as on a 60Hz laptop. Two devices, two different animations, same code.
The fix, all three at once
useFrame((state, rawDelta) => {
const delta = Math.min(rawDelta, 1 / 30);
const smooth = (k) => 1 - Math.exp(-k * delta);
u.uTime.value += delta;
const decayed = lerp(u.uProbeEnergy.value, 0, smooth(1.6));
u.uProbeEnergy.value = Math.max(0, Math.min(1, decayed + moved * 2.2));
probe.x = lerp(probe.x, target.x, smooth(8));
});
Capping at a 30fps step fixes the teleport and the overshoot together: the worst case is now a slightly slow frame rather than an unbounded one.
1 - Math.exp(-k * dt) is the part worth stealing. It converts a per-frame factor into a per-second one, so the motion is identical at any refresh rate. The constant k is how fast it approaches, in units of time rather than units of frame.
And clamp both ends of anything a shader reads. Math.min(1, x) is a habit; Math.max(0, x) is the one people forget, and it is the one that turns a subtle bug into an invisible one.
The general rule
Treat the frame delta as hostile input. It comes from outside your program, it has no upper bound, and every place you multiply by it is a place that can produce a value you never designed for.