Minimal OpenGL project
This is an in-depth description of our minimal OpenGL project which can be found on gitlab. Some non-OpenGL specific code was left out for the sake of simplicity.

Preliminaries

Before you proceed update your GPU drivers!

On linux you might have to install the development libraries for OpenGL (xorg-dev on Ubuntu)

Includes

For error handling, file reading and C++ arrays we need the standard libraries iostream, fstream and vector. As most operating systems only provide an out-of-date version of the OpenGL headers, we use glad to generate the most recent version. Finally there is GLFW which provides platform independent windows wherein our OpenGL context will be embedded. In the end, we define a dark grey color which will be used later for clearing the color buffer.

1 #include <iostream>
2 #include <fstream>
3 #include <vector>
4 
5 #include <glad/glad.h>
6 #include <GLFW/glfw3.h>
7 
8 #define DARK_GREY 0.2f, 0.2f, 0.2f, 1.0f

Code listing 1: main includes

Structs

Here we define two structs which will help us hold important data later in the program. The Scene holds the current shader program ID and the ID of the vertex array object that are supposed to be drawn. Our vertex data is described by the Vertex struct which consists of a 3D position and an RGB color. For more sophisticated applications this struct will also include texture coordinates, normals and possibly other data.

 1 struct Scene
 2 {
 3     GLuint programId = 0;
 4     GLuint vertexArrayId = 0;
 5 };
 6 
 7 struct Vertex
 8 {
 9     float position[3];
10     float color[3];
11 };

Code listing 2: struct definitions

Vertex data

In this simple program we hardcode the mesh we want to render directly into the code. vertices is a vector of Vertex structs which consists – in this example – of four vertices that together form a square. The second vector defines the faces of the mesh by specifying the vertices by their indices. Thus the first triangle will consist of the first, second and third vertex while the second face uses the first, third and forth vertex. With this approach we can reduce the memory consumption as we don't have to duplicate the vertices.

In case you are wondering how OpenGL is aware of the fact that we intend to render triangles and that exactly three vertices form a face – don't worry, we will cover that later on. In fact, the elements vector does not contain this information.

 1 std::vector<Vertex> vertices = {
 2         {{-0.5f, -0.5f, 0}, {1, 0, 0}},
 3         {{0.5f, -0.5f, 0}, {0.5f, 1, 0}},
 4         {{0.5f, 0.5f, 0}, {0, 1, 1}},
 5         {{-0.5f, 0.5f, 0}, {0.5f, 0, 1}},
 6 };
 7 
 8 std::vector<GLuint> elements = {
 9         0, 1, 2,
10         0, 2, 3,
11 };

Code listing 3: sample vertex data

Creating a window

Now, we use GLFW to create a window. Thanks to this library our code runs on all major platforms. For more information on how to use GLFW, see the official documentation.

We start by calling glfwInit(), which is required for all the other functions we need. We then set the OpenGL version we want to use version 4.0 as well as to activate the core profile which will disable all the legacy OpenGL functionality.

GPUs generally provide a higher version if you request the core profile. In case you don't want the legacy functionality you should request the core profile.

Now we can actually create the window by passing the desired width, height, and a window title. The two nullptrs are optional parameters, which may be used to specify a monitor or share resources with another window. In case the window creation fails we want to abort. Thereafter we "activate" the current window so it will be used by OpenGL.

 1 glfwInit();
 2 
 3 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
 4 glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
 5 glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
 6 
 7 GLFWwindow* window = glfwCreateWindow(
 8         width, height,
 9         "Minimal OpenGL",
10         nullptr, nullptr
11 );
12 
13 if(window == nullptr)
14 {
15     throw std::runtime_error("Failed to create GLFW window");
16 }
17 
18 glfwMakeContextCurrent(window);

Code listing 4: GLFW window creation

You can click on all GL and GLFW function to quickly access their documentation.

Basic initialization

