Draw My Canvas / studio

Contour Drift

A survey sheet for country that has never existed. Twelve heights, one line each, pulled out of a drifting noise field by thirteen thousand grid cells that each know only their own four corners — and not one of them knows that a contour line is a loop.

Plate 26marching-squares

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

This is a contour map, drawn the way a real one is drawn: pick a set of heights, and for each height draw the line where the ground is exactly that high. The ground here is three octaves of value noise — a smooth random height field, each octave half the amplitude and twice the frequency of the one before it — and the heights are twelve levels evenly spaced 0.0373 apart, running from 0.295 to 0.705. That range is not arbitrary: the field measures a mean of 0.5003 and a standard deviation of 0.1237 over 448,000 samples, so the lowest and highest lines sit at plus and minus 1.66 standard deviations and the map fills the sheet without wasting lines on ground the terrain almost never reaches.

Then the whole field drifts. There is a third noise axis, z, and it advances 0.0010 units every frame. Because each octave doubles its frequency, it doubles its speed along z too: the coarse landforms turn over completely in about 16.7 seconds at 60 fps, the middle octave in 8.3 and the finest wrinkles in 4.2. What you see is a coastline that reorganises like weather — individual lines crawling at roughly 11 pixels a second on this page’s stage, while the shape of the country underneath them changes over a quarter of a minute.

What marching squares actually does

The map is not traced. Nothing in this plate follows a line, and no loop is ever stored anywhere. The frame is cut into a grid of squares, and every square is asked one question, twelve times — once per level: which of my four edges does this height cross? A square answers by comparing each corner to the level, which gives an even number of crossings, and then places each crossing along its edge by linear interpolation between the two corner heights. Two crossings become one straight segment. That is the whole algorithm.

The loops are an emergent accident. Two neighbouring squares share an edge, so they compute the same crossing point on it from the same two corner values and their segments meet exactly. Do that across the grid and thousands of independent, ignorant local decisions turn out to have drawn closed curves. Interpolating along the edge rather than taking its midpoint is what makes the curves smooth; midpoints give the familiar 45° marching-squares staircase, and the fix costs one divide per crossing.

At 1280×720 the grid is 154×87 cells of 8.42 px, which is 13,398 height samples and 160,776 range tests per frame — of which only about 4,300 produce a segment. The other 97% are rejected by one comparison against a per-cell minimum and maximum computed once for all twelve levels. Field plus segment extraction measures 4.1 ms a frame under Node at that size.

The saddle that has two answers

There is one case where a square cannot answer honestly. If all four of its edges are crossed, two opposite corners are above the level and two are below, and there are two equally valid ways to join the four points: high ground running through the middle, or low ground running through it. Choose wrongly and you weld two separate loops into one, or cut one loop in half — and nothing later notices, because the result is still a perfectly plausible-looking curve.

The rule most tutorials give is to average the four corners and see which side of the level the average falls on. This plate uses the asymptotic decider (Nielson and Hamann, IEEE Visualization 1991) instead: the surface marching squares actually reconstructs inside a cell is the bilinear interpolant of its corners, that surface has exactly one saddle point, and its height there has the closed form (tl·br − tr·bl) / (tl + br − tr − bl). Evaluate that and you are asking the surface itself rather than approximating it.

Honest about the size of the effect: on a grid this fine the four-crossing cell is rare. Measured over 40 frames at 1280×720 it occurred nine times in 6,315,840 cell-level tests, and the two rules picked different pairings for exactly one of those nine. That is a sample of nine and no more. The reason to take the exact rule anyway is that it costs one divide and its failure mode is invisible — a bug that fires in one cell once a minute and produces a believable loop is one you would never find by looking at the picture.

Reading the plate: what to watch for

  • The coral lines are every fourth contour. Twelve levels, three bands of four, and the top line of each band is drawn in coral at 1.9 times the width. That is the index contour: on a paper map it is the heavy line carrying the printed height, and it is what lets you count how high you are without counting every line. This map has no numbers to print, so the colour does the labelling’s job.
  • Where the lines crowd, the ground is steep. Contour spacing is inversely proportional to slope — the one fact a contour map exists to carry — and it is true here. On this stage neighbouring lines average about 28 px apart; where they close to two or three pixels the slope is an order of magnitude above average, and where the sheet goes bare the ground is nearly flat.
  • A small closed loop is a summit or a basin, and you cannot tell which. Without a labelled interval there is no way to know whether the ground inside a ring is higher or lower than the ring. Real maps solve this by printing hachures inside depressions; this one does not, so the ambiguity is genuinely there and it is the same ambiguity a paper map has before it is labelled.
  • Splits and merges are the only real events. Between them the picture merely deforms — lines bend, wander, thicken and thin. When a loop pinches into two, or two loops fuse, the map has changed topology, and that happens exactly when the drifting surface carries a saddle point through one of the twelve levels. Those moments are worth waiting for; they are the ones a still frame cannot show you.
  • Nothing here follows a line. Every segment on screen was decided by one square looking at its own four corners and nothing else. If a loop looks deliberate, that is thousands of independent decisions happening to agree at the edges they share.

