Shader Art Programming: The Basics
- Abby Karnstein
- Oct 20, 2023
- 8 min read
Writing shaders in code has never been the most popular past time, and that rings even more true now that we have node graphs in most of our software to do all the annoying stuff for us. Node graphs have been wonderful at lowering the barrier for entry for a lot of us when it comes to making shaders and playing around with material rendering. But, there are still limitations, and if we know how to write what we want from scratch, well, we can do whatever we like. I'll provide examples in both Unreal and ShaderToy, to make following along easier.
A problem I've encountered starting to learn GLSL and HLSL is that there just aren't that many resources available, certainly not for entry level. And so for my own sake, and possibly yours, an introduction to the noble art of screaming at disobedient pixels awaits below.
The best resource for getting started is a website called ShaderToy, developed by Inigo Quilez and Pol Jeremias. The site is host to hundreds of shaders written by god knows who with the animation and code side by side for you to dissect at your leisure. The code will compile when you press the little play button and runs continuously to show you exactly what's happening as you go. Clicking on 'New' will bring you to the default shader, a pulsing RGB with some of the basics already set up for you to get started.
The first bit of code we see when we open ShaderToy looks like this:

The mainImage is our output, and within that are two parametres, a vec4 and a vec2. More simply, we have a variable with 2 values (x,y), and another with 4 (r,g,b,a). We can write a simple line of code to output a color. Green, for example:

The output colors are normalised in GLSL, so each pixel will have a value between 0 and 1.
The fragCoord in this instance will usually pertain to the size of our screen. It dictates the canvas on which we can start manipulating pixels. If I have a 4k monitor, my x coord will range from 0 - 3840, and my y will be 0 - 2160. Often this can be an awkward approach, as you're limiting your shader to the current resolution of the screen, and so it's generally preferred to transform them to clip space, which ranges from -1 to 1.

That's where this part of the default ShaderToy code comes in. iResolution is a vec3 parameter, and holds the current resolution of the screen. We'll often see shaders using .xy to isolate the width and height if they don't contain 3D elements. This isolation is so we have a vec2 (fragCoord) divided by another vec2 (iResolution.xy).

If we assign uv.x and uv.y to the color output, we can more easily see what this means. uv has a value of 0-1 on the x and the y dependent on the resolution, so we can see a steady gradient from black to pure red on the x and green on the y, with yellow where the values are both 1.0.
Before starting a shader, it's best to 'fix' the centrepoint. With our values going between 0 and 1 we're left with the centrepoint of the screen being (0.5, 0.5), with isn't the nicest value to work with. A better starting point would be (0, 0). We can do with by subtracting 0. 5 from each uv component, and then multiplying the result by 2:



This step isn't necessary, but does give you nicer values to work with.
Length
The length function measures the distance between a vector point and the origin. As our origin is now (0, 0) we have a much simpler result to work with.

By adding the code line above I am taking the values of uv and getting their length from the origin, meaning the farther the uv coordinate is the longer the length, and the higher the value. This is easier to see in application so within ShaderToy, we have this:

A radial gradient with black at the origin, where the distance is shortest and thus the values the lowest.
We do have one small problem with this raw output, however, as the radial is quite squished due to the screen size. To get a perfect circle gradient we have to multiply the x of the uvs by the current aspect ratio, or the width divided by the height. We can write it like so:


This is also usable inside Unreal with vector maths to create a radial gradient on your meshes:


Here we have the axes specified in floats, anything with a normal in the X direction will have dimensions on the Y and Z, so this is how I've referenced world space. By have a parameter to specify either 0, 1 or 2 corresponding to R, G and B I can very quickly make a radial gradient in world space.

Step & Smoothstep
To start I'm going to add the expression " a -= 0.5; ", which visually will increase our black gradient until it looks a bit like an eye dilating. What this actually is, however, is the Signed Distance Function of a circle. This means that everything 'outside' the 0.5 band will have a positive value, and everything inside the circle will have a negative value, with the origin of (0, 0) now having a value of -1. The circle itself, or the boundary line, will be 0.


This is because the farther you move away from the edge of the circle, the higher the distance, and higher the value.
An easy way to verify this is to use the absolute function (abs), which changes negative values into their positive counterpart:


And now for the step function itself:
Step takes in two values, the first is a threshold and the second is a value. If I take the X value of a texture coordinate and set the threshold to 0.5, anything above the value of 0.5 will return 1, and anything below will return 0.

We can use this to isolate the circle we've just drawn. Increasing the threshold will increase our thickness.


