Arbitrary Color Reduction and Palette Ordered Dithering
I was looking for a shader like this, but anything similar out there either only supported grayscale, a specified palette, 3bpp/8 color, or had errors cutting off the highest or lowest values. I couldn’t find much info on an actual color reduction algorithm besides the one on the wikipedia article which was pretty vague and I couldn’t get anything near that to work. Anyway I came up with this one, and it works pretty well. Also usable with a specified palette!
This shader is actually four effects together: Quantize, Dither, Adjustment, and Palette. They are easily separated out, so each are in their own .gdshaderinc file. Unfortunately, GodotShaders doesn’t allow you to paste multiple files, so pay attention to the comments.
To use:
- Add the .gdshader portion to a new shader file.
- Add the .gdshaderinc portions to new shader include files.
- In Godot’s shader editor, click “File”, then “New Shader Include”
- Update the #include sections to match your filepaths.
- Adjust the texture mode and alpha mode depending on the node you’re using and effect you want.
Caveats:
- Cannot guarantee total TextureRect compatibility, since TextureRect UVs are very finnicky.
Update June 2025:
- Consolidated into one main shader with selectable modes.
- Added Mask and Mesh support
- Better documentation (though due to a bug, currently doc comments don’t work in shader include files in Godot)
- Various optimizations and refactoring
Update May 2024:
- ColorRect support
- Adjusts to editor zoom scale to reflect in game resolution
- Separated quantization and dither into .gdshaderinc files for better reusability
- Less jitter
- Supports skew, rotation, flipping, centering, and offsets
- Optional handling of subpixels
The uniform parameters are:
- Main
- Texture Mode:
- 0 = Texture: For use with Sprite2Ds, TextureRects, and 2D Meshes
- 1 = Screen: For use with ColorRect
- Alpha Mode:
- 0 = Normal: No alpha change
- 1 = Alpha black: Outputs alpha as black
- 2 = Alpha cut: Cuts textures as a mask. Use with CanvasGroup node.
- Texture Mode:
- Quantization
- Quantize Size: Pixel resolution scale (0 is bypass)
- Snap To World: Pixels are snapped based on world coordinates vs local coordinates.
- Limit Subpixels: Auto-scales the quantize size when zoom is < 1, preventing subpixel artifacts
- Dithering
- Dither Enabled: Turns the dithering on or off.
- Bayer Pattern: Specifies the Bayer matrix pattern to use.
The higher the number the more detail.- 1 = 2×2
- 2 = 4×4
- 3 = 8×8
- Levels:
- When Palette Mode is 0 (color reduction), it specifies the number of shades per color channel.
- 1 = Black and white
- 2 = 8 colors
- 3 = 27 colors
- etc.
- When Palette Mode is 1 (palette), it specifies the number of shades in the palette. So if you provide a four-color palette, set it to 4.
- When Palette Mode is 0 (color reduction), it specifies the number of shades per color channel.
- Adjustment
- Contrast: Modifies the contrast of the image.
- Brightness: Shifts the colors brightness higher or lower.
- Average: When Levels is 1 (black and white), or if Palette mode is 1, the colors are averaged. This slider mixes between a luminance average, or a normal average.
- Palette
- Palette Mode:
- 0 = No palette: Simple color reduction
- 1 = Palette lookup based on average
- Palette: A palette image or a GradientTexture1D for realtime editing. Update Levels to match the number of colors in the palette.
- Palette Mode:
Feel free to make any suggestions!
Shader code
/**
This should be your main shader file.
*/
shader_type canvas_item;
group_uniforms Main;
/**
0 = Texture: For use with Sprite2Ds, TextureRects, and Meshes
1 = Screen: For use with ColorRect
*/
uniform int texture_mode : hint_range(0, 1) = 0;
/**
0 = Normal: No alpha change
1 = Alpha black: Outputs alpha as black
2 = Alpha cut: Cuts textures as a mask. Use within CanvasGroup node.
*/
uniform int alpha_mode : hint_range(0, 2) = 0;
group_uniforms;
uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, repeat_disable, filter_nearest;
#include "res://shaders/quantize.gdshaderinc"
#include "res://shaders/dither.gdshaderinc"
#include "res://shaders/adjustment.gdshaderinc"
#include "res://shaders/palette.gdshaderinc"
void vertex() {
float zoom = length(CANVAS_MATRIX[1].xyz);
v_quant_size = getQuantizeSize(zoom);
v_model_matrix = MODEL_MATRIX;
mat4 world_to_clip = SCREEN_MATRIX * CANVAS_MATRIX;
v_vertex = VERTEX;
if (texture_mode == 0) {
v_alt_matrix = inverse(MODEL_MATRIX);
v_texture_data.xy = 1. / TEXTURE_PIXEL_SIZE;
// Is texture flipped
v_texture_data.zw = max(-sign((UV - 0.5) * VERTEX), 0.);
} else {
v_alt_matrix = world_to_clip;
if (!snap_to_world) {
v_alt_matrix = inverse(v_alt_matrix);
}
vec2 local_origin = (MODEL_MATRIX * vec4(0.0, 0.0, 0, 1)).xy;
vec2 clip = (world_to_clip * vec4(local_origin, 0,1)).xy;
vec2 screen_origin_uv = clip * 0.5 + 0.5;
vec2 screen_size = vec2(textureSize(SCREEN_TEXTURE, 0));
vec2 screen_pixel_size = 1. / screen_size * zoom;
vec2 quant_pixel_size = screen_pixel_size * float(v_quant_size);
v_texture_data.xy = screen_origin_uv;
v_texture_data.zw = quant_pixel_size;
}
}
void fragment() {
vec4 color;
vec4 uvResult;
if (texture_mode == 0) {
uvResult = getQuantizeTextureUV(UV);
color = texture(TEXTURE, uvResult.xy);
} else {
uvResult = getQuantizeScreenUV(SCREEN_UV);
color = texture(SCREEN_TEXTURE, uvResult.xy);
}
// Set alpha as color
if (alpha_mode != 0) {
color.rgb = vec3(1. - color.a);
}
color.rgb = adjust(color.rgb, palette_mode == 1 || levels == 1);
color = dither(color, uvResult.zw, v_quant_size);
color = addPalette(color, float(levels));
// Set color as alpha
if (alpha_mode != 0) {
color = vec4(0, 0, 0, 1. - color.r);
}
// Show background in cutout
if (alpha_mode == 2) {
color = vec4(texture(SCREEN_TEXTURE, SCREEN_UV).rgb, color.a);
}
COLOR = color;
}
/**
Quantization Shader Include
*/
group_uniforms Quantization;
/**Pixel resolution scale (0 is bypass)*/
uniform int quantize_size : hint_range(0,100) = 1;
/**Pixels are snapped based on world coordinates vs local coordinates.*/
uniform bool snap_to_world;
/**Auto-scales the quantize size when zoom is < 1, preventing subpixel artifacts*/
uniform bool limit_subpixels = true;
group_uniforms;
varying mat4 v_model_matrix;
varying mat4 v_alt_matrix;
varying vec2 v_vertex;
varying flat int v_quant_size;
varying flat vec4 v_texture_data;
const float EPSILON = 0.0001;
int getQuantizeSize(float in_zoom) {
int q_size = quantize_size;
if (limit_subpixels && in_zoom < 1.) {
q_size = int(round(float(quantize_size) * (1. / in_zoom)));
}
return q_size;
}
vec2 _snap(vec2 in_uv, float in_q_size) {
return (floor(in_uv / in_q_size) + 0.5) * in_q_size;
}
vec4 getQuantizeScreenUV(vec2 in_screen_uv) {
vec4 result;
if (v_quant_size == 0) {
result.xy = in_screen_uv;
result.zw = (v_model_matrix * vec4(v_vertex, 0, 1)).xy;
return result;
}
if (snap_to_world) {
vec2 uv = (v_model_matrix * vec4(v_vertex, 0, 1)).xy;
result.zw = uv + EPSILON;
uv = _snap(uv, float(v_quant_size));
uv = (v_alt_matrix * vec4(uv, 0,1)).xy;
uv = uv * 0.5 + 0.5;
result.xy = uv;
return result;
} else {
vec2 origin_uv = v_texture_data.xy;
vec2 quant_pixel_size = v_texture_data.zw;
vec2 uv = in_screen_uv - origin_uv;
uv = (floor(uv / quant_pixel_size) + 0.5) * quant_pixel_size;
uv = uv + origin_uv;
vec2 clipXY = uv * 2.0 - 1.;
vec2 world_pos = (v_alt_matrix * vec4(clipXY, 0.0, 1.0)).xy;
result.zw = world_pos + EPSILON;
result.xy = uv;
return result;
}
}
vec4 getQuantizeTextureUV(vec2 in_uv) {
vec4 result;
vec2 texture_size = v_texture_data.xy;
vec2 flip = v_texture_data.zw;
if (v_quant_size == 0) {
result.xy = in_uv;
result.zw = (v_model_matrix * vec4(in_uv * texture_size, 0, 1)).xy;
return result;
}
vec2 offset;
float q = float(v_quant_size);
vec2 uv = in_uv;
uv = mix(uv, 1.0 - uv, flip);
if (snap_to_world) {
vec2 inv_texture_size = 1. / texture_size;
offset = v_vertex * inv_texture_size;
offset = uv - offset;
uv -= offset;
uv *= texture_size;
uv = (v_model_matrix * vec4(uv, 0, 1)).xy;
result.zw = uv;
uv = _snap(uv, q);
uv = (v_alt_matrix * vec4(uv, 0, 1)).xy;
uv *= inv_texture_size;
uv = offset + uv;
} else {
uv *= texture_size;
result.zw = uv;
uv = _snap(uv, q);
uv /= texture_size;
}
uv = mix(uv, 1.0 - uv, flip);
result.xy = uv + EPSILON;
return result;
}
/**
Dithering Shader Include
*/
group_uniforms Dithering;
/**Turns the dithering on or off.*/
uniform bool dither_enabled = false;
/**
The Bayer matrix pattern to use
1 -> 2x2, 2 -> 4x4, 3 -> 8x8
*/
uniform int dither_pattern : hint_range(0, 3) = 1;
/**
Specifies the number of times each color channel should be split.
The higher the number the more colors are allowed. (not applicable if a palette is used)
1 is 3bpp or 8 colors, 2 is 4bpp or 16 colors, etc.
*/
uniform int levels : hint_range(1, 128) = 2;
group_uniforms;
const int matrix2[4] = {
0, 2,
3, 1
};
const int matrix4[16] = {
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
};
const int matrix8[64] = {
0, 32, 8, 40, 2, 34, 10, 42,
48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37,
63, 31, 55, 23, 61, 29, 53, 21
};
int _getBayer(ivec2 cell) {
int bayer_size = 1 << dither_pattern;
ivec2 iv = ivec2(cell.x % bayer_size, cell.y % bayer_size);
int index = iv.x + iv.y * bayer_size;
switch (bayer_size) {
case 2:
return matrix2[index];
case 4:
return matrix4[index];
default:
return matrix8[index];
}
}
int _getDitherValue(vec2 in_world_vertex, int in_quant_size) {
int q_size = max(in_quant_size, 1);
ivec2 cell = ivec2(floor(in_world_vertex / float(q_size)));
return _getBayer(cell);
}
vec4 _ditherInternal(vec4 in_c, int in_dither_value) {
vec3 c = in_c.rgb;
float a = in_c.a;
float bayer_size = float(1 << dither_pattern);
float inv_bayer_squared = 1. / (bayer_size * bayer_size);
float levels_f = max(float(levels) - 1., 1.);
float inv_levels_f = 1. / levels_f;
// normalize the dither value
float dN = (float(in_dither_value) * inv_bayer_squared) - 0.5;
// get the normalizing value for the division gap
float r = inv_levels_f;
float l = r * (inv_bayer_squared * 0.5);
// add dither value to color
c += r * dN + l;
// convert normalized color to quantized range
c = round(c * levels_f) * inv_levels_f;
return vec4(c, a);
}
vec4 dither(vec4 in_color, vec2 in_world_vertex, int in_quant_size) {
if (!dither_enabled) {
return in_color;
}
int dither_value = _getDitherValue(in_world_vertex, in_quant_size);
vec4 color = _ditherInternal(in_color, dither_value);
return color;
}
/**
Adjustment Shader Include
*/
group_uniforms Adjustment;
/**Modifies the contrast of the image.*/
uniform float contrast : hint_range(0, 4) = 1.0;
/**Shifts the colors higher or lower.*/
uniform float brightness : hint_range (-1, 1) = 0;
/**
When Levels is 1 (black and white), or if Palette mode is 1, the colors are averaged.
This slider mixes between a luminance average, or a normal average.
0 = luminance average
1 = average
*/
uniform float average : hint_range(0.,1.) = 0;
group_uniforms;
const vec3 lum = vec3(0.2126, 0.7152, 0.0722);
float _getAverage(vec3 in_color) {
float avg = (in_color.r + in_color.g + in_color.b) * 0.3333;
float lum_avg = dot(in_color, lum);
return mix(lum_avg, avg, average);
}
vec3 adjust(vec3 in_c, bool in_average) {
vec3 c = in_c;
if (in_average) {
c = vec3(_getAverage(c));
}
return (c - 0.5 + brightness) * contrast + 0.5;
}
/**
Palette Shader Include
*/
group_uniforms Palette;
/**
0 = No palette: Simple color reduction
1 = Palette lookup based on luminance
*/
uniform int palette_mode : hint_range(0,1) = 0;
/**
A palette image or a GradientTexture1D for realtime editing.
Make sure the width of the GradientTexture1D matches the number of colors.
*/
uniform sampler2D palette : filter_nearest, repeat_disable;
group_uniforms;
vec4 addPalette(vec4 in_c, float in_levels) {
if (palette_mode == 0) {
return in_c;
}
vec3 c = in_c.rgb;
float a = in_c.a;
float levels_f = in_levels - 1.;
int index;
ivec2 palette_size = textureSize(palette, 0);
int palette_total = palette_size.x * palette_size.y - 1;
c *= levels_f;
c.r = (c.r + c.g + c.b) * 0.3333;
c.r = clamp(c.r / levels_f, 0.0, 1.0);
index = int(round(c.r * float(palette_total)));
// handle any height palette
index = clamp(index, 0, palette_total);
int col = index % palette_size.x;
int row = index / palette_size.x ;
vec2 uv = (vec2(ivec2(col, row)) + 0.5) / vec2(palette_size);
return texture(palette, uv);
}



