Following the Data

In the basic background section, we described the functioning of the OpenGL pipeline. We will now revisit this pipeline in the context of the code in tutorial 1. This will give us an understanding about the specifics of how OpenGL goes about rendering data.

Vertex Transfer

The first stage in the rasterization pipeline is transforming vertices to clip space. Before OpenGL can do this however, it must receive a list of vertices. So the very first stage of the pipeline is sending triangle data to OpenGL.

This is the data that we wish to transfer:

const float vertexPositions[] = {
    0.75f, 0.75f, 0.0f, 1.0f,
    0.75f, -0.75f, 0.0f, 1.0f,
    -0.75f, -0.75f, 0.0f, 1.0f,
};

Each line of 4 values represents a 4D position of a vertex. These are four dimensional because, as you may recall, clip-space is 4D as well. These vertex positions are already in clip space. What we want OpenGL to do is render a triangle based on this vertex data. Since every 4 floats represents a vertex's position, we have 3 vertices: the minimum number for a triangle.

Even though we have this data, OpenGL cannot use it directly. OpenGL has some limitations on what memory it can read from. You can allocate vertex data all you want yourself; OpenGL cannot directly see any of your memory. Therefore, the first step is to allocate some memory that OpenGL can see, and fill that memory with our data. This is done with something called a buffer object.

A buffer object is a linear array of memory, managed and allocated by OpenGL at the behest of the user. The content of this memory is controlled by the user, but the user has only indirect control over it. Think of a buffer object as an array of GPU memory. The GPU can read this memory quickly, so storing data in it has performance advantages.

The buffer object in the tutorial was created during initialization. Here is the code responsible for creating the buffer object:

Example 1.2. Buffer Object Initialization

void InitializeVertexBuffer()
{
    glGenBuffers(1, &positionBufferObject);
    
    glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPositions), vertexPositions, GL_STATIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

The first line creates the buffer object, storing the handle to the object in the global variable positionBufferObject. Though the object now exists, it does not own any memory yet. That is because we have not allocated any with this object.

The glBindBuffer function binds the newly-created buffer object to the GL_ARRAY_BUFFER binding target. As mentioned in the introduction, objects in OpenGL usually have to be bound to the context in order for them to do anything, and buffer objects are no exception.

The glBufferData function performs two operations. It allocates memory for the buffer currently bound to GL_ARRAY_BUFFER, which is the one we just created and bound. We already have some vertex data; the problem is that it is in our memory rather than OpenGL's memory. The sizeof(vertexPositions) uses the C++ compiler to determine the byte size of the vertexPositions array. We then pass this size to glBufferData as the size of memory to allocate for this buffer object. Thus, we allocate enough GPU memory to store our vertex data.

The other operation that glBufferData performs is copying data from our memory array into the buffer object. The third parameter controls this. If this value is not NULL, as in this case, glBufferData will copy the data referenced by the pointer into the buffer object. After this function call, the buffer object stores exactly what vertexPositions stores.

The fourth parameter is something we will look at in future tutorials.

The second bind buffer call is simply cleanup. By binding the buffer object 0 to GL_ARRAY_BUFFER, we cause the buffer object previously bound to that target to become unbound from it. Zero in this cases works a lot like the NULL pointer. This was not strictly necessary, as any later binds to this target will simply unbind what is already there. But unless you have very strict control over your rendering, it is usually a good idea to unbind the objects you bind.

This is all just to get the vertex data in the GPU's memory. But buffer objects are not formatted; as far as OpenGL is concerned, all we did was allocate a buffer object and fill it with random binary data. We now need to do something that tells OpenGL that there is vertex data in this buffer object and what form that vertex data takes.

We do this in the rendering code. That is the purpose of these lines:

glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);

The first function we have seen before. It simply says that we are going to use this buffer object.

The second function, glEnableVertexAttribArray is something we will explain in the next section. Without this function, the next one is unimportant.

The third function is the real key. glVertexAttribPointer, despite having the word Pointer in it, does not deal with pointers. Instead, it deals with buffer objects.

When rendering, OpenGL pulls vertex data from arrays stored in buffer objects. What we need to tell OpenGL is what format our vertex array data in the buffer object is stored in. That is, we need to tell OpenGL how to interpret the array of data stored in the buffer.

In our case, our data is formatted as follows:

  • Our position data is stored in 32-bit floating point values using the C/C++ type float.

  • Each position is composed of 4 of these values.

  • There is no space between each set of 4 values. The values are tightly packed in the array.

  • The first value in our array of data is at the beginning of the buffer object.

