Introduction to OpenGL

Part 3: Shaders and Shader Language

In this part we will cover the shaders and the shader language GLSL. Shaders are fundamental in OpenGL. They are the tool to take advantage of the graphics hardware that is specifically designed and optimized to perform all the operations required for all the effects we need in our games.

As we have seen in the previous part after we organize our data the control goes to OpenGL and the underlying hardware. Using shaders is the key to fast and eye-catching graphics that will make our applications stand out.

Everything we cover in this part is included in the pipeline example like the previous part.

The language

The language we use for shaders is GLSL. Its syntax is based loosely on the C programming language. Here is a simple shader program:

#version 410 core
uniform mat4 view;
in vec4 color;
out vec4 vs_color;
void main() {
    vs_color = color;
    float f = 2.0f;
    vec4 v = vec4(1,2,3,4)
}

As we can see, statements are quite simple and easy to understand and write. We can create blocks of code by enclosing the statements in curly brackets. The syntax is quite easy to understand if you have some experience with C or C++.

Since the language is so close to the C/C++ syntax we will just do a brief pass over its features.

The Preprocessor

Just like C/C++ GLSL has a preprocessor. It takes the input shader code and modifies it according to the command we issue. Here is the list of the preprocessor commands:

#version: this command must be in the first line of the code. It instructs the compiler to stick to a certain version of the language. This means that the compiler will throw an error if it encounters a feature not supported by the specified version.

#extension: used to enable or disable an OpenGL extension that is not yet part of the core implementation of OpenGL.

#pragma: allows compiler control. We can use it to enable or disable optimizations or debug mode compile.

#error, #define, #undef, #if, #ifdef, #ifndef, #else, #elif, #endif: are the same as in C/C++ and need no extra explanation.

#version 410
#extension ARB_explicit_attrib_location : enable
#pragma optimize(on)
#pragma optimize(off)
#pragma debug(on)
#pragma debug(off)
#error Some error occurred
#define MY_VALUE 2
#undef SOME_VALUE
#ifdef MY_VALUE
#ifndef SOME_VALUE
#if MY_VALUE == 3
#else
#elif MY_VALUE == 4
#endif

Data types

This is a topic that needs a thorough approach. GLSL introduces some data types we do not have in C/C++. These data types are required to handle the geometry of objects and the transformations that must be applied. Here is the list of data types in GLSL.

All the matrices in OpenGL are column major. That means that in a 4x4 matrix for example the first four elements are those of the first column. One more thing we must keep in mind is that when we talk about the dimensions of a matrix, the first number is the number of columns.

Vectors

Vectors are an important and flexible data type in GLSL. They can be used to access vertex positions, normal vectors, colors, and textures. Vectors can be found anywhere in the shader programs we create and can help us accomplish complex tasks.

First let us see the members of the vector type.

vec2 has only the first two members, vec3 the first three, and vec4 has them all. We can access them either using the index of their position in the vector, or using the corresponding name:

If v is a vector, v[0] is equivalent to v.x and v.r and v.s. Be careful though because if v is a vec2, then v.z is not defined and will yield an error.

Accessing the elements of a vector using their names is very flexible. Let us assume that v is a vector of type vec4. Here is what we get when accessing its members:

It is obvious that there are numerous combinations we can have when accessing the elements of a vector.

Another thing worth mentioning is the initialization of vectors. The language accepts many combinations of vectors, and values:

// initialize a vector with floats
vec2 v = vec2(1, 2);
// initialize using another vector and float
vec3 u = vec3(v, 1);
// or using a float and a vector
vec3 u2 = vec3(1, v);
// use part of u and v
vec4 t = vec4(u.xy, v);
// use x of u for all of t elements
vec4 t = vec4(u.xxxx);
// use 1 for all of the elements
vec4 t2 = vec4(1);
// member access can be used to set values as well
t2.xy = t.zw;

We can use another vector to initialize one, or if the vector we use is short we can fill in with another vector or values. The only imit to the combinations we use is the actual problem we are trying to solve and the data we have available.

Matrices

