2-D Rendering with OpenGL in Spectr
Modern OpenGL techniques are not very well documented online. Or, at the very least, there aren't very many high-quality, simple explanations of how to initialize an OpenGL context, and do some basic rendering with it. In this article, I'll examine the techniques used in my open-source application Spectr, which I think can be a good example.
Old-School vs. Modern OpenGL
Modern OpenGL rendering is quite a bit different from the "obvious" way to do drawing. Using "immediate mode rendering", it used to be possible to very explicitly give the GPU commands like, "draw a line", or "draw some triangles". For example, one might implement the rendering loop in a very simple "hello world" program like this:
glBegin(GL_TRIANGLES);
glColor3f(1.0f, 1.0f, 1.0f);
glVertex2f(0.0f, 1.0f);
glVertex2f(0.87f, -0.5f);
glVertex2f(-0.87f, -0.5f);
glEnd();
In modern OpenGL, though, we use vertex array objects and vertex buffer objects to store rendering state and the vertex data, respectively. These structures are useful, since it allows for a couple of improvements over immediate mode rendering:
- The vertices used when rendering are stored in the GPU's memory, rather than system memory. This makes rendering much faster, particularly when dealing with large numbers of vertices.
- We can setup various properties of how the vertices are rendered once, so that the code in the actual rendering loop is extremely simple - two lines of code can render an entire set of vertices.
The official OpenGL wiki has some good documentation about these concepts, which is worth reading for a more in-depth explanation of how these things work.
Some Context: How Spectr Works
Spectr is a tool for rendering spectrograms of audio data. The data we're rendering has three dimensions to it: time, frequency, and power. We use the first two dimensions to place each point in a 2-D plot, and then the third dimension is used to determine the color of each point. We use GLFW to initialize an OpenGL window and context, and to implement an event dispatch loop. All of this is possible using only vanilla OpenGL, but GLFW makes it quite a bit easier, without getting in the way or being too much of an abstraction.
Preparing Vertex Buffer Object Data
Before doing any OpenGL initialization, we want to prepare the data we'll use to populate our vertex buffer objects (VBOs). We'll discuss how VBOs work in more detail later, but for now, we want to prepare the structures that we'll use during that initialization. In order to keep track of our VBOs and any related data, we define the following structure:
typedef struct s_vbo_t
{
GLuint obj;
GLfloat *data;
size_t length;
GLenum usage;
GLenum mode;
} s_vbo_t;
This structure's "data", "length", "usage", and "mode" properties are intended to be filled in, and the "obj" property will be filled in by our OpenGL initialization function when it actually sets up the VBO. For example, one might initialize one of these structures with the following code:
myVBO.data = (GLfloat[24]) {
0.0f, 0.0f, -1.0f,
100.0f, 0.0f, -1.0f,
100.0f, 0.0f, -1.0f,
100.0f, 100.0f, -1.0f,
100.0f, 100.0f, -1.0f,
0.0f, 100.0f, -1.0f,
0.0f, 100.0f, -1.0f,
0.0f, 0.0f, -1.0f
};
myVBO.length = 24;
myVBO.usage = GL_STATIC_DRAW;
myVBO.mode = GL_LINES;
This example will draw a 100 pixel by 100 pixel square. Note that we're using GL_STATIC_DRAW - this means that the values in this VBO shouldn't change after it is initialized. All of Spectr works under this assumption, but it's common for other applications to need to change vertices after initialization, so we'll mention how this works a bit later.
At this point, we've finished the first rendering step: we've prepared our vertex buffer object data.
Initializing a Window and an OpenGL Context
The first part of initialization is relatively simple - we need to create an OpenGL window, and make sure it has a valid OpenGL context. Using GLFW, the code in Spectr which does this job looks something like this:
if(!glfwInit())
return -EINVAL;
window = glfwCreateWindow(WINDOW_W, WINDOW_H,
"Spectr", NULL, NULL);
if(!window)
{
glfwTerminate();
return -EINVAL;
}
glfwMakeContextCurrent(window);
We initialize GLFW, create the window, and then make the window's OpenGL context the current context, so that any OpenGL calls we make after this point will be applied to that window.
Initializing a Program
The next step in preparing to render is to initialize the OpenGL program we'll use. This program is really just a container for any shaders we'll be using, written in GLSL. Therefore, we need to initialize all of the shaders the program will use, and then initialize the OpenGL program instance.
Uniform's and Varying's
In GLSL, data can be passed from the OpenGL-based application to the shaders, as well as between shaders themselves. The first type of variable is called a uniform. A uniform can be declared in a GLSL file, and then its value can be set at any time - even after the shader has been initialized and is in use.
The other type of variable, which can be used for shaders to communicate with each other, is called a varying. These variables are declared in each shader which accesses them - whether it is reading the variable or writing to it. Once a shader has set a varying's value, that value will be available to other shaders which are executed later on in the rendering pipeline.
The Vertex Shader
The first thing we need to do is initialize our vertex shader. A vertex shader is - rather than something which applies color to things, as the name "shader" might suggest - a small program written in GLSL which repositions vertices. For example, it can be used to display a 2-D projection to the screen, while in code everything is still drawn in 3-D.
The vertex shader we use in Spectr is fairly simple. The idea is that we want to draw points, where the X and Y coordinates of each point are in the range [0, window width] and [0, window height], respectively. Furthermore, we want the point (0, 0) to be in the upper-left corner, similar to other common 2-D rendering environments. This vector shader simply transforms the X and Y portions of its inputs to fit OpenGL's rendering environment, given this method of drawing.
It's worth noting that, for Spectr, our points are given a Z value, but it is ignored by the vertex shader - namely, it is reset to 0.0. This is because we use the Z value to color the points, but we don't actually want to position the points in 3-D space.
Here's our vertex shader:
#version 440
in vec3 position;
uniform vec2 resolution;
varying float magnitude;
void main()
{
magnitude = position[2];
vec2 pixelrnd = vec2(position[0], position[1]) + 0.5;
vec2 zeroToOne = pixelrnd / resolution;
vec2 zeroToTwo = zeroToOne * 2.0;
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
}
The code to initialize our vertex shader is very simple. We simply create a new OpenGL vertex shader object, set its source code, and compile it. This is done something like this:
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertex_shader_src, NULL);
glCompileShader(vertexShader);
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &status);
if(status == GL_FALSE)
{
glGetShaderInfoLog(vertexShader, 8192, &bufl, buf);
printf("%s\n", buf);
return -EINVAL;
}
The Fragment Shader
The next thing we need to do is initialize our fragment shader. A fragment shader is a small GLSL program which decides what color a given vertex should be. In Spectr, our fragment shader is a bit complicated, since we're coloring points along a gradient based upon their "magnitude". For the sake of this example, this is the most basic fragment shader, which simply makes all points white:
#version 440
void main()
{
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
The code to initialize our fragment shader is very simple, and it is nearly identical to the code for initializing a vertex shader. The only difference is that we pass GL_FRAGMENT_SHADER to glCreateShader, instead of GL_VERTEX_SHADER.
Program Initialization
Now that our shaders have been initialized, it's time to initialize the program itself. The code to do this is very simple - including detaching the shaders after the program has been initialized:
program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &status);
if(status == GL_FALSE)
return -EINVAL;
glDetachShader(program, vertexShader);
glDetachShader(program, fragmentShader);
Initializing Vertex Buffer Objects
Vertex buffer objects are more-or-less just arrays which store the vertices we'll be rendering. As we mentioned briefly before, they are used for drawing because they are much more efficient than trying to draw things explicitly in code. We've already prepared the data we'll use for these objects, so now we need to initialize the vertex buffer objects and vertex array objects using this previously-allocated data.
Loading Data Into Vertex Buffer Objects
Loading the data we allocated earlier into actual vertex buffer objects is easy. We simply need to create a vertex buffer object, and then give it the data we allocated earlier, with a couple of extra parameters which indicate how the data is formatted. The code to do all this looks something like this:
glGenBuffers(1, &(o.obj));
glBindBuffer(GL_ARRAY_BUFFER, o.obj);
glBufferData(GL_ARRAY_BUFFER, o.length * sizeof(GLfloat),
o.data, o.usage);
glBindBuffer(GL_ARRAY_BUFFER, 0);
How Vertex Array Objects Work
The purpose of vertex array objects (VAOs) is to store all of the state needed to draw vertex data to the screen. In Spectr, we're not doing anything all that complicated with VAOs, so our initialization code looks something like this:
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, o.obj);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
The primary part of this code which configures how our vertex data will be interpreted is the call to glVertexAttribPointer. This call tells the GPU the type of the vertex data, as well as how to iterate through the list of vertices.
The Event Loop
The way our rendering works depends on an event dispatch loop, so we update the screen fast enough to appear smooth (if there was any animation), while still remaining interactive (i.e., responding to clicks or key presses). This is more or less an infinite loop, which can be interrupted by user actions, where each iteration of the loop draws a frame.
Capping Frames Per Second to Limit CPU Usage
The most basic implementation of this rendering loop will simply render things as fast as it possibly can, using up a large amount of system resources. Even in applications which render animation, it's pointless to draw more than a certain number of frames every second. For most people, animation looks perfectly smooth at somewhere around 30 frames per second.
For Spectr, the application is set to only render at most 20 frames per second, since we aren't even drawing any animation. To do this, we simply record the amount of time each frame takes to render. Then, after rendering the frame, we'll sleep if rendering took less than 50 milliseconds (since 1 second is 1000 milliseconds, and we're rendering 20 frames per second, each frame should take 1000 / 20 = 50 milliseconds to render).
Initializing the Viewport
At this point, we're ready to enter the rendering loop, clear the window, make our program active, and set any uniforms we defined previously in our shaders. This process is fairly simple, and in Spectr it looks something like this:
int set_uniforms()
{
GLint resu;
GLfloat res[] = { WINDOW_W, WINDOW_H };
resu = glGetUniformLocation(program, "resolution");
if(resu == -1)
return -EINVAL;
glUniform2fv(resu, 1, res);
return 0;
}
glViewport(0, 0, WINDOW_W, WINDOW_H);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program);
r = set_uniforms();
if(r < 0)
{
glfwTerminate();
return r;
}
It's worth noting that setting the values of our uniform variables is correct here. They don't need to be set when the shaders which use them are initialized, but rather they are set while we do rendering. And, in fact, they can even be changed partway through rendering.
Actually Rendering the Vertex Buffer Objects
At this point, we're ready to actually draw the contents of the vertex buffer objects we setup earlier to the screen. Because of the way vertex array objects work, this is trivially easy. We've already configured everything we needed to for the drawing, so all we need to do is tell OpenGL to "do it". This can be done something like this:
glBindVertexArray(vao1);
glDrawArrays(vbo.mode, 0, vbo.length / 3);
In this code, we're telling OpenGL to render the vertices in the vertex buffer object starting at index 0, and that it contains length / 3 vertices, since each vertex has X, Y, and Z components.
Swapping Buffers
The last thing we need to do is to deactivate our OpenGL program and swap the buffer we just drew onto the screen. Finally, to decide if we want to continue with the rendering loop, we check for any user input events. This is done something like this:
glUseProgram(0);
glfwSwapBuffers(window);
glfwPollEvents();
The buffer swapping is necessary to avoid the user seeing partially-drawn frames. The idea is that we continue showing the previous frame until the new frame is completely ready off-screen, and then we move it on-screen all at once.
Summary
To summarize, the steps we took to get an OpenGL rendering loop working were:
- Prepare vertex buffer object data.
- Create the OpenGL window, and make sure we have a valid OpenGL context.
- Initialize our OpenGL program, including vertex and fragment shaders.
- Initialize the actual vertex buffer objects and vertex array objects with existing data.
- Enter the rendering loop, clear the window, make the program active, and set any uniforms.
- Draw the contents of our vertex buffer objects.
- De-activate the OpenGL program and swap the buffer we just drew to the screen.
Although I've given an overview of how all of these steps work together, an actual working example is always helpful in learning how these things work. I encourage you to clone a copy of Spectr and take a look at how it works. It should be a fairly simple and complete example of how all of this works.