Tips on how to (and the way to not) repair colour banding

I love to make use of gentle gradients as backdrops when doing graphics programming, a love began by a Corona Renderer product shot sample scene shared by person romullus and its use of radial gradients to focus on the product. However they’re fairly horrible from a design standpoint, since they produce terrible color banding, additionally known as posterization. Relying on issues like display screen kind, gradient colours, viewing atmosphere, and so on., the impact could be typically not current in any respect, but typically painfully apparent. Let’s check out what I imply. The next is a WebGL Canvas drawing a black & white, darkish and gentle half-circle gradient.
Screenshot, in case WebGL does not work
WebGL Vertex Shader fullscreen-tri.vs
attribute vec2 vtx; various vec2 tex; void major() { tex = vtx; gl_Position = vec4(vtx, 0.0, 1.0); }
WebGL Fragment Shader banding.fs
precision mediump float; various vec2 tex; void major(void) { vec3 outsidecolor = vec3(0.15); vec3 insidecolor = vec3(0.2); vec3 bgcolor = combine(insidecolor, outsidecolor, size(vec2(tex.x, tex.y * 0.5 + 1.0))); gl_FragColor = vec4(bgcolor, 1.0); }
WebGL Javascript fullscreen-tri.js
"use strict"; perform setupTri(canvasId, vertexId, fragmentId) { const canvas = doc.getElementById(canvasId); const gl = canvas.getContext('webgl', { preserveDrawingBuffer: false }); const vertexShader = createAndCompileShader(gl.VERTEX_SHADER, vertexId); const fragmentShader = createAndCompileShader(gl.FRAGMENT_SHADER, fragmentId); const shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); gl.useProgram(shaderProgram); const unitTri = new Float32Array([ -1.0, 3.0, -1.0, -1.0, 3.0, -1.0 ]); const vertex_buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unitTri), gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); const vtx = gl.getAttribLocation(shaderProgram, "vtx"); gl.enableVertexAttribArray(vtx); gl.vertexAttribPointer(vtx, 2, gl.FLOAT, false, 0, 0); perform redraw() { gl.viewport(0, 0, canvas.width, canvas.peak); gl.drawArrays(gl.TRIANGLES, 0, 3); } perform createAndCompileShader(kind, supply) { const shader = gl.createShader(kind); gl.shaderSource(shader, doc.getElementById(supply).textual content); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); } return shader; } perform onResize() { const width = Math.spherical(canvas.clientWidth * window.devicePixelRatio); const peak = Math.spherical(canvas.clientHeight * window.devicePixelRatio); if (canvas.width !== width || canvas.peak !== peak) { canvas.width = width; canvas.peak = peak; redraw(); } } window.addEventListener('resize', onResize, true); onResize(); };
This produces a 24-bit (8-bits per channel) picture with clearly seen banding steps. If you happen to don’t see the banding as a consequence of being a shiny atmosphere or having the display screen brightness set to very low, reference the images beneath. Here’s what it ought to appear like on an 8-bit panel, particularly the HP Z24n G2 monitor that’s related to my laptop computer. It also needs to look the identical on a high-end 10-bit or 12-bit panel, since WebGL doesn’t permit excessive bit-depth output. The picture is brightness and distinction boosted, to make the steps apparent.

Many Laptop computer screens are actually 6-bit panels performing dithering to faux an 8-bit output. This consists of even high-priced workstations replacements, just like the HP Zbook Fury 15 G7 and its 6-bit LCD panel, that I sit in entrance of proper now. What you possibly can see are some banding steps being a clear uniform colour and some of them being dithered through the panel’s built-in look-up desk to attain a perceived 8-bit output through ordered dithering. Although be aware, how the dithering does not end result within the banding steps being damaged up, it simply dithers the colour step itself. Capturing this through a photograph is a bit troublesome, since there’s additionally the sample of particular person pixels messing with the seize and introducing moiré and interference patterns.

