Not all dither noise is the same noise

Dithering before you quantize is the right move, and the noise you dither with is a second decision that mostly gets made by accident. A hash() is not a neutral choice: it is the worst available one.

The same shallow ramp quantized to five levels, dithered with one step of noise. Left: white noise. Right: interleaved gradient noise with a golden-ratio frame offset.

Both halves carry exactly the same amount of noise and both remove the banding. The left one looks dirty and the right one looks like a smooth ramp, and the difference is entirely in how the noise is distributed, not how much of it there is.

The faint diagonal weave on the right is real and worth knowing about: interleaved gradient noise is a cheap approximation, not true blue noise, and it has a characteristic directional structure. On a shallow ramp at close range you can find it. It is still enormously better than the alternative next to it, and a real blue noise texture does not have it.

The eye is a low-frequency detector

Human contrast sensitivity peaks around 2 to 5 cycles per degree and falls off steeply above that. So visible noise is not about amplitude, it is about which frequencies the amplitude lands in.

White noise has flat energy across every frequency by definition, which means a full share of it sits right in the band the eye is best at. Perceptually that shows up as clumping: dark pixels happen to land near other dark pixels and the eye reads the clump as a blotch, because reading blotches is the one thing it is optimized for.

Blue noise is noise with its low frequencies removed. Same total energy, pushed up into the range where sensitivity has already fallen away. Nothing about it is more random. It is less random, deliberately, in a way that is arranged to be invisible.

The practical options, in order

  1. A blue noise texture. Precomputed by void-and-cluster, tiled, read with fract(uv * res / 64.0). The best quality, one texture bind, free per-sample. This is the default answer for anything shipping.
  2. Interleaved gradient noise. Three constants and no texture. Not truly blue, but its energy skews high enough to be a large improvement, and it is what the demo above uses. The right choice when a bind is inconvenient.
  3. An ordered Bayer matrix. Cheapest, and it trades clumping for a visible crosshatch. Sometimes that regular pattern is what you want stylistically; usually it is worse than IGN.
  4. White noise. Only when the noise is already being averaged over many samples, at which point the distribution stops mattering.

The temporal half, which is where it usually goes wrong

A still frame is only half the problem. Reseed the noise randomly each frame and you get a pattern that is good in space and white in time, which the eye reads as fizzing. That is a common way a correct spatial choice still ships looking bad.

The standard fix is to offset the sample by the golden ratio conjugate each frame:

float n = fract(noise(pixel) + frameIndex * 0.61803399);

The golden ratio is the irrational number that is hardest to approximate with a fraction, which is exactly the property wanted here: successive frames land far apart in the pattern and the sequence never falls into a short cycle. Over the eye's roughly 100ms integration window the frames average to the correct value instead of buzzing.

Where else this applies

Anything that takes one sample where it wants many has this decision in it, and the answer is the same every time:

Rules of thumb

  1. The amount of noise is set by the quantizer step. The kind of noise is a separate decision, and it is the one that decides how it looks.
  2. Never dither with hash(). It is the worst distribution available, not the neutral one.
  3. Ship a blue noise texture when you can, use interleaved gradient noise when you cannot.
  4. Offset by the golden ratio per frame, or good spatial noise still fizzes.
  5. Judge it on a shallow ramp. A steep gradient hides every difference between these.

All 61 notes How to use them Credits