Dither Gradient Shader

This is a dither shader inspired by Lucas Pope’s Return of the Obra Dinn.

The shader itself is a simple post-process screen shader which will take a gradient palette and apply it with a dither effect to any scene.

A fully comprehensive Godot demo for the shader is available on Itch (with HTML5 build) and Github.

Uniforms

  • u_dither_tex – This is the texture that’s used to create the dither pattern. It is most commonly a bayer matrix (8×8 or 16×16) but could technically be anything. Make sure your import settings are correct, it needs to tile (Repeat enabled) and have no filtering. The dither textures supplied in the demo are by tromero (MIT licensed).
  • u_color_tex – This is the palette used to determine which colours to dither between. It can contain as many colours as you’d like, but the more colours, the less of an effect the dither has. The texture can be any size, as long as the colours change linearly along the X axis (ideally darkest on the left). Lospec is a great source of palettes.
  • u_bit_depth – If you’d like a more banded appearance, lower this value.
  • u_contrast – Adjusts luminance scale around the mid-point, linearly. Increasing contrast will darken shadows and brighten highlights.
  • u_offset – Shifts luminance up or down, allowing you to tweak how dark or light the output is.
  • u_dither_size – Adjust size of each dither pixel. Increase for a more pixelated look.

References

Shader code
/* 
This shader is under MIT license. Feel free to use, improve and 
change this shader according to your needs and consider sharing 
the modified result to godotshaders.com.
*/

shader_type canvas_item;

uniform sampler2D u_dither_tex;
uniform sampler2D u_color_tex;

uniform int u_bit_depth;
uniform float u_contrast;
uniform float u_offset;
uniform int u_dither_size;

void fragment() 
{
	// sample the screen texture at the desired output resolution (according to u_dither_size)
	// this will effectively pixelate the resulting output
	vec2 screen_size = vec2(textureSize(TEXTURE, 0)) / float(u_dither_size);
	vec2 screen_sample_uv = floor(UV * screen_size) / screen_size;
	vec3 screen_col = texture(TEXTURE, screen_sample_uv).rgb;
	
	// calculate pixel luminosity (https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color)
	float lum = (screen_col.r * 0.299) + (screen_col.g * 0.587) + (screen_col.b * 0.114);
	
	// adjust with contrast and offset parameters
	float contrast = u_contrast;
	lum = (lum - 0.5 + u_offset) * contrast + 0.5;
	lum = clamp(lum, 0.0, 1.0);
	
	// reduce luminosity bit depth to give a more banded visual if desired	
	float bits = float(u_bit_depth);
	lum = floor(lum * bits) / bits;
	
	// to support multicolour palettes, we want to dither between the two colours on the palette
	// which are adjacent to the current pixel luminosity.
	// to do this, we need to determine which 'band' lum falls into, calculate the upper and lower
	// bound of that band, then later we will use the dither texture to pick either the upper or 
	// lower colour.
	
	// get the palette texture size mapped so it is 1px high (so the x value however many colour bands there are)
	ivec2 col_size = textureSize(u_color_tex, 0);
	col_size /= col_size.y;
	
	float col_x = float(col_size.x) - 1.0; // colour boundaries is 1 less than the number of colour bands
	float col_texel_size = 1.0 / col_x; // the size of one colour boundary
	
	lum = max(lum - 0.00001, 0.0); // makes sure our floor calculation below behaves when lum == 1.0
	float lum_lower = floor(lum * col_x) * col_texel_size;
	float lum_upper = (floor(lum * col_x) + 1.0) * col_texel_size;
	float lum_scaled = lum * col_x - floor(lum * col_x); // calculates where lum lies between the upper and lower bound
	
	// map the dither texture onto the screen. there are better ways of doing this that makes the dither pattern 'stick'
	// with objects in the 3D world, instead of being mapped onto the screen. see lucas pope's details posts on how he 
	// achieved this in Obra Dinn: https://forums.tigsource.com/index.php?topic=40832.msg1363742#msg1363742
	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(screen_size.x), float(screen_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_scaled is, the fewer pixels will be below the dither threshold, and thus will use the lower bound colour,
	// and vice-versa
	float ramp_val = lum_scaled < threshold ? 0.0f : 1.0f;
	// sample at the lower bound colour if ramp_val is 0.0, upper bound colour if 1.0
	float col_sample = mix(lum_lower, lum_upper, ramp_val);
	vec3 final_col = texture(u_color_tex, vec2(col_sample, 0.5)).rgb;
	
	// return the final colour!
	COLOR.rgb = final_col;
}
Tags
dither, gradient, obra dinn, palette, post process
The shader code and all code snippets in this post are under MIT license and can be used freely. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

Color reduction and dither

Dither opacity with GLES2 Support

16 Bit Color (C64-Like) with Dither

Subscribe
Notify of
guest

7 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
HeroRobb
HeroRobb
2 years ago

Idk if it was just me being dumb, but if anyone else gets the dithering in just a little section of the corner, make sure to have repeat enabled on the dithering texture and reimport it.

HeroRobb
HeroRobb
2 years ago
Reply to  HeroRobb

It even says it in the description here… *facepalm*

weirdybeardyman
2 years ago

Hi, just wondering why there is a strip of the dithered palette on the left side of the screen?

weirdybeardyman
2 years ago

oh it’s just that texture rectangle with the gradient on it, my bad!

subpixelmessage
2 years ago

Thank you so much for this contribution. The comments made this a really incredible teaching tool. I genuinely feel a vague sense of understanding how dithers work! Hah! Might be vague but it’s a start.

nasa109
nasa109
1 year ago

To make work with Godot 4 change line 9 from

uniform sampler2D u_dither_tex;

to

uniform sampler2D u_dither_tex : repeat_enable;

see Here and Here for more info.

TehBrian
TehBrian
2 months ago
Reply to  nasa109

Awesome! Thank you so much.

To get it to work, I also had to:

Replace TEXTURE with the recommended screen_texture uniform. (See Screen-reading shaders on Godot Docs.)Use filter_nearest on the textures.Ultimately, here’s the code that I ended up with: https://pastes.dev/UlpTD3zm4C. It’s working on Godot 4.2.

Last edited 2 months ago by TehBrian