pretty nice shader, I tried it but the placeholder texture wasn’t looking good (it always looked like a stretched 4×4 checkerboard no matter what sizing options I gave it). Then I checked the docs and apparently you’re not supposed to use that texture type for normal rendering, it’s for some technical thing with subclasses (https://docs.godotengine.org/en/stable/classes/class_placeholdertexture2d.html). I used a canvas texture instead and it worked just fine then
Updated so it should work with a ColorRect now!
Good stuff. What about blue noise? Could be a great pattern option as well.
Call me a newbie, but I could not get this working. The #include (even on proper path) insisted that the filetype is wrong. Could you split it into 3 scripts instead of 1? It would help with the confusion (is the shader_type spatial or canvasitem?)
Unfortunately, godotshaders.com doesn’t allow multiple scripts, so I have to just separate them via comments. Also, shader includes aren’t well documented yet, so you just need to adjust the path to wherever you keep your shader include files and whatever you decide to name them. Shader includes help a lot, allowing me to separate the quantization and the dithering so it’s more reusable, but it’s still difficult because shader files don’t allow a lot of things we take for granted in other languages. There’s no overloading methods, you can’t assign varyings outside of the vertex function or fragment functions, so it still requires a little work on the user’s end. I’d love it if I could just let you copy and paste and be done, but I’m gonna make an update now to try to make it clearer how to set this up. Thanks for the feedback!
Updated!
How would you turn off the adjusting to editor zoom scale? It seems to be creating strange artifacts on some screens in my project…
The concept behind this shader is compatibility with pixel perfect games, so it’s pretty hard coupled to it. It’s much easier to do dithering and quantization that isn’t compatible. Every other dither shader out there that I can find uses a method that just goes off the screen size. If that’s what you’d prefer, it may be better to use their methods.
The problem with the other method is it messes up when zooming in for pixel perfect games. If you were to zoom the camera in by 2x the size of the dither pixels would remain the same size on screen and would appear to be 1/4 the size of all the other pixels on screen. On the other hand, this becomes an issue when zooming out in a pixel perfect game… but that’s just the unfortunate artifact rendering sub-pixel patterns like dithering. Although accurate, it’s not pretty. If you want to have the best of both worlds, I’d recommend making a script that adjusts the quantization parameter to be 1 / zoom_level if the zoom level is less than 1.
thank you!! this explained a bit on the issues I’m having with the shader, it having a kind of checkerboard look when played on some screens… how would one make it scale evenly based on screen size? also, huge props on your work on the parallax node for Godot 🙂
Re Parallax2D: Thanks, and I hope you like it!
Since this is only an issue when the renderer tries to display a pattern smaller than its original size, I updated the shader with a “handle_subpixels” uniform. When it’s on, if you zoom out, the pixel size of the pattern will always be at least one pixel of your screen resolution. I’ll update the example project git repo as well. 🙂
I get an error in Godot 4 : “error(23) : Unkown identifier in expression: ‘g_q_size’. ” In the quantize.gdshaderinc
Does anyone know how to solve this?
Oops! I updated it to fix that just now. Sorry for the lateness, I don’t get notifications on this (I just turned that on too).
Has anyone had luck converting this to a spatial shader?
Hi, thanks for sharing this shader, it’s really well thought out with some nice features.
Could you explain how a custom palette gets applied? I have a texture of 128×1 pixels (colours taken from an indexed picture), sorted by value as my palette but it creates a deep-fried acid trip result. I was hoping the shader would match a pixel colour to the closest palette colour, but it doesn’t seem that way.
It applies it based on color value. (r + g + b) / 3. So if you provide 3 colors, it will apply the first for the darkest parts, the second for the mids, and the third for the highlights. A color closest-matching shader would probably be very expensive to do. I imagine it’d need to sort the 128 color array every run. I’d love to know if there’s a reasonable way to do so… or it’d need to loop through the entire 128 colors for every color and compare each.
looks really good, but it’s so damn expensive
Is it? I can imagine you’d only ever use one instance. This is about as lean as I could figure (especially moreso than similar color palette shaders), and I couldn’t find a scenario where it was even remotely a bottleneck… unless you’re talking about screen reading in general, which is def expensive if you have a ton of shaders that are reading from the screen, but that shouldn’t be the case here. What did you use to benchmark?
use palette doesn’t work with dithering, when I enable the palette using color rectangle and gradient1d it pixelates the image but doesn’t dither
nevermind I didn’t read to set the width parameter works perfectly awesome shader wow wow
Thanks! Hope you get a lot of use out of it!