Panel’s built-in dithering visualized.
It is not apparent from the picture, however the dither sample is distinctly seen when wanting intently with the bare eye.
Magic GLSL One-liner #
Let’s repair this. The principle level of this text is to share how I get banding free gradients in a single GLSL fragment shader, rendering in a single cross and with out sampling or texture faucets to attain banding free-ness. It entails the most effective noise one-liner I’ve ever seen. That genius one-liner is just not from me, however from Jorge Jimenez’s presentation on how Gradient noise was implemented in Call of Duty Advanced Warfare. You possibly can learn it on the presentation’s slide 123 onwards. It’s described as:
[…] a noise perform that we may classify as being half manner between dithered and random, and that we referred to as Interleaved Gradient Noise.
Here’s what the uncooked noise appears like. The next WebGL Canvas is about to render on the identical pixel density as your display screen. (Although some Display screen DPI and Browser zoom ranges will end in it being one pixel off and there being a tiny little bit of interpolation)
Screenshot, in case WebGL does not work
WebGL Vertex Shader noise.vs
attribute vec2 vtx; void major() { gl_Position = vec4(vtx, 0.0, 1.0); }
WebGL Fragment Shader noise.fs
precision highp float; float gradientNoise(in vec2 uv) { return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715)))); } void major(void) { gl_FragColor = vec4(vec3(0.0) + gradientNoise(gl_FragCoord.xy), 1.0); }
WebGL Javascript fullscreen-tri.js
"use strict"; perform setupTri(canvasId, vertexId, fragmentId) { const canvas = doc.getElementById(canvasId); const gl = canvas.getContext('webgl', { preserveDrawingBuffer: false }); const vertexShader = createAndCompileShader(gl.VERTEX_SHADER, vertexId); const fragmentShader = createAndCompileShader(gl.FRAGMENT_SHADER, fragmentId); const shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); gl.useProgram(shaderProgram); const unitTri = new Float32Array([ -1.0, 3.0, -1.0, -1.0, 3.0, -1.0 ]); const vertex_buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unitTri), gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); const vtx = gl.getAttribLocation(shaderProgram, "vtx"); gl.enableVertexAttribArray(vtx); gl.vertexAttribPointer(vtx, 2, gl.FLOAT, false, 0, 0); perform redraw() { gl.viewport(0, 0, canvas.width, canvas.peak); gl.drawArrays(gl.TRIANGLES, 0, 3); } perform createAndCompileShader(kind, supply) { const shader = gl.createShader(kind); gl.shaderSource(shader, doc.getElementById(supply).textual content); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); } return shader; } perform onResize() { const width = Math.spherical(canvas.clientWidth * window.devicePixelRatio); const peak = Math.spherical(canvas.clientHeight * window.devicePixelRatio); if (canvas.width !== width || canvas.peak !== peak) { canvas.width = width; canvas.peak = peak; redraw(); } } window.addEventListener('resize', onResize, true); onResize(); };
Now let’s mix each earlier WebGL examples to clear the colour banding and get a easy half-circle gradient.
Screenshot, in case WebGL does not work
You have to view this at 1:1 pixel scale, in any other case your browser’s will counteract the pixel sized dither and re-introduce colour banding!
WebGL Vertex Shader fullscreen-tri.vs
attribute vec2 vtx; various vec2 tex; void major() { tex = vtx; gl_Position = vec4(vtx, 0.0, 1.0); }
WebGL Fragment Shader gradient.fs
precision highp float; various vec2 tex; float gradientNoise(in vec2 uv) { return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715)))); } void major(void) { vec3 outsidecolor = vec3(0.15); vec3 insidecolor = vec3(0.2); vec3 bgcolor = combine(insidecolor, outsidecolor, size(vec2(tex.x, tex.y * 0.5 + 1.0))); bgcolor += (1.0 / 255.0) * gradientNoise(gl_FragCoord.xy) - (0.5 / 255.0); gl_FragColor = vec4(bgcolor, 1.0); }
WebGL Javascript fullscreen-tri.js
"use strict"; perform setupTri(canvasId, vertexId, fragmentId) { const canvas = doc.getElementById(canvasId); const gl = canvas.getContext('webgl', { preserveDrawingBuffer: false }); const vertexShader = createAndCompileShader(gl.VERTEX_SHADER, vertexId); const fragmentShader = createAndCompileShader(gl.FRAGMENT_SHADER, fragmentId); const shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); gl.useProgram(shaderProgram); const unitTri = new Float32Array([ -1.0, 3.0, -1.0, -1.0, 3.0, -1.0 ]); const vertex_buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unitTri), gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); const vtx = gl.getAttribLocation(shaderProgram, "vtx"); gl.enableVertexAttribArray(vtx); gl.vertexAttribPointer(vtx, 2, gl.FLOAT, false, 0, 0); perform redraw() { gl.viewport(0, 0, canvas.width, canvas.peak); gl.drawArrays(gl.TRIANGLES, 0, 3); } perform createAndCompileShader(kind, supply) { const shader = gl.createShader(kind); gl.shaderSource(shader, doc.getElementById(supply).textual content); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); } return shader; } perform onResize() { const width = Math.spherical(canvas.clientWidth * window.devicePixelRatio); const peak = Math.spherical(canvas.clientHeight * window.devicePixelRatio); if (canvas.width !== width || canvas.peak !== peak) { canvas.width = width; canvas.peak = peak; redraw(); } } window.addEventListener('resize', onResize, true); onResize(); };