Now, with help of GLFW, we initialize glad so we can use all new OpenGL functions of the currently active version. Thereafter, we enable the z-buffer which will take care of rendering only those faces which are not obstructed by others. Actually, we don't need that for our simple mesh, as it is flat, however we enable it here anyway. In the end, we set the GL_CLEAR_COLOR to the same dark grey we defined earlier. This color will be used every time the scene is rendered again.

1 if(!gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress)))
2 {
3     throw std::runtime_error("Failed to initialize GLAD");
4 }
5 
6 glEnable(GL_DEPTH_TEST);
7 glClearColor(DARK_GREY);

Code listing 5: glad loader initialization

Building the shader program

As the OpenGL core profile only supports the programmable pipeline we need shaders to do the rendering work. This part consists of compiling the individual shaders and then linking them to a shader program which will be used when rendering meshes.

Compiling

First, we read the file into a char array codeAsCharArray. After that, we let OpenGL create a shader of appropriate type for us which we can refer to using the shaderId. Next we pass the code to the shader using glShaderSource. The arguments are: the shader we want to fill, the number of code lines (here, a single one), the lines of code (here, everything as one line), the length of the code (nullptr means that the length will be automatically determined). The last step is to compile the shader and optionally check whether the compilation succeeded.

This compilation step has to be executed for both of the shaders. Once with SHADER_TYPE=GL_VERTEX_SHADER for the vertex shader and then again with SHADER_TYPE=GL_FRAGMENT_SHADER for the fragment shader. There are also matching shader types for tessellation, geometry, and compute shaders. As a result, we have a shader ID for each shader we compiled.

 1 std::ifstream fin(filename);
 2 std::string code((std::istreambuf_iterator<char>(fin)), std::istreambuf_iterator<char>());
 3 const char* codeAsCharArray = code.c_str();
 4 
 5 GLuint shaderId = glCreateShader(SHADER_TYPE);
 6 
 7 glShaderSource(shaderId, 1, &codeAsCharArray, nullptr);
 8 glCompileShader(shaderId);
 9 
10 int success;
11 glGetShaderiv(shaderId, GL_COMPILE_STATUS, &success);
12 
13 if(!success)
14 {
15     throw std::runtime_error("Failed to compile shader");
16 }

Code listing 6: reading and compiling a shader

Linking

In this step we link the individual shaders together to create a shader program. First, we tell OpenGL to create a shader program. Subsequently, we attach our compiled shaders to the program using the shader IDs from the previous step. Finally, we call glLinkProgram() and optionally check for errors. As the shaders themselves are not used anymore, we can delete them to save resources. By now, the important information are contained in the programId.

 1 GLuint programId = glCreateProgram();
 2 glAttachShader(programId, vertexShaderId);
 3 glAttachShader(programId, fragmentShaderId);
 4 glLinkProgram(programId);
 5 
 6 int success;
 7 glGetProgramiv(programId, GL_LINK_STATUS, &success);
 8 
 9 if(!success)
10 {
11     throw std::runtime_error("Failed to link shader program");
12 }
13 
14 glDeleteShader(vertexShaderId);
15 glDeleteShader(fragmentShaderId);

Code listing 7: linking the shaders

Preparing the buffers

OpenGL needs the mesh data to be stored in buffers, which tell it what the data is about and how to use it. These buffers are called vertex buffer objects (VBO). However, as an object could consist of more than one buffer object these are grouped together under a so called vertex array object(VAO). Now let's create them

Creation and data

As with the shaders, OpenGL creates both the VAO and VBOs for use with simple functions and returns a handle for each of them. The first parameter denotes the number of objects we want to create (only 1 here). In order to change the contents of a VAO we first have to bind it. Then we bind the first VBO and fill it with the previously described vertex data. As the buffer contains the actual vertex data, we have to specify GL_ARRAY_BUFFER. The other arguments are: the size in bytes of the data, a pointer to the beginning of the data, and a hint on how the data will be used. GL_STATIC_DRAW means that the data does not change at all (this will help OpenGL optimize the storage).

