./jerome{m}

~/Labs/Dither Lab.md · lab · 6 min · updated

Dither Lab

2026 · Rust · WebAssembly · How this site works

Every cover on this site is dithered when the site is built: a picture goes in, a 1-bit amber one comes out. The Dither Lab is that step turned into a toy. Drop in a picture or a video, paste one, or point the camera at yourself, then push it through a dozen dithering methods, a few palettes, and lights you place by hand, and take it away as a PNG, an animated GIF or a video. Nothing is uploaded: the picture never leaves your browser.

dither · liveDrop, paste or open a picture or a video, or use the camera. Click it to place a sun or a halo, drag to move one, double-click to remove it; hold C to see the original, space to pause a video. Copy link shares the recipe, never the picture.open ↗

A few colours for every tone

Dithering trades colours for pattern: when a screen or a printer has only a few colours, a pattern of them, seen from far enough, reads as the tones in between. The lab does it two ways.

  • A tone ramp, the default. The palette runs dark to light and each pixel's brightness picks a place along it; the dither decides, pixel by pixel, which of the two nearest colours to use. The picture's hues are thrown away and the palette's take their place, which is how navy, ochre and gold make a night scene.
  • Nearest colour. Each pixel goes to the palette colour closest to its own, and the error goes into the pattern. That keeps hues, so it suits colourful palettes like the PICO-8's sixteen.

One switch does a lot of the look: last colour for light. The picture's tones spread over every colour but the brightest, which only light can reach. Without a light the image is two colours of ink; place a sun and it burns through in a colour nothing else in the picture has.

Ordered: a pattern that never moves

Ordered dithering compares every pixel with a fixed threshold that repeats in a tile. The classic tile is Bryce Bayer's matrix (1973), built by doubling: a 2×2 seed, then four scaled copies of it interleaved, and again.

text
0 2      0  8  2 10      bayer 8 is the same
3 1     12  4 14  6      step once more: the
         3 11  1  9      covers on this site
        15  7 13  5      use exactly that one

Its strength is that it is local: a pixel's result depends only on its own value and position, so a dithered video or a dragged light doesn't crawl. Its weakness is the visible cross-hatch, which is half the charm here. The lab builds its matrices by that doubling, and a test checks the 8×8 one against the matrix the covers use.

Blue noise is the same idea with a tile that looks random but has no clumps: thresholds whose low values sit as far from one another as possible. The lab makes a 64×64 one when it first needs it, with Robert Ulichney's void-and-cluster method (1993): scatter a few points, move the tightest cluster into the emptiest void until nothing changes, then rank every pixel by taking clusters out and filling voids in.

Error diffusion: passing the mistake on

The other family walks the picture pixel by pixel, picks the closest colour, and pushes the difference onto the neighbours it hasn't reached yet. The kernels say where the error goes:

text
floyd–steinberg (1976)        · 7      ÷ 16
                            3 5 1

atkinson (Macintosh)          · 1 1    ÷ 8, but only 6/8 of it
                            1 1 1
                              1

sierra lite                   · 2      ÷ 4
                            1 1

Jarvis–Judice–Ninke (1976) and Stucki (1981) spread it over twelve neighbours, which gives smoother tones and softer edges. Atkinson's is my favourite: dropping a quarter of the error keeps highlights and shadows clean, which is a lot of why the original Mac looked the way it did. Serpentine runs every other row right to left, so the error doesn't pile up in diagonal streaks.

Screens: dots and lines

Halftone is how print does it: dots on a grid, larger where the tone is lighter, with the screen's cell size and angle as controls. Lines is an engraving screen: parallel lines that thicken with the tone, which turns a photo into something closer to a banknote than a newspaper.

Lights

A sun is three things. A solid disk in the light colour; a glow, added to the picture's tones before the dither, so it dissolves through the pattern instead of sitting on top; and, with reflection on, a column below it that widens with distance and is cut into ripples by a sine, like a low sun on water. A halo is a one-pixel ring, drawn after the dither so it stays crisp. Lights are stored in fractions of the picture, so they stay put when the resolution changes.

Moving pictures

A video, or the camera, goes through the same engine every frame, and that is where the two families part ways. An ordered dither decides each pixel from its own value and its place in the tile, so a still part of the picture stays still. Error diffusion decides each pixel from everything before it in the scan, so a change anywhere shifts the pattern in every pixel after it, and the whole picture fizzes. Sometimes that's the look you want; usually Bayer or blue noise is what holds.

A still picture can move too. Shimmer slides the threshold pattern a few pixels each frame, and ripple runs the waves of a sun's reflection down the water. Both are loops: every pattern but blue noise comes back to where it started after 16 frames, and the ripples move a whole number of wavelengths, so an exported still is always a whole number of those cycles and its last frame leads back into the first.

GIF is the format a dither was made for, because a dither is already what a GIF holds: a few colours and one index per pixel, so nothing is quantised a second time. The engine writes it. Each frame is scaled to whole pixels, then cut to the rectangle that changed since the frame before, with the pixels that didn't change made transparent, so a mostly still dither costs little more than its first frame. What's left goes through GIF's LZW, the 1984 compression that grows a table of runs it has already seen, with codes that start a bit wider than the palette's indices and grow to twelve bits. A video's frames are read by seeking through it one at a time, so a slow machine makes the same GIF as a fast one, just later.

The video export is the browser's own recorder, MP4 where it can, WebM where it can't, fed one painted frame at a time and recorded in real time. Video codecs are built to blur exactly the fine detail a dither is made of, so it's exported at as many whole pixels per dot as fit in 1920, and a generous bitrate; the GIF is the exact one.

How it's built

The engine is a small Rust crate with no dependencies, compiled to a 90 KB WebAssembly module with no bindings at all: the page writes RGBA, settings and palette into buffers the module owns, calls run, and reads back a palette index per pixel, or calls gif_frame to add the run to a GIF it's writing. One trap worth writing down: a buffer call can grow WebAssembly memory, and growing it detaches every view onto the old memory, so the page asks for the pointer first and only then looks at memory.buffer.

The preview paints each dot as a whole number of device pixels and lets nothing scale it, because a dither stretched by a fraction turns into stripes. Export paints at a whole-number scale too, up to 4096 pixels on the long side. Settings and lights live in the frame's address as compressed JSON, like the shader playground's, which is what the island's copy link shares.

Recipes

  • Nocturne, the default: Bayer 8, the nocturne palette, last colour for light, gamma around 1.4, and a sun low over something that can take a reflection.
  • Handheld: 160 columns, the Game Boy palette, round pixels.
  • Newsprint: halftone, the paper palette, cell 6 at 45°.
  • Sixteen colours: Floyd–Steinberg, the PICO-8 palette, nearest colour.
  • Low tide: the street, ripple on, a GIF at 12 fps: three seconds of water that never ends.
  • Phosphor life: the life clip (my Game of Life running), the phosphor palette, Bayer 8, shimmer on.

Back to Labs · see how this site works · browse the Notes.