https://blog.maximeheckel.com/posts/post-processing-as-a-creative-medium/
@MaximeHeckel
Post-Processing Shaders as a Creative Medium
Home
Post-Processing Shaders as a Creative Medium
February 4, 2025 / 33 min read
Last Updated: February 4, 2025
Spending the better part of 2024 learning new shader techniques and
concepts through the lens of post-processing has been the spark I
needed to come up with ever more intricate, detailed, and ambitious
creative work. Not only do I now know the inner workings of specific
styles like Moebius, Painting, or Retro, but it also got me to a
point where I strengthened the many shader mental models I built
along the years enough to experiment with new styles that I see
online or that I envision in my mind.
That led me on a sort of creative spree for the past few months that
originated from many creative developers, artists, and designers on
Bluesky and Twitter such as: @dghez_, Polygon1993, @darkroomdevs,
@hahajohnx, @samdape, 27-b (and many others sources listed throughout
this article), whose works have, quite frankly, nerd-sniped me into
reproducing or expanding their ideas into stylized shaders. Whether
those featured complex pixelated patterns, trompe l'oeil, optical
illusions, or surprising interactions, transcribing their art styles
into shaders allowed me to not only sharpen my shader skills but also
experiment with combining those effects in unique ways.
Through this process, I collected a few new shading tricks in my
toolbox to share with you and, more importantly, new ways to reuse
what I had learned in previous years in a new context. This is the
reason why I wanted to write this article. In it, you'll see the many
sources of inspiration that led to those beautiful post-processing
effects, my train of thought to re-implement them, and the full-on
recipe behind them so you can reproduce them, expand them, or simply
get inspired to create your own.
Models
I relied on many 3D models for the many post-processing effects I
built, and some of them are featured in the demos of this article.
Among them are:
* An icon representing an arrow
Venus de Milo by SMK - National Gallery of Denmark
* An icon representing an arrow
The Creation of Adam by Loic Norgeot
* An icon representing an arrow
Teenage Engineering TP-7 by zachernuk
* An icon representing an arrow
Rusty Orange Spaceship by Sousinho
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!
Intricate Pixel Patterns
I already explored pixelation in The Art of Dithering and Retro
Shading for the Web where I introduced it alongside color
quantization and dithering as it was a necessary effect to achieve a
"retro style" akin to old video games on CRT displays.
However, this time I want to go beyond that and show you some of the
many effects you can craft with this technique by literally sculpting
pixels and creating intricate and elaborate patterns.
Pixelating your scene
As a reminder, let's re-examine the code that I used and still use to
this day for all my pixelation work:
Sampling and pixelating a texture
1
vec2 normalizedPixelSize = pixelSize / resolution;
2
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
3
4
vec4 color = texture2D(inputBuffer, uvPixel);
1. An icon representing an arrow
We first define a pixelSize as the number of pixels in height or
width we want in a single "new pixel" for our final render/
sampling. I tend to keep those as powers of 2: 1, 2, 4, 8, 16,
...
2. An icon representing an arrow
We then normalize the pixelSize based on the resolution, which
gives us the size of a single pixel in "UV coordinates" (ranging
[(0, 0), (1, 1)]). This is necessary to keep our "new pixels"
square no matter the window size.
3. An icon representing an arrow
We define a grid of cells by dividing our UV coordinates by the
normalizedPixelSize.
4. An icon representing an arrow
Adding the floor creates the block effect: the UV coordinates no
longer vary smoothly across the screen but instead jump between
fixed points.
5. An icon representing an arrow
Finally, we multiply by the normalizedPixelSize to convert the
grid of cells to UV coordinates.
6. An icon representing an arrow
We can then use our newly mapped UV coordinates to sample our
texture.
If this formula still feels overwhelming, the best is to break it
down with an example:
Breakdown of pixelation code
1
Example: If we have:
2
resolution = vec2(800, 600) // Screen size in pixels
3
pixelSize = vec2(8, 8) // We want 8x8 pixel blocks
4
uv = vec2(0.374, 0.567) // Current texture coordinate
5
6
1. Calculate the size of each pixel block in normalized coordinates (0 to 1)
7
normalizedPixelSize = (8, 8) / (800, 600) = (0.01, 0.0133)
8
9
10
2. Snap the UV coordinate to the nearest pixel block grid
11
floor(uv / normalizedPixelSize) = floor((0.374, 0.567) / (0.01, 0.0133))
12
= floor((37.4, 42.6)) = (37, 42)
13
14
Then multiply back by normalizedPixelSize
15
uvPixel = (0.37, 0.559)
I also built the widget below for you to see what happens when you
apply this code to a texture/scene:
0.0,1.0
1.0,1.0
0.0,0.0
1.0,0.0
Pixelation:
1 x 1
[1 ]
An icon representing the letter 'i' in a circle
We can see that what this pixelation effect accomplishes can be
summarized as remapping the UV coordinates used to sample the
inputBuffer of said effect.
Moreover, each new pixel, or cell, now contains many pixels within
them that we can leverage to create anything that we want
(foreshadowing )
Shaping pixels
We could leave our pixelated output as such, but squares quickly get
boring. Moreover, our cells have a lot of pixels we can use to draw
interesting patterns, or even better, sculpt any shape we may want.
I saw this very cool Japanese receipt website made by @samdape last
month, and I liked it so much that I recreated this pattern as a
post-processing effect. I not only wanted it to work on top of
everything and be dynamic but also thought it would be a great first
example for this article, as it's an easy effect to break down with a
lovely/original output.
[]samdapesamdapesam@samdape
saw this japanese receipt laying around & turned it into my
website https://t.co/TfdoXlNIb9 from idea to live in ~3h with
@figma & @v0 https://t.co/lHniV7Lezo
[]saw this japanese receipt laying around & turned it into my
website https://t.co/TfdoXlNIb9 from idea to live in ~3h with @figma
& @v0 https://t.co/lHniV7Lezosaw this japanese receipt laying
around & turned it into my website https://t.co/TfdoXlNIb9 from
idea to live in ~3h with @figma & @v0 https://t.co/lHniV7Lezo
[]saw this japanese receipt laying around & turned it into my
website https://t.co/TfdoXlNIb9 from idea to live in ~3h with @figma
& @v0 https://t.co/lHniV7Lezosaw this japanese receipt laying
around & turned it into my website https://t.co/TfdoXlNIb9 from
idea to live in ~3h with @figma & @v0 https://t.co/lHniV7Lezo
12:07 PM - Dec 5, 2024
852998
Here is how I interpreted this effect by just looking at the
screenshots of Sam's website:
* An icon representing an arrow
We have a pretty blocky-looking output, so we'll need to pixelate
our inputBuffer by quite a bit.
* An icon representing an arrow
Each cell is composed of horizontal black bars.
* An icon representing an arrow
The darker the area, the longer the bar.
* An icon representing an arrow
The lighter the area, the shorter the bar (or no bar).
With this in mind, we can leverage the pixelation formula we
dissected just above and the luma of a given pixel to build this
receipt effect.
Diagram showcasing the bar pattern rendered on each cell based on the
luma
Diagram showcasing the bar pattern rendered on each cell based on the
luma
Receipt Bar fragment shader
1
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
2
vec2 normalizedPixelSize = pixelSize / resolution;
3
float rowIndex = floor(uv.x / normalizedPixelSize.x);
4
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
5
6
vec4 color = texture2D(inputBuffer, uvPixel);
7
8
float luma = dot(vec3(0.2126, 0.7152, 0.0722), color.rgb);
9
10
vec2 cellUV = fract(uv / normalizedPixelSize);
11
12
float lineWidth = 0.0;
13
14
if (luma > 0.0) {
15
lineWidth = 1.0;
16
}
17
18
if (luma > 0.3) {
19
lineWidth = 0.7;
20
}
21
22
if (luma > 0.5) {
23
lineWidth = 0.5;
24
}
25
26
if (luma > 0.7) {
27
lineWidth = 0.3;
28
}
29
30
if (luma > 0.9) {
31
lineWidth = 0.1;
32
}
33
34
if (luma > 0.99) {
35
lineWidth = 0.0;
36
}
37
38
float yStart = 0.05;
39
float yEnd = 0.95;
40
41
if (cellUV.y > yStart && cellUV.y < yEnd && cellUV.x > 0.0 && cellUV.x < lineWidth) {
42
color = vec4(0.0, 0.0, 0.0, 1.0);
43
} else {
44
color = vec4(0.70,0.74,0.73, 1.0);
45
}
46
outputColor = color;
47
}
One thing that we should highlight in the code snippet above is this
line:
1
vec2 cellUV = fract(uv / normalizedPixelSize);
It returns the cellUV coordinates ranging again from [(0, 0), (1,
1)], giving us the relative position of a given pixel within each
cell. This is the "magic line" that allows us to step inside each
cell and start shaping and sculpting them the way we want. In this
specific case, we are conditionally turning the pixels within each
cells black or white based on the lineWidth that's defined through
the luma of the pixelated texture.
We will use similar techniques to define many patterns, some of which
are depicted in the widget below which uses a similar principle to
display those features:
[ ]Enable Patterns
[Squares ]
0.0,1.0
1.0,1.0
0.0,0.0
1.0,0.0
Thanks to both the pixelation formula and this use of the fract glsl
function, we established what is to me the two main pillars that
we're going to keep encountering in most post-processing shaders:
1. An icon representing an arrow
Remapping or distorting the UV coordinates
2. An icon representing an arrow
Shape, sculpt, or tweak each cell individually to create a
pattern
Both of these points are at work in the receipt effect in the demo
below:
We can port this example to render a completely different effect
while keeping approximately 90% of the code above unchanged. This
time, let's try to build this dotted/halftone pattern used in this
picture that I saw on Twitter a few months ago from once again
@samdape:
[]samdapesamdapesam@samdape
create https://t.co/gyeq67FQHq
[]create https://t.co/gyeq67FQHqcreate https://t.co/gyeq67FQHq
1:01 PM - Nov 22, 2024
7611468
The principle is the same, but with a few notable differences:
* An icon representing an arrow
We render circles in each cell
* An icon representing an arrow
For cells with luma above a certain threshold, we render a wide
white circle centered in the middle of the cell
* An icon representing an arrow
For the rest, a smaller circle centered this time in the bottom
left corner of the cell
Finally, as our final example for this section, why not try to
rebuild Three.js' ASCII effect? This time, instead of creating our
pattern within our fragment shader code, we will get it from an
external texture containing all the characters for our ASCII palette
where each character maps to a given cell luma:
Diagram showcasing a simplified version of the ASCII texture passed
as an argument of the ASCII effect. It also breaks down the UV
coordinates used to sample each character individually.
Diagram showcasing a simplified version of the ASCII texture passed
as an argument of the ASCII effect. It also breaks down the UV
coordinates used to sample each character individually.
As for the implementation, we can create the texture of ASCII
characters within our React Three Fiber code.
Creating our ASCII palette within our React Three Fiber scene
1
//...
2
3
const ASCII_CHARS = './nohameramaMu ';
4
5
const ASCIIEffect = () => {
6
7
//...
8
9
useEffect(() => {
10
const CHAR_SIZE = pixelSize;
11
const canvas = document.createElement('canvas');
12
const ctx = canvas.getContext('2d');
13
14
canvas.width = CHAR_SIZE * asciiChars.length;
15
canvas.height = CHAR_SIZE;
16
17
ctx.fillStyle = 'black';
18
ctx.fillRect(0, 0, canvas.width, canvas.height);
19
20
ctx.fillStyle = 'white';
21
ctx.font = \`\${CHAR_SIZE}px \${fontFamily}\`;
22
ctx.textBaseline = 'middle';
23
ctx.textAlign = 'center';
24
25
asciiChars.split('').forEach((char, i) => {
26
ctx.fillText(char, (i + 0.5) * CHAR_SIZE, CHAR_SIZE / 2);
27
});
28
29
const texture = new THREE.CanvasTexture(canvas);
30
texture.minFilter = THREE.NearestFilter;
31
texture.magFilter = THREE.NearestFilter;
32
33
if (effectRef.current) {
34
effectRef.current.asciiTexture = texture;
35
effectRef.current.charCount = [asciiChars.length, 1];
36
effectRef.current.charSize = CHAR_SIZE;
37
}
38
}, [pixelSize, asciiChars]);
39
40
//...
41
}
And then translate the luma of a given cell to an ASCII character by:
* An icon representing an arrow
converting the brightness (luma) to a character index
* An icon representing an arrow
creating UV coordinates from this index to sample the ASCII
texture
Sampling ASCII character from the ASCII texture
1
float charIndex = clamp(
2
floor(luma * (charCount.x - 1.0)),
3
0.0,
4
charCount.x - 1.0
5
);
6
7
vec2 asciiUV = vec2(
8
(charIndex + cellUV.x) / charCount.x,
9
cellUV.y
10
);
11
12
float character = texture2D(asciiTexture, asciiUV).r;
On top of that, we can get inspired by the talented folks at
@darkroomdevs, who, over the past few months, have shared a lot of
ASCII work:
[]darkroomdevsdarkroomdevsdarkroom.engineering@darkroomdevs
Pixelated or not ? https://t.co/iFLXOQRmKA
[]Pixelated or not ? https://t.co/iFLXOQRmKAPixelated or not ? https:
//t.co/iFLXOQRmKA
[]Pixelated or not ? https://t.co/iFLXOQRmKAPixelated or not ? https:
//t.co/iFLXOQRmKA
9:26 AM - Sep 6, 2024
549
The demo below implements their take on ASCII and lets you define the
characters you want to render in the effect.
This example is mainly here to show you that the source of your
pattern can be varied:
* An icon representing an arrow
defined within your fragment shader
* An icon representing an arrow
defined in a texture and sampled within your fragment shader
Complex pixel patterns
Now that we've covered the basics from the previous section, let's
explore more complex patterns.
This time, we'll be looking at some of John Provencher's (@hahajohnx)
artwork, which we can adapt to glsl in multiple ways, a few of which
involve concepts you may have seen in other contexts or other
articles of mine:
* An icon representing an arrow
Signed Distance Functions (SDFs)
* An icon representing an arrow
Threshold Matrices
[]hahajohnxhahajohnxjohn provencher@hahajohnx
https://t.co/JujtrRbeTl
[]https://t.co/JujtrRbeTlhttps://t.co/JujtrRbeTl
6:05 PM - Oct 4, 2024
61791
Like the previous demos, we can base the pixel pattern featured in
John Provencher's work on the luma of a given cell:
Diagram showcasing the Provencher style circle pattern rendered on
each cell based on the luma
Diagram showcasing the Provencher style circle pattern rendered on
each cell based on the luma
This time, let's use signed distance functions--specifically, the SDF
of a circle--to reproduce the pattern:
Leveraging SDFs to render patterns within our cells
1
float circleSDF(vec2 p) {
2
return length(p - 0.5);
3
}
4
5
// ...
6
7
float d = circleSDF(cellUV);
8
9
if (luma > 0.2) {
10
if (d < 0.3) {
11
color = vec4(0.0,0.31,0.933,1.0);
12
} else {
13
color = vec4(1.0,1.0,1.0,1.0);
14
}
15
}
16
17
if(luma > 0.75) {
18
if(d < 0.3) {
19
color = vec4(1.0,1.0,1.0,1.0);
20
} else {
21
color = vec4(0.0,0.31,0.933,1.0);
22
}
23
}
An icon representing the letter 'i' in a circle
Wondering what Raymarching or SDFs are? I covered all these topics in
Painting with Math: A Gentle Study of Raymarching.
This way of defining pixel patterns allows for a large set of
patterns. With enough pixels to work within a cell, we can display
any shape with an equivalent 2D SDF. You can try it yourself in the
demo below, where I defined several extra functions like crossSDF or
triangleSDF:
If you want to define patterns that are impossible to write via an
SDF or simply enjoy the elegance of having a single matrix to render
all the patterns you need: you may be interested in leveraging custom
threshold matrices for your effect.
Akin to dithering, this method lets you define the luma thresholds
within one or multiple matrices, which we can then use to compare
with the luma of the pixel within a given cell. If the pixel's luma
is above the threshold value defined in the matrix, it's turned on;
otherwise, it remains turned off.
Dithering
I covered the concept of threshold matrix in my article on Dithering
in case you need a refresher.
The widget below lets you visualize this principle and edit the
values defined within the threshold matrix to create any pattern you
want.
[0.75 ]
[1 ]
[1 ]
[0.75 ]
[1 ]
[0.25 ]
[0.5 ]
[1 ]
[1 ]
[0.5 ]
[0.25 ]
[1 ]
[0.75 ]
[1 ]
[1 ]
[0.75 ]
Luma:
0.5
[0.5 ]
Using this technique, we can expand upon Provencher's work and create
more patterns. Among those I enjoyed implementing were the following:
* An icon representing an arrow
stripes which uses two threshold matrices
* An icon representing an arrow
weave which creates a complex pattern with only a single
threshold matrix!
Defining custom threshold matrices to render different cell patterns
based on luma
1
if(pattern == 0) {
2
const float stripesMatrix[64] = float[64](
3
0.2, 1.0, 1.0, 0.2, 0.2, 1.0, 1.0, 0.2,
4
0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 1.0, 1.0,
5
1.0, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 1.0,
6
1.0, 1.0, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2,
7
0.2, 1.0, 1.0, 0.2, 0.2, 1.0, 1.0, 0.2,
8
0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 1.0, 1.0,
9
1.0, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 1.0,
10
1.0, 1.0, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2
11
);
12
13
const float crossStripeMatrix[64] = float[64](
14
1.0, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 1.0,
15
0.2, 1.0, 0.2, 0.2, 0.2, 0.2, 1.0, 0.2,
16
0.2, 0.2, 1.0, 0.2, 0.2, 1.0, 0.2, 0.2,
17
0.2, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 0.2,
18
0.2, 0.2, 0.2, 1.0, 1.0, 0.2, 0.2, 0.2,
19
0.2, 0.2, 1.0, 0.2, 0.2, 1.0, 0.2, 0.2,
20
0.2, 1.0, 0.2, 0.2, 0.2, 0.2, 1.0, 0.2,
21
1.0, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 1.0
22
);
23
24
int x = int(cellUV.x * 8.0);
25
int y = int(cellUV.y * 8.0);
26
int index = y * 8 + x;
27
28
if(luma < 0.6) {
29
color = (stripesMatrix[index] > luma) ? vec4(1.0) : vec4(0.0, 0.31, 0.933, 1.0);
30
} else {
31
color = (crossStripeMatrix[index] > luma) ? vec4(1.0) : vec4(0.0, 0.31, 0.933, 1.0);
32
}
33
}
34
35
if(pattern == 1) {
36
const float sineMatrix[64] = float[64](
37
0.99, 0.75, 0.2, 0.2, 0.2, 0.2, 0.99, 0.99,
38
0.99, 0.99, 0.75, 0.2, 0.2, 0.99, 0.99, 0.75,
39
0.2, 0.99, 0.99, 0.75, 0.99, 0.99, 0.2, 0.2,
40
0.2, 0.2, 0.99, 0.99, 0.99, 0.2, 0.2, 0.2,
41
0.2, 0.2, 0.2, 0.99, 0.99, 0.99, 0.2, 0.2,
42
0.2, 0.2, 0.99, 0.99, 0.75, 0.99, 0.99, 0.2,
43
0.75, 0.99, 0.99, 0.2, 0.2, 0.75, 0.99, 0.99,
44
0.99, 0.99, 0.2, 0.2, 0.2, 0.2, 0.75, 0.99
45
);
46
47
int x = int(cellUV.x * 8.0);
48
int y = int(cellUV.y * 8.0);
49
int index = y * 8 + x;
50
color = (sineMatrix[index] > luma) ? vec4(1.0) : vec4(0.0, 0.31, 0.933, 1.0);
51
}
We can see the result of those matrices yield in the demo below:
Trompe l'oeil and other optical illusions
Mimicking close to real life/physical textures and effects is my
favorite thing to achieve with post-processing. With a few simple
techniques, we can make our output appear to have depth, texture, and
lighting. Applied right, these techniques can transform our scenes,
making them appear as if they were made out of Legos or woven like
Crochet, to look like they are displayed on an LED panel or behind a
slick pane of frosted glass.
This section goes through the details behind a few of my favorite
post-processing effects that I came up with recently. We'll dissect
each technique used, and see how combining them and tweaking them the
right way can yield beautiful trompe l'oeil or optical illusion
effects running right in your browser.
Stagged LED cell panel
This section features the technique behind my Pixel Statue demo,
which aimed to mimic a staggered LED cell panel. I was originally
inspired by the many LED panels I saw during my recent trip to Japan,
whether used on trains or in public spaces to display ads.
The main trick for this scene is to stagger our cells, and eventually
pixels within our cells, to create a more elaborate LED cell matrix.
This is done by offsetting the UV coordinates before remapping them.
Staggering pixels
1
float maskStagger = 0.5;
2
3
vec2 normalizedPixelSize = pixelSize / resolution;
4
vec2 coord = uv/normalizedPixelSize;
5
6
float columnStagger = mod(floor(coord.x), 2.0) * maskStagger;
7
8
vec2 offsetUV = uv;
9
offsetUV.y += columnStagger * normalizedPixelSize.y;
10
11
vec2 uvPixel = normalizedPixelSize * floor(offsetUV / normalizedPixelSize);
This specific code adds an arbitrary vertical offset to every odd
column of cells.
Before
After
An icon representing an arrowAn icon representing an arrow
Comparing the simple pixelated output with the staggered one
Yet, we can push things further and introduce offsets at the
"sub-cell level" to create an even more intricate effect. We could,
for instance, split our cell into three sub-cells as we did for the
CRT effect in my Dithering article and stagger each of those
sub-cells.
Defining sub-pixels with offset
1
vec2 normalizedPixelSize = pixelSize / resolution;
2
vec2 coord = uv/normalizedPixelSize;
3
4
float columnStagger = mod(floor(coord.x), 2.0) * maskStagger;
5
6
vec2 subcoord = coord * vec2(3,1);
7
float subPixelIndex = mod(floor(subcoord.x), 3.0);
8
float subPixelStagger = subPixelIndex * maskStagger;
9
10
vec2 offsetUV = uv;
11
offsetUV.y += (columnStagger + subPixelStagger) * normalizedPixelSize.y;
Once we remap those staggered UVs, it will look as if you cut each of
the cells into three vertical thin slices that you can manipulate at
will.
Before
After
An icon representing an arrowAn icon representing an arrow
Comparing the pixelated staggered output with the sub-pixel staggered
one
We can also use this offset within each cell of our effect to
introduce the same staggered offset to any pattern we may want to
render within them. In this case, we want our LED cells to be
relatively visible, thus having a mask border around each sub-pixel
is necessary. The trick to adding this border is to create a
subCellUV vector by taking the fractional of the subcoord that ranges
from (-1, -1) to (1, 1) to create a symmetrical border that surrounds
the cell.
Drawing a black border around each staggered sub-cell
1
vec2 cellOffset = vec2(0.0, columnStagger + subPixelStagger);
2
vec2 subCellUV = fract(subcoord + cellOffset) * 2.0 - 1.0;
3
4
float mask = 1.0;
5
vec2 border = 1.0 - subCellUV * subCellUV * (MASK_BORDER - luma * 0.25);
6
mask *= border.x * border.y;
7
float maskStrength = smoothstep(0.0, 0.95, mask);
8
9
color += 0.005;
10
color.rgb *= 1.0 + (maskStrength - 1.0) * MASK_INTENSITY;
Finally, to polish this LED panel illusion, we can make the darker
pixels in the background somewhat visible by increasing all color
channels by a small amount.
An icon representing the letter 'i' in a circle
Notice how, as you rotate the model, the individual cells turn on/off
or get slightly lighter/dimmer like an actual monochrome LED panel.
Woven Crochet Effect
Speaking of staggered columns/rows and offsets, my crochet
post-processing effect uses a similar technique to create a more
organic look and feel for its knitted fabric illusion. This time,
however, the offset is applied to the cellUV coordinates themselves.
The inspiration behind this effect comes from a very neat Blender
plugin that I saw on Twitter way back in November 2024:
[]DoubleGum_DoubleGum_Rahul Parihar@DoubleGum_
Crochet Shader 2.0 #b3d #procedural https://t.co/kVeza2yJ9j
5:53 PM - Nov 4, 2024
7215789
Unlike the LED panel effect, where we want to physically move pixels
by offsetting the sampling coordinates, the crochet effect maintains
the underlying pixelated texture grid while only shifting the pattern
mask that creates the knitted appearance.
Offset defined for our crochet effect
1
vec2 normalizedPixelSize = pixelSize / resolution;
2
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
3
vec4 color = texture(inputBuffer, uvPixel);
4
5
vec2 cellPosition = floor(uv / normalizedPixelSize); // coordinate of the current cell
6
vec2 cellUV = fract(uv / normalizedPixelSize);
7
8
float rowOffset = sin((random(vec2(0.0, uvPixel.y)) - 0.5) * 0.25);
9
cellUV.x += rowOffset;
As for the pattern, I opted for a simple ellipsis rotated -65 degrees
for even cells and 65 degrees for odd cells.
Diagram showcasing a simplified depiction of the ellipsis pattern
used in the crochet post-processing effect
Diagram showcasing a simplified depiction of the ellipsis pattern
used in the crochet post-processing effect
Getting an ellipsis rotated as such in GLSL is luckily
straightforward:
* An icon representing an arrow
We use a standard rotation matrix around the center of the cell
to have it positioned at an angle
1
float isAlternate = mod(cellPosition.x, 2.0);
2
float angle = isAlternate == 0.0 ? radians(-65.0) : radians(65.0);
3
4
vec2 rotated = vec2(
5
center.x * cos(angle) - center.y * sin(angle),
6
center.x * sin(angle) + center.y * cos(angle)
7
);
* An icon representing an arrow
We create the ellipsis around said center by calculating the
distance between the current pixel and the center of the cell,
stretching the shape vertically and shifting it up. Eventually,
we use that distance with the smoothstep function to create the
elliptical shape with soft edges.
1
float aspectRatio = 1.55;
2
float ellipse = length(vec2(rotated.x, rotated.y * aspectRatio - 0.075));
3
color.rgb *= smoothstep(0.2, 1.0, 1.0 - ellipse);
Once we reach that stage, 90% of the work is done, we just need to
add a few more details. Here's a list of functions and effects that
compound once added to the pattern and make our crochet shader look
like fabric:
* An icon representing an arrow
Apply noise to the center of each cell in the crochet pattern so
that the edges of each ellipsis look more rough.
* An icon representing an arrow
Create a stripe pattern for each ellipsis to mimic the fabric.
Far from perfect but good enough
* An icon representing an arrow
Add a slight hue shift to the color of each ellipse to give it a
more organic look, as the thread color may vary.
Lego bricks
This one was a fun effect to build. For one, it has to do with Legos,
which I love, but also, it is a simple yet elegant post-processing
effect:
* An icon representing an arrow
It uses your classic pixelation logic that we went through in the
first part
* An icon representing an arrow
The stud at the center of each cell is a lighting illusion.
Demo
Head to my React Three Fiber playground to check out and play with
this effect.
To create the stud at the center of the cell, we can reuse what we
learned about the Blinn-Phong lighting model in Refraction,
dispersion, and other shader light effects.
Defining diffuse lighting for our 2D lego brick effect
1
float lighting = dot(normalize(cellUV - vec2(0.5)), lightPosition) * 0.7;
2
float dis = abs(distance(cellUV, vec2(0.5)) * 2.0 - 0.5);
3
color.rgb *= smoothstep(0.1, 0.0, dis) * lighting + 1.0;
Then, from the center of the cell:
* An icon representing an arrow
We calculate the distance between each point and the center.
* An icon representing an arrow
Create a soft circular edge using smoothstep.
* An icon representing an arrow
Combine it with the lighting value.
Diagram showcasing how the lighting technique used in this effect
draws the stud at the center of each cell
Diagram showcasing how the lighting technique used in this effect
draws the stud at the center of each cell
With this, each of our cells features a circular shaded stud in the
center, just like a 1x1 Lego brick! To polish the effect, we can
reuse constructs we've seen previously in this article or other
pieces of content I got to write in the past:
* An icon representing an arrow
We can use color quantization to limit the color palette of this
effect. Lego pieces come in a limited set of colors so it is fair
to impose a limit here. I showcased how color quantization works
in The Art of Dithering and Retro Shading for the Web
* An icon representing an arrow
We can add a subtle border around each cell by reusing the same
logic introduced in the LED panel effect, giving the impression
that the final output is a mosaic of single-stud Lego pieces.
* An icon representing an arrow
Finally, we can add a dash of hue shift to bring some variety in
the colors used, especially for the scene's background, which may
only feature a single color.
* An icon representing an arrow
To top it all off, we can clamp the min and max of each color
channel slightly to avoid having Lego pieces that are either too
dark or too bright, as the stud would not be very visible in
those cases.
An icon representing the letter 'i' in a circle
This effect is not only very efficient on 3D scenes, but even more so
on images, photos, or paintings. It's the perfect example that
illustrates how post-processing shaders can also act as great image
filters.
Fluted & frosted glass
There has been a recent trend in art and media, whether digital or
printed, to use fluted glass to add what I'd call physically-based
distortions to an image. As a post-processing effect, it's really
enticing as it truly feels like a layer of frosted glass is placed
between you the viewer and the scene.
Demo
You can judge my own attempt at building this fluted/frosted glass
effect. It also features additional patterns like glass bubbles and
lenses.
This effect might feel like a departure from the ones we've seen so
far, as it's the only one in this post that will not feature
pixelation. Yet, the techniques and principles behind it are similar
to the ones used in some of our previous examples:
* An icon representing an arrow
UV Distortion
* An icon representing an arrow
Blinn-Phong lighting model
To build this effect, let's first see how the shape of the pane of
glass gives us the mathematical function that describes the
distortion. We want fluted glass so our distortion will look like a
sine wave such as: sin(uv.x * PI). When looking at this shape, we can
expect that the distortion will be as follows:
* An icon representing an arrow
Minimum at the peaks and valleys of the wave as the surface is
flat.
* An icon representing an arrow
Maximum in between when the curve grows or decreases.
Through this reasoning, we can define the distortion as the
derivative of the function defining our fluted glass shape, which, in
this case, would be: cos(uv.x * PI) * PI.
Diagram showcasing the sine wave representing our fluted glass shape
in relation to its derivative which represents the amount of
distortion the fluted glass creates at any given point. (Apologies
for the inaccuracy of this chart I have yet to find a good plugin on
Figma to draw them. It should be good enough to help you visualize
the math behind this effect.)
Diagram showcasing the sine wave representing our fluted glass shape
in relation to its derivative which represents the amount of
distortion the fluted glass creates at any given point. (Apologies
for the inaccuracy of this chart I have yet to find a good plugin on
Figma to draw them. It should be good enough to help you visualize
the math behind this effect.)
Translating those mathematical concepts into code yields the
following result:
Before
After
An icon representing an arrowAn icon representing an arrow
Comparing the non-distorted output of our effect with the distorted
variant
Simple fluted glass-like distortion
1
float fluteCount = 25.0;
2
float flutePosition = fract(uv.x * fluteCount + 0.5);
3
4
vec2 distortion = vec2(cos(flutePosition * PI * 2.0) * PI * 0.15, 0.0);
5
6
vec2 distortedUV = uv + distortion * distortionAmount;
This is great, but, we're not seeing any glass yet. The illusion
we're trying to build relies on light to give it its glass texture,
so we need to convert our derivative, which tells us "how steep is
the surface/how intense is the distortion", into a vector that tells
"which way does the surface point?", i.e. a normal, which is what we
need for light calculations. Our effect is a curved piece of glass,
so the normal vector will point towards us the viewer, slightly
sideways in the slopes and straight towards us in the valleys and
peaks of the curve.
We already have the x component of our normal vector, the y component
is 0 for our case, so we can deduce the z component using the formula
normal.x2 + normal.y2 + normal.z2 = 1.
From distortion to lighting
1
float fluteCount = 25.0;
2
float flutePosition = fract(uv.x * fluteCount + 0.5);
3
4
vec3 normal = vec3(0.0);
5
normal.x = cos(flutePosition * PI * 2.0) * PI * 0.15;
6
normal.y = 0.0;
7
normal.z = sqrt(1.0 - normal.x * normal.x);
8
normal = normalize(normal);
9
10
vec3 lightDir = normalize(vec3(lightPosition));
11
float diffuse = max(dot(normal, lightDir), 0.0);
12
float specular = pow(max(dot(reflect(-lightDir, normal), vec3(0.0, 0.0, 1.0)), 32.0);
13
14
vec2 distortedUV = uv + normal.xy * distortionAmount;
Finally, to polish things up, we can add a couple of effects to this
shader to make it as realistic as possible:
* An icon representing an arrow
Gaussian Blur, to give some more depth.
* An icon representing an arrow
A dash of noise to create a frosted glass effect.
* An icon representing an arrow
Some slight chromatic dispersion, because why not.
An icon representing the letter 'i' in a circle
Notice how we took intuitive steps to craft this effect:
* An icon representing an arrow
We started with a simple shape.
* An icon representing an arrow
Then, we used a bit of math to derive () the distortion.
* An icon representing an arrow
Finally, combined those with some shader basics and techniques
learned previously.
This effect is 100% built through combining concepts that are
relatively simple when approached separately from one another but yet
yield a very complex/elaborate effect when put together.
Dynamic and interactive post-processing
So far, we have considered post-processing effects solely as mere
image filters, but we can achieve more with them. Adding a dash of
interactivity into the mix, whether time-based or by leveraging the
cursor of the user for example, can yield some very unique and
delightful results and make your effects really stand out.
Progressive Depixelation
I like leveraging pixelation in many of my post-processing
experiments, so it felt natural to start my journey into dynamic
effects with this progressive pixel loading one. We can easily make
the pixelSize depend on time or an arbitrary progress uniform, but
even more challenging is to have the effect progressively
de-pixelated the screen row-by-row, pixel-by-pixel. The diagram below
illustrates my original sketch showcasing how this effect would
eventually work:
Diagram showcasing a sketch breaking down the process behind the
Depixelation effect.
Diagram showcasing a sketch breaking down the process behind the
Depixelation effect.
To achieve this, the trick consists of:
1. An icon representing an arrow
Setting a concept of level based on the original basePixelSize
and how far along in the transition we are.
2. An icon representing an arrow
Each level representing "a power of 2" and going down 1 level at
a time as the basePixelSize is decreasing.
3. An icon representing an arrow
Calculating the number of pixels per row/column and the current
row/column processed at the current level.
4. An icon representing an arrow
Get the row and position within the row for a given UV.
Main variables defined for our depixelation effect
1
float LEVELS = 5.0;
2
3
float basePixelSize = pow(2.0, LEVELS);
4
float currentLevel = floor(progress * LEVELS);
5
6
float currentPixelSize = max(basePixelSize / pow(2.0, currentLevel), 1.0);
7
8
float currentPixelsPerRow = ceil(resolution.x / currentPixelSize);
9
float currentPixelsPerCol = ceil(resolution.y / currentPixelSize);
10
float currentTotalPixels = currentPixelsPerRow * currentPixelsPerCol;
11
12
float levelProgress = fract(progress * LEVELS) * currentTotalPixels;
13
float currentRowInLevel = floor(levelProgress / currentPixelsPerRow);
14
float currentPixelInRow = mod(levelProgress, currentPixelsPerRow);
15
16
vec2 gridPos = floor(uv * resolution / currentPixelSize);
17
float row = floor(currentPixelsPerCol - gridPos.y - 1.0);
18
float posInRow = floor(gridPos.x);
Once we have all that defined, it's just a matter of reusing our
pixelation effect from part 1, but this time conditionally:
* An icon representing an arrow
If the a row of pixels is within any previous row that's already
been processed: we should use the updated pixel size (e.g. if we
started at 128, then it should be 64).
* An icon representing an arrow
If we're on the currently processing row AND the pixel's
horizontal position is less than or equal to how far we've
processed in this row: we should use the updated pixel size as
well (e.g. if we started at 128, then it should be 64).
* An icon representing an arrow
Else we should be on the old pixel size (128).
* An icon representing an arrow
We can also add a final case for when we reach a pixel size
inferior to or equal to 1.
The demo below showcases the effect linked to a progress uniform. You
could also hook it up to a time uniform if you ever wished to have it
trigger on page load or any other event without needing a user
interaction.
Pixelating Mouse Trail
For this section, I have to shoutout the incredible work of @dghez_
for his work on rosehip.xyz and 27b's lab section, who both had a
take on a pixelating mouse trail effect that increases the pixel size
and distorts the underlying image as you move your cursor across the
screen.
I had previously built a reusable MouseTrail component that I used in
some of my shader experiments and I thought to myself that, to
achieve a similar effect as the one in the examples above, I could
repurpose it and put its output inside a Frame Buffer Object (FBO) to
feed the resulting texture as an argument to my pixelating effect.
The texutre itself would be visible as the user moves the cursor, so
I'd only have to make the pixel size and distortion a function of the
speed and direction of said cursor.
Mouse trail
Going through the MouseTrail code with as many details as I want
would make this article too long for my liking. To give you the gist
of it, my current implementation uses:
* An icon representing an arrow
Two FBOs.
* An icon representing an arrow
A method called ping pong rendering.
* An icon representing an arrow
The position and speed of the cursor: the faster, the brighter.
You can take time to go through the code separately from this
article. The only thing that you need to remember is that we'll use
this output as a texture input for our effect.
Note: the demo below will require you to move your cursor on the
canvas to see its output.
Using React Three Fiber's createPortal function, we can render the
MouseTrail component demoed above in a separate scene, and dedicate a
FBO to store its output as a texture:
Rendering the MouseTrail component in a portal and storing its output
as a texture
1
const PixelatingMouseTrail = () => {
2
const mouseTrail = useMemo(() => new THREE.Scene(), []);
3
const mouseTrailFBO = useFBO({
4
minFilter: THREE.LinearFilter,
5
magFilter: THREE.LinearFilter,
6
format: THREE.RGBAFormat,
7
type: THREE.FloatType,
8
});
9
10
//...
11
useFrame((state) => {
12
const { gl, camera } = state;
13
14
//...
15
16
gl.setRenderTarget(mouseTrailFBO);
17
gl.render(mouseTrail, camera);
18
gl.setRenderTarget(null);
19
20
//...
21
});
22
23
const { camera } = useThree();
24
25
return (
26
<>
27
{createPortal(, mouseTrail, {
28
camera,
29
})}
30
31
32
33
38
>
39
);
40
};
We can then pass this mouseTrailTexture as a uniform to our shader
effect, and use it to have a variable pixelization based on:
* An icon representing an arrow
The distance between the current pixel and the mouse, ranging
from 1.0 if it is close to the mouse to 0.0 if it is far away.
* An icon representing an arrow
The intensity of the mouse trail, its rg color channel, since its
color ranges from red (vertical mouse movements) to green
(horizontal mouse movements)
* An icon representing an arrow
With this, we can sample a pixelated version of the mouse trail,
which we can use to offset our main UV coordinates. Once again,
we'll reuse the pixelation logic we detailed in the first
section.
Pixelation based on the mouse trail texture
1
uniform sampler2D mouseTrailTexture;
2
uniform vec2 mouse;
3
uniform vec2 mouseDirection;
4
5
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
6
vec4 mouseTrailOG = texture2D(mouseTrailTexture, uv);
7
float distanceToCenter = 1.-distance(uv, mouse);
8
9
float pixelSize = 32.0 + length(mouseTrailOG.rg) * distanceToCenter;
10
vec2 normalizedPixelSize = pixelSize / resolution;
11
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
12
vec4 mouseTrail = texture2D(mouseTrailTexture, uvPixel);
13
14
// Disort the texture based on the mouse direction
15
vec2 textureUV = uv;
16
textureUV -= mouseTrail.rg * distanceToCenter * mouseDirection;
17
18
vec4 color = texture2D(inputBuffer, textureUV);
19
vec4 trailColor = vec4(0.9, 0.9, 0.9, 0.1);
20
outputColor = max(color, mix(color, trailColor, mouseTrail.r));
21
}
Finally, those distorted/offset UV coordinates can be used to sample
the main scene:
This is one of the many examples of incorporating dynamic/variable
pixelation as an effect. You could pass any texture to this, such as
a Perlin Noise, a Fractal Brownian Motion noise, etc. The
possibilities in terms of dynamic post-processing are truly endless.
The best thing to do, as you may have guessed by now, is simply to
try more things you've learned on your own shader journey and to
combine them with the ideas of this article in a fun and unique way.
Final Thoughts
Each post-processing effect detailed in this post could have deserved
its own article, but I thought it would be more interesting to look
at them as a whole, as they share the same tricks and techniques
despite yielding different outputs. This highlights the value of
blending ideas and concepts to see what emerges as you learn more
about shaders, develop your style, and discover the aesthetics that
resonate with you.
When people come to me and ask, "How do you learn about/build those
things?" what you saw in this blog post (waving arms around) is what
I spend some of my free time doing. The added bonus of doing it with
post-processing is that it allows me to experiment with ideas
quicker, as it's a simple 2D canvas on which you can paint pixels in
any way you want.
A follow-up goal of mine for post-processing would be to make those
shader bits more composable, a la Lygia, and who knows, maybe build
my own FX package (?). For now, my main focus will be to transition
some of those effects to WebGPU as it would be a nice entry point
into learning about compute shaders and the other new concepts
brought by this new API. That topic, however, will be for another
time in the meantime, I'll be on the lookout for your own creative
post-processing effects to show up on my Twitter or Bluesky timeline
using everything you learned in this post, and if done well enough,
maybe you'll get me to spend some of my time trying to reproduce
them.
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
2025-02-04T08:00:00.000+01:00
TBD
HomeDesignRSS
BlueskyTwitterGithub
PortfolioBuy me a coffeeRoadmap
(c) 2025 Maxime Heckel ---- New York