Shader Lab
11.Dithering

Dithering (Bayer)

Arc 2 · 2D Screen-Space · Lesson 11 of 35
coordinate signal color
2

Purpose

Bayer dithering fakes smooth gradients with a tiny repeating grid of brightness thresholds — compare each pixel's brightness to the threshold at its grid cell, snap to black or white, and the eye averages the resulting crosshatch back into the gradient.

Key insight

A monitor can show any color, but a ditherpunk look wants only two or four tones. The naive approach — "if brightness > 0.5, white, else black" — produces ugly banded posters. Bayer's insight: instead of one global threshold, use a different threshold for each pixel, arranged in a small 4×4 matrix that tiles across the screen.

Dark pixels pass the threshold only in cells whose threshold is very low; bright pixels pass almost everywhere. The result is a patterned mix of on and off pixels whose density tracks the original brightness, which reads as a gradient from a step back. This is simultaneously a coordinate-layer operation (which matrix cell am I in?), a signal-layer operation (compare to the cell's threshold), and a color-layer operation (map the binary result to palette entries). It's the cleanest three-layer effect in the arc.

Break it

Set tones = 2, then look closely at the canvas. From a step back you see a gradient; up close you see the explicit Bayer crosshatch grid. Teaches: dithering only works because your eye integrates many pixels. The pattern isn't hidden — it's just small enough that your visual system averages it. Every "gradient" in a dithered image is a lie, and you're seeing the lie's mechanism.

Now slide tones up to 8. The crosshatch dissolves and the image looks nearly smooth — the tradeoff is that the distinctive ditherpunk look fades.

Direct Claude

"1-bit look / Game Boy / Mac Classic" tones = 2, high-contrast palette "4-tone ditherpunk" tones = 4 "subtle noise to break up banding" tones high, Bayer pattern barely visible (polish-level use) "bigger dither pattern" larger Bayer matrix (8×8 vs. 4×4) "dither only the shadows" apply the effect inside a luminance mask
Meta-phrase you gain here: "Bayer-matrix threshold quantization" — or, more briefly, "dither to N tones with a Bayer matrix." A fully specified brief.
Combines with: L5 (pixelation first, then dither — the canonical retro stack), L8 (noise as an alternative threshold source — blue-noise dithering — same shader, different threshold map), L12 (some CRTs show mild dithering due to limited color precision), L4 (dither-rendered vignette has its own aesthetic).
the fragment shader running above
uniform int u_tones;

// Scene: a smooth diagonal gradient so the banding/dithering is obvious.
vec3 scene(vec2 uv) {
  float g = (uv.x * 0.6 + uv.y * 0.4);
  vec3 a = vec3(0.10, 0.04, 0.18);
  vec3 b = vec3(1.00, 0.88, 0.70);
  return mix(a, b, g);
}

// 4x4 Bayer matrix, values 0..15 / 16 → thresholds in [0, 1).
float bayer4(ivec2 p) {
  int x = p.x - (p.x / 4) * 4;
  int y = p.y - (p.y / 4) * 4;
  int i = x + y * 4;
  if (i == 0)  return  0.0/16.0;
  if (i == 1)  return  8.0/16.0;
  if (i == 2)  return  2.0/16.0;
  if (i == 3)  return 10.0/16.0;
  if (i == 4)  return 12.0/16.0;
  if (i == 5)  return  4.0/16.0;
  if (i == 6)  return 14.0/16.0;
  if (i == 7)  return  6.0/16.0;
  if (i == 8)  return  3.0/16.0;
  if (i == 9)  return 11.0/16.0;
  if (i == 10) return  1.0/16.0;
  if (i == 11) return  9.0/16.0;
  if (i == 12) return 15.0/16.0;
  if (i == 13) return  7.0/16.0;
  if (i == 14) return 13.0/16.0;
  return  5.0/16.0;
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 col = scene(uv);
  float lum = dot(col, vec3(0.299, 0.587, 0.114));

  // COORDINATE LAYER — which cell of the 4×4 Bayer matrix does this pixel land in?
  ivec2 cell = ivec2(mod(gl_FragCoord.xy, 4.0));
  float threshold = bayer4(cell);

  // SIGNAL LAYER — push the brightness by (threshold - 0.5) / tones, then quantize.
  float levels = float(u_tones) - 1.0;
  float nudged = lum + (threshold - 0.5) / levels;
  float quantized = floor(clamp(nudged, 0.0, 1.0) * levels + 0.5) / levels;

  // COLOR LAYER — snap color to a grayscale palette entry (could be any palette).
  gl_FragColor = vec4(vec3(quantized), 1.0);
}