Skip to content
Rendering · OutputIntermediate

Banding & dithering

Why smooth gradients break into stripes at 8 bits, and how a little noise before rounding hides them.

Live demo coming soon

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 sRGB) across a 1440-pixel-wide screen. That range holds only about 20 of the 256 values, so the gradient becomes 20 flat strips about 70 pixels wide. Each step is tiny, but the eye is tuned to find edges and exaggerates every one into a line.

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 targets in 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.

Further reading