DitherImage

A photograph reduced to two tones by error diffusion, which is the kind of dither a shader cannot do.

switch mode to ordered to see what a shader can manage

Paste into Claude Code, Cursor or any agent. Pinned to a tag, so it will not change under you.

Options

dot 3

2 to 20 px, good: 2 to 5

Size of one dot, in CSS pixels. The resolution of the whole thing, and the first version of this item had it at 6, which is wrong: at 6 the picture is big soft squares and the subject is mush. At 3 it is a legible one-bit photograph, which is the point. The pass costs four times as much each time this halves, which is why it only runs `rate` times a second rather than every frame: 4.8ms at 3, and 12.6ms at 2, which is a dropped frame every time it runs.

mode atkinson

atkinson · floyd · ordered

Which dither. `atkinson` throws away a quarter of the error, which cleans the highlights and the shadows and is the Macintosh look. `floyd` keeps all of it, which holds more midtone and reads greyer and flatter. `ordered` is the Bayer matrix, the only kind a shader can do, and it is here to be switched to once: notice that it sits over the picture rather than in it.

levels 2

2 to 8, good: 2 to 4

How many tones. 2 is one bit and it is the whole point. Past about 4 there is enough tone that the dither stops doing any work and you have a posterised photograph, which is a different and much less interesting thing.

ink #16141c

css colour

The dark tone. A soft near-black rather than a true one: a screen printed in true black against true white reads as a screenshot of a bitmap rather than as something on a page.

paper #f4f1e6

css colour

The light tone. Set this and the ink the other way round for the terminal version, which is green on black and which everybody else ships.

exposure 1.05

0.2 to 3, good: 0.8 to 1.6

Multiplies the tone before quantising. The main tone control. It works on the picture as stored rather than on reconstructed light, which is deliberate and is explained in the notes, and it means this behaves like an ordinary brightness rather than like a stop of exposure.

contrast 1.15

0.2 to 4, good: 0.9 to 1.8

Pushes tone away from the midpoint before quantising. A photograph with a narrow range gives a picture that is all one grain size, and this is what opens it out. Past about 2.5 the midtones are gone and you have a stencil.

swell 0.05

0 to 0.5, good: 0.03 to 0.12

How far the exposure swells over the period. Small. Animated error diffusion boils, because the error propagates and one flipped pixel changes everything downstream of it, so whole runs of grain reorganise at once. At 0.3 it is static. At 0 it holds still and the item draws one frame and stops.

period 4

2 to 60 s, good: 8 to 24

Seconds for one swell. The loop closes exactly on it. Long, for the same reason the swell is small: slow, the boil reads as film grain on a plate that is breathing, which is the one kind of motion this technique is any good at.

serpentine true

true · false

Scan alternate rows backwards. Always going left to right drags the error one way and a smooth gradient comes out with diagonal streaks running down and to the right. Turn it off once on a sky: the streaks are not subtle, and they are the reason this option exists rather than being hard-coded.

rate 8

0.5 to 60 hz, good: 5 to 12

How many times a second the dither is recomputed. Every other frame redraws the plate it already has, which is one scaled drawImage and costs nothing. This is the option that lets the dots be fine, because the pass is expensive and running it eight times a second instead of sixty is near enough an eighth of the work. It is also what a one-bit display did: a Macintosh did not re-dither its screen sixty times a second, the bitmap sat there until something changed it. And it is what stops the boil reading as television static.

reducedMotionTime 0

0 to 60 s

The single frame shown when the user prefers reduced motion. Any time works here: the swell is the only thing that moves and the picture is the point, so this is just a choice of exposure.

maxDpr 2

1 to 3

Device pixel ratio ceiling. It changes how crisply a dot's edges land on the display, not how many dots there are: the grid is measured in CSS pixels so the picture is the same picture on any screen.

What it needs