Colour, weight and how the picture is put together

Twelve levels are painted in four strokes, not twelve. The nine intermediate lines are grouped into the three bands they belong to — one path and one alpha per band — and the three index contours share a fourth path that is stroked last, so a coral line always sits over the cobalt ones rather than under them. Twelve strokes would mean twelve strokeStyle writes and twelve path submissions for a picture that contains four inks.

The alphas ramp with height so the high ground reads brighter than the low: 0.36 / 0.50 / 0.66 cobalt and 0.90 coral on the near-black theme, and 0.42 / 0.54 / 0.68 / 0.92 on the warm paper one, because the same one-pixel hairline that reads clearly against #0B0D12 all but disappears into #E7E2D5. Lines are 1 CSS px; index contours are 1.9.

Not one colour is named in the drawing code. The background and both ink triplets are handed in by the caller from the shared palette — so the embed follows the reader’s light or dark setting, the catalogue tile follows the tile frame, and the two cannot drift into different pictures.

The grid is 6.5 CSS px per cell until that would cost more than 13,000 samples a frame, and from there it coarsens rather than getting denser. The base wavelength is pinned to the cell, not to the frame, so the ratio of contour spacing to facet length — the only thing that decides whether a line reads as a curve or a staircase — is identical at every size. The visible consequence is that a small gallery tile is a small window onto the same country rather than a shrunken copy of the stage, and that a 3840×2160 screen gets a wider view (154×87 cells of 25.3 px, a 1,010 px wavelength) instead of four times the work.

Nothing here is random. The terrain is hash-based value noise read at a fixed origin, so every visitor meets the same coastline on load. That is a deliberate choice rather than a shortcut: it is what makes the catalogue tile and the standalone embed comparable frame for frame, which is how test/embed-plate-parity.test.cjs can check that the two draw the same picture at all. Variety comes from z, which never revisits a landscape — and, in the embed, from a click, which jumps 37 noise units down that axis to somewhere completely unrelated.

Under prefers-reduced-motion no animation loop is scheduled. One frame of this plate is already the finished map — there is nothing accumulating and nothing settling — so a visitor who asked for stillness is served the whole artwork rather than a partial one.

Honest limits

This is not terrain, and a surveyor would say so in a second. Real topography is carved by water, and water leaves signatures a contour map shows plainly: dendritic valley networks, ridge lines that branch and never cross, and the sharp V that every contour makes where it crosses a stream, always pointing upstream. Noise has none of that. Its contours are smooth blobs with no drainage, no watersheds and no rivers, because nothing here has ever eroded anything. It is a plausible-looking map of a place that could not form.

There is no datum, no interval and no scale bar. A contour map without a labelled interval is decoration — you cannot read a height off this, you cannot tell a hill from a hollow, and the coral banding tells you the lines come in fours and nothing more.

The grid is a straitjacket. Marching squares can only resolve what the sampling grid can see. A peak narrower than one cell — 8.42 px at this size — is either missed entirely or appears as a single squarish ring, and the algorithm has no way to tell you which happened.

The drawn line is not the true contour. Marching squares reconstructs a bilinear surface from the four corner samples and contours that, so where the real field curves inside a cell the drawn segment is a chord of the true curve. The error shrinks with the square of the cell size, which is why the cell is pinned small relative to the wavelength — but it never reaches zero.

Value noise, not gradient noise. Value noise puts its extrema on the integer lattice, which leaves a faint axis-aligned bias: pause on a still frame and you will occasionally catch a small loop that is a little too square, or a run of lines a little too willing to line up with the horizontal. Perlin-style gradient noise would not do that, and would cost more per sample. This is a plate, not a terrain generator.

The drift is not geology. Nothing uplifts, nothing erodes, nothing is conserved. z is simply a third axis of the same noise, so the land has no history and the sequence you are watching would look exactly as plausible run backwards.

