Dithering (Bayer)
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
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);
}