Draw My Canvas / studio

Flow Field

Ten thousand particles follow a wind you cannot see. The wind’s direction at every point comes from Perlin noise, a smooth random field Ken Perlin published in 1985, here seeded so that each seed is a different wind. Each particle leaves a hairline trail, and in sixty seconds the trails build up a drawing of the whole field: broad currents, and bright seams where the flow converges. Then the drawing holds, and a new seed slowly fades in beneath it.

Plate 36perlin-noise

Waiting for the animation above to load…

What lands in the file, and what the width does

One click writes whatever the animation is drawing at that moment to a PNG, with the drawmycanvas.com mark drawn into the picture rather than laid over it. Leave the width box empty and you get the stage exactly as your browser rasterised it — your window’s width times its device pixel ratio, which is about 1,600 px across from a 1280‑px window on a HiDPI laptop and about 353 px from a 390‑px phone.

Type a width instead, or take a preset, and the frame is redrawn into a canvas that wide: the height follows the stage’s own shape and the mark scales with it. The stage is 16:9 on a wide window and 4:3 below 560 px, so a width of 1200 saves 1200×675 on a laptop and 1200×900 on a phone. 1200 px is the width Open Graph and X link cards are cut from — the canonical card is 1200×630, and a card crops the extra height rather than letterboxing it.

Honest limits. Asking for more pixels than the stage was drawn at resamples pixels that were never drawn: a 1920‑px file exported from a 353‑px phone stage is bigger, not sharper. For a big file that is sharp, use Save as wallpaper: it draws the plate again from scratch at exactly 1179×2556 or 1290×2796 (phones), 1920×1080, 2560×1440 or 3840×2160 (4K), so every line is rasterised at that size and the shape is the screen’s, never stretched. Because the plate restarts, a wallpaper is a fresh run of it rather than the exact frame on screen; it is given as long as the stage has been running, up to 20 seconds, to develop. A very large width is a real memory allocation and a browser is allowed to refuse it; when that happens the line above says so plainly and nothing else on the page changes. Stills are PNG only — no JPEG, no WebP. On browsers that can record video, Record a clip saves 5, 10 or 20 seconds of the running animation as MP4 or WebM (whichever this browser can encode) at the same width, with the mark in every frame. A clip is not a seamless loop, it has no audio, and a width bigger than the stage is resampled rather than sharper — only the wallpaper is redrawn at its size. Where a browser cannot record video the button never appears and a PNG is the only export. And nothing is uploaded: the frame or clip is assembled in your own browser, so no frame of this plate ever reaches us.

Live on an HTML canvas · vanilla JavaScript · no dependencies Open fullscreen

About this piece

Imagine a wind blowing across the frame that you cannot see: at every point it has a direction, and the direction changes smoothly from place to place. Drop ten thousand particles into it, let each one drift a short way along the wind and leave a hairline behind, and the trails draw a map of the wind. That is a flow field. Here every particle takes 10 steps a second, each step 0.18% of the frame’s short side long (about 1.2 px on this page’s stage). A particle lives for 30 to 120 steps, then is reborn somewhere random. After 60 seconds, 600 steps, about 5.9 million segments have been laid down. The drawing holds for 6 seconds, then the next seed starts drawing and the finished picture fades out under it over another 6. The very first drawing opens twelve seconds in (those 120 steps are taken at once when the page loads), so the shape of the wind is on screen straight away; every later drawing starts from an empty frame. Every visitor sees the same drawings in the same order: drawing 0 first, then 1, 2 and so on. With reduced motion switched on you get one still, the first drawing, and nothing moves.

Where the wind comes from: Perlin noise

The wind’s direction is gradient noise, the kind Ken Perlin introduced in his SIGGRAPH 1985 paper “An Image Synthesizer” (Computer Graphics 19(3), pages 287–296). Space is cut into a grid of unit squares. Each grid corner gets a gradient, a short arrow picked by hashing the corner’s coordinates through a shuffled table of the numbers 0 to 255. At a point inside a square, each of the four corners contributes the dot product of its arrow with the offset from that corner to the point, and the four numbers are blended. Because every corner’s ramp is zero at the corner itself, the noise is exactly 0 on every grid point and rises and falls smoothly between them.

This plate follows the two changes Perlin made in “Improving Noise” (SIGGRAPH 2002), as written in his own Java reference implementation (read 12 October 2026). First, the blend uses the fade curve 6t⁵ − 15t⁴ + 10t³ instead of the older 3t² − 2t³, so that the second derivative is also continuous across grid lines. Second, the arrow is chosen from a short fixed list by the low bits of the hash, not from a table of random vectors. Perlin’s reference is three-dimensional, with twelve arrows. This is the two-dimensional version, with eight: the four diagonals (±1, ±1) and the four axes. One deliberate difference from the reference: Perlin uses one fixed permutation table, and this plate shuffles the table from a seed, so each seed is a different wind and the same seed is always the same wind.

noise(x, y)  in [-1, 1], exactly 0 at whole-number x, y
angle(x, y)  = base + noise(2.2·x + ox, 2.2·y + oy) · 2.4π
step         = 0.0018 · (cos angle, sin angle)
               (x, y and step in short sides of the frame)

