OpenGL Notebook #1

9 minute read

Prefacing piece

Basically just a record and a distillation of stuff from here. Also OpenGL is mostly obsolete now and so this is largely educational, for me.

As in the resource used, OpenGL 3.3 is the version in use here. This also means this is based on GLFW and GLAD libraries.

This section hereon corresponds to this page

Drawing

OpenGL is a 3D graphics library so all coordinates used are correspondingly in 3D (x, y, z coordinates). Also, OpenGL uses normalized device graphics, meaning the only coordinates drawn are between -1.0 and 1.0. So we can have a set of vertex data like this:

1
2
3
4
5
float vertices[] = {
    -0.5f, -0.5f, 0f,
    0.5f, -0.5f, 0f,
    0f, 0.5f, 0f
};

This needs to be sent to be stored in the GPU memory, which we do so using vertex buffer objects (VBO) to allow sending as much vertex data at once as possible (sending stuff to GPU memory is slow).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned int VBO;           // the ID for the buffer
glGenBuffers(1, &VBO);      // create the buffer assigned to that ID

// bind the buffer to be used to the appropriate target GL_ARRAY_BUFFER
glBindBuffer(GL_ARRAY_BUFFER, VBO);

// copy vertex data onto the buffer's memory
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// unbind until use (then bind again)
glBindBuffer(GL_ARRAY_BUFFER, 0);

// inside a render loop, draw with
glDrawArrays(GL_TRIANGLES, 0, 3);

It should be noted that multiple buffers can be bound at once, only if they are of different types, which in this case is a GL_ARRAY_BUFFER.
glBufferData is a function used to copy user-defined data to the bound buffers, so the first argument is used to specify which buffer to copy to. The second and third arguments are related to the data we want to send.
The fourth argument has 3 forms:

  • GL_STATIC_DRAW: the data doesn’t change and used many times by the GPU
  • GL_STREAM_DRAW: the data doesn’t change and used a few times at most
  • GL_DYNAMIC_DRAW: the data is set often and used many times

Element Buffer Objects

There is an alternative to VBOs called element buffer objects (EBO). This allows the drawing of shapes that may share the same vertices, such as two triangles that may form a rectangle. In this case, we can create an array of vertices alongside an additional array of indices that indicate which vertices make up a shape:

1
2
3
4
5
6
7
8
9
10
11
12
/* taken directly from the learnopengl page */
float vertices[] = {
    0.5f,  0.5f, 0.0f,  // top right
    0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left
};

unsigned int indices[] = {
    0, 1, 3,   // first triangle
    1, 2, 3    // second triangle
};  

The generation of an EBO is similar to a VBO as well:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* first create the VBO to store the vertex information */
/* then the EBO to link to it */

unsigned int EBO;
glGenBuffers(1, &EBO);

// use a different specifier, GL_ELEMENT_ARRAY_BUFFER
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

/* all of the above (including both buffer objects) can then be linked, using a VAO (comes later on) */

// inside the render loop, drawing is also different
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

As noted, the EBO has to be linked to the VBO using a vertex array object, which comes later on in this post.

Shading

OpenGL requires the minimum of a vertex shader and a fragment shader to be made

A vertex shader converts the location data into some other location data to draw the vertices. Its input is one vertex at a time; it is also where the vertex data can be processed. A fragment shader determines the color of the pixels. It can calculate the final color to be shown based on the data in the 3D scene, such as lights and shadows.

In between those shaders, more processing happens. The vertices are assembled into primitive shapes (here, a triangle). The geometry shader generates new primitives based on the initial vertices and also its own emitted vertices. This data is then converted into pixels, called rasterization, to create the fragments for the fragment shader. After the fragment shader, the final stage performs calculations based on the depth to determine which pixels shouldn’t be colored, as well as checking the alpha levels(opacity) and blending colors correspondingly.

Vertex Shaders

Shaders in OpenGL are written in GLSL. A very basic vertex shader looks like this:

1
2
3
4
5
6
7
#version 330 core
layout (location = 0) in vec3 pos;

void main()
{
    gl_Position = vec4(pos.x, pos.y, pos.z, 1.0);
}

It begins with the version number, where the GLSL version corresponds with the OpenGL version. The next line indicates the input the shader takes, in this case a 3D vector called pos from location = 0, corresponding to the first argument to glVertexAttribPointer (which comes later).
Meanwhile, gl_Position is the vector to be used as the output of the vertex shader.

The shader then has to be compiled and assigned a shader ID, specified to be GL_VERTEX_SHADER:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
// here we assume vertexShaderSource is a character string containing the shader code
// it can be loaded from a file or written as a string in C++
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

