@MaximeHeckel

The Art of Dithering and Retro Shading for the Web

August 6, 2024 / 30 min read

Last Updated: August 10, 2024

I spent the past few months building my personal website from the ground up, finally taking the time to incorporate some 3D work to showcase my shader and WebGL skills. Throughout this work, I got to truly understand the crucial role that post-processing plays in making a scene actually look good, which brought some resolutions to long-term frustrations I had with my past React Three Fiber and shader projects where my vision wouldn't materialize regardless of the amount of work and care I was putting into them.

Taking the time to build, combine, and experiment with custom post-processing effects gave me an additional creative outlet, and among the many types I got to iterate on, I always had a particular affection for the several "retro" effects I came up with. With subtle details such as dithering, color quantization, or pixelization/CRT RGB cells, they bring a pleasant contrast between the modern web landscape and a long-gone era of technology we 90s/early 2000s kids are sometime longing for.

Given the time I invested dissecting every aspect of these effects, I wanted to dive deep with you into the concepts powering them and the shader techniques I learned along the way. In this article, I hope to convince you of the power of post-processing effects and that nothing beats an elegant retro vibe applied to a website 👌✨. We'll also look into examples of dithering and pixel art from very talented folks who use the same processes that I'll be introducing later on, as well as some of my own creations that I built while learning about all this.

Dithering techniques

Dithering originated as an early graphics technique to trick the viewer's brain into seeing more color or smoothness in gradients/shadows that the machines back in the day could output by intentionally introducing noise on top of an image or a render. Color palettes were very limited back then, thus relying on techniques like these was vital for game designers to realize their vision. This gave, as a result, a unique look and feel to games and media from that specific moment in time where computers became ubiquitous, but advanced graphic capabilities were not yet there.

Today, dithering is more an artistic choice than a workaround. Many artists or game designers use this technique as a creative outlet to give their work a unique retro vibe, calling out to that early gaming era, or work within the realms of self-imposed limits in colors. Some great examples of such use of dithering include:

  • ArrowAn icon representing an arrow
    Basement Studio's Basement Chronicle game: a well-executed point-and-click game that reminds me a lot of my own early gaming experience.
  • ArrowAn icon representing an arrow
    @loackme's art, which I'm an absolute fan of.
  • ArrowAn icon representing an arrow
    @aweusmeuh's use of the original Game Boy camera for experimental photography which features a sublime dithering effect.
Examples of beautifully executed dithering art from left to right by Basement Studio, @aweusmeuh, and @loackme_

The latter is how we will approach dithering in this blog post: to give our React Three Fiber/Three.js projects a unique style! In this first part, we'll explore how the dithering technique works, implement it as a shader, and build a first iteration of a custom dithering post-processing effect that we can apply on top of any 3D scene.

A first pass at dithering in React Three Fiber

For this project, we'll create a custom post-processing effect. As we did for the Moebius stylized shader, relying on post-processing will allow us to apply a shader to an already rendered scene and alter its style like adding an "image filter" to a photo.

To create a custom effect, we will:

  1. ArrowAn icon representing an arrow
    Declare a class that extends from Effect
  2. ArrowAn icon representing an arrow
    Define our fragment shader and call it from the parent constructor using the super keyword.
  3. ArrowAn icon representing an arrow
    Define the set of uniforms we will need for our effect.
  4. ArrowAn icon representing an arrow
    Call the wrapEffect function from @react-three/post-processing with our effect class as an argument. This will allow us to use our effect as a JSX component within EffectComposer.

Sample custom shader post-processing effect used in an R3F scene