The factor 2.2 sets the scale: about 2.2 noise cells across the frame’s short side, which is why you see three or four large swirls rather than dozens of small ones. The factor 2.4π sets how hard the wind turns: when the noise moves from 0 to 0.5 the wind swings through 216°. The seed also picks base, a starting direction, and an offset (ox, oy) into the noise. Without base, the wind would blow due right at every grid point, where the noise is zero, and every drawing would lean the same way.

How this differs from Contour Drift

Contour Drift also starts from a noise field, so the two plates are easy to confuse. They differ in three ways. The noise: Contour Drift uses value noise, a random height at each grid corner blended with 3t² − 2t³, summed over three octaves. This plate uses one octave of Perlin’s gradient noise, which has no peaks sitting on the grid and so less of a square bias. What the number means: there the noise is a height, and marching squares draws the lines where the height equals twelve fixed levels. Here the noise is an angle, and nothing is ever drawn where a value is reached; lines are drawn where particles go. Time: Contour Drift’s field moves along a third axis, and each frame is a complete, fresh map. This field stands still, and the picture is an accumulation of trails: a single frame here is only the latest step of a drawing that takes a minute. The home page hero is a flow field too, but a simpler one: about 440 to 1,500 particles on summed value noise that drifts slowly, with trails that fade continuously instead of building up to a finished drawing.

Reading the plate: what to watch for

  • The bright ridges are where the wind converges. Nothing in this field forces the flow to spread evenly. Where neighbouring arrows point toward one line, particles from a wide area are funnelled onto it and their trails pile up into a bright seam; where they point apart, the frame stays thin and dark. Most of the structure you see is that piling up, not the noise itself.
  • One in four lines is coral. Particle i takes the palette’s ink slot i mod 4, and three of the four slots are cobalt. That is the site’s own cobalt-to-coral weighting. In the dense seams the coral blends into the cobalt; it shows best in the thin regions between them.
  • A new drawing starts out looking like fur. For its first few seconds each trail is a short dash and you can see individual particles. As particles die and are reborn, new strands fill the gaps, and by about 30 seconds the dashes have merged into continuous currents.
  • The same wind is drawn twice per cycle, once fading. During the 6-second cross-fade both drawings are on screen. Their ridges sit in completely different places, because a new seed reshuffles the whole permutation table.

How it is computed

Each drawing has a clock in wall-clock seconds, and the number of steps taken is that time multiplied by 10, rounded down. The frame rate only decides how many steps fall between two pictures, never which steps are taken, so the drawing at a given second is the same at 30 frames a second and at 144. The test suite checks this on the plate’s own code: run for 10 seconds and for 70 seconds (past a change of seed) at both frame rates, every particle’s position is identical. All the randomness (the shuffle, the offset, every birth point and lifetime) comes from one seeded generator (mulberry32), drawn in particle order.

Each step’s segments are stroked into an off-screen layer that is never cleared, in four paths, one per ink slot, with the same low opacity for every segment. That opacity is worked out from the frame size and the particle count, so that a whole drawing lays about the same amount of ink per pixel on any screen. On this page’s stage it is 0.04 on an ordinary screen and about 0.08 on a 2× (Retina-class) one, where the frame has four times the pixels. The visible frame is the layer drawn over the background; during a cross-fade it is two layers at complementary opacities. The gallery tile runs the same code with the same ten thousand particles, so the thumbnail and this stage are one picture at two sizes. The reduced-motion still is drawing 0 after 240 steps, with each segment 2.5 times as opaque, so it carries the ink of a finished drawing for less than half the work.

Honest limits

It is not a fluid. Real air cannot pile up in one place, but this wind can: the field is just an angle at each point, with no rule that what flows in must flow out. That is exactly why the bright seams form. A divergence-free field, such as curl noise, would spread the trails evenly and look like smoke instead; it is a different picture, and not this one.

No line avoids another. Tyler Hobbs’s widely read essay “Flow Fields” describes keeping a minimum distance between curves so that each line stays visible. Nothing here does that. Ten thousand particles cross and overlap freely, and the image is a density map of where they went, not a set of separate, clean curves.

Thin lines at low opacity hit the limits of 8-bit colour. Each segment adds only a few units out of 255 to a pixel. On a phone, where the same ten thousand particles share far fewer pixels, the opacity is held at a floor of 0.04 so the strokes still register, and the drawing ends up denser than on a laptop. In the densest seams, rounding can leave faint dark specks.

A resize or a theme change starts the drawing again. Strokes already laid down cannot be recoloured or moved, so the current seed is redrawn from its first step, and after a theme change the old-coloured picture fades out beneath it.

One octave, eight arrows. A single octave of noise gives broad, smooth swirls and no fine detail. Eight fixed gradients can leave faint preferred directions along the diagonals and axes. Adding octaves, or using Perlin’s later simplex noise, would change both.

A 30-line version