The second buffer holds the indices for the vertices. Thats why the first parameter changes to GL_ELEMENT_ARRAY_BUFFER.

 1 GLuint vertexArrayId;
 2 GLuint vertexBufferId;
 3 GLuint elementBufferId;
 4 
 5 glGenVertexArrays(1, &vertexArrayId);
 6 glGenBuffers(1, &vertexBufferId);
 7 glGenBuffers(1, &elementBufferId);
 8 
 9 glBindVertexArray(vertexArrayId);
10 
11 glBindBuffer(GL_ARRAY_BUFFER, vertexBufferId);
12 glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices.data(), GL_STATIC_DRAW);
13 
14 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementBufferId);
15 glBufferData(GL_ELEMENT_ARRAY_BUFFER, elements.size() * sizeof(GLuint), elements.data(), GL_STATIC_DRAW);

Code listing 8: vertex array object and vertex buffer object creation

Vertex attribute arrays

In the next step we describe what kind of data the first vertex buffer contains (the one that holds the vertex data). In the second code listing we see that a vertex consists of two different properties: the position and the color. This structure is what we are going to describe now using vertex attribute arrays.

With glEnableVertexAttribArray(0) we tell OpenGL that we are using the first entry in the vertex attribute array. To configure it we call glVertexAttribPointer() again with index 0 and tell it that we have 3 elements of type GL_FLOAT which we don't want to normalize (GL_FALSE). The stride is sizeof(Vertex), meaning that every sizeof(Vertex) bytes a new vertex begins and the offset is the same as the offset of the position in the Vertex struct (0 here). The color attribute is handled in the exact same way. If your buffers contain normal vectors you might want to set normalize to GL_TRUE, however for positions and colors that does not make sense.

In the end, we can unbind the VAO to prevent an accidental reconfiguration of it.

1 glEnableVertexAttribArray(0);
2 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(offsetof(Vertex, position)));
3 
4 glEnableVertexAttribArray(1);
5 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(offsetof(Vertex, color)));
6 
7 glBindVertexArray(0);

Code listing 9: describing the vertex format

Shaders

As we have said many times OpenGL Core uses the programmable pipeline. That's why we must provide the code for these programmable steps in the form of so called shaders. These are small programs that run on the GPU. There must be at least a vertex shader which runs for each vertex and a fragment shader which colors the individual pixels affected by the triangles. Note, that the shaders are programmed in a separate language called GLSL (OpenGL shading language).

Let's start with the vertex shader. The first line must specify the used shader version in OpenGL Core version. We are using the same shader version and OpenGL version. The next line is used to match the general structure of Vulkan shaders so they are more similar. After that, we declare the inputs to the shader. These correspond to the vertex array attributes defined here. There exists one for the position and one for the colors. In the following line, we define the outputs that will be passed eventually to the fragment shader (or other shaders that could be in between). In this example we are only interested in the color. Another step involves the declaration of the uniform data that we send to the shader which we describe in the next step. Finally, we give the actual code that will be executed for each vertex in a main function. The standard vertex shader simply transforms the input vertices according to the three transformation matrices and passes the color on to the next shader.

 1 #version 400 core
 2 #extension GL_ARB_separate_shader_objects : enable
 3 
 4 layout(location = 0) in vec3 inPosition;
 5 layout(location = 1) in vec3 inColor;
 6 
 7 layout(location = 0) out vec3 outColor;
 8 
 9 uniform mat4 model;
10 uniform mat4 view;
11 uniform mat4 projection;
12 
13 void main()
14 {
15 	gl_Position = projection * view * model * vec4(inPosition, 1.0);
16 	outColor = inColor;
17 }

Code listing 10: minimal vertex shader

