Getting started with shaders
What is a shader?
Shaders are bits of GLSL code, a dedicated language for shaders that resembles C. There are two types of shaders :
- Vertex shaders (used to define the position of vertices in 3D space)
- Fragment shaders (used to define the color of a given pixel on the screen) - also sometimes called “Pixel shaders”
This article will cover the basics of creating 2D visuals in the context of a webpage, so we will focus on fragment shaders and won’t worry about vertex shaders. All of the examples are written for WebGL using the glslCanvas
NPM library.
What is a fragment shader?
A fragment shader is a script that receives the position of a pixel and chooses a color for it.
Example:
void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }
A fragment shader executes within a main
function, and must always set a value inside the gl_FragColor
variable. This variable holds the end result: you must set it to the color you’ve calculated for the given pixel, expressed as a vec4
type. A vec4
is simply a 4-dimensional vector, or a box that contains 4 floating-point numbers. In this case, the numbers represent:
void main() { gl_FragColor = vec4( 1.0, // Red: 100% 0.0, // Green: 0% 0.0, // Blue: 0% 1.0 // Alpha: 100% (Opacity) ); }
This shader always chooses red. But how can we decide the color based on the position of the pixel? The following draws a gradient from black to red:
uniform vec2 u_resolution; void main() { vec2 pos = gl_FragCoord.xy / u_resolution.xy; gl_FragColor = vec4(pos.x, 0.0, 0.0, 1.0); }
The position of the pixel is accessible through gl_FragCoord.xy
, which is a vec2
, a vector with two floating-point numbers in it: x and y coordinates. To transform the raw pixel coordinates into values constrained between 0.0
and 1.0
, which is what we need to show a color, we simply divide the pixel position by the size of the drawable area u_resolution.xy
. So a pixel on the left edge will have pos.x equal to 0.0
, a pixel in the center will have pos.x equal to 0.5
, and a pixel on the right edge will have pos.x equal to 1.0
.
If we plug this value in the red channel of our final color, we get a smooth gradient!
How are shaders so fast?
We’ve established that a fragment shader is executed independently for every pixel in the drawable area. This stacks up quickly: imagine that you have a canvas that measures 1.000 pixels in width and 500 pixels in height. That amounts to 500.000 individual pixels, all of which need to run your shader to compute their color. This means that your GLSL code is executed 500.000 times for each and every frame of your animation. A typical web animation runs at 60 frames per second these days, which makes this even crazier: your shader is executed 30 million times every single second.
This sounds catastrophic, but shaders have a trick up their sleeve: they run on the GPU instead of the CPU.
The CPU and the GPU are similar in that they both process data. However, the GPU is built specifically for enormous parallelisation — exactly what we need. Instead of processing each pixel’s shader one after the other like the CPU would, the GPU is able to process them all simultaneously.
Another advantage of the GPU is the hardware accelerated math functions. GPUs have dedicated hardware that make math operations extremely fast — which is a nice bonus if you write your shaders to properly take advantage of them. This may push you to change your approach a little bit if you’re used to writing CPU-bound programs, but it’s fun to think in different ways!
Using a shader playground
If you want to follow along with the examples, I can recommend using The Book of Shaders editor. Open it, and you will notice the example code on there has three new lines at the top, which we haven’t talked about yet:
#ifdef GL_ES precision mediump float; #endif
I have omitted these so far to simplify the code bits, but they are required when building shaders for the web. They specify how precise floating-point numbers should be when running your shader. Without these lines, WebGL throws an error, so make sure you include them.
Uniforms
One thing we have not explained yet is the concept of uniforms, or how the resolution of the drawable area was magically available to us in our last example.
Uniforms are pieces of data provided to your shader, which are read-only and always the same regardless of which pixel the shader is processing (you could say their value is uniform across all the pixels). You can define uniforms yourself as you need them, but you may already have some available, defined by your development environment. In this case, we are using shaders on a canvas in a webpage, using the glslCanvas
NPM library, and it makes a few uniforms available to us automatically:
-
uniform vec2 u_resolution
Contains the width and height of the drawable area (the canvas) -
uniform float u_time
Contains the number of seconds since the shader was first loaded -
uniform vec2 u_mouse
Contains the position of the cursor over the drawable area
Shaders that evolve over time
Earlier, I mentioned using shaders to create animations; visuals that change as time progresses. You can do this with a uniform that contains the time since the shader was loaded, and use that value to influence the color of the pixel.
uniform float u_time; void main() { gl_FragColor = vec4(u_time, 0.0, 0.0, 1.0); }
This will start as completely black (because u_time
starts with a value of 0.0
) and will progressively become more red, until it becomes completely red after one second, as u_time
reaches 1.0
and beyond. We can wrap it with a sine function to make it alternate between black and red infinitely, to make the effect easier to see. The sine function will modulate u_time
so that it alternates between 0.0
and 1.0
making the colors cycle forever.
gl_FragColor = vec4(sin(u_time), 0.0, 0.0, 1.0);
Shaders that respond to the mouse
The u_mouse
uniform available to us allows us to know precisely the coordinates of the mouse pointer. Let’s draw a red circle that follows the cursor:
uniform vec2 u_mouse; void main() { vec4 color = vec4(0.0, 0.0, 0.0, 1.0); if (distance(u_mouse, gl_FragCoord.xy) < 50.0) { color = vec4(1.0, 0.0, 0.0, 1.0); } gl_FragColor = color; }
As you can see, we start by defining the color as black (0% on RGB channels and 100% alpha). Then we call the distance function to know how far the mouse is from the pixel we’re currently processing. If the distance is less than 50 pixels, we update the color to red. This results in a circle that follows your cursor around!
This is fine for a simple example, but remember that shaders are executed by the GPU. Another difference between the CPU and the GPU, aside from their ability to parallelize tasks, is that the CPU is very good at handling conditionals quickly and efficiently, and the GPU is much more suited to mathematical operations. It has what we call hardware acceleration for some of these operations, which means that the hardware itself has shortcuts it can take to make these functions execute extremely quickly. This is why you will often see shaders prefer using math instead of conditionals.
uniform vec2 u_mouse; void main() { float dist = distance(u_mouse, gl_FragCoord.xy); gl_FragColor = vec4(step(dist, 50.0), 0.0, 0.0, 1.0); }
This replaces the conditional with the step function, which is hardware accelerated. The step function is simple, it compares the first value (the distance between the mouse and the pixel being drawn) with the second (50.0
) and crunches it down to either 0.0
or 1.0
. Any distance lower than 50.0
returns 1.0
, and any value higher returns 0.0
.
Next steps
There’s a lot more to talk about, but allow me to defer to the excellent Book of Shaders by Patricio Gonzalez Vivo for the next step in your shader-learning journey.
Specifically, I recommend that you read Chapter 5: Algorithmic drawing next, which will explain how you can use the different math functions to create algorithms that draw the graphics you want.
The Book of Shaders is a gold mine to get you going. If you need it, the beginning chapters will cover the contents of this article with additional details, and the rest will go much much further than that. It also has a glossary of all the types, functions, variables and constants you can use in your shaders, which is very helpful. I always end up forming my Google searches like “Book of shaders normalize function“ to make sure I end up on one of those pages.