The snippet below draws one complete flow-field drawing in 30 lines of HTML and plain JavaScript, with no library. It keeps the plate’s noise and its constants: the same seeded shuffle of Perlin’s table, the same 2002 fade and eight gradients, 10,000 particles, 600 steps at 10 a second, 2.2 cells per short side, a 2.4π swing, steps of 0.0018 short sides and lives of 30 to 120 steps. For seed 7, the test suite runs the snippet’s noise function against the plate’s on 10,000 points and requires the same value at every one. What it leaves out is the gallery machinery: the cross-fade to new seeds, the off-screen layer, sharp drawing on high-density screens (it draws in CSS pixels) and the reaction to theme changes.

  • Noise. p is Perlin’s permutation table: the numbers 0 to 255, shuffled from the seed, then repeated so p[X + 1] never runs off the end. grad turns a hash into one of the eight arrows and takes its dot product with the offset from the corner. noise blends the four corners with fade.
  • Wind. Positions are measured in short sides, so the swirls have the same size relative to the window on any screen. base and the offset ox, oy come from the seed.
  • Particles. Each step moves a particle one STEP along the wind and draws that segment. A particle whose life runs out, or that leaves the window, is reborn at a random point on its next turn. Particle i draws in ink slot i % 4, so each step is four strokes.
  • Clock. Like the plate, it counts steps from the time since it started, not from frames, so a 120 Hz screen draws at the same speed as a 60 Hz one. Under reduced motion it draws all 600 steps at once, which takes a second or two, and then never redraws.

Things worth changing: TURN = 0.5 gives a gentle, almost straight drift; TURN = 6 gives tight curls and small vortices. FREQ = 8 shrinks the swirls to a fine grain. Change SEED for a different wind. Replace fade with (t) => t and the grid shows: currents bend sharply where they cross a cell edge, which is the artefact the fade curve exists to remove.

<canvas id="flow" style="position:fixed; inset:0"></canvas>
<script>
const dark = matchMedia('(prefers-color-scheme: dark)').matches, still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const [ground, inks] = dark ? ['#0B0D12', ['150,180,255', '150,180,255', '120,158,255', '255,120,84']]
                            : ['#E7E2D5', ['40,72,205', '40,72,205', '52,88,214', '190,68,28']];  // the site's palette: 3 cobalt slots, 1 coral
const N = 10000, STEPS = 600, RATE = 10, FREQ = 2.2, TURN = 2.4, STEP = 0.0018, SEED = 7;  // the plate's constants
const rng = (a) => () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const rand = rng(SEED), p = new Uint8Array(512).map((_, i) => i & 255);
for (let i = 255; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }  // shuffle Perlin's permutation from the seed
p.copyWithin(256, 0, 256);
const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10), lerp = (t, a, b) => a + t * (b - a);  // Perlin 2002: 6t^5 - 15t^4 + 10t^3
const G = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]], grad = (h, x, y) => G[h & 7][0] * x + G[h & 7][1] * y;
function noise(x, y) { const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
  const u = fade(x), v = fade(y), A = p[X] + Y, B = p[X + 1] + Y;  // hash the four corners, blend their gradient ramps
  return lerp(v, lerp(u, grad(p[A], x, y), grad(p[B], x - 1, y)), lerp(u, grad(p[A + 1], x, y - 1), grad(p[B + 1], x - 1, y - 1))); }
const cv = document.getElementById('flow'), ctx = cv.getContext('2d'), W = cv.width = innerWidth, H = cv.height = innerHeight, S = Math.min(W, H);
const ox = rand() * 256, oy = rand() * 256, base = rand() * Math.PI * 2, alpha = Math.max(0.04, Math.min(0.5, 0.35 * W * H / (N * STEPS * STEP * S)));
const spawn = (q) => { q.x = rand() * W / S; q.y = rand() * H / S; q.life = 30 + Math.floor(rand() * 91); return q; };
const pts = Array.from({ length: N }, () => spawn({}));
ctx.fillStyle = ground; ctx.fillRect(0, 0, W, H); ctx.lineWidth = 1;
function step() { for (let j = 0; j < 4; j++) { ctx.strokeStyle = `rgba(${inks[j]},${alpha})`; ctx.beginPath();
  for (let i = j; i < N; i += 4) { const q = pts[i]; if (q.life-- <= 0) { spawn(q); continue; }
    const a = base + noise(q.x * FREQ + ox, q.y * FREQ + oy) * TURN * Math.PI;  // the wind's angle at this point
    ctx.moveTo(q.x * S, q.y * S); q.x += Math.cos(a) * STEP; q.y += Math.sin(a) * STEP; ctx.lineTo(q.x * S, q.y * S);
    if (q.x < 0 || q.y < 0 || q.x > W / S || q.y > H / S) q.life = 0; }
  ctx.stroke(); } }
let done = 0; const t0 = performance.now();
function frame(now) { const due = Math.min(STEPS, Math.floor((now - t0) / 1000 * RATE)); while (done < due) { step(); done++; } if (done < STEPS) requestAnimationFrame(frame); }
if (still) { while (done < STEPS) { step(); done++; } } else requestAnimationFrame(frame);  // reduced motion: the whole drawing at once
</script>
Paste it into an empty .html file.

Curious how the loop and canvas fit together? Read how it works →

More from the gallery

All 37 animations in the gallery →