https://blog.maximeheckel.com/posts/the-art-of-dithering-and-retro-shading-web/
@MaximeHeckel
The Art of Dithering and Retro Shading for the Web
Home
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.
Sources
* An icon representing an arrow
Color Quantization and Dithering a great video from @Acerola_t
* An icon representing an arrow
Dithering on the GPU from Alex Charlton which features a nice
deep dive into alternative color quantization techniques
* An icon representing an arrow
GM Shaders Mini: CRT from @XorDev which deep dives into many
shader techniques to mimic some specific aspects of CRTs
Support my work
Enjoying the content and feeling like supporting my work? You can
show your appreciation by buying me a coffee which will give me the
much-needed energy to take on more ambitious/high-quality articles
and projects.Thank you for reading!
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:
* An 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.
* An icon representing an arrow
@loackme's art, which I'm an absolute fan of.
* An 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_
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.
Pass VS Effect
Post-processing passes and effects are tools to reach the same goal:
alter the final render of the scene by applying a custom layer of
post-processing. However, they work very differently:
* An icon representing an arrow
Passes can be stacked on top of one another and will take as
input the output of the previous pass alter it and send the
result to the following pass, applying custom shaders one at a
time.
* An icon representing an arrow
Effects, however, get merged into a single Effect Pass which can
improve performances.
See Effect Merging for more information
To create a custom effect, we will:
1. An icon representing an arrow
Declare a class that extends from Effect
2. An icon representing an arrow
Define our fragment shader and call it from the parent
constructor using the super keyword.
3. An icon representing an arrow
Define the set of uniforms we will need for our effect.
4. An 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
25
26
27
28
29
30
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:
* An icon representing an arrow
Look at the luminance of each pixel.
* An icon representing an arrow
Compare it to a random number (hence the "white noise" in the
name).
* An 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:
[ ]Enable Dithering
An icon representing the letter 'i' in a circle
You will notice throughout this article that the syntax for the
fragment shader of our effect differs from what you may be used to
when working with passes or materials.
Here, you'll find no main but instead a mainImage function which
contains the inputColor and uv coordinates the current pixel. No need
to set the gl_FragColor value at the end, that is taken care of by
the outputColor variable that you simply need to set for your effect
to be applied on top of your scene.
On top of that, effects come with a series of preset variables that
can come very handy such as:
* An icon representing an arrow
inputBuffer: a texture of the underlying scene
* An icon representing an arrow
time: the current time
* An icon representing an arrow
resolution: the width/height of your window
You can find more information about it in the Custom Effect
documentation of pmndrs/postprocessing.
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.
Bayer Matrix
The generic formula of the Bayer Matrix is
M(2n) = 1/(2n)^2 * [[(2n)^2 * M(n), (2n)^2 * M(n) + 2],[(2n)^2 * M(n)
+ 3, (2n)^2 * M(n) + 1]]
However, in many examples I found online, developers tended to define
those matrixes pre-computed. We will do the same in all the examples
introduced in this article by using the following definitions:
* An icon representing an arrow
2 x 2: 1/4 * [[0.0, 2.0,], [3.0, 1.0]]
* An icon representing an arrow
4 x 4: 1/16 * [[0.0, 8.0, 2.0, 10.0], [12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0], [15.0, 7.0, 13.0, 5.0]]
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
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:
[ ]Enable Dithering
Bayer Matrix size
( )2x2
(*)4x4
( )8x8
As you can see through the examples I showcased above, we get some
pretty distinct dithering patterns based on
* An icon representing an arrow
the shades of gray used in the underlying pixel grid
* An icon representing an arrow
the size of the Bayer Matrix used to get the threshold value
Before
After
An icon representing an arrowAn 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:
* An icon representing an arrow
if the difference between those values is positive, the pixel is
white
* An 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:
An icon representing the letter 'i' in a circle
Notice how:
* An icon representing an arrow
Changing the size of the Bayer matrix used to obtain the
threshold value of a given pixel influences the effect's output.
* An icon representing an arrow
A larger Bayer matrix gives us a more refined dithering effect.
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.
Don't forget to set your texture wrapping properties after loading
the blue noise texture so it wraps both horizontally and vertically:
1
const texture = useTexture('/path/to/my/texture');
2
3
texture.wrapS = THREE.RepeatWrapping;
4
texture.wrapT = THREE.RepeatWrapping;
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.
An icon representing the letter 'i' in a circle
There are many other dithering techniques that I wanted to explore
and detail in this article. Unfortunately some of the most
interesting ones like error diffusion or Floyd-Steinberg dithering
are not fragment shader friendly due to their sequential nature.
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:
* An icon representing an arrow
We calculated our dithering threshold based on the pixel
luminance, thus relying on a grayscale version of our scene.
* An 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:
* An icon representing an arrow
vec3(0.0) for the color vec3(0.3) i.e. black
* An 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
}
An icon representing the letter 'i' in a circle
Notice that when increasing the number of colors in our demo scene
above, we get more shades of gray and dithering patterns for each of
those shades.
Before
After
An icon representing an arrowAn 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:
* An 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
* An 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
}
[ ]Enable Dithering
Number of colors:
4
[2 ]
This quantization technique lets us approximate the look and feel of
older graphics. We can obtain color palettes that are more in line
with what computers and consoles could output back in the day.
Custom color palettes
We now know a technique to reduce the number of colors in our final
render to an arbitrary number, but what about reducing it to an
arbitrary set of colors?
In his video on quantization and dithering, @Acerola_t introduces a
technique to do just that by using the value of a grayscale color
palette to sample a texture defining a custom color palette.
For example, if our last grayscale example scene from earlier sets
the color number to four, we will get the following grayscale values:
Diagram showcasing how to use the grayscale values to sample a
texture containing the same amount of colors
Diagram showcasing how to use the grayscale values to sample a
texture containing the same amount of colors
These values correspond to the horizontal values of the UV
coordinates of our color palette texture, thus letting us use those
values to sample the texture and get the custom colors from it:
Using quantization grayscale value to sample custom color palette
texture"
1
vec3 {13} 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 * 0.2;
9
color.r = floor(color.r * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
10
color.g = floor(color.g * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
11
color.b = floor(color.b * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
12
13
vec3 paletteColor = texture2D(palette, vec2(color.r)).rgb;
14
15
return paletteColor;
16
}
If we were to apply that technique to the previous scene, this is
what the final output would look like:
I'd encourage you to fork this demo and try with:
* An icon representing an arrow
different textures
* An icon representing an arrow
different number of colors
The only thing to pay attention to is to keep the number of color
blocks in your palette texture the same as the number of color you
set in your custom effect.
Hue-lightness-based color quantization
In his article Dithering on the GPU Alex Charlton introduces an
alternative color quantization technique. Instead of using the
quantization formula we introduced at the beginning of this section,
he relies on the hue of a color to find its closest neighboring
colors from an arbitrary palette and the lightness of those colors to
obtain the ordered dithering pattern.
To do so, he proceeds as follows:
1. An icon representing an arrow
For each pixel, convert the color to HSL (Hue Saturation
Lightness).
2. An icon representing an arrow
Find its two closest neighbors in "hue" from the arbitrary color
palette defined statically or provided via a uniform.
3. An icon representing an arrow
Get the distance between the pixel and its closest color over the
distance between the previously obtained colors.
4. An icon representing an arrow
Compare this distance with the threshold value from the Bayer
Matrix and, based on the result pick the first or second closest
color.
5. An icon representing an arrow
Get the distance between the two closest lightness that match the
original pixel's color.
6. An icon representing an arrow
Compare this distance with the threshold value from the Bayer
Matrix and, based on the result pick the first or second
lightness to set in the final color.
I vividly recommend taking the time to read the full article as it
goes in-depth into an original and more artistic dithering process.
Below you'll find the demo I re-implemented from the process
showcased in the article. It also features some of the missing
functions the author did not include in their post.
Pixelization
We now know how to:
* An icon representing an arrow
Reduce the number of colors of a given scene to a specific number
or an arbitrary color palette.
* An icon representing an arrow
Use dithering to get back some of the details of the scene in the
form of a dithering pattern like shadows or color gradients.
In this section, we will look at some techniques to downsample the
final output of our scene to get a more pixelated look and see our
dithering and quantization process shine at lower resolutions.
The key to getting a pixelated version of our original scene is to
remap the UV coordinate system used in our effect shader and snap it
to a grid so, once sampled, the texture from our input buffer appears
as if it were at a lower resolution.
Pixelating a texture in GLSL
1
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
2
vec2 normalizedPixelSize = pixelSize / resolution;
3
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
4
5
vec4 color = texture2D(inputBuffer, uvPixel);
6
color.rgb = dither(uvPixel, color.rgb);
7
8
outputColor = color;
9
}
In the code snippet above:
* An icon representing an arrow
We define the size of our pixel via the pixelSize uniform.
* An icon representing an arrow
We divide this value by the resolution of our scene to convert it
to a normalized texture coordinate ranging from 0 to 1.
* An icon representing an arrow
We snap the UV coordinate to the grid defined by uv/
normalizedPixelSize using the floor function.
* An icon representing an arrow
We rescale the snapped UV coordinates to the original UV space by
multiplying the result by normalizedPixelSize.
The demo below adds this process along with an additional uniform for
you to tweak the pixel size used in our scene:
Notice how the higher the value of pixelSize is, the more pixelated
our scene becomes.
On top of that, we can see that our dithering pattern becomes less
and less visible as the pixel size increases: there are not enough
pixels in the final output to get any pattern at all. We'll have to
strike the right balance between dithering pattern and pixel size to
get the most compelling effect.
With this added to our effect, we have all the ingredients to produce
gorgeous pixel art pieces or pixelated 3D scenes! One thing I
immediately tried upon wrapping up this post-processing effect was to
try it on top of some of my earlier shader work, like in my scene
titled Dithering Waves where I applied a grayscale version of it on
top of a simple scene rendering a domain wrapping texture using
Fractal Brownian Motion, inspired by Inigo Quilez' article on the
matter.
Explorations
I also used this scene to try using a custom texture to define an
arbitrary color palette and also switch to the blue noise dithering
technique for a less repetitive output.
To experiment with contrasting aesthetics, I also wanted to try out
ordered dithering and color quantization on some of my Raymarching
work from last year. The ordered pattern contrasts nicely with the
more organic nature of some of those scenes, especially at lower
resolutions. Below are two examples that I particularily enjoyed:
Cathode-Ray Tube effect
While pixelization brings us one step closer to an accurate retro
post-processing effect, there is one essential aspect of this effect
that's missing: emulating the look of a good ol' CRT monitor.
CRTs work differently than our current displays. Thus, the best we
can do here is to approach the look and feel of those old monitors by
stacking a series of effects in our custom shader effect. The first
and most fundamental effect that we'll work on, that also highlights
the inner workings of CRTs, is the RGB cell pattern from the display.
Credit
This first part includes some of the work of @XorDev who dedicated
one of his mini-shader tutorials to fully emulating CRTs: GM Shaders
Mini: CRT.
I also highly recommend the newsletter as a must-read. It taught me
many techniques that I use in my side projects.
RGB Cells
First and foremost, let's look at how CRT displays work so we can
reproduce the effect as accurately as possible. Those monitors have 3
electron guns for each of the color channels (red, green, and blue)
that run across the screen and excite their corresponding phosphors,
which in return emit light to form an image. To prevent those beams
from hitting the wrong phosphor dots and causing color issues on the
final image, CRTs use a shadow mask with a metal plate made of tiny
holes. They can have many different configurations which yield
different mask types.
In this section, we'll attempt to emulate the Schiltzmaske: an
aperture grill where each column is staggered by half a cell height.
Diagram showcasing the staggered RGB cells shadow mask effect on a
sample scene
Diagram showcasing the staggered RGB cells shadow mask effect on a
sample scene
This implementation could also allow us to try to get another mask
type called Streifermaske, which is similar except that it does not
feature the staggered cells and only features a mask on its column.
The implementation of such a "pixel pattern" in GLSL goes as follows:
* An icon representing an arrow
We first need to define our RGB cells and their subcells: a slot
for each red, green, and blue channel.
1
vec2 pixel = uv * resolution;
2
vec2 coord = pixel / pixelSize;
3
vec2 subcoord = coord * vec2(3,1);
4
vec2 cellOffset = vec2(0, mod(floor(coord.x), 3.0) * 0.5);
* An icon representing an arrow
We create a cell "offset" for every two cells: some will have an
offset of vec2(0.0) and some of vec2(0.0, 0.5), thus creating the
vertical staggered effect of our shadow mask.
1
vec2 cellOffset = vec2(0, mod(floor(coord.x), 3.0) * 0.5);
* An icon representing an arrow
We pick which subcell the current pixel belongs to and output the
corresponding subcell color based on the subcell index.
1
float ind = mod(floor(subcoord.x), 3.0);
2
vec3 maskColor = vec3(ind == 0.0, ind == 1.0, ind == 2.0) * 2.0;
* An icon representing an arrow
We now need to draw the borders of our masks. The first step is
to create a set of UV coordinates for each subcell, then make a
border vector that gets higher values in the edges of each
subcell and lower towards the center. Finally, we blend the
result with the current mask color, thus creating a colored
subcell with a border.
1
vec2 cellUv = fract(subcoord + cellOffset) * 2.0 - 1.0;
2
vec2 border = 1.0 - cellUv * cellUv * MASK_BORDER;
3
maskColor.rgb *= border.x * border.y;
* An icon representing an arrow
The last step is to create a rgbCellUV vector that we can use to
sample the input buffer of our underlying scene and map it in
this new CRT RGB Cell coordinate system.
1
vec2 rgbCellUV = floor(coord+cellOffset) * pixelSize / resolution;
This results in a more accurate look and feel for a retro
post-processing effect. Those are the types of details that can make
the whole difference when making a 3D scene for the web.
Before
After
An icon representing an arrowAn icon representing an arrow
Pixelization VS RGB Cell shadow mask effect. Notice how the output
appears slightly sharper in the RGB Cell variant.
The demo below fully implements the process highlighted above, which
yields a beautiful and soft CRT effect. Setting a high pixel size
will also allow you to admire our subcells at work, lighting up
individually based on the color of the underlying scene.
blending
Notice that the output can yield some strange colors or even lack
some luminosity. To alleviate the issue, we can:
1. An icon representing an arrow
Increase the intensity of the mask color by multiplying it by an
arbitrary factor >1. However, if abused, it can lead to the image
appearing washed out white.
2. An icon representing an arrow
Blend the mask color with the colors of the underlying scene and
tweak the mask intensity via a constant or a uniform.
1
color.rgb *= 1.0 + (maskColor - 1.0) * maskIntensity;
You can toggle the blending on/off in the demo above and play with
the mask intensity variable to see how it impacts the resulting
scene.
Curving the display
CRTs were not flat like their LCD counterparts. Instead, they
featured some slight curvature which we can emulate with a few lines
of GLSL. To do so, much like we just did for our RGB cells shadow
mask, we can remap our UV coordinates to introduce some curvature:
Modifying our UVs to introduce curvature to our effect
1
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
2
vec2 curveUV = uv * 2.0 - 1.0;
3
vec2 offset = curveUV.yx * curve;
4
curveUV += curveUV * offset * offset;
5
curveUV = curveUV * 0.5 + 0.5;
6
7
//...
8
}
In the code snippet above:
1. An icon representing an arrow
We convert our UV coordinates range to a new range of [-1, 1] as
we want the curvature to be centered relative to the screen.
2. An icon representing an arrow
Swapping the x and y components of our UV coordinates and
multiplying by a CURVE_INTENSITY variable lets us define the
strength/offset of our curvature. In this case, the offset will
be stronger at the corners and not as strong as we reach the
center of both the x and y-axis.
3. An icon representing an arrow
Finally, we want our curvature to be quadratic, i.e. stronger the
closer we get to the fringes of the screen. We then convert the
resulting UV coordinates back to a range of [0, 1] allowing us to
use it in our shader as the base UVs.
Using those new UV coordinates, we can draw the edges of our CRT
using smoothstep:
Drawing edges of our curved CRT using curveUV
1
vec2 edge = smoothstep(0., 0.02, curveUV)*(1.-smoothstep(1.-0.02, 1., curveUV));
2
color.rgb *= edge.x * edge.y;
This gives us black curved edges on the top, bottom, left, and right
sides of the display. The demo below implements this curvature
element to the effect we've been iterating on since the beginning of
this blog post and also lets you tweak the curvature intensity from 0
(flat) to 0.5 (realistically curved, anything higher than that is
"too much").
An icon representing the letter 'i' in a circle
You can also choose to use the curveUV when creating our RGB cells
shadow mask, however, this may reveal some unwanted artifacts due to
the cell curving especially at a low pixel size.
Screenshot showcasing artifacts introduced when using the curveUV
coordinates as base uv for our RGB Cell shadow mask
Screenshot showcasing artifacts introduced when using the curveUV
coordinates as base uv for our RGB Cell shadow mask
Scanlines, distortion, and final touches
In this last part, I wanted to walk you through some of the final
touches I added to my own retro post-processing effect to make it as
accurate as possible.
One of the first tweaks I felt was necessary was to add some slight
chromatic aberration when sampling the input buffer of our effect,
which we can find the code of in Refraction, dispersion, and other
shader light effects. Due to the screen curvature and some imperfect
alignments of the electron beams of the CRT, it was frequent that
color channels would appear slightly offset, yielding a slightly
blurred image.
Arbitrary chromatic aberration applied to our effect
1
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
2
3
//...
4
vec4 color = vec4(1.0);
5
color.r = texture2D(inputBuffer, rgbCellUV + SPREAD).r;
6
color.g = texture2D(inputBuffer, rgbCellUV).g;
7
color.b = texture2D(inputBuffer, rgbCellUV - SPREAD).b;
8
9
color.rgb = dither(rgbCellUV, color.rgb);
10
//...
11
}
Moreover, due to the inner workings of CRTs we highlighted earlier,
some Bloom may also occur. We can consider this using the Bloom
component from @react-three/postprocessing.
Adding Bloom within our EffectComposer
1
const Retro = () => {
2
const spaceship = useRef();
3
const effect = useRef();
4
5
return (
6
<>
7
8
9
10
11
12
17
18
>
19
);
20
};
Finally, we can overlay the final output of our custom shader effect
with horizontal scanlines running through the screen vertically:
Simple scanlines added on top of our effect output
1
float lines = sin(uv.y * 2000.0 + time * 100.0);
2
color *= lines + 1.0;
We can also include some additional distortion to the UV coordinates
of our effect by adding code to the mainUv function of our shader.
Adding some slight imperfections like these can make our CRT look
even more accurate. Below is a simple example, but feel free to
further experiment with more complex distortion patterns:
Adding distortion in mainUv
1
float noise (in vec2 st) {
2
vec2 i = floor(st);
3
vec2 f = fract(st);
4
5
float a = random(i);
6
float b = random(i + vec2(1.0, 0.0));
7
float c = random(i + vec2(0.0, 1.0));
8
float d = random(i + vec2(1.0, 1.0));
9
10
vec2 u = f*f*(3.0-2.0*f);
11
12
return mix(a, b, u.x) +
13
(cfloat noise (in vec2 st) {
14
vec2 i = floor(st);
15
vec2 f = fract(st);
16
17
float a = random(i);
18
float b = random(i + vec2(1.0, 0.0));
19
float c = random(i + vec2(0.0, 1.0));
20
float d = random(i + vec2(1.0, 1.0));
21
22
vec2 u = f*f*(3.0-2.0*f);
23
24
return mix(a, b, u.x) +
25
(c - a)* u.y * (1.0 - u.x) +
26
(d - b) * u.x * u.y;
27
}
28
29
void mainUv(inout vec2 uv) {
30
float shake = (noise(vec2(uv.y) * sin(time * 400.0) * 100.0) - 0.5) * 0.0025;
31
uv.x += shake * 1.5;
32
} - a)* u.y * (1.0 - u.x) +
33
(d - b) * u.x * u.y;
34
}
35
36
void mainUv(inout vec2 uv) {
37
float shake = (noise(vec2(uv.y) * sin(time * 400.0) * 100.0) - 0.5) * 0.0025;
38
uv.x += shake * 1.5;
39
}
mainUv
In the context of an effect, the mainUv function lets us modify the
input UV coordinates that will then be passed to the mainImage
function.
You can find more information about it in the Custom Effect
documentation of pmndrs/postprocessing.
We're finally done! Or at least I was satisfied enough to make this
the stopping point of this article, you can still experiment and
continue to tweak this shader at your heart's content! You can admire
the final version of our retro post-processing effect below, which
includes:
* An icon representing an arrow
Color quantization to reduce the number of colors in our final
output.
* An icon representing an arrow
Pixelization and RGB Cell shadow mask effect to create realistic
downsampling typically visible in old CRT displays.
* An icon representing an arrow
Ordered Dithering which alleviates the low pixel and color count
in the output image and gives us back some details of the
underlying scene in the form of pixel patterns.
* An icon representing an arrow
Screen Curvature, scanlines with distortions, bloom, and
chromatic aberration as final touches to make our effect pop.
Conclusion
Through the many examples and techniques we covered, I hope this
article demonstrated how powerful custom shader effects are and how
transformative they can be when applied on top of your own WebGL/3D
work. This particular "Retro" effect is, of course, just a particular
case of a beautiful effect you can build, a drop in the ocean of
what's possible. The underlying dithering, quantization, and
pixelization/RGB Cells that we learned about are applicable on their
own as well as infinitely tweakable to make your work stand out in
the ever-growing scene of 3D websites and digital art.
I'm looking forward to seeing what you will come up with now that you
know pretty much everything I do on the matter (there are no excuses
not to build!), there's a lot you can create by combining the effect
and building blocks we've just seen. Meanwhile, I'll keep working
through my endless list of shader techniques to study, and the many
other post-processing effects and styles I'm trying to mimic. As
always, I'll report back on my findings .
Liked this article? Share it with a friend on Bluesky or Twitter or
support me to take on more ambitious projects to write about. Have a
question, feedback or simply wish to contact me privately? Shoot me a
DM and I'll do my best to get back to you.
Have a wonderful day.
- Maxime
2024-08-06T08:00:00.000+01:00
A detailed walkthrough of the inner workings of dithering and other
shading techniques to give a retro look and feel to your WebGL and
React Three Fiber work.
HomeDesignRSS
BlueskyTwitterGithub
PortfolioBuy me a coffeeRoadmap
(c) 2025 Maxime Heckel ---- New York