Completely easy on my monitor with the 8-bit panel!
Similar monitor and picture setup because the color-banded mess from the start of the article. No trickery with totally different zoom ranges or filters. The noise is actually invisible. It’s my very own article and nonetheless I’m shocked myself on the effectiveness of that straightforward one-liner.
Technically, the correct solution to obtain banding free-ness is to carry out error diffusion dithering, since that will breakup simply the quantized steps of the gradient, with out touching the colour between the steps. However aside from ordered dithering, there isn’t any GPU pleasant manner to do that and even very faint ordered dithering is detectable by human imaginative and prescient, because it applies a set sample. When speaking about gradients, including noise works simply advantageous although, regardless that it’s not correct error diffusion. Merely making use of noise with the power of 1 8-bit grayscale worth (1.0 / 255.0) * gradientNoise(gl_FragCoord.xy)
side-steps a bunch of points and the code footprint is tiny besides. Moreover we subtract the typical added brightness of (0.5 / 255.0)
to maintain the brightness the identical, since we’re introducing the noise through addition, although the distinction is barely noticeable. Right here is part of the gradient with a threshold utilized and zoomed in, to see how each gradient and noise work together.

Right here is how I normally use this Shader setup to attract a background for objects and scenes to reside on.
Screenshot, in case WebGL does not work
You have to view this at 1:1 pixel scale, in any other case your browser’s will counteract the pixel sized dither and re-introduce colour banding!
WebGL Vertex Shader fullscreen-tri.vs
attribute vec2 vtx; various vec2 tex; void major() { tex = vtx; gl_Position = vec4(vtx, 0.0, 1.0); }
WebGL Fragment Shader full_example.fs
precision highp float; various vec2 tex; float gradientNoise(in vec2 uv) { return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715)))); } void major(void) { vec3 outsidecolor = vec3(0.22, 0.23, 0.25); vec3 insidecolor = vec3(0.40, 0.41, 0.45); vec3 bgcolor = combine(insidecolor, outsidecolor, size(tex)); bgcolor += (1.0 / 255.0) * gradientNoise(gl_FragCoord.xy) - (0.5 / 255.0); gl_FragColor = vec4(bgcolor, 1.0); }
WebGL Javascript fullscreen-tri.js
"use strict"; perform setupTri(canvasId, vertexId, fragmentId) { const canvas = doc.getElementById(canvasId); const gl = canvas.getContext('webgl', { preserveDrawingBuffer: false }); const vertexShader = createAndCompileShader(gl.VERTEX_SHADER, vertexId); const fragmentShader = createAndCompileShader(gl.FRAGMENT_SHADER, fragmentId); const shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); gl.useProgram(shaderProgram); const unitTri = new Float32Array([ -1.0, 3.0, -1.0, -1.0, 3.0, -1.0 ]); const vertex_buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unitTri), gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer); const vtx = gl.getAttribLocation(shaderProgram, "vtx"); gl.enableVertexAttribArray(vtx); gl.vertexAttribPointer(vtx, 2, gl.FLOAT, false, 0, 0); perform redraw() { gl.viewport(0, 0, canvas.width, canvas.peak); gl.drawArrays(gl.TRIANGLES, 0, 3); } perform createAndCompileShader(kind, supply) { const shader = gl.createShader(kind); gl.shaderSource(shader, doc.getElementById(supply).textual content); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); } return shader; } perform onResize() { const width = Math.spherical(canvas.clientWidth * window.devicePixelRatio); const peak = Math.spherical(canvas.clientHeight * window.devicePixelRatio); if (canvas.width !== width || canvas.peak !== peak) { canvas.width = width; canvas.peak = peak; redraw(); } } window.addEventListener('resize', onResize, true); onResize(); };
Don’t Double Dither #
However what about that 6-bit laptop computer display screen? Let’s have a look, by photographing the dithered gradient like at first of the article…

Arrows to indicate the interference following the gradient route
…ohh you gotta be kidding me
Each the 6-bit display screen’s dithering sample and our Interleaved Gradient Noise intrude with one another. Precisely the colour bands the place the panel performs the dithering, we are able to see the the interference showing within the type of saw-tooth ridges. Perhaps by growing the noise power to correspond to 6-bit values? (1.0 / 64.0) * gradientNoise(gl_FragCoord.xy) - (0.5 / 64.0)
By dividing by 64 as an alternative of 255, we get 6-bit noise. Let’s see…

Arrows to indicate the interference route
…it is worse!
Clearly apparent diagonal stripes all through the entire gradient. Yeah, 6-bit panels are a travesty. Particularly on a product of this caliber. I imply the outdated Thinkpad T500 & X200 I hardware modded have 6-bit panels, however these are a number of tech generations outdated. We may tweak the noise algorithm, however it’s simply not value to drop our denominator so low. It’s 2024 in a pair days and each human being deserves a minimum of 256 totally different shades in every colour channel.
Bufferless Model #
Here’s what the shaders appear like for those who use OpenGL 3.3, OpenGL 2.1 with the GL_EXT_gpu_shader4
extension (#model
must change) or WebGL2 and wish to skip the Vertex Buffer setup by placing the fullscreen triangle into the vertex shader. If you happen to get an error round gl_VertexID
lacking, you don’t have GL_EXT_gpu_shader4
enabled.
These could be rewritten to work with even probably the most primary OpenGL or WebGL customary by importing the vertex buffer prior, as performed in all of the WebGL examples up until now. The fragment shader stays principally the identical.
Bufferless Vertex Shader
#model 330
out vec2 tex;
const vec2 pos[3] = vec2[] (
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0)
);
void major()
{
tex = pos[gl_VertexID];
gl_Position = vec4(pos[gl_VertexID], 0.0, 1.0);
}
Bufferless Fragment Shader
#model 330
in vec2 tex;
out vec4 Out_Color;
float gradientNoise(in vec2 uv)
{
return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715))));
}
vec3 outsidecolor = vec3(0.22, 0.23, 0.25);
vec3 insidecolor = vec3(0.40, 0.41, 0.45);
void major()
{
vec3 bgcolor = combine(insidecolor, outsidecolor,
sqrt(tex.x * tex.x + tex.y * tex.y));
bgcolor += (1.0 / 255.0) * gradientNoise(gl_FragCoord.xy) - (0.5 / 255.0);
Out_Color = vec4(bgcolor, 1.0);
}
What are the big-boys doing? #
To complete off, let’s have a look how colour banding is solved in different items of software program. Not simply within the context of gradients, but additionally past.
Alien: Isolation #
I think about Alien: Isolation to be a technical grasp piece when it comes to lighting, particularly contemplating the time it was launched. They faked realtime world illumination in a very fascinating vogue, with the flashlight lighting up the realm when shining straight at a wall pretty shut and casting a redish ambiance when shining at a deep purple door. It’s largely a hardcoded faux impact working with particular surfaces, however I digress…
Horror video games like Alien: Isolation have a whole lot of darkish scenes with lights creating gradient like falloffs. These are very susceptible to paint banding. The programmers over at creative assembly present a number of methods of tackling this. Let’s check out how by dissecting this scene.

I photographed the center of the scene, as considered on my Alienware AW3423DW. On this first instance, with none colour banding mitigation and once more with brightness & distinction boosted for readability inside this text. In real-life the colour banding is clearly seen when gaming in a darkish atmosphere. These are precise pictures and never screenshots, which is able to matter somewhat later.

Movie grain #
There may be in fact the simple manner of simply slapping a whole lot of movie grain all over the place and Alien: Isolation is unquestionably responsible of this. Actually far more egregious than different video games, with the VHS aesthetic of giant darkish blobs.

It’s not fairly as unhealthy when turned right down to decrease settings and through gameplay it’s animated, however I’m nonetheless not a fan. Let’s see if we are able to do one higher…
Deep-Shade #

