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 recuction 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! Unfortunately, GodotShaders doesn’t allow you to paste multiple files, so pay attention to the comments.
To use:
- Add the .gdshaderinc portions to new shader include files.
- Add the .gdshader portion to a new shader file.
- Update the #include sections to match your filepaths.
- Use the ColorRect shader for ColorRects, and the Sprite2D shader for Sprite2Ds.
Caveats:
- Cannot guarantee TextureRect compatibility, since TextureRect UVs are very finnicky.
Updates:
- 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:
- Quantization
- Quantize Size: Pixel resolution scale (0 is bypass)
- Handle Scale: Determines how scaled images should display.
- Handle Subpixels: Auto-scales the quantize size when zoom is < 1, preventing 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 - Divisions: 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. - Contrast: Modifies the contrast of the image.
- Shift: Shifts the colors higher or lower.
- Grayscale: Turns the resulting image to grayscale.
- Use Palette: Switches between arbitrary color reduction and palette based dithering.
- Palette: Takes a palette image or you can use a GradientTexture1D and play with the palette gradient in real time! Make sure the width of the Gradient matches the number of colors you’re using!
Feel free to make any suggestions!
Shader code
/*
* This should be your main shader file if using with a ColorRect.
*/
shader_type canvas_item;
// This file relies on a dither shader include file, listed further down.
// The paths must match your file's location.
#include "res://dither.gdshaderinc"
uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, repeat_disable, filter_nearest;
void vertex() {
g_q_size = getQuantizeSize(CANVAS_MATRIX);
g_model_matrix = MODEL_MATRIX;
g_world_to_clip = SCREEN_MATRIX * CANVAS_MATRIX;
g_vertex = VERTEX;
}
void fragment() {
COLOR = ditherScreen(SCREEN_TEXTURE, SCREEN_UV, g_vertex);
}
/*
* This should be your main shader file if using with a Sprite2D.
*/
shader_type canvas_item;
// This file relies on a dither shader include file, listed further down.
// The paths must match your file's location.
#include "res://dither.gdshaderinc"
void vertex() {
g_q_size = getQuantizeSize(CANVAS_MATRIX);
g_model_matrix = MODEL_MATRIX;
g_texture_size = 1. / TEXTURE_PIXEL_SIZE;
g_vertex = VERTEX;
g_flat_vertex = VERTEX;
}
void fragment() {
COLOR = ditherTexture(TEXTURE, UV);
}
/*
* This is the dither shader-include file. Please make a copy in a
* separate file, and update the #include in the main shader to match.
*/
// This file relies on a quantize shader include file, listed further down.
// The paths must match your file's location.
#include "res://quantize.gdshaderinc"
group_uniforms Dithering;
uniform bool dither_enabled = false;
// The Bayer matrix pattern to use
// 1 -> 2x2, 2 -> 4x4, 3 -> 8x8
uniform float bayer_pattern : hint_range(1, 3, 1.0) = 1;
uniform float divisions : hint_range(1, 16, 1.0) = 1.0;
uniform float contrast : hint_range(0, 4) = 1.0;
uniform float shift : hint_range (-1, 1) = 0;
uniform bool grayscale;
uniform bool use_palette;
uniform sampler2D palette;
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 getBayerValue(float dSize, vec2 in_world_vertex) {
int iDSize = int(dSize);
float q_size = max(g_q_size, 1.);
ivec2 iv = ivec2(mod(in_world_vertex, dSize * q_size));
iv /= int(q_size);
int index = iv.x + (iv.y * iDSize);
switch (iDSize) {
case 2:
return matrix2[index];
case 4:
return matrix4[index];
default:
return matrix8[index];
}
}
vec4 dither_internal(vec4 in_c, vec2 in_world_vertex, bool in_cut_alpha) {
if (!dither_enabled) {
return in_c;
}
vec4 c = in_c;
if (in_cut_alpha) {
c.rgb = vec3(1. - c.a);
}
// Get dither size based on matrix selected
float dSize = pow(2.0, bayer_pattern);
float dSquared = dSize * dSize;
// Get space between divisions
float div = divisions;
if (use_palette) {
ivec2 pSize = textureSize(palette, 0);
pSize /= pSize.y;
div = float(pSize.x) - 1.0;
c.rgb = vec3((c.r * 0.299) + (c.g * 0.587) + (c.b * 0.114));
}
// add contrast and shift
c = (c - 0.5 + shift) * contrast + 0.5;
c = clamp(c, 0.0, 1.0);
// get dither value
int d = getBayerValue(dSize, in_world_vertex);
// normalize the dither value
float dN = (float(d) / dSquared) - 0.5;
// get the normalizing value for the division gap
float r = 1.0 / div;
float l = r / (dSquared * 2.);
c += r * dN + l; // add dither value to color
c *= div; // convert normalized color to quantized range
c = round(c); // round to nearest available color
c /= div; // normalize again
if (use_palette) {
c.rgb = texture(palette, vec2(c.r, 0.5)).rgb;
}
if (grayscale) {
c.rgb = vec3((c.r + c.g + c.b) / 3.0);
}
if (in_cut_alpha) {
return vec4(vec3(0), 1. - c.r);
}
return vec4(c.rgb, c.a);
}
vec4 ditherScreen(sampler2D in_texture, vec2 in_uv, vec2 in_vertex) {
vec2 world_vertex;
vec4 c = quantizeScreen(in_texture, in_uv, in_vertex, world_vertex);
return dither_internal(c, world_vertex, false);
}
vec4 ditherTexture(sampler2D in_texture, vec2 in_uv) {
vec2 world_vertex;
vec4 c = quantizeTexture(in_texture, in_uv, world_vertex);
return dither_internal(c, world_vertex, false);
}
/*
* This is the quantize shader-include file. Please make a copy in a
* separate file, and update the #include in the dither shader-include to match.
*/
group_uniforms Quantization;
uniform float quantize_size : hint_range(0,100, 1.0) = 1;
uniform bool handle_scale;
uniform bool handle_subpixels = true;
group_uniforms;
varying mat4 g_model_matrix;
varying mat4 g_world_to_clip;
varying vec2 g_texture_size;
varying vec2 g_vertex;
varying flat vec2 g_flat_vertex;
varying float g_q_size;
float getQuantizeSize(mat4 canvas_matrix) {
vec2 g_zoom = vec2(length(canvas_matrix[0].xyz), length(canvas_matrix[1].xyz));
float q_size = quantize_size;
if (handle_subpixels && g_zoom.x < 1.) {
q_size = round(quantize_size * (1. / g_zoom.x));
}
return q_size;
}
vec4 quantizeTexture(
sampler2D in_texture,
vec2 in_uv,
out vec2 out_world_vertex
) {
if (g_q_size == 0.) {
out_world_vertex = (g_model_matrix * vec4(in_uv * g_texture_size, 0, 1)).xy;
return texture(in_texture, in_uv);
}
float q_size = g_q_size;
// gets -1 if not flipped, 1 if flipped
vec2 flipped = sign(g_flat_vertex - g_vertex);
// gets the offset of the texture
// adds 1 when flipped
vec2 offset = g_flat_vertex / g_texture_size;
vec2 uv = in_uv;
// add offset so positioned back at 0,0 locally
// so grid lines up
uv *= flipped;
uv = offset - uv;
// scale to texture size
uv *= g_texture_size;
if (handle_scale) { // convert to world space
uv = (g_model_matrix * vec4(uv, 0, 1)).xy;
}
out_world_vertex = uv;
// quantize to specified pixel size
uv /= q_size;
uv = floor(uv) + 0.5;
uv *= q_size;
if (handle_scale) { // convert back to local space
uv = (inverse(g_model_matrix) * vec4(uv, 0, 1)).xy;
}
// normalize
uv /= g_texture_size;
// remove offset so pixel taken at correct location
uv = offset - uv;
uv *= flipped;
return texture(in_texture, uv);
}
vec4 quantizeScreen(
sampler2D in_screen_texture,
vec2 in_screen_uv,
vec2 in_vertex,
out vec2 out_world_vertex
) {
out_world_vertex = (g_model_matrix * vec4(in_vertex, 0, 1)).xy;
if (g_q_size == 0.) {
return texture(in_screen_texture, in_screen_uv);
}
vec2 uv = out_world_vertex;
uv /= g_q_size;
uv = floor(uv) + 0.5;
uv *= g_q_size;
uv = (g_world_to_clip * vec4(uv, 0, 1)).xy;
uv = uv * 0.5 + 0.5;
return texture(in_screen_texture, 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?