Shader Adventures: GLSL Language

By Victor Gridnevsky
Dec. 16, 2019

This article introduces GLSL fundamentals using ShaderToy site.

GLSL language is a computer programming language, similar to C. To program your own fragment shaders, you need a basic understanding of its syntax: if you can write your “Hello, World” program, you can ignore this part of the article.

So, let's start with a brief explanation if you are not sure.

You will definitely need:

  • a concept of functions and function arguments
  • to understand what main function does
  • to know about data types like int, float

(Alternatively, you can read more about C in a tutorial by TutorialsPoint.)

So, programs are executed sequentially, meaning that in the simplest case, your code is being executed line-by line. Sequences of these lines are written inside braces / curly brackets: { } so the code is divided to different parts doing different things. There are several ways to tell the computer how this part is called.

For example, it’s hard to achieve much by simply executing sequences of lines: we can calculate something the way we do on a calculator, but it’ll be harder to express something like this: z = (sin(y^2) + 15) / 2.

GLSL language makes it possible to use parentheses “( )” for computation order. It also considers priorities between things like sum and subtraction, multiplication and division while calculating values.

So, taking the idea of parentheses in mind, we can write code like this:

float z = sin(pow(uv.y, 2.0) + 15.0) / 2.0;

You might wonder what float before z variable means in this context.

It means that we are thinking about a floating ‑ point number, i.e. real number like (1.0), which has a fractional component with a given precision, not an integer like 1. We write a data type before each variable, once.

Data type describes what we return, for example:

  • float for a real number like 0.5
  • integer for a number like 10
  • vec2 and vec3 for vectors, which contain several numbers: vec2(0.1, 0.2) or vec3(0.1, 0.5, 0.8)

In a little piece of the code above, z is a variable and it stores some data.



Each variable in GLSL needs a data type, i.e. a way for the GPU to know how it should process contained data:

  • float — floating ‑ point value, for example, 1.0
  • vec2 — vector of two floating ‑ point values, i.e. vec2(0.1, 1.0)
  • vec3 — vector of three floating ‑ point values
  • vec4 — vector of four floating ‑ point values
  • mat2 — 2x2 matrix of floating ‑ point values
  • mat3 — 3x3 matrix of floating ‑ point values
  • mat4 — 4x4 matrix of floating ‑ point values
  • bool — boolean value type, i.e. true or false
  • bvec2 — boolean vector with 2 elements, i.e. bvec2(true, false)
  • bvec3 — boolean vector with 3 elements, i.e. bvec3(false, true, false)
  • bvec4 — boolean vector with 4 elements, i.e. bvec4(false, false, false, false)
  • int — integer value, i.e one of 1, 2, 759, ...
  • ivec2 — integer vector with 2 elements, like ivec2(3, 4)
  • ivec3 — integer vector with 3 elements, like ivec3(5, 6, 7)
  • ivec4 — integer vector with 4 elements, like ivec4(0, 1, 2)

So, before we move forward, let's review:

int u = 255;
float v = 0.5;
bool w = false;
vec2 x = vec2(0.4, 0.5);
vec4 y = vec4(1);
mat4 z = mat4(
    1.5, 2.4, 3.3, 4.2,
    5.1, 6.0, 7.9, 8.8,
    9.7, 0.6, 1.5, 2.4,
    3.3, 4.2, 5.1, 6.0
);

Vector types

These are used to unify several numbers together. There are three floating ‑ point vector types in GLSL: vec2, vec3 and vec4. There are also boolean and integer vector types, which are filled in a similar fashion.

You can initialize a vector type by providing one value for all its components or by providing each one separately. For vectors more than two, you can provide vectors so new vector is built from previous one’s values.

Look through this code so it’ll be clear.

// Vector with two float values (two-component vector),
// each value is equal to 1
vec2 vectorTwoComp = vec2(1.0);
// Two-component vector, first value equals to 1,
// second one is equal to zero
vectorTwoComp = vec2(1.0, 0.0);
// Three-component vector, first value is equal to 1,
// second is equal to 0, 3rd is equal to 1
vec3 vecThreeComp = vec3(vectorTwoComp, 1.0);
// Four-component vector,
// made by repeating vectorTwoComp twice 
vec4 vecFourComp = vec4(vectorTwoComp, vectorTwoComp);
// These two should be readable now
vecFourComp = vec4(vectorTwoComp, 1.0, 0.0);
vecFourComp = vec4(0.0, 1.0, vectorTwoComp);

You also can access vector values with letters like xyzw or rgba. It doesn’t matter which of these two sets of letters you use.

You can both read and write vectors.

// Reading a vector
float x = col.z;
// Writing vectors
col.rgb = vec3(1.0);
col.xyz = vec3(1.0);

Vectors are used to represent 2D coordinates for a shader, so we can write something like fragCoord.x or fragCoord.y later, but it’s a data-type and math concept, so we are using vectors for colors and anything we want to.

We are using RGB palette, output has transparency / alpha-channel, but it is ignored in ShaderToy. So, to represent a black color, where red, green and blue values are equal to zero, we write vec3(0.0, 0.0, 0.0).