Between the vertex and fragment shaders, the primitive assembly and rasterizer will operate. These are merely configurable steps in the pipeline, so there are no shaders for them (there is some control with tessellation and geometry shaders over that process). Corresponding to the single output in the vertex shader we need a single input in the fragment shader: the color. As there exist far more fragments than vertices the, colors are interpolated between the vertices, so we actually obtain a mix of the corner colors passed to the fragment shader. The main function here simply passes the incoming interpolated color to the only implicit output variable plus an alpha channel whose value is always 1.0 (fully opaque).

1 #version 400 core
2 #extension GL_ARB_separate_shader_objects : enable
3 
4 layout (location = 0) in vec3 inColor;
5 
6 void main()
7 {
8 	gl_FragColor = vec4(inColor, 1.0);
9 }

Code listing 11: minimal fragment shader

Uniforms

Sometimes we need data that does not change during a draw call, i.e., all the vertices and fragments need to receive the same data. These values are called uniforms and they are sent from the host during the render loop. A typical use case is to pass the classical transformation matrices like model, view, and projection.

For the sake of simplicity, we only pass an identity matrix for each of the uniforms to the shaders, which will result in no transformation at all. Normally, the model matrix will describe translation, rotation, and other affine transformations for the model inside the world. The view matrix describes the fact that the camera moves and the projection matrix can either provide orthographic or perspective projections.

The first step is to find out the uniform location inside the shader. This lookup is done by name and actually matches the variable names in the shaders. The call to glUniformMatrix4fv() then updates the uniform at the provided location (only 1 matrix is passed), the matrix is not transposed (GL_FALSE, compare row major vs column major).

In order to fill these matrices with meaningful data, you could take a look at the excellent glm (OpenGL Mathematics) library which mimics the matrix and vector operations from GLSL. Despite its name, it also works with Vulkan.

1 float identity[16] = {
2         1, 0, 0, 0,
3         0, 1, 0, 0,
4         0, 0, 1, 0,
5         0, 0, 0, 1
6 };
7 glUniformMatrix4fv(glGetUniformLocation(programId, "model"), 1, GL_FALSE, identity);
8 glUniformMatrix4fv(glGetUniformLocation(programId, "view"), 1, GL_FALSE, identity);
9 glUniformMatrix4fv(glGetUniformLocation(programId, "projection"), 1, GL_FALSE, identity);

Code listing 12: minimal fragment shader

Actually drawing something

The actual drawing happens in a large while loop. We want to leave the loop if GLFW tells us that the window should be close, which can be triggered by, you guessed it, closing the window. Now, three important steps have to be taken: First, we clear the color buffer so we can start to draw onto a clear canvas. If you don't do that and move the camera you will see that the objects 'smear' all over the window. More importantly the z-buffer must be cleared, otherwise nothing might be drawn.

Second for every object we want to draw (only a single one here) we bind its VAO using the handle we got from code listing 8, activate a shader program with the ID we obtained from the shader program creation and finally issue a draw call. As we are using an index buffer, we want to call glDrawElements(). We provided three indices per face, so we draw GL_TRIANGLES. We also provide the number of indices, which type they are, and a nullptr, whose meaning is not important here, though.

After every object is drawn, we tell OpenGL that we are done with GLFWSwapBuffers(). This swaps the buffer that is currently being displayed on your monitor with the canvas we just drew on.

 1 while(!glfwWindowShouldClose(window))
 2 {
 3     glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 4 
 5     glBindVertexArray(vertexArrayId);
 6     glUseProgram(programId);
 7     glDrawElements(GL_TRIANGLES, static_cast<GLsizei>(elements.size()), GL_UNSIGNED_INT, nullptr);
 8 
 9     glfwSwapBuffers(window);
10 }

Code listing 13: render loop

Cleanup

After the window was closed, we should do some cleanup. GLFW fortunately handles that for us.

1 glfwDestroyWindow(window);
2 glfwTerminate();

Code listing 14: cleaning up

Result

Now go ahead and download the code. In the readme you find all the information on how to build the project. If everything worked you should see a colored square.

Rendered result: a colored square on grey background.
References:
Learn OpenGL: