Shader of Two Color Linear Gradient with Dithering Pattern

Godot Version

3.6

Question

I have been working on writing a shader to create a linear gradient that dithers between the two colors instead of gradually interpolating. Something like this:
Option Button Base

I am very new to writing shaders and have been relying on referencing other sources.

Linear Gradient

I found some shader code that creates a linear gradient with parameters for adjusting the position, gradient size, and gradient rotation.

This is the shader modified for my needs:

shader_type canvas_item;
render_mode unshaded;

uniform vec4 override_color : hint_color = vec4(1.0);
uniform vec4 color_1 : hint_color = vec4(1.0);
uniform vec4 color_2 : hint_color = vec4(0.0, 0.0, 0.0, 1.0);
// position of the colors
uniform float position : hint_range(-0.5, 0.5) = 0.0;
// size of the gradient effect
uniform float size : hint_range(0.5, 2) = 0.5;
// rotation angle of gradient
uniform float angle : hint_range(0.0, 360.0) = 0.0;

// Gets the interpolation value for the UV.
float get_pos(vec2 uv)
{
	float p = position + 0.5;
	vec2 p_uv = uv - p;
	float rot = p_uv.x * cos(radians(angle)) - p_uv.y * sin(radians(angle)); 
	return smoothstep((1.0 - size) + position, size + 0.0001 + position, rot + p);
}

void fragment()
{
	vec4 p_color = texture(TEXTURE, UV);
	// Only apply gradient to override color.
	if(override_color == p_color) {
		COLOR = mix(color_1, color_2, get_pos(UV));
	} else {
		COLOR = p_color;
	}
}

Adding Dithering

The function get_pos returns a float that is used to decide how much to mix the two colors, with only values between 0.0 and 1.0 being effective. I did some searching for how to implement the dithering effect I want and came across something called ordered dithering.
https://maximmcnair.com/p/webgl-dithering

This tutorial provides some shader code that implements ordered dithering. I decided to try and use it.

const float bayer_2x2[4] =  {
	0.0, 3.0,  
	2.0, 1.0
};

// Gets the threshold data for a UV given the 4x4 bayer dither matrix.
vec4 dither4x4(vec2 uv, float g) {
	float dither_amount = 2.0;
	int x = int(mod(uv.x, dither_amount));
	int y = int(mod(uv.y, dither_amount));
	int index = x + y * int(dither_amount);
	float limit = (float(bayer_2x2[index]) + 1.0) / (1.0 + 4.0);
	return g < limit ? color_1 : color_2;
}

The original code used the luma values of the pixels to determine the dither result. In my case, I want to use the interpolation value from get-pos. It seemed like I could just plug that value in place of the luma. This does not work.

My Full Shader Code

shader_type canvas_item;
render_mode unshaded;

uniform vec4 override_color : hint_color = vec4(1.0);
uniform vec4 color_1 : hint_color = vec4(1.0);
uniform vec4 color_2 : hint_color = vec4(0.0, 0.0, 0.0, 1.0);
// position of the colors
uniform float position : hint_range(-0.5, 0.5) = 0.0;
// size of the gradient effect
uniform float size : hint_range(0.5, 2) = 0.5;
// rotation angle of gradient
uniform float angle : hint_range(0.0, 360.0) = 0.0;

// Reference: https://maximmcnair.com/p/webgl-dithering
const float bayer_2x2[4] =  {
	0.0, 2.0,  
	2.0, 1.0
};

// Gets the interpolation value for the UV.
float get_pos(vec2 uv)
{
	float p = position + 0.5;
	vec2 p_uv = uv - p;
	float rot = p_uv.x * cos(radians(angle)) - p_uv.y * sin(radians(angle)); 
	return smoothstep((1.0 - size) + position, size + 0.0001 + position, rot + p);
}

// Gets the threshold data for a UV given the 4x4 bayer dither matrix.
vec4 dither4x4(vec2 uv, float g) {
	float dither_amount = 2.0;
	int x = int(mod(uv.x, dither_amount));
	int y = int(mod(uv.y, dither_amount));
	int index = x + y * int(dither_amount);
	float limit = (float(bayer_2x2[index]) + 1.0) / (1.0 + 4.0);
	return g < limit ? color_1 : color_2;
}

void fragment()
{
	vec4 p_color = texture(TEXTURE, UV);
	// Only apply gradient to override color.
	if(override_color == p_color) {
		COLOR = dither4x4(UV, get_pos(UV));
	} else {
		COLOR = p_color;
	}
}

Moving Forward

I am not sure how to proceed. I found a different algorithm here Ordered dithering - Wikipedia that I tried to implement, but was not able to completely. I don’t understand how to determine the color space.

This would be a ‘wrong’ answer: the program is too ‘data divergant’ and the fragment threads that arent applying dithering are definitely forced to wait until the warp finishes.

I think you set the color space using the gradient … you would need to use some script to generate colors, then if you choose 8 bit you select the particular 8 bit blending code . So you would need a dithering function for each color space.
Then if you need to apply the dithering effect to an unknown color space, you find out what itnis by experimenting and see which dithering functions actually work in the particular situation.

If the color space is going to vary, then its conplicated.

So i need a color template based on the colors picked as part of getting the dithering to work. I wonder if I could initially just create a dither using black and white colors, replacing them with the desired colors, but that feels like looping back around to trying to make my original approach work.

I am using an art program called Resprite to draw my art, which I believe is similar to Aseprite. In said program there is a gradient tool that can both create the gradual transition between the two colors, or create a dither transition based on a matrix size (1x1, 2x2, or 4x4). I want to replicate this dither gradient effect to use for my UI panels.

I don’t know what to do here. My current approach is not working, and I don’t know what to look for. Any guidance is appreciated.

I just dived into the article and …

If you trying to compute a color palette via color quantisation using a clustering algorithm … then you might need to use a compute shader … there are good cuda implementations of k-means on google search.

So basically the RGB is treated as a vector and the color pallete entries are the geometric mean values (centroids) of the k clusters.

If you only had two colors, then i suppose you create a line between themnin color space …
I.e. x → r, y->g, z->b
Then the line has two points, P1 = (x1, y1, z1) and P2=(x2, y2, z2),
Then use Linear Interpolation to grab points between them with the same increment as each dithering pattern…
Label the dithering patterns based on the points …
Then index the dithering patterns using the color quantization as a lookup.

The lookup function works like clustering … you get the nearest
Hope that helps.

Which article did you look into? Is the color-quantization meant to be run as part of the shader or separately, with some result being fed into the shader?

I was not sure how to proceed with approach @pizza_delivery_man suggested. So I found something else that made sense to me.

Another Shader

I found this shader and modified it per my needs and constraints.

shader_type canvas_item;
render_mode unshaded;

uniform sampler2D u_dither_tex;
uniform vec4 override_color : hint_color = vec4(1.0);
uniform vec4 color_1 : hint_color = vec4(1.0);
uniform vec4 color_2 : hint_color = vec4(0.0, 0.0, 0.0, 1.0);
// position of the colors
uniform float position : hint_range(-0.5, 0.5) = 0.0;
// size of the gradient effect
uniform float size : hint_range(0.5, 2) = 0.5;
// rotation angle of gradient
uniform float angle : hint_range(0.0, 360.0) = 0.0;

const vec4 black = vec4(0.0, 0.0, 0.0, 1.0);
const vec4 white = vec4(1.0);

/*
Gets the interpolation value for the UV.
Reference: https://godotshaders.com/shader/linear-gradient/
*/
float get_pos(vec2 uv) {
	float p = position + 0.5;
	vec2 p_uv = uv - p;
	float rot = p_uv.x * cos(radians(angle)) - p_uv.y * sin(radians(angle)); 
	return smoothstep((1.0 - size) + position, size + 0.0001 + position, rot + p);
}

/*
Gets the gradient color.
Reference: https://godotshaders.com/shader/dither-gradient-shader/
*/
vec4 dither_gradient(vec2 uv, sampler2D t) {
	/*
	Multiply by 5.0 to try and match pixel size on screen. Need to update
	this to determine the correct value based on texture size.
	*/
	vec2 t_size = vec2(textureSize(t, 0)) * 5.0;
	vec2 t_uv = floor(uv * t_size) / t_size;
	vec4 g_col = mix(black, white, get_pos(t_uv));

	/*
	calculate pixel luminosity
	(https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color)
	*/
	float lum = (g_col.r * 0.299) + (g_col.g * 0.587) + (g_col.b * 0.114);
	lum = clamp(lum, 0.0, 1.0);
	
	// map the dither texture.
	ivec2 noise_size = textureSize(u_dither_tex, 0);
	vec2 inv_noise_size = vec2(1.0 / float(noise_size.x), 1.0 / float(noise_size.y));
	vec2 noise_uv = uv * inv_noise_size * vec2(float(t_size.x), float(t_size.y));
	float threshold = texture(u_dither_tex, noise_uv).r;
	
	/*
	adjust the dither slightly so min and max aren't quite at 0.0 and 1.0
	otherwise we wouldn't get fullly dark and fully light dither patterns
	at lum 0.0 and 1.0
	*/
	threshold = threshold * 0.99 + 0.005;
	
	/*
	The lower lum is, the fewer pixels will be below the dither threshold,
	 and thus will use color_1, and vice-versa.
	*/
	return lum < threshold ? color_1 : color_2;
}

void fragment() {
	vec4 p_color = texture(TEXTURE, UV);
	if (override_color == p_color) {
		COLOR = dither_gradient(UV, TEXTURE);
	} else {
		COLOR = p_color;
	}
}

New Problems

This shader is designed to be used as part of a UI node. It currently works as desired, but only if the node has a style defined as a StyleBoxFlat.


NOTE: The shader was adjusted to always run the dithering effect for the purposes of showcasing the issue

The UI element I have uses a StyleBoxTexture for panels, which causes the shader to compress the dithering near the edges.


NOTE: This running the shader as is

I think this is a result of the StyleBoxTexture margins. This particular UI style has them set to 4 on all sides to keep the border consistent. I tested this by overriding the style using a StyleBoxTexture with the margins set to zero. The dithering remained consistent using the overridden style.

NOTE: I modified the shader to always run the dithering and keep the original texture size when testing this.

Thoughts?

I read the first link shared and then color quantization, and anything that cropped including clustering algorithms.

The color quantization is usually pre calculated and this is for performance reasons when using a larger pallet of colors.

So i took the guess that if you have two colors you can directly color quantize by lerping the colors … lerp(p1,p2,val)
Where

val = 1.0 / num_dither_patterns;

and then each color you read from the source image (s) is quantized dynamically in the shader using ‘nearest point on line’.

The line is parametrically something like

line = (p2-p1) * t + p1;

With t as parameter.

Then you would compute t with something like

new_t = (NearestPointOnLine(sample) - p1)/ (p2-p1);

Then use ‘val’ from above

quant = floor(new_t / val);

Just to find out which dithering pattern to use, as you have a range of say 17 dithering patterns and theres 1/17 steps on the linear interpolation function, so you need to compute which of those steps the variable ‘new_t’ fits into.

1 Like

Oh yeah and if the color is nearest to a point that is not between the two initial colors

p1 ( r1, g1, b1) and p2 (r2, g2, b2 ) , theyre pretending to be vectors.

Then presumably it clips to the point its nearest to, so it the value new_t is nearest p1 and not inbetween them you just use p1.

1 Like

Absolutely no clue about making them work on gui elems sorry.

Thank you for the help, regardless. I’ll try your techniques when I need to implement something for screenwise dithering.

I should have been more explicit about this being for GUI elements.

Looks like you’ve got it covered. I just thought about how it works with an abstract color space and limited pallet. When you have a gradient you already know none of the colors can be outside of the range (color1-color2).

You could work without the gradient etc, you just need two colors and you can dither between them.
I thought the concept was interesting, Ive never written a dithering shader anyway.

1 Like

Forgot to mention a couple of errors i made …

  1. You have to use a index for the dither pattern and multiply the lerp ‘val’ by the index.
  2. The ‘floor’ function is incorrect if you are after the nearest pattern you have to add 0.5 mulitplied by the scaling factor to the argument … so floor( A + 0.5 × 1 / num_dither_patterns ) where A is the argument of the floor() function used once you have computed the nearest point on the line. Because floor on its own just returns the the lowest nearby value. The scaling factor puts it into the range.
  3. NearestPointOnLine should take the line and the point you wish to sample from, in this case input color.

Note: this is also probably wrong.

1 Like

I am officially stumped.

Issues with Matching Screen Pixel Resolution

The original shader code I based this on runs across the entire screen, so the rendered dither pattern will always match the screen resolution. This allows for the pixel size to be determined by dividing the screen size by the desired pixel size. When applying to a GUI node of a different size, the dither pattern scales with the size of the node. I don’t know how to get the dither pattern to stay consistent with the screen resolution aside from manually adjusting it.