It is a plane. No shading, no relief, no light direction, no sense of which way is up. A contour map is an honest projection of a surface, but it is still only twelve slices of one, and nine tenths of what makes a landscape legible is missing on purpose.

Draw your own

The map is short enough to draw yourself. The snippet below is this plate in 28 lines of HTML and plain JavaScript with no library. It keeps the plate’s rule and its constants: the same value-noise terrain (the same hash, three octaves, the same fixed origin), 6.5 px cells capped at 13,000 samples, twelve levels from 0.295 to 0.705 in three bands of four, a coral index line on every fourth, and a drift of 0.001 noise units a frame. The one change is that it writes marching squares the way textbooks do, as a lookup table of all 16 cases, where the plate uses four edge tests and a branch. The test suite runs both on the plate’s own terrain at 1280×720 for 40 frames, and the two draw the same segments, saddles included. It leaves out what exists for the gallery rather than for the algorithm: the per-cell minimum and maximum that let the plate skip most cells cheaply, sharp drawing on high-density screens (it draws in CSS pixels), and the embed’s click-to-jump and colour controls.

  • Grid. f holds one height per grid corner. At 1280×720 the 13,000-sample cap wins over the 6.5 px cell, so the grid is 154×87 corners of 8.42 px. One grid step is 1/RATIO = 1/40 of a noise unit, so the land has the same shape at every window size. A bigger window shows more of it, not a stretched copy. Each level is LO + (HI − LO)·l/11, so the levels are 0.0373 apart.
  • Rule. Each cell builds its case number the way Wikipedia’s Marching squares article describes: one bit per corner, going clockwise from top-left (8) through top-right (4) and bottom-right (2) to bottom-left (1). A bit is 1 when the corner is above the level. This code counts a corner exactly on the level as above, so no corner is ever counted twice. CASES[n] lists the edges to join. Cases 0 and 15 draw nothing, twelve cases draw one segment, and the two saddles, 5 and 10, draw two. Each end is placed on its edge by linear interpolation between the two corner heights. For the saddles, the article suggests the average of the four corners. This code, like the plate, uses the asymptotic decider (Nielson and Hamann, 1991). It takes the height of the bilinear surface at its own saddle point, (tl·br − tr·bl) / (tl + br − tr − bl). The table entries for 5 and 10 join the high corners. If the centre is below the level, n ^= 15 swaps 5 for 10, and the low corners are joined instead.
  • Speed. Measured on this code at 1280×720 over 40 frames: 12 levels × 153×86 cells is 157,896 cell tests a frame, and they produce 4,323 segments on average. Only 9 of the 6,315,840 tests landed on a saddle. Building the field and running all twelve marches takes about 3.5 ms a frame in Node on the machine that built this page. The land drifts 0.001 noise units a frame. Like the plate, it counts frames, not seconds, so a 120 Hz screen drifts twice as fast.
  • Colour. The ground, the cobalt bands and the coral index lines come from this site’s palette, light or dark to match your device. The band opacities are the plate’s: 0.36, 0.50 and 0.66 with coral at 0.90 on dark, and 0.42, 0.54, 0.68 and 0.92 on light. Lines are 1 px and index lines 1.9 px. Under reduced motion it draws one frame and stops, and one frame is already the whole map.

Things worth changing: LEVELS = 24 doubles the line density, and replacing each interpolated crossing with the edge midpoint (cell / 2) brings back the 45° staircase that interpolation removes. Flip the saddle test to mid >= L and the picture still looks plausible. That is why the test checks every saddle against the decider rather than looking at the picture. One honest limit: the snippet draws loose segments, one per crossed cell and two in a saddle, and never joins them into loops. If you want closed polylines, for SVG or a pen plotter, you have to chain segments by their shared endpoints yourself. Neighbouring cells compute a shared crossing from the same two corner heights, so the two ends agree to within a millionth of a pixel. They are not always identical, because x + cell and the next cell’s own x can round differently. Join ends by a small tolerance, not by exact equality.

<canvas id="contours" style="position:fixed; inset:0"></canvas>
<script>
const dark = matchMedia('(prefers-color-scheme: dark)').matches, still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const [ground, cool, warm, INK] = dark ? ['11,13,18', '150,180,255', '255,120,84', [0.36, 0.5, 0.66, 0.9]]
                                       : ['231,226,213', '40,72,205', '190,68,28', [0.42, 0.54, 0.68, 0.92]];  // INK: alpha of the three bands, then of the index contours