The glVertexAttribPointer function tells OpenGL all of this. The third parameter specifies the base type of a value. In this case, it is GL_FLOAT, which corresponds to a 32-bit floating-point value. The second parameter specifies how many of these values represent a single piece of data. In this case, that is 4. The fifth parameter specifies the spacing between each set of values. In our case, there is no space between values, so this value is 0. And the sixth parameter specifies the byte offset from the value in the buffer object is at the front, which is 0 bytes from the beginning of the buffer object.

The fourth parameter is something that we will look at in later tutorials. The first parameter is something we will look at in the next section.

One thing that appears absent is specifying which buffer object this data comes from. This is an implicit association rather than an explicit one. glVertexAttribPointer always refers to whatever buffer is bound to GL_ARRAY_BUFFER at the time that this function is called. Therefore it does not take a buffer object handle; it simply uses the handle we bound previously.

This function will be looked at in greater detail in later tutorials.

Once OpenGL knows where to get its vertex data from, it can now use that vertex data to render.

glDrawArrays(GL_TRIANGLES, 0, 3);

This function seems very simple on the surface, but it does a great deal. The second and third parameters represent the start index and the number of indices to read from our vertex data. The 0th index of the vertex array (defined with glVertexAttribPointer) will be processed, followed by the 1st and 2nd indices. That is, it starts with the 0th index, and reads 3 vertices from the arrays.

The first parameter to glDrawArrays tells OpenGL that it is to take every 3 vertices that it gets as an independent triangle. Thus, it will read just 3 vertices and connect them to form a triangle.

Again, we will go into details in another tutorial.

Vertex Processing and Shaders

Now that we can tell OpenGL what the vertex data is, we come to the next stage of the pipeline: vertex processing. This is one of two programmable stages that we will cover in this tutorial, so this involves the use of a shader.

A shader is nothing more than a program that runs on the GPU. There are several possible shader stages in the pipeline, and each has its own inputs and outputs. The purpose of a shader is to take its inputs, as well as potentially various other data, and convert them into a set of outputs.

Each shader is executed over a set of inputs. It is important to note that a shader, of any stage, operates completely independently of any other shader of that stage. There can be no crosstalk between separate executions of a shader. Execution for each set of inputs starts from the beginning of the shader and continues to the end. A shader defines what its inputs and outputs are, and it is illegal for a shader to complete without writing to all of its outputs (in most cases).

Vertex shaders, as the name implies, operate on vertices. Specifically, each invocation of a vertex shader operates on a single vertex. These shaders must output, among any other user-defined outputs, a clip-space position for that vertex. How this clip-space position is computed is entirely up to the shader.

Shaders in OpenGL are written in the OpenGL Shading Language (GLSL). This language looks suspiciously like C, but it is very much not C. It has far too many limitations to be C (for example, recursion is forbidden). This is what our simple vertex shader looks like:

Example 1.3. Vertex Shader

#version 330

layout(location = 0) in vec4 position;
void main()
{
    gl_Position = position;
}

This looks fairly simple. The first line states that the version of GLSL used by this shader is version 3.30. A version declaration is required for all GLSL shaders.

The next line defines an input to the vertex shader. The input is a variable named position and is of type vec4: a 4-dimensional vector of floating-point values. It also has a layout location of 0; we'll explain that a little later.

As with C, a shader's execution starts with the main function. This shader is very simple, copying the input position into something called gl_Position. This is a variable that is not defined in the shader; that is because it is a standard variable defined in every vertex shader. If you see an identifier in a GLSL shader that starts with gl_, then it must be a built-in identifier. You cannot make an identifier that begins with gl_; you can only use ones that already exist.

gl_Position is defined as:

out vec4 gl_Position;

Recall that the minimum a vertex shader must do is generate a clip-space position for the vertex. That is what gl_Position is: the clip-space position of the vertex. Since our input position data is already a clip-space position, this shader simply copies it directly into the output.

Vertex Attributes. Shaders have inputs and outputs. Think of these like function parameters and function return values. If the shader is a function, then it is called with input values, and it is expected to return a number of output values.

Inputs to and outputs from a shader stage come from somewhere and go to somewhere. Thus, the input position in the vertex shader must be filled in with data somewhere. So where does that data come from? Inputs to a vertex shader are called vertex attributes.

You might recognize something similar to the term vertex attribute. For example, glEnableVertexAttribArray or glVertexAttribPointer.

This is how data flows down the pipeline in OpenGL. When rendering starts, vertex data in a buffer object is read based on setup work done by glVertexAttribPointer. This function describes where the data for an attribute comes from. The connection between a particular call to glVertexAttribPointer and the string name of an input value to a vertex shader is somewhat complicated.

Each input to a vertex shader has an index location called an attribute index. The input in this shader was defined with this statement:

layout(location = 0) in vec4 position;

The layout location part assigns the attribute index of 0 to position. Attribute indices must be greater than or equal to zero, and there is a hardware-based limit on the number of attribute indices that can be in use at any one time[2].

In code, when referring to attributes, they are always referred to by attribute index. The functions glEnableVertexAttribArray, glDisableVertexAttribArray, and glVertexAttribPointer all take as their first parameter an attribute index. We assigned the attribute index of the position attribute to 0 in the vertex shader, so the call to glEnableVertexAttribArray(0) enables the attribute index for the position attribute.

Here is a diagram of the data flow to the vertex shader:

Figure 1.1. Data Flow to Vertex Shader

Data Flow to Vertex Shader

Without the call to glEnableVertexAttribArray, calling glVertexAttribPointer on that attribute index would not mean much. The enable call does not have to be called before the vertex attribute pointer call, but it does need to be called before rendering. If the attribute is not enabled, it will not be used during rendering.

Rasterization

All that has happened thus far is that 3 vertices have been given to OpenGL and it has transformed them with a vertex shader into 3 positions in clip-space. Next, the vertex positions are transformed into normalized-device coordinates by dividing the 3 XYZ components of the position by the W component. In our case, W for our 3 positions was 1.0, so the positions are already effectively in normalized-device coordinate space.

After this, the vertex positions are transformed into window coordinates. This is done with something called the viewport transform. This is so named because of the function used to set it up, glViewport. The tutorial calls this function every time the window's size changes. Remember that the framework calls reshape whenever the window's size changes. So the tutorial's implementation of reshape is this:

Example 1.4. Reshaping Window

void reshape (int w, int h)
{
    glViewport(0, 0, (GLsizei) w, (GLsizei) h);
}

This tells OpenGL what area of the available area we are rendering to. In this case, we change it to match the full available area. Without this function call, resizing the window would have no effect on the rendering. Also, make note of the fact that we make no effort to keep the aspect ratio constant; shrinking or stretching the window in a direction will cause the triangle to shrink and stretch to match.

Recall that window coordinates are in a lower-left coordinate system. So the point (0, 0) is the bottom left of the window. This function takes the bottom left position as the first two coordinates, and the width and height of the viewport rectangle as the other two coordinates.

Once in window coordinates, OpenGL can now take these 3 vertices and scan-convert it into a series of fragments. In order to do this however, OpenGL must decide what the list of vertices represents.

OpenGL can interpret a list of vertices in a variety of different ways. The way OpenGL interprets vertex lists is given by the draw command:

glDrawArrays(GL_TRIANGLES, 0, 3);

The enum GL_TRIANGLES tells OpenGL that every 3 vertices of the list should be used to build a triangle. Since we passed only 3 vertices, we get 1 triangle.

Figure 1.2. Data Flow to Rasterizer

Data Flow to Rasterizer

If we rendered 6 vertices, then we would get 2 triangles.

Fragment Processing

A fragment shader is used to compute the output color(s) of a fragment. The inputs of a fragment shader include the window-space XYZ position of the fragment. It can also include user-defined data, but we will get to that in later tutorials.

Our fragment shader looks like this:

Example 1.5. Fragment Shader

#version 330

out vec4 outputColor;
void main()
{
   outputColor = vec4(1.0f, 1.0f, 1.0f, 1.0f);
}

As with the vertex shader, the first line states that the shader uses GLSL version 3.30.

The next line specifies an output for the fragment shader. The output variable is of type vec4.

The main function simply sets the output color to a 4-dimensional vector, with all of the components as 1.0f. This sets the Red, Green, and Blue components of the color to full intensity, which is 1.0; this creates the white color of the triangle. The fourth component is something we will see in later tutorials.

Though all fragment shaders are provided the window-space position of the fragment, this one does not need it. So it simply does not use it.

After the fragment shader executes, the fragment output color is written to the output image.

Note

In the section on vertex shaders, we had to use the layout(location = #) syntax in order to provide a connection between a vertex shader input and a vertex attribute index. This was required in order for the user to connect a vertex array to a vertex shader input. So you may be wondering where the connection between the fragment shader output and the screen comes in.

OpenGL recognizes that, in a lot of rendering, there is only one logical place for a fragment shader output to go: the current image being rendered to (in our case, the screen). Because of that, if you define only one output from a fragment shader, then this output value will automatically be written to the current destination image. It is possible to have multiple fragment shader outputs that go to multiple different destination images; this adds some complexity, similar to attribute indices. But that is for another time.



[2] For virtually all hardware since the beginning of commercial programmable hardware, this limit has been exactly 16. No more, no less.