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.
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.
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:
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.
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.
If we rendered 6 vertices, then we would get 2 triangles.
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.
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.