1
import { OrbitControls, OrthographicCamera, useFBO } from '@react-three/drei';
2
import { Canvas } from '@react-three/fiber';
3
import { wrapEffect, EffectComposer } from '@react-three/postprocessing';
4
import { Effect } from 'postprocessing';
5
import { Suspense, useRef, useState } from 'react';
6
import { v4 as uuidv4 } from 'uuid';
7
import fragmentShader from './fragmentShader.glsl';
8
9
class RetroEffectImpl extends Effect {
10
constructor() {
11
super('RetroEffect', fragmentShader, {
12
uniforms: new Map([]),
13
});
14
}
15
}
16
17
const RetroEffect = wrapEffect(RetroEffectImpl);
18
19
const Retro = () => {
20
const mesh = useRef();
21
22
return (
23
<>
24
<mesh receiveShadow castShadow>
25
<torusKnotGeometry args={[1, 0.25, 128, 100]} />
26
<meshStandardMaterial color="cyan" />
27
</mesh>
28
<EffectComposer>
29
<RetroEffect />
30
</EffectComposer>
31
</>
32
);
33
};

As a first step to building our effect, we'll start with a simple luminance-based white noise dithering. The idea behind this is to:

  • ArrowAn icon representing an arrow
    Look at the luminance of each pixel.
  • ArrowAn icon representing an arrow
    Compare it to a random number (hence the "white noise" in the name).
  • ArrowAn icon representing an arrow
    Output a white or black pixel based on whether the luminance falls above or below said random number.

White noise dithering implemented in a fragment shader of a custom effect

1
float random(vec2 c) {
2
return fract(sin(dot(c.xy, vec2(12.9898, 78.233))) * 43758.5453);
3
}
4
5
vec3 whiteNoiseDither(vec2 uv, float lum) {
6
vec3 color = vec3(0.0);
7
8
if (lum < random(uv)) {
9
color = vec3(0.0);
10
} else {
11
color = vec3(1.0);
12
}
13
14
return color;
15
}
16
17
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
18
vec4 color = texture2D(inputBuffer, uv);
19
20
float lum = dot(vec3(0.2126, 0.7152, 0.0722), color.rgb);
21
color.rgb = whiteNoiseDither(uv, lum);
22
23
outputColor = color;
24
}

You can observe the effect that this code would yield on the widget below which re-implements a similar process:

Doing this will result in a grayscale version of our scene where any pixel not purely black or white will be dithered tricking our brains into seeing more shades of gray. The demo below shows the effect applied on top of a simple React Three Fiber scene:

Ordered Dithering and Bayer Matrix

The effect we just built works, but it relies on white noise for its dithering threshold, leading to a messy result. We can bring order to all this (🥁) using a technique commonly known as ordered dithering due to the ordered pattern it yields when applied.

This technique relies on a threshold map defined via a Bayer Matrix that contains values used to determine whether we should adjust the color of a given pixel to black or white.

Diagram showcasing the process of applying the 4x4 Bayer Matrix on the input buffer of a scene and obtaining the dithering pattern based on the threshold value matching each pixel

To demonstrate how this dithering type works, I built the widget below where you can see how this matrix changes the output of a grid of pixels once applied on top of it:

As you can see through the examples I showcased above, we get some pretty distinct dithering patterns based on

  • ArrowAn icon representing an arrow
    the shades of gray used in the underlying pixel grid
  • ArrowAn icon representing an arrow
    the size of the Bayer Matrix used to get the threshold value
Before
After
ArrowAn icon representing an arrowArrowAn icon representing an arrow
Ordered dithering applied on a simple grayscale gradient

To implement this in GLSL, we need to get the luminance of each pixel and compare its value with the corresponding threshold value for that same pixel obtained from the Bayer Matrix:

  • ArrowAn icon representing an arrow
    if the difference between those values is positive, the pixel is white
  • ArrowAn icon representing an arrow
    otherwise, it is black

Ordered dithering using a 4x4 Bayer Matrix

1
const mat4x4 bayerMatrix4x4 = mat4x4(
2
0.0, 8.0, 2.0, 10.0,
3
12.0, 4.0, 14.0, 6.0,
4
3.0, 11.0, 1.0, 9.0,
5
15.0, 7.0, 13.0, 5.0
6
) / 16.0;
7
8
vec3 orderedDither(vec2 uv, float lum) {
9
vec3 color = vec3(0.0);
10
11
float threshold = 0.0;
12
13
int x = int(uv.x * resolution.x) % 4;
14
int y = int(uv.y * resolution.y) % 4;
15
threshold = bayerMatrix4x4[y][x];
16
17
if (lum < threshold + bias) {
18
color = vec3(0.0);
19
} else {
20
color = vec3(1.0);
21
}
22
23
return color;
24
}

