The Art of Dithering and Retro Shading for the Web
August 6, 2024 / 30 min read
Last Updated: August 10, 2024I 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:
- Basement Studio's Basement Chronicle game: a well-executed point-and-click game that reminds me a lot of my own early gaming experience.
- @loackme's art, which I'm an absolute fan of.
- @aweusmeuh's use of the original Game Boy camera for experimental photography which features a sublime dithering effect.
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:
- Declare a class that extends from
Effect
- Define our fragment shader and call it from the parent constructor using the
super
keyword. - Define the set of uniforms we will need for our effect.
- 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 withinEffectComposer
.
Sample custom shader post-processing effect used in an R3F scene
1import { OrbitControls, OrthographicCamera, useFBO } from '@react-three/drei';2import { Canvas } from '@react-three/fiber';3import { wrapEffect, EffectComposer } from '@react-three/postprocessing';4import { Effect } from 'postprocessing';5import { Suspense, useRef, useState } from 'react';6import { v4 as uuidv4 } from 'uuid';7import fragmentShader from './fragmentShader.glsl';89class RetroEffectImpl extends Effect {10constructor() {11super('RetroEffect', fragmentShader, {12uniforms: new Map([]),13});14}15}1617const RetroEffect = wrapEffect(RetroEffectImpl);1819const Retro = () => {20const mesh = useRef();2122return (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:
- Look at the luminance of each pixel.
- Compare it to a random number (hence the "white noise" in the name).
- 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
1float random(vec2 c) {2return fract(sin(dot(c.xy, vec2(12.9898, 78.233))) * 43758.5453);3}45vec3 whiteNoiseDither(vec2 uv, float lum) {6vec3 color = vec3(0.0);78if (lum < random(uv)) {9color = vec3(0.0);10} else {11color = vec3(1.0);12}1314return color;15}1617void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {18vec4 color = texture2D(inputBuffer, uv);1920float lum = dot(vec3(0.2126, 0.7152, 0.0722), color.rgb);21color.rgb = whiteNoiseDither(uv, lum);2223outputColor = 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.
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
- the shades of gray used in the underlying pixel grid
- the size of the Bayer Matrix used to get the threshold value
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:
- if the difference between those values is positive, the pixel is white
- otherwise, it is black
Ordered dithering using a 4x4 Bayer Matrix
1const mat4x4 bayerMatrix4x4 = mat4x4(20.0, 8.0, 2.0, 10.0,312.0, 4.0, 14.0, 6.0,43.0, 11.0, 1.0, 9.0,515.0, 7.0, 13.0, 5.06) / 16.0;78vec3 orderedDither(vec2 uv, float lum) {9vec3 color = vec3(0.0);1011float threshold = 0.0;1213int x = int(uv.x * resolution.x) % 4;14int y = int(uv.y * resolution.y) % 4;15threshold = bayerMatrix4x4[y][x];1617if (lum < threshold + bias) {18color = vec3(0.0);19} else {20color = vec3(1.0);21}2223return 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:
1vec4 noise = texture2D(uNoise, gl_FragCoord.xy / 128.0);2float 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:
- We calculated our dithering threshold based on the pixel luminance, thus relying on a grayscale version of our scene.
- 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:
vec3(0.0)
for the colorvec3(0.3)
i.e. blackvec3(1.0)
for the colorvec3(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
1vec3 dither(vec2 uv, float lum) {2vec3 color = vec3(lum);34int x = int(uv.x * resolution.x) % 8;5int y = int(uv.y * resolution.y) % 8;6float threshold = bayerMatrix8x8[y * 8 + x];78color.rgb += threshold;9color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);10color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);11color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);1213return color;14}
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:
- for a two-color palette, we'll get the 2 possible values for each color channel thus 2^3 = 8 colors
- for a four-color palette, it would be 4^3 = 64 colors
Color quantization implemented in our custom effect
1vec3 dither(vec2 uv, vec3 color) {2int x = int(uv.x * resolution.x) % 8;3int y = int(uv.y * resolution.y) % 8;4float threshold = bayerMatrix8x8[y * 8 + x];56color.rgb += threshold;7color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);8color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);9color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);1011return color;12}