I’ve gotten some influx of people visiting my webpage recently, and there were many questions about my prime flow field background, so I thought I’d go over it in more detail. There were also some mentions about the poor performance, so I optimized it while I was at it .
Feel free to use it: Repo
At it’s foundation is a flow field, that’s made by particles drifting through a vector field and leaving little fading trails. I’ll build it up from scratch as I go along.
Flow Field
A flow field is a rule that hands you an arrow at every point $(x, y)$:
$$ \mathbf{F}(x, y) = \big(u(x, y),\; v(x, y)\big) $$
Here $u$ is how fast to move horizontally and $v$ how fast to move vertically. At each frame the particle checks the arrow where it stands and takes a small step along it:
$$ x \leftarrow x + u\,\Delta t, \qquad y \leftarrow y + v\,\Delta t $$
Doing that per particle while also fading the old trails a little each frames draws the desired effect.
The field, as a function
We sample each point over and over again:
nx,ny— where we’re asking, in the normalized coordinates from above;t— elapsed time, which lets the field drift
const [u, v] = field(particle.x, particle.y, t);
particle.x += u * dt;
particle.y += v * dt;
We choose a random spot on the canvas, a random lifetime within certain limits, color, and a random starting age:
spawn(pt) {
pt.x = Math.random() * this.W;
pt.y = Math.random() * this.H;
pt.life = 60 + Math.random() * 180;
pt.age = Math.random() * pt.life;
pt.color = this.palette.colors[(Math.random() * this.palette.colors.length) | 0];
}
Additionally, the particles “melt” in and out, which is accentuated by the trail. The swirls emerge and depend on the landscape described by our function. We need to define a good one in order to make the swirls appearance feel natural.
One swirl
Any such landscape can be described as a collection of hills of different heights, $\psi(\tilde{x}, \tilde{y})$: hills where $\psi$ is large, pits where it is small. The velocity we hand the particles is the perpendicular gradient at the current location in the landscape:
$$ \mathbf{F} = \nabla^{\perp}\psi = \left(\frac{\partial \psi}{\partial \tilde{y}},\; -\frac{\partial \psi}{\partial \tilde{x}}\right) $$
The ordinary gradient $\nabla\psi$ points straight uphill; by rotating it a quarter turn we make the particles run along the contour lines of $\psi$ instead of across them. The contour might be extended by other hills that intersect, but ultimately they are closed loops so the flow just goes round and round resulting in the swirl effect. As a bonus, since particles ride contour lines, they never drain into a point, so the flow never collects into clumps.
The simplest possible landscape is described by a single Gaussian hill, of height $h$ and width $\sigma$:
$$ \psi(\tilde{x}, \tilde{y}) = h \, e^{-(\tilde{x}^2 + \tilde{y}^2) / 2\sigma^2} $$
psi(nx, ny) {
// one Gaussian hill of height h and width sigma
return h * Math.exp(-(nx * nx + ny * ny) / (2 * sigma * sigma));
}
We work that derivative out once, by hand — the perpendicular gradient of the Gaussian is a clean rotation around the peak:
$$ \mathbf{F} = \nabla^{\perp}\psi = \frac{\psi}{\sigma^2}\,(-\tilde{y},\; \tilde{x}) $$
At runtime, we never differentiate anything, but evaluate the closed-form velocity directly.
I’ve added a small demo in the top right corner displaying a miniature screen and the hill placed inside of it. The actual behavior can be adjusted by dragging peak height below zero and turning the hill into a pit, thereby reversing the swirl.
to turn the hill into a pit and watch the swirl reverse.
Visually it is best in three dimensions, tracking a single particle. The shadow on the floor traces the path you’d see from straight above.
I’ve added a second hill to show how the particle behaves in the full landscape. It never leaves it’s elevation and would eventually loop, but it’s path is determined by the different hills that intersect at the same elevation. The velocity changes depending on the slope, so technically it keeps speeding up and slowing down again, but this is not as apparent since the lifetime of a particle is typically not long enough to observe it.
A grid of swirls
One bump makes one swirl. To fill the screen we need such a landscape full of hills and pits and the simplest one that repeats is a product of two sine waves:
$$ \psi(\tilde{x}, \tilde{y}) = \sin(k\tilde{x})\,\cos(k\tilde{y}) $$
psi(nx, ny) {
const k = 2.2;
// the 1/k keeps the flow speed steady as k changes
return Math.sin(k * nx) * Math.cos(k * ny) / k;
}
Call that “egg-carton”.
So we need to get from this orderly, tidy grid to a scattered one that never quite repeats.
Stacking waves
In order to break that regularity we need add more waves and sum them up. We chose $p$ out of a set of primes.
$$ \psi(\tilde{x}, \tilde{y}) = \sum_{p}\; \frac{1}{p^2}\, \sin(pK\tilde{x} + \varphi_p)\,\cos(pK\tilde{y} + \phi_p) $$
Each term is a grid of its own at wavenumber $pK$ (with higher primes resulting in finer grids), with a fixed phase and additionally weighted by $1/p^2$ so the big, low-frequency swirls lead.
psi(nx, ny) {
const K = 1.15;
const primes = [2, 3, 5, 7, 11, 13];
let sum = 0;
for (let k = 0; k < primes.length; k++) {
const q = primes[k];
const ax = q * K * nx + q * 0.7;
const ay = q * K * ny + q * 1.3;
sum += (1 / (q * q)) * Math.sin(ax) * Math.cos(ay);
}
return sum;
}
The demo below shows how it behaves with more and more primes enabled.
Primes
Almost any mix of waves stacked would result in a scattered grid, but we care about repetition. A sum of waves repeats only where all of its terms line up again, so at the least common multiple of their periods. Wave $p$ has period $2\pi/(pK)$, so the whole field repeats on a tile of size
$$ \text{tile} = \frac{2\pi}{\gcd(\text{wavenumbers}) \cdot K}. $$
Since primes share no common factor besides 1, the tile is as large as it can possibly be, $2\pi/K$, which is sized to go past the edge of the screen.
If we’d swap in even wavenumbers for example that share $2$ as the $\gcd$, the tile halves. Now it fits on screen and the pattern visibly tiles, which also looks cool but is not so desirable in my case.
psi(nx, ny) {
const K = 1.0;
const primes = [2, 3, 5, 7, 11, 13];
let sum = 0;
for (let k = 0; k < primes.length; k++) {
const q = primes[k];
sum += (1 / (q * q)) * Math.sin(q * K * nx + q * 0.7) * Math.cos(q * K * ny + q * 1.3);
}
return sum;
}
To make the effect of the even numbers more visible, I’ve left the demo a little more zoomed out than it would typically be.
Making it breathe
Another addition to make it seem less like a snapshot is my making the landscape drift/breathe over time. We change each wave’s phase slightly, each at varying rates. The idea is to make it sort of resemble clouds going past in the sky.
Concretely, the this is done by keeping a clock that ticks forward a little each frame; that clock is fed into every wave’s phase, each with a small rate of its own:
this.time += 0.016;
const ax = q * K * nx + q * 0.7 + (0.10 + 0.03 * k) * t;
const ay = q * K * ny + q * 1.3 + (0.08 + 0.025 * k) * t;
Implementation & Optimization
A few implementation details, since this runs as a live background and the cost has to stay low (which it didn’t at first).
The sketch earlier trimmed some bookkeeping from the real per-frame loop. What actually runs is we sample the arrow, step along it, draw a segment with it’s brightness dependin on its speed, and fades in and out as mentioned. After that, it respawns. By “Sampling the arrow” I mean we evaluate the closed-form velocity as mentioned before.
function field(x, y, t) {
const nx = (x - cx) / scale, ny = (y - cy) / scale;
let u = 0, v = 0;
for (let k = 0; k < primes.length; k++) {
const p = primes[k];
const ax = p * K * nx + driftX[k] * t + phaseX[k];
const ay = p * K * ny + driftY[k] * t + phaseY[k];
u -= (1 / p) * Math.sin(ax) * Math.sin(ay);
v -= (1 / p) * Math.cos(ax) * Math.cos(ay);
}
vel.x = u; vel.y = v;
}
and the per-frame loop steps each particle along it, draws the segment, and respawns:
for (const pt of particles) {
field(pt.x, pt.y, time);
const sp = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
const nx = pt.x + vel.x * speed, ny = pt.y + vel.y * speed;
const f = Math.min(pt.age, pt.life - pt.age) / 18;
ctx.globalAlpha = baseAlpha * (0.35 + 0.65 * Math.min(sp / maxSpeed, 1)) * Math.min(1, f);
ctx.strokeStyle = pt.color;
ctx.beginPath(); ctx.moveTo(pt.x, pt.y); ctx.lineTo(nx, ny); ctx.stroke();
pt.x = nx; pt.y = ny; pt.age++;
if (pt.age >= pt.life || nx < -20 || nx > W + 20 || ny < -20 || ny > H + 20) spawn(pt);
}
This generally cooks the CPU, but we can limit it by capping the pixel ratio. A soft trail field gains nothing from full retina resolution, and fill cost scales with the square of that ratio:
const dpr = Math.min(window.devicePixelRatio || 1, dprCap); // dprCap = 1.5 (on mobile this is 1.25
canvas.width = W * dpr;
canvas.height = H * dpr;
The loop runs on a timer at about 30 frames a second.
function tick() {
step();
timer = setTimeout(tick, 1000 / fps);}
Each demo now also only animates while it’s scrolled into view.
Of course these changes leave the per-frame particle work unchanged in shape. With the hardcoded amounts I get 9600 trig calls and 400 draws per frame. Going from the default settings I had before and assuming a 120 Hz display is used, we run it 4x fewer per second. And folding the DPR cap, the full-screen trail-fill scales as:
$$fps \times \text{dpr}^2$$
, so we go from
$$120 \times 2^2$$
to
$$30 \times 1.5^2$$
and thereby have about 7x fewer pixels pushed per second. Still probably not efficient by design, but a good trade-off.
Overall, the dominant term is clearly the fill since it theoreticlaly touches millions of pixels. Compared to that, the trig work is tiny. I thought about using a field LUT, but since it just trades the trig work for interpolation, it wouldn’t improve the results much since the trig isn’t the real bottleneck anyways. The $\frac{1}{p}$ are also precomputed (although the code snippets here leave this out for better explainability).
Some more pragmatic changes were adding a mobile detection, and running the rendering in a self-contained function rather than in the main thread so it never competes with scrolling/interaction. And of course, adding a button to turn it off entirely (bottom left). I didn’t have time to do full perf testing yet but at least btop tells me there’s a noticeable improvement.
Note I have not much Javascript experience, so the code can probably be improved in many places. For this I mostly cared about the mathematics of it. Also, I did not write the 3d demo version in this particular article myself, but used Claude to do it.
Thanks for all the questions and comments about it. Really made me re-think the concept and go over some optimizations again. And I finally got around to finishing the write-up because of it! Also, Armin Ronacher’s beautiful website header served somewhat as inspiration for it, initially I also wanted some lavalamp like look (which I’ve deviated from obviously).