We have cleared that matrices in OpenGL are column major, so m[0] is a vector of the elements of the first column. Its length depends on the number of rows in the matrix.

vec2 v = vec2(1, 2);
vec3 u = vec3(v, 1);
mat4 m;
m[0] = vec4(v, 2, 3);
m[1][0] = 5;
m[2] = vec4(u.xy, v);

Operations between matrices and vectors

We said earlier that vectors can be used to represent vertex positions and matrices can hold transformations. In this section we will see the operations we can do with vectors and matrices to do all the calculations we need.

Here is a sample code snippet with the operations we can do:

mat3 t, r;  // translation, rotation
vec3 v, u;  // two vectors
float f;    //
// assume the variables are initialized

u = f * v;  // scaling vector v
// or
u = t * v;  // translate v
// or
u = r * v;  // rotate v
// or
// translate and then rotate v
u = r * t * v;
// setting the final location of vertex_pos
// using view and model matrices set by our program
gl_Position = view * model * vertex_pos;

Just a little reminder here. As we saw in part 2, although matrix multiplication is performed from left to right, the results propagate from right to left. So, when we perform the operation u=r*t*v, we end up applying translation and the rotation.

Arrays

Vectors and matrices are collections of numeric data organized in certain ways, based on the mathematical concepts behind them. If we want collections of data of arbitrary size we need arrays. Arrays in GLSL are declared like arrays in C++.

int indices[5];
vec3 vertices[] = {vec3(0,0,0), vec3(1,1,1), vec3(2,2,2)};

Accessing the elements in an array is similar as well.

vec3 pos = vertices[1];
indices[2] = 3;

Flow control

GLSL, like any other language, supports flow control of the code depending on conditions. Being modeled after the C programming language means that whatever we present here is like thing we already know from our experience with C++, which is based on C to ease transition.

In GLSL we have if/else and switch/case to direct code execution based on conditions.

in int a_variable;
out vec4 s_color;
void s_conditional()
{
    if (a_variable == 2)
    {
        gl_Position = vec4(1, 2, 3, 4);
        s_color = vec4(1, 1, 1, 1);
    }
    else
    {
        gl_Position = vec4(4, 3, 2, 1);
        s_color = vec4(1, 1, 1, 0.5);
    }

    // or 
    switch (a_variable)
    {
    case 1:   // set color to white
        s_color = vec4(1, 1, 1, 1);
        break;
    case 2:   // set color to red
        s_color = vec4(1, 0, 0, 1);
        break;
    case 2:   // set color to green
        s_color = vec4(0, 1, 0, 1);
        break;
    default:   // set color to blue
        s_color = vec4(0, 0, 1, 1);
        break;
    }
}

Loops

GLSL supports the three basic loops we know. These are the for, while, and do/while loops. Whatever we said about these loops in our C++ introduction is valid for GLSL loops as well. They behave in the same way. for and while loops perform the check in the beginning of the loop, while the do/while loop performs the check at the end. This means that the first two may never enter the code inside the loop, and the third will execute that code at least once.

Their syntax is the same as in C/C++ we know already:

void loops()
{
    for (int i = 0; i < 10; i = i + 1)
    {
        // do something
    }
    int j = 0;
    while (j < 10)
    {
        // do something
        j = j + 1; // remember to increase counter!
    }
    int j = 10;
    do {
        // do something
        j = j - 1; // remember to decrease counter!
    } while (j > 0);
}

Structures

As we saw in C++ structures are custom data types. They give the opportunity to put together data describing one entity.

struct my_vertex {
    vec3 pos; // position
    vec2 tex; // texture coordinates
    vec3 normal; // normal vector
};
// here we have an array of structures
my_vertex mv[10];
mv[0].pos = vec3(1, 1, 1);
// initializing an array 
my_vertex v = my_vertex(vec2(0, 0, 0), vec2(1, 1), vec3(1, 0, 0));

We see that GLSL structures are simple, and they do not defer much from C++ structures. Declaring, accessing, and initializing a structure is modeled after the simplest versions of their C/C++ counterparts.

Functions

Now is the time to see how we can organize our shader code into meaningful and reusable blocks. As in C/C++ functions can take arguments and return values.

