Tip: Visualising ODEs and PDEs in the Browser

Almost every simulation on this site is, underneath the pixels, a differential equation being stepped forward in time. This tip covers the practical Canvas 2D toolkit we reach for again and again: choosing an integrator, laying out finite-difference grids, keeping the maths stable, and squeezing it all into a steady 60 frames per second.

Differential equations describe how things change — a planet's velocity, the temperature across a metal plate, the swirl of a fluid. The browser cannot solve them symbolically in real time, but it does not need to. It just needs to advance the state by a small time step, draw the result, and repeat. Get that loop right and a static equation turns into a living, pannable, parameter-tweakable picture. Get it wrong and your simulation either crawls or explodes into NaN within a few seconds.

Below is the workflow we use across the catalogue, from ordinary differential equations (ODEs) that track a handful of variables to partial differential equations (PDEs) that evolve a whole grid of values.

1. Pick the Right Integrator: Euler vs RK4

An ODE solver answers one question repeatedly: given the current state and the rate of change, what is the state a tiny moment later? The simplest answer is the forward Euler method — take the current slope and step straight along it.

// state = {x, v}, derivs(s) returns {dx, dv}
function eulerStep(s, dt) {
  const d = derivs(s);
  return { x: s.x + d.dx * dt, v: s.v + d.dv * dt };
}

Euler is cheap and perfectly adequate for gentle, slow systems or particle clouds where a little drift is invisible. But it accumulates error fast, and for oscillating or chaotic systems it injects fake energy until the orbit spirals out. That is where fourth-order Runge–Kutta (RK4) earns its keep. RK4 samples the slope four times per step — once at the start, twice in the middle, once at the end — and blends them into a far more accurate estimate.

function rk4Step(s, dt) {
  const k1 = derivs(s);
  const k2 = derivs(add(s, scale(k1, dt / 2)));
  const k3 = derivs(add(s, scale(k2, dt / 2)));
  const k4 = derivs(add(s, scale(k3, dt)));
  return add(s, scale(add4(k1, k2, k2, k3, k3, k4), dt / 6));
}

RK4 costs four derivative evaluations instead of one, but it lets you take much larger time steps for the same accuracy — usually a net win. Our Lorenz attractor in 3D relies on RK4: the butterfly shape only stays crisp because the integrator does not bleed energy. For stiff or energy-conserving mechanical systems, a symplectic variant like velocity Verlet is often the better pick, but RK4 is the safe default when you are not sure.

2. Finite-Difference Grids for PDEs

PDEs evolve a field — a value at every point in space — rather than a few scalars. The standard browser-friendly approach is the finite-difference method: store the field as a flat Float32Array indexed as i = y * width + x, and approximate spatial derivatives by comparing each cell to its neighbours.

The workhorse is the discrete Laplacian, which drives diffusion, heat flow, and wave propagation:

// 5-point Laplacian on a width×height grid
const i = y * width + x;
const lap = field[i - 1] + field[i + 1]
          + field[i - width] + field[i + width]
          - 4 * field[i];
next[i] = field[i] + alpha * lap * dt;

Always write into a separate output buffer and swap the two arrays after the full sweep — updating in place corrupts the neighbours you have not visited yet. This double-buffer pattern is exactly what powers grid simulations like plasma instability, where a charged fluid field is marched forward cell by cell, and the diffusion-style smoothing behind granular heating, where local collisions spread energy through a packed bed of grains.

3. Respect the Stability Condition

Here is the trap that catches everyone the first time: explicit finite-difference schemes are only stable if the time step is small enough relative to the grid spacing. For a diffusion equation in two dimensions the CFL (Courant–Friedrichs–Lewy) condition roughly requires alpha * dt / dx² < 0.25. Push past it and the field does not just get inaccurate — it oscillates wildly and overflows to infinity within a handful of frames.

Rule of thumb: if your PDE blows up to NaN, halve dt before you touch anything else. Nine times out of ten you have simply violated the stability limit, not written a bug.

Two practical fixes keep you safe without slowing the visible animation. First, run several small physics sub-steps per rendered frame — the maths stays stable while the screen still updates 60 times a second. Second, clamp or gently damp the field each step so a stray spike cannot snowball. Wave-style PDEs, like the dispersive ripple pattern in our Kelvin wake simulation, are especially sensitive here: the V-shaped wake only holds its shape when the wave speed and time step stay in balance.

4. Decouple Physics from Rendering

A common mistake is tying the integration step directly to requestAnimationFrame and passing the real elapsed time as dt. When the tab stalls, that delta balloons to hundreds of milliseconds and a single giant step detonates your stable scheme. The fix is a fixed-timestep accumulator: advance the physics in constant slices and only render once per frame.

let acc = 0;
const FIXED = 1 / 120; // physics seconds per sub-step

function frame(now) {
  acc += Math.min((now - last) / 1000, 0.1); // cap the spike
  last = now;
  while (acc >= FIXED) { step(FIXED); acc -= FIXED; }
  render();
  requestAnimationFrame(frame);
}

Capping the accumulated time (Math.min(..., 0.1)) prevents the dreaded "spiral of death" where a slow frame demands more sub-steps than the next frame can afford. This single pattern is the difference between a simulation that survives a background tab and one that returns to a screen full of garbage.

5. Render Fast: Batch Your Canvas Calls

Even perfect maths feels broken at 20 FPS. Canvas 2D is plenty fast for these visualisations if you stop fighting it:

Together these turn a sluggish prototype into something that feels instantaneous, even on a mid-range phone.

Putting It Together

The recipe is the same whether you are evolving three coupled ODEs or a million-cell PDE field: choose an integrator that matches how sensitive your system is (Euler for the forgiving, RK4 for the rest), lay your field out as a flat typed array with a double-buffered finite-difference update, keep your time step under the stability limit, decouple physics from rendering with a fixed accumulator, and batch every Canvas call you can. Master those five habits and almost any differential equation in a textbook becomes a simulation you can ship.

Want to see these techniques in motion? Explore the Lorenz attractor, plasma instability, Kelvin wake, and granular heating simulations — every one of them is a differential equation running the loop described above. Or browse the full simulation catalogue to find the equation behind your favourite phenomenon.

Related Posts