The TEXTURE size does not appear to match the Rect size of the GUI node. I honestly don’t really know what the TEXTURE size is. I am aware that shaders use the UV coordinate system, but in GLSL there is a function textureSize that retrieves the dimensions of a texture. For a GUI node using a texture, I can see it using the original size. For a panel using StyleBoxFlat, what’s the size?

StyleBoxTexture Margins Causing Pattern Compression

As stated before, when the shader is applied to a GUI panel node that uses a StyleBoxTexture as its theme, the dithering pattern compresses within the margins. My initial thought was to adjust the UV coordinate when it is within the margin border, but I don’t know if that will work. I’m not even sure how to check if a UV coordinate is within the margins.

Trying to use the size of TEXTURE seems like a good start, but I’m not sure if that will work.

Current Code

shader_type canvas_item;
render_mode unshaded;

uniform sampler2D u_dither_tex;
uniform vec2 u_pix_size = vec2(1.0, 1.0);
uniform vec4 override_color : hint_color = vec4(1.0);
uniform vec4 color_1 : hint_color = vec4(1.0);
uniform vec4 color_2 : hint_color = vec4(0.0, 0.0, 0.0, 1.0);
// position of the colors
uniform float position : hint_range(-0.5, 0.5) = 0.0;
// size of the gradient effect
uniform float size : hint_range(0.5, 2) = 0.5;
// rotation angle of gradient
uniform float angle : hint_range(0.0, 360.0) = 0.0;

const vec4 black = vec4(0.0, 0.0, 0.0, 1.0);
const vec4 white = vec4(1.0);

/*
Gets the interpolation value for the UV.
Reference: https://godotshaders.com/shader/linear-gradient/
*/
float get_pos(vec2 uv) {
	float p = position + 0.5;
	vec2 p_uv = uv - p;
	float rot = p_uv.x * cos(radians(angle)) - p_uv.y * sin(radians(angle)); 
	return smoothstep((1.0 - size) + position, size + 0.0001 + position, rot + p);
}

/*
Gets the gradient color.
Reference: https://godotshaders.com/shader/dither-gradient-shader/
*/
vec4 dither_gradient(vec2 uv, sampler2D t) {
	// Set the pixel size by dividing the texture size by a defined scale
	vec2 t_size = vec2(textureSize(t, 0)) / u_pix_size;
	vec2 t_uv = floor(uv * t_size) / t_size;
	vec4 g_col = mix(black, white, get_pos(t_uv));

	/*
	calculate pixel luminosity
	(https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color)
	*/
	float lum = (g_col.r * 0.299) + (g_col.g * 0.587) + (g_col.b * 0.114);
	lum = clamp(lum, 0.0, 1.0);
	
	// map the dither texture.
	ivec2 noise_size = textureSize(u_dither_tex, 0);
	vec2 inv_noise_size = vec2(1.0 / float(noise_size.x), 1.0 / float(noise_size.y));
	vec2 noise_uv = uv * inv_noise_size * vec2(float(t_size.x), float(t_size.y));
	float threshold = texture(u_dither_tex, noise_uv).r;
	
	/*
	adjust the dither slightly so min and max aren't quite at 0.0 and 1.0
	otherwise we wouldn't get fullly dark and fully light dither patterns
	at lum 0.0 and 1.0
	*/
	threshold = threshold * 0.99 + 0.005;
	
	/*
	The lower lum is, the fewer pixels will be below the dither threshold,
	 and thus will use color_1, and vice-versa.
	*/
	return lum < threshold ? color_1 : color_2;
}

void fragment() {
	vec4 p_color = texture(TEXTURE, UV);
	if (override_color == p_color) {
		COLOR = dither_gradient(UV, TEXTURE);
	} else {
		COLOR = p_color;
	}
}

Bayer Matrix Textures

A texture describing a Bayer Matrix needs to be provided. The texture needs to be imported with no filter and with repeating enabled.

2x2 matrix pattern
bayer2tile16
4x4 matrix pattern
bayer4tile16
8x8 matrix pattern

The functions genrally always need the texture size … i think gui panel size might be the size of the portion of the screen it ends up occupying…

(Ive noticed the 0.5 in the floor function isnt scaled)