int success;
char buf[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

if (!success) {
    glGetShaderInfoLog(vertexShader, 512, NULL, buf);
    std::cout << "ERROR: SHADER COMPILATION FAILED: VERTEX\n" << buf << std::endl;
}

Note that the second argument in glShaderSource specifies the number of strings (only 1 here). Also note that we should check if the shader compilation succeeded, using glGetShaderiv and printing any errors obtained by glGetShaderInfoLog.

Fragment Shader

As for the fragment shader, a very basic one follows a structure similar to a vertex shader

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(0.5f, 1.0f, 0.2f, 1.0f);
}

Here, note that there is a size 4 vector output on the second line called FragColor. This fragment shader does the basic task of setting primitives to a solid color with an alpha value (RGBA values respectively, with alpha 1.0 being completely opaque).

Also similarly, we assign the shader to an ID and compile it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned int fragShader;
fragShader = glCreateShader(GL_FRAGMENT_SHADER);
// also assuming fragShaderSource contains the shader source code
glShaderSource(fragShader, 1, &fragShaderSource, NULL);
glCompileShader(fragShader);

int success;
char buf[512];
glGetShaderiv(fragShader, GL_COMPILE_STATUS, &success);

if (!success) {
    glGetShaderInfoLog(fragShader, 512, NULL, buf);
    std::cout << "ERROR: SHADER COMPILATION FAILED: FRAGMENT\n" << buf << std::endl;
}

Note the use of GL_FRAGMENT_SHADER as the type when creating the shader and assigning the ID.

Shader Program

With both the vertex and fragment shaders created, we link them to a shader program. When we make calls to render the scene, the shaders in the active shader program will be the ones that are used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// create the shader program and get its ID
unsigned int shaderProgram;
shaderProgram = glCreateProgram();

// link both shaders created
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragShader);
glLinkProgram(shaderProgram);

// also check for linking errors!
int success;
char buf[512];
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, buf);
    std::cout << "ERROR: PROGRAM LINKING FAILED\n" << buf << std::endl;
}

// then set the shader program to active
glUseProgram(shaderProgram);

// delete shader objects (no needed anymore)
glDeleteShader(vertexShader);
glDeleteShader(fragShader);

Vertex Attributes

OpenGL knows how to draw each vertex but doesn’t know how to interpret the vertex data we’ve sent to memory yet, so we’ll have to tell it using glVertexAttribPointer (from earlier). Our vertex buffer data is currently structured like this:

Each vertex is made of 3 floats (for x, y and z coordinates), so they’re 12 bytes in size (3 4-byte floats). The vertices are stored in an array so there is no space between each vertex value. From that, we pass them as arguments to the function:

  1. The index of the vertex attribute to configure, corresponding to the property in the vertex shader - vertex position was at layout (location = 0) in the vertex shader
  2. The number of components in the vertex attribute - a size 3 vector in this case
  3. The data type of each component
  4. Whether or not the data should be normalized
  5. The stride or the offset between consecutive vertex attributes
  6. The offset of where position data begins in the buffer - the type is of (void*)
1
2
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

That’s it for drawing and shading vertices in OpenGL. So far, the steps are to copy the array of vertex data onto the VBO, create and compile our shaders, then set the vertex attribute pointers and finally linking our shader program. And all that has to be done every time we draw a new shape or use another shader. There is an easier way.

Vertex Array Objects

These vertex array objects (or VAO) basically allows grouping of vertex attribute configurations to the VBOs, so it can be reused again by just binding and unbinding the correct VAOs. As such, it is as simple as this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// create the VAO and get its ID
unsigned int VAO;
glGenVertexArrays(1, &VAO);

// ...after creating the VBOs, compiling shaders and linking programs
// first bind the VAO
glBindVertexArray(VAO);

// bind the VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// set vertex attributes
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// unbind VAO
glBindVertexArray(0);

/* do a bunch of other stuff here */

// inside render loop, to draw objects
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
// drawing code

And that’s it for vertex array objects.

The Render Loop

Not much goes on in this part, except now we draw the output onto the screen. We detect if the window should be closed with glfwWindowShouldClose(), which can be set using an input of choice, e.g. the ESC key (in this case, using GLFW).
Also the screen has to be cleared every time it is updated so that’s done with glClearColor() and glClear().

Then comes the code to draw the vertices/shapes, where we specify the shader program to use and bind the VAO we want before drawing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// the render loop (simple)
while (!glfwWindowShouldClose(window)) {
    
    /* process input and set glfwSetWindowShouldClose here */

    // clear the screen
    glClearColor(0.25f, 0.25f, 0.25f, 1.0f);        // set color to clear to
    glClear(GL_COLOR_BUFFER_BIT);

    // draw the vertices --> triangle
    glUseProgram(shaderProgram);
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    //glBindVertexArray(0);         technically should unbind array

    glfwSwapBuffers(window);
    glfwPollEvents();
}

Do note that when we exit the render loop, to delete the VAOs, VBOs and shader programs before exiting the program.

1
2
3
4
5
6
7
8
glDeleteVertexArraya(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteProgram(shaderProgram);

/* yadda yadda yadda, more clean up */

glfwTerminate();
return 0;

And that should be it for this initial part of the notebook.