Let us start with the entry point to our shader. The first function of our shader called by the system is as you may have guessed the main function. This function takes no arguments and returns nothing.

Our shaders are supposed to perform whatever calculations we need and finally set predefined OpenGL variables that will be used to create the scene. Here is a main function that sets the final position of a vertex:

void main(){
    gl_Position = vec4(aPos, 1.0);
}

As we do in any programming task that is complex and big, putting all the code in one function is not a clever idea. The following example shows how we can use a function that takes a direction vector and returns a new direction.

vec3 redirect(vec3 dir)
{
    // this might be the result of a complex calculation
    return vec3(-dir.x, dir.y, dir.z);
}

We have seen the benefits of creating and using functions when we talked about the subject in C++.

There are many built-in functions in GLSL to assist us in developing our code. These include trigonometric functions, logarithmic and exponential functions, common mathematical functions, geometric functions, relation and logical functions, and texture functions.

Shader input and output

Data defined by our application is input for the shader. The shader performs some calculations and then outputs the results.

The vertex shader for example, accepts as input the vertex coordinates and converts them into clip coordinates, using view and model transformations, for the fragment shader to use.

Input variables

Shaders know nothing about our world. They are great at exploiting the graphics hardware and performing complex calculations. We can layout our vertex data and attributes so that OpenGL can pass them to the shader.

Input variables to the shader, are declared with the keyword in:

in vec4 color;

Our first job is to 'tell' the shader how our data is organized. Let us assume that our vertex has its coordinates organized as a three-dimensional vector. For this information se are creating a variable of type vec3. As we saw in the previous part this information is the first we put in the vertex array. This is accessed by the shader with a layout qualifier. The layout qualifier specifies where the variable is stored within the vertex array.

To read the coordinates of the vertex we will create a variable of type vec3 and call it aPos. To read the actual vertex data we must use the layout qualifier.

layout(location = 0) in vec3 aPos;

This means that in the first buffer of the vertex array and in the form of a vec3 structure we have stored the vertex coordinates. Remember how we set up the vertex data:

// create a buffer for the vertices
glGenBuffers(1, &vertex_buffer);
glBindBuffer(GL_ARRAY_BUFFER, vertex_buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
glEnableVertexAttribArray(0);

OpenGL knows how to 'walk' through our data and set the data pointers correctly, and now our shader knows how to read that data.

The vertex sampler can have similar input, the texture coordinate of the vertex. This information must be passed to the fragment shader. We will see how the vertex shader outputs the information in the next section, now we are interested in how the fragment shader reads it. Reading input requires the in directive at the variable declaration. Variables that are passed from one step to the other do not require the layout qualifier.

in vec2 texCoord;

Another type of input mechanism is the uniform. With this we pass named variables of any type we need to the shader. A uniform is a global storage location that is accessed by its name. we can write data to it from our C/C++ code and then read that data in our shader.

We use the glUniform family of functions to write to a location. In our pipeline example we pass the model and view matrices using our set_mat4 function, which passes them like this:

glUniformMatrix4v(glGetUniformLocation(program, matrix_name), 1, GL_FALSE, matrix_variable);

And then we read it in our shader like this:

uniform mat4 view;

Uniforms are accessible in all stages of the pipeline.

Output variables

As we have said, shaders must generate some output. This either by setting OpenGL internal variables like vertex position in clip coordinates, or by setting variables for the next step in the pipeline.

gl_Position is a built-in function in OpenGL that holds the vertex location after we have finished our calculations about it.

The variables passed to the next step are preceded with the keyword out, letting the compiler generate the code to link this variable with the next step. To complete the example of the previous section, where we needed to read the vertex texture coordinate in the vertex shader and pass it to the fragment shader, here is how we output the variable:

out vec2 texCoord;

Which was then treated as input by the next stage like this:

in vec4 es_color;

Summary

In this part we did a brief pass over the OpenGL Shading Language. Our main objective was to get the basics of the language. Its features will be explained in depth in the next parts as we will study the techniques used for the various visual effects.

Introduction to OpenGL