Banding & dithering
Why smooth gradients break into stripes at 8 bits, and how a little noise before rounding hides them.
How it works
The screen gets 8 bits per channel: 256 possible values. Most of the time that is plenty. Then a dark, slow gradient shows up, a night sky or a vignette, and it breaks into visible stripes. That is banding.
The numbers explain it. Say a sky goes from 0.10 to 0.18 (in encoded sRGBThe curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end. sRGB encoding The curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end.c ≤ 0.0031308 ? 12.92c : 1.055c^(1/2.4) − 0.055
Banding is worst where few values cover a wide area: dark tones, slow gradients, fog and skies, and images whose intermediate steps were stored at low precision. Video compression makes it worse again.
Two fixes
More bits where you can. Intermediate render targetsA texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them. Render target A texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them.RGBA16F don’t band;
linear values stored in plain RGBA8 band badly in the darks. But the screen itself still takes 8 bits, so the last
step always rounds.
Dither the last step. Before the value is rounded to 8 bits, add a tiny offset that differs from pixel to pixel, up to about half a step either way. Where the true value lies 30% of the way between two levels, about 30% of the pixels now round up and the rest round down. Seen from normal distance, the neighbours average back to the true value. The stripes’ hard edges become fine, even grain, which the eye ignores.
Where to add it
Dither at the precision that will cut the value, right before it is cut: after tone mapping and after the sRGB
encode, as the last thing the final pass does. One 8-bit step is 1.0 / 255.0:
color = linearToSrgb(color);
color += (noise - 0.5) / 255.0; // noise in 0..1, different per pixel
fragColor = vec4(color, 1.0);
Dithering before the encode, in linear values, gets the step size wrong. The encode stretches the darks, so half a step of offset in linear becomes several steps of visible noise near black, and too little in the brights.
Which noise
The offset needs a value per pixel. Three common sources:
- Random noise from a hash of the pixel position. No texture needed. Its grain is uneven: random clumps and gaps.
- Ordered dithering, from a small repeating matrix (a Bayer matrix). Cheap and fully predictable. Its regular cross-hatch shows if you look closely, which is exactly the retro look stylized dither effects go for.
- Blue noise, from a precomputed texture. Random, but with the clumps removed, so it gives the finest, least visible grain. The usual choice in production.
An ordered dither with a 4×4 Bayer matrix:
const float BAYER4[16] = float[16](
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0
);
float bayer4(vec2 fragCoord) {
ivec2 p = ivec2(fragCoord) & 3; // position inside the 4×4 tile
return (BAYER4[p.y * 4 + p.x] + 0.5) / 16.0; // 16 evenly spaced values in 0..1
}
Pass it gl_FragCoord.xy and use the result as noise above. The same function with one bit instead of eight, two
levels per channel instead of 256, is the classic 1-bit dither: the Posterize & dither technique turns the fix into a
style.
In Godot
The engine has a debanding option for 3D (use_debanding on the viewport, or the Use Debanding project setting). It
adds the dither at the end of the frame for you. In your own post shaders, add it as above, in the last pass.
Animated or still
A still pattern can look like a dirty screen in a slow camera move. Changing the noise every frame turns it into flicker the eye averages away, and temporal anti-aliasing smooths it further. For a still image, a still pattern is fine.