|> 2023-10-27
|> Reading time: 20mn
This article is interactive: you can play with the code and sliders to interact with the shaders. Enjoy!
What if I told you that it could takes just few lines of code to create graphics as simple as gradients or as complex as rain effects? Welcome to the world of shaders!
I’ve been fascinated by shaders for a couple of years, but each time I attempted to dive into the subject, I felt like I was learning to read and write all over again — it was overwhelming.
When I transitioned this website to Svelte, I saw an opportunity to replace a simple CSS animation on my homepage with a shader-based animation. The original CSS animation manipulated the border-radius
property to produce a calm and minimalist animation, illustrated below.
You might wonder why I would bother re-doing something that already exists. Well, it’s because the simplicity of the task seemed like the perfect stepping stone—challenging, yet manageable. Plus, having recently defended my PhD, I finally had the time to delve into this passion project!
I hear about shaders all the time, when scrolling generative artists on twitter X, when I want to change the look of Minecraft, or even when I want to train an AI (CUDA is basically an API for shaders). So now it’s the time to demystify this damn thing and start writing one of my own! In this article, you’ll join me on my journey as we explore the world of fragment shaders, making it as approachable as possible for a beginner with basic understanding in programing.
For anyone looking for an in-depth introduction to shaders, I highly recommend The Book of Shaders
If you’re into video games, you’ve likely heard of shaders. They’re the magic behind enhancing lighting, conjuring up special effects, and even generating cartoonish looks (yes, that’s why there’s a ‘shade’ in ‘cel shading’). In a way, shaders is what makes modern games look so good when compared to their ’90s counterparts. But what exactly is a shader?
Let’s start simple: A shader is a small program running on your GPU that takes, at the very least, pixel coordinates as input and spits out a color as output. The reason why they are so popular in video games and computer graphics is that they are extremly fast. Their secret sauce? Parallelization. These programs are designed to work on multiple pixels at the same time, making them ridiculously efficient.
Side Note: Shaders come in different dialects. For this article, I’ll focus on the OpenGL Shading Language (GLSL), mainly because it’s browser-friendly!
This incredible power comes, however, at some costs: Shaders have to be compact and low-level. This means you can’t lean on high-level abstractions or import libraries to do the heavy lifting (* laugh in javascript *). Moreover, their parallel nature makes them memoryless and stateless. This translates to: “You can’t store or share data between pixels or shader executions.” These constraints make shaders a tough nut to crack, especially if you’ve been pampered by high-level languages (guilty as charged).
Shaders transform pixel coordinates into colors, encoded in RGBA—each channel ranging from 0 to 1. (It is also possible to manipulate vertex positions, but this topic is left as an exercise to the reader). Typically, coordinates are normalized between 0 and 1. In this coordinate space, (0, 0) is the lower left corner, and (1, 1) is the upper right. These coordinates are commonly referred to as st or uv by convention. Now, let’s imagine you want to write the simplest shader: a gradient where the red component increases from left to right and the green component ascends from bottom to top. That is, find the function f(x,y) in the following illustration:
Sure, it might appear too basic, but think of it as a prime playground to get cozy with shader syntax. Go ahead, check out the implementation below and tinker with it — how about changing the gradient from black to blue?
gl_FragColor = vec4(0.0, 0.0, st.x, 1.0);
There are a few interesting things to note here about the syntax:
vUv
, which is a 2D vector representing the position of the pixel on a plane. It is declared as varying
because the value is different for each pixel on the screen.vec2
, vec3
, vec4
, mat2
, mat3
, and the list goes on.vec2(1, 2).x
gives you 1
). Want to slice and dice your vector? Use the xy notation (vec4(1, 2, 3, 4).xy
returns vec2(1, 2)
). If you’re working with colors, feel free to use the myvector.rgba
syntax — This is entirely up to you.gl_FragColor
at the end of the main()
function.So even with our super simple example, you can already feel the power of shaders. Without it, an equivalent result would have required a loop over all the pixels of the canvas — 90000 in this case — just to create this gradient. But this is just the beginning; shaders could do so much more than that.
Now, to reproduce my original animation, I need to draw shapes with salient edges. While this may seem trivial, it is not. Forget about a handy drawCircle() function. Instead, we turn to our ever-reliable friends: math and trigonometry.
To create something like a disk, consider each pixel’s distance to the disk’s center. This distance calculation could be done using the Pythagorean theorem, however, we also have a built-in function for that: distance(vec2 p1, vec2 p2)
. If you map this distance to the color of the pixel, you will get a circular gradient.
But wait, you may anticipate, “a gradient is not a solid disk!” And you’d be right. The secret sauce for that is another built-in function: step(float threshold, float value)
. The step() function takes in the distance and sharply transitions it into either 0 or 1, depending on whether the distance crosses a certain threshold.
Noticed those jagged edges, also known as aliasing, around the disk when applying
step()
? That’s because the transition from 0 to 1 is a bit too abrupt. The solution is another built-in function calledsmoothstep(float t_start, float t_end, float x)
, which—as you might guess—smooths things out.
You may find it initially challenging, but this method of shaping with distance is your Swiss Army knife for crafting the mind-blowing shaders you often stumble upon online. So let’s dive a bit deeper into it!
When you think of shapes, it’s natural to imagine them as a series of connected points. But here’s a twist: you can also represent shapes in terms of their distance to other points in space. This is where Signed Distance Functions (SDFs) come into play. Why “signed,” you ask? The distance is signed because it can be negative if the point is inside the shape.
To start off, let’s revisit the circle we created earlier and adapt it using SDFs. The key is to determine a function that calculates the distance from any given point in space to our circle. Starting simply, let’s find the distance to the origin. In the image below, it becomes evident that the distance d
from the origin to the circle is essentially the distance from the origin to the center of the circle C
minus the radius r
.
This observation translates beautifully into a function:
float circleSDF(vec2 p, float r) {
return length(p) - r;
}
You can interpret this function in two ways. It either measures the distance from a point p to a circle centered at the origin, or the distance from the origin to the circle itself. It’s all a matter of perspective!
However, we’re rarely interested in just the distance to the origin. We want the distance to any point in the UV space. To achieve this, we merely translate the point p
by the pixel’s position uv
. The SDF function then returns negative distances for pixels inside the circle and positive distances for those outside. These two realms are separated by the circle, where the distance is exactly zero.
What about shading this SDF to make it visually compelling? Simple. Apply the 1. - step() function to the distance. The pixels with negative distances (inside the circle) take the value 1, and those outside take the value 0.
This article won’t delve into the other shapes you can define with SDFs—though I strongly recommend this comprehensive list by Inigo Quilez for those curious minds. Instead, we’ll focus on how to merge these individual shapes to craft our end-goal: a beautiful blob.
SDFs has some interesting properties, one of them is that it is especially easy to create new shapes with boolean operations. To have the union of the two SDFs, you need to take the minimum of the two distances. For pixels that are in either of the two shapes (or in both), the min() will output a negative distance, and for pixels that are outside both shapes, the min() will output a positive distance.
We end up with a new SDF that is negative inside the union of the two shapes, and positive outside. In the exemple below, I start by showing the two SDFs, one in red and one in green. With the slider, you can see the result of the union of the two shapes using the min() function.
Have you noticed that I used
1.-smoothstep()
? This is becausestep()
(andsmoothstep()
) outputs 1 when the distance is above the threshold (i.e outside the disk). To get a positive value inside the shape, we need to invert the output.
Complex shapes — like a blob! — are thus the combination of many simple SDFs. Like legos, you have many simple SDFs (building blocks) that can be combined to any shape you want. That said, a blob is smooth and jelly-like, unlike the sharp angle at the junction of our two disks. Luckily, SDFs have one last magic property for us.
To create an appealing effect, we would like the shapes to blend smoothly together like in a lava lamp. However, the min()
function is not smooth, it has sharp discontinuites when it transitions between two distances. Instead, we would prefer a function that smoothly shift from one distance to another. Luckily, this problem has already been solved and is unoriginally called smooth minimum. The function takes an additional argument to control the smoothing strengh (often denoted k
).
We can pass any arbitrary variable to our shader, much like the slider you’ve played with in this article. To get closer to our goal, we need to animate the circles. Doing so is as simple as feeding the shader with a time uniform that can then be used to define the circles’ positions. Here I generate my time uniform u_time
through javascript and then use it as an input in my shader to control my SDFs. The shader will refresh 60 times per second by default, each time with a new u_time
value, creating a smooth animation. With a few extra balls and a bit of parameter tweeking, we end up with a cute blobby shape.
To make the blob oscillating, we can use periodic functions (e.g. sin,cos) to control each balls.
A metaball is a combination of multiple SDFs, to clean up our code, we can use a loop to combine them together, instead of manually updating the final distance variable like in our previous exemple. To further speed-up the process, we first define the centers of each balls, and then store it in an array that can be easily accessed in the loop to iteratively update the distance value. Pay attention to lines 40-43 in the code below.
And voila, our baby’s born. You should now be ready to write some shaders of your own. If writing code is not your thing, you now have a better understanding of what’s going under the hood of node-based editor in Blender’s shader nodes or Unity’s Shader Graph.
This sad monochrome blob is functional but boring. Let’s make it juicer!
To truly appreciate the magic of shaders, there’s nothing like taking the wheel and manipulating the blob in real-time. This final section will guide you on how to introduce user interactivity into your shader. Essentially, you will learn how to let users control the position of a ball within the blob by using their mouse.
First things first: We’ll use the mouse coordinates as a uniform input into the shader. This will allow real-time interaction with our creation.
Once the mouse coordinates are received, adding them to the array of ball centers will allow the user to interactively control a ball. As you see, it only takes one line of code to create interactivity!
Next, it’s just fun and iterations. To get to the final result, I extensively use the mix(colorA, colorB, percent)
function. It’s equivalent to if/else blocks when percent
is a boolean. For example, to get red outside the metaball (where metaball == 0
) and green within it, you can write.
Finally, we get this beauty
That concludes this introduction. I’m glad I’ve finally learned to write shaders! This article barely scratches the surface of the basics, but there’s no reason to be afraid anymore—neither for you nor for me. Stay tuned for future articles where we’ll explore how to elevate this blob into the third dimension. In the meantime, feel free to experiment; you can change the color scheme or tweak the positions of the balls. If you liked this article and want to support my work, you can hop on my Ko-Fi page. For updates, you can follow me on Twitter.