Deep Shade is what Alien: Isolation calls rendering and outputting at 10-bits per channel. The way in which this setting works is completely not apparent although. You possibly can flip it on, however it’ll solely be really lively below a sure set of circumstances:
- Anti-Aliasing has to be disabled.
- That’s a critical bummer. Not one of the Anti-Aliasing shaders deal with the 10-bit sign and simply crush the end result again right down to 8-bit. It’s as for those who didn’t flip it on in any respect :[
- Your monitor needs to accept a 10 or 12-bit signal. Otherwise, the game won’t switch into that higher bit-depth mode.
- Interestingly enough, the monitor doesn’t need to be in that mode, but that mode just has to be available and the switching happens automatically, which I did not expect. In the case of my monitor, this entails switching from 175hz to 144hz, to unlock the 10-bit color option.
- Interestingly enough, the monitor doesn’t need to be in that mode, but that mode just has to be available and the switching happens automatically, which I did not expect. In the case of my monitor, this entails switching from 175hz to 144hz, to unlock the 10-bit color option.

What an excellent result! All banding banished to the shadow realm.
No tricks with different processing of the photo either. The camera captured the exact same exposure and the brightness was boosted in the same way. I suspect, that it’s not just the output being 10-bit, that is giving such a good result, but also some passes being merged at a higher bit-depth and thus reducing color banding further. The result is just way too good for being a mere bump from 256 -> 1024 steps per channel. Please note, that this is in no way shape or form related to the standard of HDR. In fact, HDR is explicitly disabled in Windows.
Of course, you need to have a rather expensive screen, being able to run 10-bits per channel or higher. And even if, sometimes the graphics card you have doesn’t have the right generation of connector, leading you to have to drop color-resolution and/or refresh-rate in order to do so. What else is there?
Reshade’s Deband Effect #
ReShade (sometimes mistakenly referred to as SweetFx, a shader collection that used to be part of it) is a popular graphics hook, that applies various effects on top of many games, with many presets custom tuned by the community. ReShade’s versatility and maturity has proven itself over many years of releases and broad palette of supported games. Among the effects you can apply is “Deband” (Simply called “Dither” in the past).

The Deband.fx
Shader (Source code below, for reference) applies dithering to areas, that it detects as affected by color banding, based on the “Weber Ratio”.
ReShade‘s Deband.fx source code, for reference
#include "ReShadeUI.fxh"
#include "ReShade.fxh"
uniform bool enable_weber <
ui_category = "Banding analysis";
ui_label = "Weber ratio";
ui_tooltip = "Weber ratio analysis that calculates the ratio of the each local pixel's intensity to average background intensity of all the local pixels.";
ui_type = "radio";
> = true;
uniform bool enable_sdeviation <
ui_category = "Banding analysis";
ui_label = "Standard deviation";
ui_tooltip = "Modified standard deviation analysis that calculates nearby pixels' intensity deviation from the current pixel instead of the mean.";
ui_type = "radio";
> = true;
uniform bool enable_depthbuffer <
ui_category = "Banding analysis";
ui_label = "Depth detection";
ui_tooltip = "Allows depth information to be used when analysing banding, pixels will only be analysed if they are in a certain depth. (e.g. debanding only the sky)";
ui_type = "radio";
> = false;
uniform float t1 <
ui_category = "Banding analysis";
ui_label = "Standard deviation threshold";
ui_max = 0.5;
ui_min = 0.0;
ui_step = 0.001;
ui_tooltip = "Standard deviations lower than this threshold will be flagged as flat regions with potential banding.";
ui_type = "slider";
> = 0.007;
uniform float t2 <
ui_category = "Banding analysis";
ui_label = "Weber ratio threshold";
ui_max = 2.0;
ui_min = 0.0;
ui_step = 0.01;
ui_tooltip = "Weber ratios lower than this threshold will be flagged as flat regions with potential banding.";
ui_type = "slider";
> = 0.04;
uniform float banding_depth <
ui_category = "Banding analysis";
ui_label = "Banding depth";
ui_max = 1.0;
ui_min = 0.0;
ui_step = 0.001;
ui_tooltip = "Pixels under this depth threshold will not be processed and returned as they are.";
ui_type = "slider";
> = 1.0;
uniform float range <
ui_category = "Banding detection & removal";
ui_label = "Radius";
ui_max = 32.0;
ui_min = 1.0;
ui_step = 1.0;
ui_tooltip = "The radius increases linearly for each iteration. A higher radius will find more gradients, but a lower radius will smooth more aggressively.";
ui_type = "slider";
> = 24.0;
uniform int iterations <
ui_category = "Banding detection & removal";
ui_label = "Iterations";
ui_max = 4;
ui_min = 1;
ui_tooltip = "The number of debanding steps to perform per sample. Each step reduces a bit more banding, but takes time to compute.";
ui_type = "slider";
> = 1;
uniform int debug_output <
ui_category = "Debug";
ui_items = "None Blurred (LPF) image Banding map ";
ui_label = "Debug view";
ui_tooltip = "Blurred (LPF) image: Useful when tweaking radius and iterations to make sure all banding regions are blurred enough.nBanding map: Useful when tweaking analysis parameters, continuous green regions indicate flat (i.e. banding) regions.";
ui_type = "combo";
> = 0;
uniform int drandom < source = "random"; min = 0; max = 32767; >;
float rand(float x)
{
return frac(x / 41.0);
}
float permute(float x)
{
return ((34.0 * x + 1.0) * x) % 289.0;
}
float3 PS_Deband(float4 vpos : SV_Position, float2 texcoord : TexCoord) : SV_Target
{
float3 ori = tex2Dlod(ReShade::BackBuffer, float4(texcoord, 0.0, 0.0)).rgb;
if (enable_depthbuffer && (ReShade::GetLinearizedDepth(texcoord) < banding_depth))
return ori;
float3 m = float3(texcoord + 1.0, (drandom / 32767.0) + 1.0);
float h = permute(permute(permute(m.x) + m.y) + m.z);
float dir = rand(permute(h)) * 6.2831853;
float2 o;
sincos(dir, o.y, o.x);
float2 pt;
float dist;
for (int i = 1; i <= iterations; ++i) {
dist = rand(h) * range * i;
pt = dist * BUFFER_PIXEL_SIZE;
h = permute(h);
}
float3 ref[4] = {
tex2Dlod(ReShade::BackBuffer, float4(mad(pt, o, texcoord), 0.0, 0.0)).rgb,
tex2Dlod(ReShade::BackBuffer, float4(mad(pt, -o, texcoord), 0.0, 0.0)).rgb,
tex2Dlod(ReShade::BackBuffer, float4(mad(pt, float2(-o.y, o.x), texcoord), 0.0, 0.0)).rgb,
tex2Dlod(ReShade::BackBuffer, float4(mad(pt, float2( o.y, -o.x), texcoord), 0.0, 0.0)).rgb
};
float3 imply = (ori + ref[0] + ref[1] + ref[2] + ref[3]) * 0.2;
float3 ok = abs(ori - imply);
for (int j = 0; j < 4; ++j) {
ok += abs(ref[j] - imply);
}
ok = ok * 0.2 / imply;
float3 sd = 0.0;
for (int j = 0; j < 4; ++j) {
sd += pow(ref[j] - ori, 2);
}
sd = sqrt(sd * 0.25);
float3 output;
if (debug_output == 2)
output = float3(0.0, 1.0, 0.0);
else
output = (ref[0] + ref[1] + ref[2] + ref[3]) * 0.25;
bool3 banding_map = true;
if (debug_output != 1) {
if (enable_weber)
banding_map = banding_map && ok <= t2 * iterations;
if (enable_sdeviation)
banding_map = banding_map && sd <= t1 * iterations;
}
float grid_position = frac(dot(texcoord, (BUFFER_SCREEN_SIZE * float2(1.0 / 16.0, 10.0 / 36.0)) + 0.25));
float dither_shift = 0.25 * (1.0 / (pow(2, BUFFER_COLOR_BIT_DEPTH) - 1.0));
float3 dither_shift_RGB = float3(dither_shift, -dither_shift, dither_shift);
dither_shift_RGB = lerp(2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position);
return banding_map ? output + dither_shift_RGB : ori;
}
method Deband <
ui_tooltip = "Alleviates colour banding by attempting to approximate unique colour values.";
>
{
cross
{
VertexShader = PostProcessVS;
PixelShader = PS_Deband;
}
}

Within the brightness boosted picture, it might appear like the impact solely did half the job. While technically true, to the bare eye it’s surprisingly efficient. It takes the sting off the seen colour bands and makes it primarily invisible to even my pixel-peeping eyes. It additionally works with Anti-Aliasing, because it’s a mere post-processing shader utilized on high.
I severely advocate injecting this impact, when you’ve got a sport the place such colour banding annoys you.
Adobe After Results #
The Gradient ramp “generator” in Adobe After Effects is used to generate gradients. It has an fascinating “Ramp Scatter” slider, that diffuses the colour bands with noise. It does it in a manner, that defuses simply the colour bands although. Here’s what the official documentation has to say about it:
Notice: Ramps typically don’t broadcast properly; extreme banding happens as a result of the published chrominance sign doesn’t include adequate decision to breed the ramp easily. The Ramp Scatter management dithers the ramp colours, eliminating the banding obvious to the human eye.

When cranked to the max, you possibly can see streaks working by means of the noise. Surprisingly, the efficiency is sort of unhealthy. At 0 ramp scatter, the gradient renders immediately, no matter decision. To do a 4k body of this at max ramp scatter takes my high-end AMD Ryzen 9 7900x 1 / 4 second although. 4fps playback with nothing, however a mere gradient. Each details lead me to imagine, that there’s some form iterative algorithm at play right here, although I can solely guess. To be honest, so long as not one of the impact’s properties are animated, it caches only one body and that’s it. After results is fairly good about it. But it surely’s additionally identified to nonetheless carry a legacy set of single-threaded slowness throughout a whole lot of its options.
KDE Kwin Blur #
Lastly, let’s discuss blur. Blur produces easy gradients, which shortly undergo from colour banding. The KDE Plasma Desktop, one of the crucial common Desktop Environments for Linux and FreeBSD, makes use of certainly one of my favourite items of graphics programming wizardry, the Dual Kawase Blur, to blur the backdrops of home windows, as implemented a while back. To defuse mentioned colour banding, a noise could be utilized on high. The supply code for the implementation can be found here.

Zoomed and distinction boosted in circle
Microsoft Home windows Acrylic #
To complete off, right here is how Home windows 11 and its “Acrylic” does it. It applies each blur and noise to attain the identical.

Right here is the way it appears in Microsoft’s “New Windows Terminal”. The circle has once more brightness and distinction boosted to see the impact extra clearly within the context of the article. Although new Home windows terminal is open supply, the implementation of acrylic is inside the supply code of Home windows itself, so we can’t check out the precise implementation.

And that warps up our little journey by means of all issues colour and hopefully none issues banding.
Addendum #
Lobste.rs person luchs requested here for a solution to decide the bit-depth of the present output, if one doesn’t belief the software program stack to take action appropriately.
Do you may have any good check photos for figuring out a monitor’s colour depth? Between functions, compositors, graphics drivers and screens, it appears like a whole lot of issues can go flawed. Displays that settle for 8 or 10 bit after which use dithering don’t assist both.
That is certainly an fascinating test-case, so I shortly whipped up a 16-bit PNG of a darkish, grayscale gradient, with no dithering. By way of grayscale worth, the gradient goes from 0-2
in 8-bit, 0-8
in 10-bit and 0-256
in 16-bit. The file is 1024px vast, so there’s a new grayscale worth each 4 pixels.

Right here is how the check works. Load up the picture, level a digicam at it and take a photograph with an extended sufficient publicity to differentiate the colour bands. The digicam doesn’t should be something particular, an 8bpp jpeg is greater than sufficient. Relying on what number of colour bands you see, you possibly can decide the true results of the bit-depth-ness hitting your hopefully higher than 8-bit eyes.

On an 8-bit monitor, it is best to see 3 distinct stripes. If the file is correctly decoded with the sRGB gamma curve, then the stripes must be as per the picture above: first and final stripe precisely half the scale of the center one. (Because of the gradient beginning and ending on integer boundaries and colour bands forming in even sizes in-between)

On a 10-bit monitor with correct software program help, it is best to see 9 distinct stripes. All stripes must be the identical dimension, besides the primary and final one, which must be half-sized. If you happen to see 33, then your monitor and software program are in 12-bit mode.
If the stripes should not even or you’re seeing roughly than the numbers above, then one thing is happening when it comes to colour house throughout picture decoding.

On Home windows, Microsoft Edge skews the gradient to 1 aspect, while Firefox doesn’t. It has both to do with Microsoft Edge making use of additional colour administration or the brightness response curve being approximated as Gamma 2.2, instead of the piece-wise curve that it’s, resulting in a slight shift in how the 16-bit gradient is being displayed on the 8-bit output. Your monitor’s colour gamut and gamma settings ought to have zero impact on the quantity and distribution of stripes. Simply the way in which of decoding the picture ought to influences the end result.