Step is excellent for separating values, but what if we want a smoother transition? Then we have the almostly boringly named, smoothstep. The smoothstep function takes in 2 thresholds, returning 0 below the first threshold and 1 above the second. As you can probably guess, the closer these two threshold values are, the sharper the contrast will be.


So at this point, our code looks something like this:

And that means we have a very firm grasp of the basics, which is great, because we're about to get into sine, which is helpfully not as terrifying as highschool maths made it seem. First though, let's briefly talk about how to use frac.
Frac
In principle, the frac function will cut any value down to just it's decimal value. if we have 0.4, it'll still be 0.4, but 2.4 will now also be 0.4. 1.6 will be 0.6. 10.8 will be 0.8. The most common usage of this is to force a tiled texcoord.

Which combined with our prior code can give us things like this:


This is different to just multiplying the uv, as without the frac we'd get this:

What the frac is doing isn't 'tiling' so much as creating multiple tiles of itself, which our additional inputs are then mapped on to. Inside ShaderToy, we can do this with 'fract', but let's add this in after our shader looks a bit more interesting.
Sine
A lot of people will be using sine in the material graph alongside a 'Time' input, so let's start with that:
Our 'time' inside ShaderToy will be iTime, multiplied against our original value of 'a' will give us this circle disappearing into the ether on a loop:

To get multiple rings we have to multiply our value of 'a' by the same amount, to stretch out the values:

Two forward slashes '//' can be used to comment out a line, disabling it on compile.
Reintroducing the abs function here shows exactly how sine is working from -1 to 1, as we established before that abs flips the negative to it's positive equivalent, and so gives us visible rings in the negative space:


And if we reintroduce the frac from earlier:

We get a horrific wall of eyes<3
(and in Unreal)


Color
Color in HLSL & GLSL doesn't really differ from inside Unreal Engine. To add a color to our shader we just need to make a vec3 and assign some RGB values to it, the same as we did with fragColor earlier.

It's important to note the values are not restricted to 0-1 space, and can be any positive value. Anything above 1.0 will have more of an emissive quality, which gets stronger as we increase the value.

Sine can also be used in the same way to animate the color. We can copy the code we have on sin(a) to change the color channels over time:


To stop the color from going to full black we can substitute one of the sine functions to a cosine, as cos starts half a cycle earlier than sin, and will display blue while red is in the negatives. Cos staring at 1 rather than 0 can make it better for certain operations, and both cos and sin are 4 GPU cycles.



ShaderToy does have a color palette plugin to give you a color wheel, so it's less guess work what you're getting. There's also this default function we can use that can be found on Quilez's website, which will give us a palette to sample from:

You can use this gradient creator which I'll link again at the bottom of this post to help visualise and create your own gradient values. Those values you generate can be copied into vec3 variables before the mainImage function.

Calling the palette means we can remove the extra code with cos and sin function in fragColor, as the palette is now taking care of the color transitions with it's own cosine.

1-x

To flip our results and get something that looks more Tron-inspired we can use the 1-x function, which is straight out of the box in Unreal, but if we want to write it ourselves we have a bit more flexibility. Here, instead of diving 1.0 by a, I'm going to use 0.1, for a nicer output. Occasionally you may have to scale down this value below 1.0 if your values exceed 1.0 or -1.0, since your output may no longer be visible.
Much like Unreal, we can also use multiple sets of uv data. Creating a call to the uv under a variable before they're altered will allow you to same the color of the original uv before the frac operation, like so:


Using larger values may result in something a little bit visually overwhelming, so if your background runs the risk of setting off a bout of epilepsy, multiply iTime by a decimal value so it chills out a bit.

For Loops
And of course the thing everyone looks to HLSL for in Unreal: iteration. Here we can wrap this function in a for loop to create circles upon circles. The usual way to write this out is:

If we add our color code into this function, and change the value of 1. to something higher, we'll get numerous loops of our code, resulting in layers of smaller and smaller circles as the uv is frac'd (new word) again and again.


I've also changed the uv multiplier to a decimal number for a less uniform break up of shapes: uv = fract(uv * 0.7)- 0.5;
Power
We can use the power operation to increase the contrast of our output, raising lower values while keeping those closer to 1 relatively unchanged.

Here it's the 1-x operation we're adding to, as that's determining our current output width of the circles. The value after the comma is our exponent, and the higher it is, the sharper the contrast will be.
And despite that being super insanely long, that's the basics. If you're familiar with materials in Unity or Unreal, you'll notice a lot of the operations done there are identical to GLSL and HLSL, albeit with some different names. I hope this serves as some good quick reference for anyone who needs it, and I'll provide links to good resources below.
Thanks for reading ! Go make some zany shaders 🧨
Helpful resources:
Comments