Final Boss: The Drift
Build the macOS Drift screensaver from scratch, one concept at a time, ending in a real-time fluid simulation rendered as a grid of glowing line segments.
The Boss Fight
You have built fire, snow, confetti, starfields, and a complete particle system with a thousand-particle pool. Time for the boss fight. We are going to rebuild Drift, the macOS Catalina screensaver: the hypnotic field of glowing ribbons that twists and slides across a velvet-black canvas. The reference implementation in Lesson 8 dropped this in your lap as a single 300-line code block. This chapter takes that block apart, one concept per demo, and rebuilds it.
Drift looks like moving particles, but it isn’t. The trick is a fixed grid of line segments sampling a moving velocity field underneath. The grid never goes anywhere. What you see is the field, made visible by the lines. Get that idea in your head now; the next dozen demos are about making the field interesting enough to be worth watching.
The plan:
┌─────────────────────────────────────────────────────────┐ │ Phase A. the naive attempt │ │ 1. Static grid of lines │ │ 2. Lines aimed by a noise function │ │ 3. Animate the noise → it jitters, doesn't flow │ │ │ │ Phase B. give the field memory │ │ 4. Velocity grid + force injection │ │ 5. Diffusion (viscosity) │ │ 6. Reflective boundaries │ │ │ │ Phase C. the killer steps │ │ 7. Advection (the field carries itself) │ │ 8. Pressure projection (no sources, no sinks) │ │ │ │ Phase D. the look │ │ 9. Bilinear sampling + easing line grid │ │ 10. Palette, blending, gradient │ │ │ │ Phase E. polish │ │ 11. Pre-warming + drifting noise origin │ │ 12. The full Drift │ └─────────────────────────────────────────────────────────┘
The published open-source tribute to Drift is sandydoo/flux, which runs the same algorithm on a GPU. We are going to run a smaller version on the CPU in plain JavaScript, and it will still hit 60fps. The whole simulation fits in about 200 lines.
Step 1: A Grid of Lines
Start with the dumbest possible version: a regular grid of identical horizontal line segments. No physics, no field, no animation. Just the canvas split into cells, with one short line per cell. This is the canvas Drift draws on. Every later demo just changes what each line points at.
The grid:
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ Each cell has one anchor (the center) and one line that grows outward in some direction.
Try this: Change SPACING to 12 for a much denser grid, or 40 for sparse. Change the lineTo end point so all lines aim diagonally instead of right. The visual you should reach for next: lines that each pick their own direction based on where they sit.
Step 2: Aiming Lines With Noise
The first interesting thing you can do is give every cell its own angle, derived from its position. If neighboring cells get similar angles, the lines will look like they belong to the same smooth field, like a brushed metal pattern, or the lines drawn around a magnet to show its field. The function that gives us “similar values for nearby inputs” is noise.
Noise is not the same as Math.random(). Random gives uncorrelated samples; every call is independent of every other call, so adjacent lines would point in completely unrelated directions and the result would look like static. What we want is a function that is continuous: noise(100, 100) and noise(101, 100) should return nearly identical values. Perlin noise is the famous one, but it is overkill for a screensaver. Three layered sines at incommensurate frequencies do the job in ten flops.
From noise value to angle:
noise(x, y) = Math.sin(x * 0.9)
+ Math.cos(y * 1.1)
+ Math.sin((x + y) * 0.6) * 0.6
Output is roughly in [-2.6, 2.6].
Multiply by Math.PI to get an angle in radians.
Then (cos(angle), sin(angle)) is a unit vector.
Important: one noise call → one angle. If you sampled
noise twice (once for vx, once for vy) you would get
vectors of wildly varying length: some cells barely
moving, some lines stretched across half the canvas. That is your first flow field. Bands of lines bend and twist together, because the noise function gives nearby cells nearly-identical angles. It is static (the pattern never changes), but you can already see structure. Drop a particle anywhere and it would follow these arrows around a curving path. This is, by the way, the technique behind a thousand “generative art” pieces on Codepen.
Try this: Multiply the noise input by 0.05 instead of 0.02 for tighter swirls, or 0.005 for broad gentle bands. Change the third sine’s 0.6 multiplier to 1.5 and watch the diagonals get wilder.
Step 3: Animate the Noise
Static is fine for a poster. For a screensaver, the field has to evolve. The standard move: add a third argument to noise, call it t, and increment it every frame. Now the field at each cell drifts through different angles as time passes.
Watch it for ten seconds. It looks… fine? The bands twist, swirls form, dissolve, reform. If you saw this on a stranger’s portfolio you would tap “save” and move on. Put it side by side with Drift and something is off. Drift looks alive. This looks like a smooth pattern with the time knob being turned. Why?
Why Noise Alone Is Not Flow
Pay attention to a single swirl in the demo above. It appears at some point on the canvas, twists into shape, then dissolves and reappears elsewhere. It never travels. A real fluid carries its disturbances downstream. A puff of dye dropped in a river drifts with the current. Here, every cell just samples the noise function fresh each frame. There is no causal link between what a cell pointed at one frame ago and what it points at now. The whole grid is being repainted from scratch every tick.
The defining property of a fluid is self-transport: the velocity field carries itself. A noise function has no memory. noise(x, y, t + dt) does not know what noise(x, y, t) was. It is a fresh math lookup every frame. Your eye reads this immediately, even when it can’t name the problem. It looks like grass wiggling in place instead of water flowing.
Codepen tutorials at this point reach for two knobs to hide the failure: lower spatial frequency (bigger, fewer features so the eye averages over the jitter) and slower temporal speed (so any individual frame looks nearly static). Both work in the sense that they make the problem less noticeable. Neither fixes it. Zoom in or speed up time and the jitter returns.
The real fix is to throw out the noise field as our velocity source and replace it with a velocity array: a grid of floats that we update by reading itself. Forces still inject from noise, but the field then evolves under its own rules. That is Jos Stam’s “Stable Fluids” algorithm, and it is what powers Drift, Flux, and basically every real-time fluid demo you have ever seen on the web. Phase B starts here.
Step 4: A Velocity Grid
Stop drawing the field directly from a function. Allocate two flat arrays, vx and vy, each holding one float per grid cell. Together they encode “at every grid cell, here is the velocity vector right now.” Every frame, we will do two things: inject small noise-derived forces into the grid (to keep the simulation fed), then later update it (to make it flow). For step 4 we only do the injection, so you can see what the bare field looks like with no transport yet.
Grid storage:
NX = 40 cells wide NY = 32 cells tall N = NX * NY = 1280 cells vx, vy: Float32Array, length N each IX(i, j) = i + NX * j (flat index from 2D cell coords) Why Float32Array? - No GC pressure: allocate once, reuse forever - Contiguous memory: cache-friendly, JIT can vectorize - V8 specializes typed-array loops aggressively
To visualize the field we will draw a tiny arrow in every cell, pointing along that cell’s velocity, with length proportional to the velocity’s magnitude. This is a debug view; in Phase D we replace it with the proper line grid.
You should see a sparse, jittery field of arrows. Every other cell gets kicked each frame (the rest stay near zero because nothing transports the velocities sideways). Compared to step 3’s noise field this looks worse: chunky, disconnected, ugly. That is the point. We have stopped lying with the noise function and started honestly storing velocities. Now we add the steps that make those velocities behave like a fluid.
Step 5: Diffusion
The first plumbing step is diffusion. Real fluids smear momentum sideways: poke water and the surrounding water tugs along because viscosity drags neighboring molecules into agreement. Without diffusion, each force-injection cell sits in isolation; the field looks like a checkerboard. With diffusion, each cell drifts toward the average of its four neighbors, and the field smooths out.
The diffusion update:
For each cell (i, j): x_new = (x_old + a * (left + right + up + down)) / (1 + 4 * a) where a = dt * diffusionRate * (NX-2) * (NY-2) Naive Euler would overshoot and explode when a is large (the simulation is "stiff"). Stam's trick is the implicit scheme above: solve "what new values would yield the old values under reverse diffusion?" via Gauss-Seidel sweeps. Four sweeps is enough at our resolution. Each cuts the residual error roughly in half. The system is unconditionally stable: no matter how big dt or diff, it stays bounded.
Now the arrows are no longer isolated dots. Diffusion has spread each injection across a small patch of neighbors. The field looks woolly. It is still not flowing (the patches do not travel, they just morph and dissolve), but adjacent cells now agree. That agreement is the soil eddies will grow in once we add transport.
We also relaxed the damping factor from 0.98 to 0.995, and moved it out of injectForces into a new fluidStep function. With diffusion now bleeding energy sideways every frame, we no longer need to clamp velocities so aggressively to keep them bounded.
Step 6: Reflective Boundaries
Quick plumbing: the cells at the very edge of the grid have no neighbor on one side. Without special handling, whatever garbage was last written there leaks into the interior the next time we run diffusion. We need walls that reflect, so the simulation behaves like a sealed box.
The boundary rule:
Tangential component (along the wall): copy from neighbor inside. Normal component (into the wall): flip sign. Why? Setting the outside ghost cell to -inside means their average is zero, and that average sits on the wall surface, which is exactly the "no flow through the wall" condition. setBnd(b, x): b = 1 → x is vx, flip on left/right walls b = 2 → x is vy, flip on top/bottom walls b = 0 → x is a scalar (we'll need this in step 8 for pressure)
Visually, this barely changes. The walls were never very leaky to begin with. But every later step (advection, projection) will read from the boundary, and without setBnd they would silently corrupt the interior. Phase B is now done: we have a velocity grid, forces injecting smoothly, diffusion smearing them sideways, and walls that hold. The field still does not flow. That comes next.
Step 7: Advection (The Field Carries Itself)
This is the killer step. Without it, every previous demo is just a different way of jittering. With it, the field becomes a fluid.
Advection is the answer to the question: “if the velocity at cell (i, j) is pointing east, where will the value at (i, j) end up one timestep later?” Answer: somewhere east of (i, j). The fluid carries its own contents. A swirl born on the left side of the canvas should be dragged toward the right by its own velocity.
Why backwards, not forwards:
Naive forward: "for each cell, push its value to where
its velocity points."
→ Multiple cells may push to the same destination (double-write).
→ Other cells receive nothing (holes).
→ You'd have to accumulate and normalize. Painful.
Semi-Lagrangian: "for each destination cell, ask where its
fluid came FROM one timestep ago."
→ Walk backward along the velocity. (i - vx*dt, j - vy*dt)
→ That lands at some non-integer point in the grid.
→ Bilinearly interpolate the value from the 4 nearest cells.
→ Every destination gets exactly one value. No holes.
This trick is Stam's headline contribution. It is also why
the scheme is unconditionally stable: backward tracing
can't blow up. Wait twenty seconds. Watch a single bright patch. It moves. Not just morphs. It drifts across the canvas, deforming as it goes, getting picked up by its neighbors’ velocities. The field has gained memory. This is the moment the noise-flow-field demo from step 3 has been envying for the last six demos.
But look carefully and you will see a new problem: the field breathes. Bright regions expand outward as if everything is exploding from a source; dark regions act like sinks where velocity drains away. Eddies pile up and dissolve without ever quite curling. That is because advection alone does not enforce incompressibility, the rule that says fluid can be stirred but not created or destroyed. Fix that and the breathing turns into curling.
Step 8: Pressure Projection (Eddies Curl Now)
Pressure projection is the second killer step. It enforces ∇·v = 0: the divergence of the velocity field is zero everywhere. In English: at every cell, the amount of fluid flowing in equals the amount flowing out. No sources, no sinks. The eddies you wanted have nowhere to “pile up to,” so they curl in place instead.
Helmholtz decomposition, in one breath:
Any vector field can be split, uniquely, into two parts:
1. A curl-free part. Think: water flowing downhill from a
height map. Pure expansion and contraction. No swirls.
2. A divergence-free part. Think: a hurricane. Pure swirling.
No sources or sinks.
We want only the second part. So we:
1. Compute divergence of the current field.
2. Solve a Poisson equation to find a scalar 'pressure' field
whose gradient equals the curl-free component.
3. Subtract pressure's gradient from the velocity.
What's left is divergence-free. The eddies curl. The Poisson solve is iterative. Same Gauss-Seidel pattern as diffusion, but you need many more sweeps (18 in our code) because the Poisson operator has no diagonal dominance to make it converge fast. The number 18 was picked by Stam decades ago for a 64-cell grid and still works fine for our scale.
The canonical step order:
fluidStep(dt): inject forces diffuse → project (diffusion adds divergence, scrub it out) advect → project (advection adds divergence too, scrub again) Skip the second project → breathing returns. Reorder advect/diffuse → instability. This is the recipe from Jos Stam's 1999 paper.
Damping relaxes one more notch, from 0.995 to 0.9995. Advection and projection together dissipate enough energy on their own that we want the field to retain its eddies between injection bursts; bigger eddies survive longer at lower damping. We also switch from a raw dt substep to a fixed 1/220, which gives the fluid a lazier, more cinematic timescale (the lines you draw on top will still move at full frame rate).
That is a fluid. Eddies curl. Disturbances flow downstream. Bright patches travel rather than breathing in place. The visualization is still ugly (arrows on a grid), but the field under it is doing real physics. Everything from here is presentation.
Try this: Comment out the second project(vx, vy, pf, dvg); call in fluidStep. The breathing comes back. Eddies pile up and vanish instead of curling. Now comment out both projections. You get the step-7 problem: it moves, but expands outward. Projection is what separates “fluid motion” from “shapeless transport.”
Step 9: Bilinear Sampling and an Easing Line Grid
Time to fix the look. Replace the arrow visualization with a denser grid of fixed line segments. Each line stays at its anchor point forever; what changes is its angle and length, derived from the velocity field at its position. This is the “fixed grid, moving field” trick that gives Drift its identity.
Two pieces matter here:
Bilinear sampling:
Line at canvas pixel (px, py) doesn't sit on a cell center. It lives at some continuous (fi, fj) in cell-index space. Find the 4 surrounding cells and weigh each by area: i0 = floor(fi) j0 = floor(fj) s = fi - i0 u = fj - j0 weight(i0, j0) = (1-s) * (1-u) weight(i0+1, j0) = s * (1-u) weight(i0, j0+1) = (1-s) * u weight(i0+1, j0+1) = s * u Sum: 1. Result varies smoothly inside the cell. Nearest-neighbor sampling would give blocky stairs.
Easing toward the target:
Don't snap a line to its new target angle each frame. Ease toward it with an exponential filter: k = 1 - exp(-EASE * dt) // frame-rate independent angle += (targetAngle - angle) * k EASE = 1.5 gives roughly 0.67-second time constant. Why? Real flowing ribbons lag the current; they curl OVER TIME, they don't whip around instantly. The lag IS the ribbon look.
The arrows are gone. In their place is a much denser carpet of lines, each glued to its anchor, each curling slowly toward whatever direction the local fluid is pointing. Strong flow regions show long lines bunched together; calm regions collapse to nearly-invisible nubs. The lag from the easing factor makes the whole thing look like ribbons being pulled through space.
Step 10: Color, Blending, and the Drift Look
Three rendering tricks are left between us and the screensaver: a real palette, the right blending mode, and a per-line gradient. Pull them in together.
Palette interpolation:
A 5-stop HSL palette: deep violet → magenta → coral → gold → cyan. Sample the palette at a value t01 in [0, 1]: f = t01 * (n - 1) i = floor(f) k = f - i color = lerp(palette[i], palette[i + 1], k) What drives t01? Three signals blended: - velocity magnitude (faster = brighter colors) - position diagonal (sweeps hue across the canvas) - slow temporal sine (the whole palette breathes)
Blending and gradients:
globalCompositeOperation = 'screen' Additive in the dark, saturating toward white in the bright. 'lighter' (pure additive) blows out to harsh X-shapes where lines cross. 'screen' stays in glow territory. Per-line gradient along the line: transparent at the anchor → opaque at the tip Lines read as STREAKS, not bars. Same trick Flux uses.
That is starting to look like a screensaver. Plasma colors sweep across the canvas in slow bands, lines glow where they bunch, dark regions look like deep space. The motion is real fluid motion. There is one annoying problem: when the demo starts, every line is at angle: 0, len: 0, tone: 0. For the first second or two you see a parking lot of identical hairs while the field warms up. We fix that next.
Step 11: Pre-warming and Wandering Eddies
Two small additions, both invisible if you do them right.
Pre-warming. Before the first requestAnimationFrame, run 240 fluid steps in a tight synchronous loop. That is four seconds of simulated time. Forces inject, advection carries them, projection sculpts them, the field reaches a steady-state energy where damping balances injection. When the user finally sees the canvas, the flow is already in full swing.
Drifting noise origin. If the noise we sample is always evaluated at the same coordinates, eddies form at fixed canvas positions and just morph in place. To get them to wander, offset the noise input by a slowly-drifting amount. Two slow sines at incommensurate frequencies will never repeat exactly, so the eddy positions wander aperiodically across the canvas. This is the trick that gives Drift its characteristic gliding motion.
The wandering offset:
driftX = sin(t * 0.07) * 60 + cos(t * 0.041) * 40 driftY = cos(t * 0.063) * 45 + sin(t * 0.033) * 30 Two oscillators at frequencies whose ratio is irrational. Their sum traces a Lissajous-like path that never repeats. Use as: noise( (i + driftX) * 0.035, (j + driftY) * 0.035, t * 0.04 ) Result: one or two large eddies that glide around the canvas instead of being pinned to fixed spots.
The opening frame is no longer a parking lot. It shows an already-flowing field, because we ran the simulation forward four seconds before drawing. And if you let it run, the bright eddies travel across the canvas instead of pulsing in place. We are one demo away from Drift.
Step 12: The Final Boss
Everything pulls together. The last demo scales the grid to the canvas (mobile gets fewer cells, big monitors get more), pools its line objects once at startup so the hot loop allocates nothing, and culls cells where the field is too weak to bother drawing. This is the same code you saw at the end of Lesson 8, finally explained.
Performance choices:
Scale everything to canvas width:
GRID_SPACING = max(20, w / 55) (fewer lines on small canvases)
LINE_LENGTH = max(60, w / 8)
LINE_WIDTH = max(2, w / 240)
NX = clamp(round(w/14), 40, 96)
Object pool:
Build the lines[] array ONCE at startup.
Each frame, mutate { angle, len, tone } in place.
No 'new' calls in the loop. No GC.
Culling:
if (line.len < MIN_LINE) continue;
Skips ~half the strokes in calm regions. Try this: Comment out the second project() call in fluidStep and watch the flow lose its incompressibility. Eddies pile up or vanish into nothing, exactly the visual difference between Drift and a noise-only field. Push the force amplitude s from 800 to 2000 for a hurricane. Drop the damping from 0.9995 to 0.9999 and disturbances persist for much longer, building into massive vortices. Swap the PALETTE for cool blues ([200,90,30], [195,85,50], [180,80,65], [165,70,75], [210,60,85]) for the Poolside theme.
What You Built
You shipped a real-time fluid simulation. Forces inject; diffusion smooths; pressure projection enforces incompressibility; advection carries the field through itself. On top of that, a static grid of line segments samples the field with bilinear interpolation, eases toward its target angle and length, and renders with a plasma palette and screen blending. Everything in this chapter (every demo from step 1 forward) exists in roughly 200 lines of JavaScript, with no library, no GPU, and no allocations in the hot loop.
Real-time fluid simulation was a SIGGRAPH paper in 1999. Twenty-five years later it runs in a browser tab on a budget laptop while you read this. That is the gradient the web has been climbing.
Where to Go Next
This chapter ends the Canvas Lab. Some directions to take what you learned:
- Add dye/density: the same fluid solver can carry a scalar field (color, temperature, dye concentration). Render it as a heat map instead of arrows or lines. See Stam’s original paper for the full equations.
- MacCormack advection: Flux uses a second-order advection scheme that loses far less energy than vanilla semi-Lagrangian. The fix is two semi-Lagrangian passes (forward + reverse) blended together.
- Move it to WebGL: port the solver to fragment shaders and you can run a 256×256 grid at 60fps. sandydoo/flux is the reference.
- Coupling to mouse input: add a brush that injects velocity along the cursor’s motion vector. You now have an interactive fluid toy. (See Lesson 7 for the input handling.)
- Stam’s paper: Real-Time Fluid Dynamics for Games (GDC 2003). The same algorithm in 8 pages, very readable.
- GPU Gems Chapter 38: Fast Fluid Dynamics Simulation on the GPU. The GPU port that Flux is built on top of.
Congratulations. You started with ctx.fillRect and finished with a real fluid solver. There is no more Canvas Lab. Go make something.