Category
Surfaces, an effect
Dependencies
None at all
Browser
No WebGL
Frame budget
~4.8 ms · 0 context
Pinned to
v0.13.0
  • Every dithering effect on the web is an ordered dither: a fixed matrix of thresholds compared against the picture one pixel at a time. It is the only kind a fragment shader can do, because a fragment shader has to answer for one pixel without knowing anything about its neighbours
  • Error diffusion is the other kind and the one that looks like something. Quantise a pixel, work out how much tone you threw away, hand that error to the pixels you have not reached yet. It is why a dithered Macintosh picture has grain that follows the subject instead of a crosshatch laid over it, and it is strictly sequential, so there is no way to put it on a GPU
  • It runs on the CPU, and it does not run at the frame rate. Time is quantised to `rate` steps a second and the pass only runs when the step changes, so every other frame is one scaled drawImage. That is what lets the dots be fine: the pass costs four times as much each time the dot halves, so at sixty frames a second anything under about five pixels was unaffordable and the picture came out as big soft squares. It is still pure in t, because the step is a function of t alone
  • Do not dither in linear light. A trap, and the first version here fell into it. The argument for linear is good as far as it goes: a half-and-half field of black and white dots averages in the eye to half the light, and light adds linearly. But a mid grey is 128, which is 0.50 encoded and 0.22 linear, so dithering in linear lays down 22 per cent white dots where the photograph says 50 per cent grey, and most of the frame goes solid. The flaw is that a photograph is not a scene: it has already been tone-mapped for a display, and undoing its encoding undoes the tone mapping with it. The quantity to preserve is the one the picture is stored in, which is why every implementation since 1976 works on the bytes as they come
  • Atkinson throws a quarter of the error away. Floyd and Steinberg pass all of it over four neighbours; Atkinson passes six eighths over six, and the missing two eighths is not an oversight. Losing error pulls flat areas towards whichever tone they were nearest, so highlights go clean white and shadows go clean black, which is the whole reason the Macintosh look reads as contrasty rather than as a field of grey speckle
  • Scan alternate rows backwards. Always going left to right drags the error one way, and in a smooth gradient that comes out as diagonal streaks running down and to the right. A serpentine scan cancels the drag, and the kernel is mirrored with it so a backwards row pushes its error the way it is travelling
  • The source is sampled through a canvas the size of the grid, so the browser's own downscaler does the averaging. Taking one source pixel per dot aliases badly and a picture of foliage comes back as noise rather than as tone
  • Scaled up with image smoothing off. With it on the browser interpolates the two tones back into the greys the dither existed to avoid, and the result is a slightly noisy photograph
  • Animated error diffusion boils. The error propagates, so lifting the exposure by a hair flips one pixel, which changes what every pixel after it inherits, and whole runs of grain reorganise at once. At sixty frames a second that is television static; at eight it reads as grain, which is the other reason the rate is low
  • A 2D canvas, so this is the one image item that costs nothing against the sixteen-million-pixel WebGL budget and can sit on a page beside a shader without competing with it
  • The <img> stays in the document, hidden. Its alt text is the accessible version of the picture and a page whose script never runs still shows the photograph
  • Two numbers, not one, because the dither does not run at the frame rate. The pass costs 4.8ms at 1280 by 720 with a three-pixel dot; every frame in between redraws the plate it already has, which is one scaled drawImage at 0.003ms. At the default rate of eight a second that is about 38ms of work per second of animation
  • That split is what lets the dots be fine. The pass costs four times as much each time the dot halves, so at the frame rate anything under about five pixels was unaffordable and the picture came out as big soft squares, which is exactly how the first version of this item shipped and exactly what was wrong with it. 12.6ms at dot 2, 4.8 at 3, 2.7 at 4, 1.2 at 6
  • It is also what a one-bit display did. A Macintosh did not re-dither its screen sixty times a second; the bitmap sat there until something changed it
  • The pass itself went from 3.5ms to 2.0ms at the old six-pixel dot before any of that. The kernel was an array of triples destructured six times per cell, and the output was written four bytes at a time when for one bit there are only two possible words
  • The source is resampled only when the grid changes. The plate context asks for willReadFrequently, which keeps it where putImageData is cheap
  • Holds no WebGL context at all, so it costs nothing against the sixteen-million-pixel budget and can sit on a page beside a shader

Concept credit: Error diffusion and its kernels are from the printing and computer-graphics literature: Floyd and Steinberg's 1976 paper, and Bill Atkinson's variant written for the Macintosh. The observation that it cannot be a fragment shader, and what to do about that, are this item's own, by Robert Floyd and Louis Steinberg. The implementation here is written from scratch.