Let's return to braces: {}. Blocks of code inside braces can be used in several ways:

  • Called on a certain condition
  • Repeated in a cycle
  • Called in a fashion similar to math functions

Let's consider conditions.

How do we make a GPU follow conditions? Let's assume we have a variable named s. We'll change it, just to do something.

int s = 1;
if( s > 0 ) {
    s = s - 1;
} else {
    s = 5;
}

Let's discuss an example of a cycle. What if we want to repeat an action one hundred times? Well, we move from 0 to 99. We also need a variable. An integer, to be precise.

Also, we'll also have a changing s variable, again, just to do something inside our cycle.

float s = 0.0;
for(int i=0;i<99;i++){
    s += 1.0;
}

We can write functions with arguments. Yes, these things that are called sometimes, in math-like fashion. Functions usually return something with return keyword at the start of a line. Functions are written using a syntax like this:

// comment
data_type functionName (argument1, argument2, ...) {
    code line;
    code line;
    ...
    return variableName;
}

So, let's review with a function that returns RGB / (red, green, blue) values in a vector of tree float values:

// Returns an RGB color vector for a black color:
// no red, no green, no blue.
vec3 black() {
    return vec3(0.0);
}

// Returns a color for each individual pixel
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    fragColor.rgb = black();
}

Comment means nothing for a program, but should be readable to us humans.

It's useful to have meaningful comments when we want to remember what happens in our code. It is also useful to know what happens in someone else's, and it'd better be, but it depends on programmer's conscience.

Function name can contain uppercase and lowercase letters, as well as an underscore character. You put variables function will be interacting with, i.e. reading or writing, as arguments inside parentheses, as x and y in this example:

vec2 fn(float x, float y) { ... }

Now we can move forward. Styles differ, but lines inside brackets are usually padded with 2 or 4 spaces before each. Consistency is nice for reading your code, so use 2 spaces, 4 spaces, or tabs and don't mix them. Please.

Your code will have both lines and finished expressions: not everything in your code will be as simple as x=y+z, so you might want to break some long expression to several lines. Each separate expression ends with a semicolon sign: ;. Remember the matrix definition? I will copy it.

mat4 z = mat4(
    1.5, 2.4, 3.3, 4.2,
    5.1, 6.0, 7.9, 8.8,
    9.7, 0.6, 1.5, 2.4,
    3.3, 4.2, 5.1, 6.0
);

There is a function that is called runs by default, for each pixel in our shader's viewport.

It’s called main in C language and GLSL by itself, but we’d use mainImage in ShaderToy.

Main function

Let's finally write something. For now, please register at ShaderToy.

We start with a New button in ShaderToy and see our first function.

ShaderToy_new_shader_button.png
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // Normalized pixel coordinates (from 0 to 1)
    vec2 uv = fragCoord/iResolution.xy;

    // Time varying pixel color
    vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));

    // Output to screen
    fragColor = vec4(col,1.0);
}

First, we notice it’s void function, i.e. it has no data type, so it returns nothing.

By its name, we assume it’s similar to C language's main function.

This function is running for each pixel or a point in our 2D space simultaneously, not just once from the start of the program. You are setting colors for points in Cartesian coordinates with X (horizontal) and Y (vertical) axis, which are passed into fragCoord vector. To access each coordinate, you write: fragCoord.x, fragCoord.y.

It's useful to think in these terms. You start to understand that:

  • the program will be faster if you don't run pieces of code not related to the current coordinate
  • too much cycles or linear code is slowing each pixel's rendering down

Since GPUs are using triangles for rendering, the rectangular “screen” of ShaderToy is actually represented by two triangles in OpenGL context.

two_triangles.png

Main function calls

In some cases, the main function can be called more or less often.

It will be called less often if one of the triangles, for example in game model, is not visible: no reason to draw a shader for it. There's also a supersampling option, but we'll discuss it later.

Function arguments

So, each time our mainImage function is called, it has two arguments. An input containing the coordinates, i.e. fragCoord, and an output, receiving a color.

If we won’t return any color, we can see a black screen or a random mess of pixels:

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {}
mess.png

Our screen or shader viewport's resolution count starts from zero, so instead of maximum horizontal value being equal to 512, we will see 511. We can check that by looking at division remainders now. It will set a visible color for a line of pixels if we are right.

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3( mod(fragCoord.x, 511.0) );
}

We can write an output color for each point in two ways: creating a variable or accessing fragColor vector, which I prefer to do.

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    vec3 color = vec3( 1.0 );
    fragColor = vec4( color, 1.0 );
}

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3( 1.0 );
}

We can obtain separate colors easily by setting one of the values in our color vector.

// Blue color
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3(0.0, 0.0, 1.0);
}
// Green color
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3(0.0, 1.0, 0.0);
}
// Red color
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3(1.0, 0.0, 0.0);
}
// We can mix colors.
// For example, let’s type code for yellow color.
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3(1.0, 1.0, 0.0);
}
// Now, white color is easy to get:
// it’s a mix of red, green and blue.
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    fragColor.rgb = vec3(1.0, 1.0, 1.0);
}


(Article uses a photo “Five birds flying on the sea” by Frank McKenna.)

To get back to Shader Adventures cycle, use this link.