const CELL = 6.5, RATIO = 40, MAXCELLS = 13000, OCT = 3, LEVELS = 12, BAND = 4, LO = 0.295, HI = 0.705, DRIFT = 0.001, ORIGIN = [137.31, 61.07, 8.5];  // the plate's constants
const hash = (x, y, z) => { let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1440662683); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };
const ease = (t) => t * t * (3 - 2 * t), mix = (a, b, t) => a + (b - a) * t;
function noise(x, y, z) { const i = Math.floor(x), j = Math.floor(y), k = Math.floor(z), u = ease(x - i), v = ease(y - j), w = ease(z - k);  // value noise: a hash at each lattice corner, smoothly blended
  const g = (k) => mix(mix(hash(i, j, k), hash(i + 1, j, k), u), mix(hash(i, j + 1, k), hash(i + 1, j + 1, k), u), v); return mix(g(k), g(k + 1), w); }
function terrain(x, y, z) { let amp = 1, sum = 0, norm = 0; for (let o = 0; o < OCT; o++, amp *= 0.5, x *= 2, y *= 2, z *= 2) { sum += amp * noise(x, y, z); norm += amp; } return sum / norm; }
// The 16 cases. Corner bits tl = 8, tr = 4, br = 2, bl = 1, set when the corner is >= L. Edges: 0 top, 1 right, 2 bottom, 3 left.
const CASES = [[], [[3, 2]], [[2, 1]], [[3, 1]], [[0, 1]], [[0, 3], [2, 1]], [[0, 2]], [[0, 3]], [[0, 3]], [[0, 2]], [[0, 1], [3, 2]], [[0, 1]], [[3, 1]], [[2, 1]], [[3, 2]], []];
function march(c, f, nx, ny, cell, L) { for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) {
  const tl = f[j * nx + i], tr = f[j * nx + i + 1], br = f[(j + 1) * nx + i + 1], bl = f[(j + 1) * nx + i], x = i * cell, y = j * cell;
  let n = (tl >= L) * 8 + (tr >= L) * 4 + (br >= L) * 2 + (bl >= L); if (n === 0 || n === 15) continue;
  if (n === 5 || n === 10) { const den = tl + br - tr - bl, mid = den !== 0 ? (tl * br - tr * bl) / den : (tl + tr + bl + br) / 4; if (mid < L) n ^= 15; }  // saddle: 5 and 10 join the high corners; a low centre swaps them
  const at = [[x + cell * (L - tl) / (tr - tl), y], [x + cell, y + cell * (L - tr) / (br - tr)], [x + cell * (L - bl) / (br - bl), y + cell], [x, y + cell * (L - tl) / (bl - tl)]];  // linear interpolation on each edge
  for (const [a, b] of CASES[n]) { c.moveTo(...at[a]); c.lineTo(...at[b]); } } }
const cv = document.getElementById('contours'), ctx = cv.getContext('2d'), DN = 1 / RATIO; let z = ORIGIN[2];
function draw() { const W = cv.width = innerWidth, H = cv.height = innerHeight, cell = Math.max(CELL, Math.sqrt(W * H / MAXCELLS)), nx = Math.ceil(W / cell) + 1, ny = Math.ceil(H / cell) + 1, f = new Float32Array(nx * ny);
  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) f[j * nx + i] = terrain(ORIGIN[0] + i * DN, ORIGIN[1] + j * DN, z);  // one grid step = 1/RATIO noise units
  ctx.fillStyle = 'rgb(' + ground + ')'; ctx.fillRect(0, 0, W, H); ctx.lineCap = ctx.lineJoin = 'round';
  for (let b = 0; b <= LEVELS / BAND; b++) { ctx.beginPath();  // bands 0-2: three plain levels each; b = 3: the index contour of every band, stroked last so it sits on top
    for (let l = 0; l < LEVELS; l++) if (b < LEVELS / BAND ? Math.floor(l / BAND) === b && l % BAND < BAND - 1 : l % BAND === BAND - 1) march(ctx, f, nx, ny, cell, LO + (HI - LO) * l / (LEVELS - 1));
    ctx.lineWidth = b < LEVELS / BAND ? 1 : 1.9; ctx.strokeStyle = 'rgba(' + (b < LEVELS / BAND ? cool : warm) + ',' + INK[b] + ')'; ctx.stroke(); } }
const frame = () => { z += DRIFT; draw(); requestAnimationFrame(frame); }; draw(); if (!still) requestAnimationFrame(frame);  // reduced motion: one still frame
</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 32 animations in the gallery →