I had an idea for a cute little roguelike dungeon crawler. I briefly considered making it using my own game engine, and then decided to just use Godot to get something working quickly. I managed to get a map building system, player collision and movement, and a post-processing dithering shader for a cool art style up and running in 1 day. One of the takeaways is how quick you can get something good looking in GLSL:

shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture, filter_nearest;

const vec3 PALETTE[4] = vec3[4](
    vec3(0.059, 0.059, 0.106), // #0f0f1b
    vec3(0.337, 0.353, 0.459), // #565a75
    vec3(0.776, 0.718, 0.745), // #c6b7be
    vec3(0.980, 0.984, 0.965)  // #fafbf6
);

const float BAYER_MATRIX[64] = float[64](
    0.0/64.0, 32.0/64.0, 8.0/64.0, 40.0/64.0, 2.0/64.0, 34.0/64.0, 10.0/64.0, 42.0/64.0,
    48.0/64.0, 16.0/64.0, 56.0/64.0, 24.0/64.0, 50.0/64.0, 18.0/64.0, 58.0/64.0, 26.0/64.0,
    12.0/64.0, 44.0/64.0, 4.0/64.0, 36.0/64.0, 14.0/64.0, 46.0/64.0, 6.0/64.0, 38.0/64.0,
    60.0/64.0, 28.0/64.0, 52.0/64.0, 20.0/64.0, 62.0/64.0, 30.0/64.0, 54.0/64.0, 22.0/64.0,
    3.0/64.0, 35.0/64.0, 11.0/64.0, 43.0/64.0, 1.0/64.0, 33.0/64.0, 9.0/64.0, 41.0/64.0,
    51.0/64.0, 19.0/64.0, 59.0/64.0, 27.0/64.0, 49.0/64.0, 17.0/64.0, 57.0/64.0, 25.0/64.0,
    15.0/64.0, 47.0/64.0, 7.0/64.0, 39.0/64.0, 13.0/64.0, 45.0/64.0, 5.0/64.0, 37.0/64.0,
    63.0/64.0, 31.0/64.0, 55.0/64.0, 23.0/64.0, 61.0/64.0, 29.0/64.0, 53.0/64.0, 21.0/64.0
);

float get_threshold(vec2 pixel_pos) {
    int x = int(mod(pixel_pos.x, 8.0));
    int y = int(mod(pixel_pos.y, 8.0));
    return BAYER_MATRIX[y * 8 + x];
}

vec3 find_closest_color(vec3 color, float threshold) {
    float lum = dot(color, vec3(0.299, 0.587, 0.114));
    float scaled_lum = lum * 3.0;

    int lower_idx = int(floor(scaled_lum));
    int upper_idx = min(lower_idx + 1, 3);

    float frac_part = fract(scaled_lum);

    if (frac_part < threshold) {
        return PALETTE[lower_idx];
    } else {
        return PALETTE[upper_idx];
    }
}

void fragment() {
    // Get screen texture color
    vec3 screen_color = texture(screen_texture, SCREEN_UV).rgb;

    // Get the threshold value from the Bayer matrix
    float threshold = get_threshold(FRAGCOORD.xy);

    // Find the closest color in the palette using dithering
    vec3 dithered_color = find_closest_color(screen_color, threshold);

    // Output the dithered color
    COLOR.rgb = dithered_color;
    COLOR.a = 1.0;  // Ensure full opacity
}

It's pretty simple:

  1. Steal a colour palette off of Lospec (I went with 'Hollow'), you need ideally 2 - 4 to get something good.
  2. Load a Bayer Matrix up, dimensions determine the resolution of dithering (could also use noise but I don't like how it looks).
  3. Assume you are being passed in a frame of gameplay. There are only 2 things we consider: pixel colour and luminosity. We offset each pixel on the screen based on a value from the Bayer Matrix based on location so it gets quantized based on that location. If luminosity is less than the value at the matrix, it gets set to a darker colour thresholded based on how many colours are in our palette.