Modifying the effect code we implemented in the previous part with the code we just introduced will give us an ordered dithering effect for our underlying scene:

Blue noise dithering

We got ourselves a satisfying ordered dithering effect! While this is the most popular dithering technique, as well as the main one we'll leverage in this article, I still wanted to touch upon an additonal way to dither that you perhaps remember seeing in my article on Volumetric Raymarching: blue noise dithering.

I used this technique in my raymarched cloud scenes to "erase the banding or layering effect due to a less granular [raymarching] loop" which funny enough is the same use case we need dithering for in our Retro post-processing effect. Unlike the previous techniques, this one relies on a texture that we'll pass to the shader of our custom post-processing effect via a uniform and then sample it as follows:

1
vec4 noise = texture2D(uNoise, gl_FragCoord.xy / 128.0);
2
float threshold = noise.r;

where 128.0 is the width/height of said texture. We also define the threshold as the red color channel of the resulting noise color we obtain from the sampling, given that we're using a grayscale texture, it doesn't matter much which value you pick.

Below is the resulting output when we use a blue noise texture to obtain our dithering threshold value:

As you can see, it feels less repetitive and structured than ordered dithering while also not being as random as white noise dithering; a nice middle ground.

Color Quantization

So far, all our dithering examples also converted the underlying scene to black and white, thus making us lose a lot of information and color. That is because:

  • ArrowAn icon representing an arrow
    We calculated our dithering threshold based on the pixel luminance, thus relying on a grayscale version of our scene.
  • ArrowAn icon representing an arrow
    We manually returned a black or white pixel based on the threshold value relative to the luminance.

That technique is commonly referred to as luminance-based dithering and the color conversion used here compresses the color palette to 2-bit: each pixel of the resulting scene with our post-processing effect applied is either black or white, and any shade in-between appears to us through dithering.

This color compression is known as color quantization, and it supports more than just black and white pixels as we'll see in this section.

Shades of gray and colors

Manually setting the colors of our dithering pattern can quickly get out of hand, especially with large color palettes. Instead, to get more than just a black or white pixel and leverage shades of gray, we'll use a formula to find the nearest neighboring color of a given pixel color based on the total number of colors we want to output in our effect:

floor(color * (n - 1) + 0.5)/n - 1 where n is the total number of color.

For example, if we wanted only two colors in our final color palette we would get a value of:

  • ArrowAn icon representing an arrow
    vec3(0.0) for the color vec3(0.3) i.e. black
  • ArrowAn icon representing an arrow
    vec3(1.0) for the color vec3(0.6) i.e. white

If we were to increase the number of colors we would get more shades of gray in the case of our grayscale scene.

Grayscale color quantization implemented in our custom effect

1
vec3 dither(vec2 uv, float lum) {
2
vec3 color = vec3(lum);
3
4
int x = int(uv.x * resolution.x) % 8;
5
int y = int(uv.y * resolution.y) % 8;
6
float threshold = bayerMatrix8x8[y * 8 + x];
7
8
color.rgb += threshold;
9
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
10
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
11
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
12
13
return color;
14
}
Before
After
ArrowAn icon representing an arrowArrowAn icon representing an arrow
Ordered dithering with 2 VS 4 color quantization. Notice how the 4-color variant yields a better looking gradient.

This formula doesn't just work for shades of gray, we can use it directly on the original pixel color to compute its nearest neighbor:

  • ArrowAn icon representing an arrow
    for a two-color palette, we'll get the 2 possible values for each color channel thus 2^3 = 8 colors
  • ArrowAn icon representing an arrow
    for a four-color palette, it would be 4^3 = 64 colors

Color quantization implemented in our custom effect

1
vec3 dither(vec2 uv, vec3 color) {
2
int x = int(uv.x * resolution.x) % 8;
3
int y = int(uv.y * resolution.y) % 8;
4
float threshold = bayerMatrix8x8[y * 8 + x];
5
6
color.rgb += threshold;
7
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
8
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
9
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
10
11
return color;
12
}