Loading...
PV112: LECTURE 2

Uniforms, VBO, VAO

This lecture discusses setting uniform variables, existing types of graphic primitives and how to use Vertex Buffer and Vertex Array objects for rendering.

Uniform Variables

Typically, an OpenGL application follows the life cycle depicted in the diagram below. At the start, when the application is initialized, resources that remain static throughout execution are set up.

Within the render loop, we process all the objects intended for rendering on the screen. Some data may be constant across the entire rendering pipeline and can be pre-initialized, while other data might require dynamic updates each frame—or even multiple updates within a single frame.

In the inner loop, we aim to efficiently distribute the workload between the CPU and GPU to avoid bottlenecks caused by both accessing the same resources simultaneously. Additionally, minimizing the transfer of data between the CPU and GPU is crucial, as these operations are resource-intensive.

Once the frame is fully processed, it is rendered to the screen, and the cycle is repeated for the next frame.

OpenGL Lifecycle

Setting Uniform Variables

Before diving into the principles of drawing graphical primitives in OpenGL, let’s first take a closer look at how to set uniform variables, which were introduced in the previous lecture. These variables play a key role in the dynamic update step, as mentioned above.

The value of a single uniform variable can be set using the glProgramUniform functions. The numbers (1, 2, 3, 4) specify the dimension of the variable, while the letters (f, i, ui) denote the data type.

void glProgramUniform[1|2|3|4][f|i|ui](GLuint program, GLint location, [GLfloat|Glint|GLuint] v1, ...);

Similarly, the value of an array of uniform variables is set using the following version of these functions. Note that the v flag indicates that the data is stored in an array.

void glProgramUniform[1|2|3|4][f|i|ui]v(GLuint program, GLint location, GLsizei count, const GLint* value);

Uniform variables can also take the form of a matrix. In this case, the function used to set the values of the uniform variable is defined as follows:

void glProgramUniformMatrix[NxM]fv(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLint* value);

where NxM specifies the size of the matrix. The values 2, 3, and 4 correspond to matrices of sizes 2x2, 3x3, and 4x4, respectively. Note that we can also use a matrix with a different number of rows and columns: 2x3, 3x2, 2x4, 4x2, 3x4, and 4x3.

The meaning of the other parameters is as follows:

Location

The location is specified in the shader before declaring the uniform variable, where it is set using layout(location = int).

#version 450
layout(location = 0) uniform type name;
void main() {  }

In older versions of OpenGL, the location was obtained using a specific function that is still supported but should not be used anymore.

GLint glGetUniformLocation(GLuint program, const Glchar* name);

Graphic Primitives

OpenGL is a low-level application programming interface (API) that facilitates rendering 2D and 3D graphics. It does not provide built-in tools for drawing complex geometric objects directly; rather, it offers a set of basic graphical primitives that programmers can use to construct more intricate shapes. The core OpenGL profile provides several fundamental graphical primitives, including points, lines, and triangles. By combining these primitives, developers can create complex geometric forms tailored to their specific application needs. For simplicity, this discussion will focus on demonstrating the use of OpenGL’s graphical primitives in a 2D context. However, it’s important to note that the same principles and techniques apply equally to 3D graphics.

GL_POINTS

Isolated points are among the simplest graphical primitives in OpenGL. Each point is defined by a single vertex in the input array.

GL_POINTS

GL_LINES

A line segment is described by two endpoints, or vertices. Two consecutive vertices are always taken from the input array to form a line. If an odd number of vertices is provided, the last vertex will be ignored.

GL_LINES

GL_LINE_STRIP

This primitive allows for the drawing of a polyline, which is an extension of individual lines. The first two vertices define the starting segment of the polyline, and each additional vertex adds a new segment. The beginning of the new segment is the end vertex of the current polyline, while the new vertex defines the segment’s endpoint. Consequently, for ( n ) vertices, this creates a polyline with ( n - 1 ) segments. This method is efficient because it reduces the array size of stored vertices by approximately half (((n-2)/2)) when drawing a connected set of lines compared to traditional single line drawing.

GL_LINE_STRIP

GL_LINE_LOOP

A line loop is a minor modification of the previous case. It forms a polyline where the last vertex is connected to the first one. Thus, for ( n ) vertices, this shape contains ( n ) segments. Note that the area created is not a polygon; it represents only a border without a fill.

GL_LINE_LOOP

GL_TRIANGLES

A triangle is the simplest surface that can be rendered in OpenGL. It is specified by three vertices that should not be collinear. Three consecutive vertices in the input array define one triangle. If the number of vertices is not divisible by three, the remaining vertices are ignored. Triangles are advantageous because they are convex shapes whose vertices lie in a single plane. As a result, triangular meshes are commonly used to define more complex objects. However, specifying three vertices for each triangle can result in significant data flow between the processor and the graphics card. To alleviate this issue, OpenGL supports additional primitives described below.

GL_TRIANGLES

GL_TRIANGLE_STRIP

The triangle strip is another primitive composed of triangles, useful for drawing the surfaces of more complex objects. The first three vertices define the initial triangle, and each subsequent vertex defines a new triangle that shares an edge with the last added triangle. This approach saves memory and bandwidth since fewer vertices need to be sent to the GPU.

GL_TRIANGLE_STRIP

GL_TRIANGLE_FAN

The triangle fan is another primitive based on triangles, designed to reduce data flow during vertex input. In this case, all triangles share a common vertex. A typical application is drawing a spherical cap.

GL_TRIANGLE_FAN

Other Supported Primitives

OpenGL also supports additional primitives, including GL_LINES_ADJACENCY, GL_LINE_STRIP_ADJACENCY, GL_TRIANGLES_ADJACENCY, GL_TRIANGLE_STRIP_ADJACENCY, and GL_PATCHES. However, these are not supported in vertex and fragment shaders, and thus will not be covered in this course.

Drawing Triangles

We will illustrate the drawing process using the example of four triangles, as depicted in the figure below. From a geometric perspective, a triangle is defined by the positions of its three vertices. In OpenGL terminology, a vertex consists of a set of attributes, such as position, color, texture coordinates, and more. In our example, we have four triangles, which correspond to a total of 12 vertices.

Assuming that each of these vertices has only its position defined (while ignoring color and other attributes for now), we can store these vertices in the following array.

float vertex_positions[24] = {
   0.0, 0.0,
   0.5, 0.0,
   0.5, 0.5,

   0.0, 0.0,
   0.0, 0.5,
   -0.5, 0.5,

   0.0, 0.0,
   -0.5, 0.0,
   -0.5, -0.5,

   0.0, 0.0,
   0.0, -0.5,
   0.5, -0.5,
}
Vertices

It is important to note that the vertices must be inserted into the array in counter-clockwise (CCW) order. In OpenGL, this counter-clockwise orientation defines the front faces of objects. For triangles, we differentiate between two sides: the front face and the back face. Each side can be rendered using different settings, or one side can be omitted entirely from rendering. By default, however, both sides are drawn.

The default settings for the orientation of triangle faces in OpenGL are as follows:

CCW vs CW

You can change the orientation of triangle faces in OpenGL using the command glFrontFace(GLenum mode). The mode parameter can take one of the following values:

By altering the face orientation, you can control which side of the triangles is considered the front face during rendering.

For the GL_TRIANGLE_STRIP and GL_TRIANGLE_FAN primitives, the orientation of the individual components is automatically determined based on the orientation of the first inserted triangle.

CCW Triangle Strip
CCW Triangle Fan

By default, the drawing of both polygon faces (front and back) is enabled, which can be time-consuming. To enhance rendering performance, we can use face culling, which removes certain faces (e.g., those facing away from the observer) before rasterization. We enable face culling with glEnable(GL_CULL_FACE) and can disable it using glDisable(GL_CULL_FACE). The specific faces to be removed are specified by glCullFace(GL_FRONT | GL_BACK | GL_FRONT_AND_BACK).

Face Culling

Vertex Buffer Objects

Now, let’s continue the description of the triangle drawing principle. How do we draw a triangle defined by the array mentioned above? The first step is to convert the contents of this array into a Vertex Buffer Object (VBO). A VBO is essentially a piece of memory (buffer) managed by OpenGL and located directly on the graphics card.

A VBO must be created, allocated, and populated with data. Subsequently, using a combination of Vertex Array Objects (VAO) and VBOs, we define the graphical primitives for rendering (composed of vertices).

VBOs are a type of buffer object used by OpenGL. Generally, buffer objects (BOs) are managed as follows: First, we generate the buffer on the GPU and obtain its index/identifier. Next, we initialize the buffer store and store the data in this buffer. We then use the data from the buffer (e.g., for drawing) and finally delete the buffer from memory if it is no longer needed.

Step 1 – Creating a New Buffer Object

The glCreateBuffers function is used to generate buffer objects (BOs).

void glCreateBuffers(GLsizei count, GLuint* buffers);

The parameters are defined as follows:

The function returns n identifiers of buffer objects and stores them in buffers. While the identifiers may not form a continuous set of integers, it is guaranteed that none of these identifiers were used immediately before the function call, allowing them to serve as unique identifiers. However, no actual buffer memory is associated with these numbers until the glNamedBufferStorage function is called.

Example of generating an identifier for one buffer:

GLuint bufferID;
glCreateBuffers(1, &bufferID);

Step 2 – Initialize Store (Memory) & Populate Buffer with Data

The function glNamedBufferStorage creates and possibly initializes a new buffer store (memory).

void glNamedBufferStorage(GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags);

The parameters are defined as follows:

The following two examples demonstrate different cases of buffer initialization.

glNamedBufferStorage(bufferID, sizeof(float) * 9, nullptr, GL_DYNAMIC_STORAGE_BIT);

In this scenario, the buffer memory is not initialized because the data is set to nullptr, which allows modifications later from the CPU using the glNamedBufferSubData function, thanks to the GL_DYNAMIC_STORAGE_BIT flag.

float positions[9] = {1.0f, };
glNamedBufferStorage(bufferID, sizeof(float) * 9, positions, 0);

Here, the memory is initialized with values from the positions array and cannot be modified from the CPU since GL_DYNAMIC_STORAGE_BIT is absent. However, values can still be overwritten on the GPU side! It’s also worth noting that NULL can be used instead of 0, although it may not be defined on all platforms.

Step 3 – Updating the Buffer Content

The glNamedBufferSubData function copies data of a specified range into a buffer object. It updates an existing buffer starting at a given offset, which must be predetermined by calling the glNamedBufferStorage function. This function is useful for initializing a buffer as empty and then filling it with data afterward.

void glNamedBufferSubData(GLuint buffer, GLintptr offset, GLsizeiptr size, const void* data);

The parameters are defined as follows:

Be careful not to confuse this with glNamedBufferData, which is an older version of glNamedBufferStorage. Additionally, there is a glGetNamedBufferSubData function that retrieves a subset of data from the current buffer object.

Direct Access to Buffer Objects

The functions listed below map/unmap the buffer to/from the application’s address space, allowing access to the beginning of the buffer via a temporary pointer. This enables direct reading and writing of data relative to the returned pointer. If the buffer cannot be mapped, the implementation returns a NULL pointer.

void* glMapNamedBuffer(Gluint buffer, GLenum access);
GLboolean glUnmapNamedBuffer(Gluint buffer);

To use these methods, the buffer must be initialized with the GL_MAP_READ_BIT | GL_MAP_WRITE_BIT flags. It is also highly recommended to use GL_MAP_COHERENT_BIT, which ensures that both your application and the GPU see modified data immediately, and GL_MAP_PERSISTENT_BIT, which ensures that the pointer remains valid as long as the data store is mapped.

Step 4 – Using VBO for Drawing

For the drawing process, we will first need to explain additional concepts, so we will skip this point for now.

Step 5 – Deleting the Buffer from Memory

The glDeleteBuffers function is used to delete the specified buffer objects.

void glDeleteBuffers(Glsizei n, const Gluint* buffers); 

The parameters are defined as follows:

Additionally, glIsBuffer can be used to query the validity of a buffer object identifier.

GLboolean glIsBuffer(GLuint buffer);

The parameters are defined as follows:

Back to Rendering Triangles

Now let’s return to the previous example with four triangles. First, we create a VBO and allocate space in GPU memory to copy data from our array in the CPU using the following commands.

GLuint vbo;
glCreateBuffers(1, &vbo);

glNamedBufferStorage(vbo, sizeof(vertex_positions), vertex_positions, 0);

Positions loaded into the VBO are processed using a vertex shader. Below is a simple example in which we create a global variable of type vec4 to receive the position of the vertex stored in the VBO. If we do not specify the variable components x, y, z, and w in the buffer, these components will default to (0,0,0,1) in the shader. If we send only x and y, the last two values will remain unchanged, resulting in (x, y, 0, 1). The shader must contain a main() function, in which we set the value of the internal GLSL variable gl_Position to the vertex coordinate value. Note that gl_Position expects the position in clip space.

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

Vertex Array Objects

Since individual vertices can have other attributes, such as color or texture coordinates, this information must be linked to these vertices stored in the VBO. OpenGL uses Vertex Array Object (VAO) for this purpose.

Vertex Arrays = a set of user-defined arrays that contain the attributes (e.g., positions, colors, normals, etc.) of individual vertices.
Vertex Array Objects = objects that contain information about which buffers store the data of these attributes (and how they store it).

The glCreateVertexArrays function generates n vertex array objects and stores their unique identifiers within the arrays.

void glCreateVertexArrays(GLsizei n, Gluint *arrays);

The binding of VAO is done via the glBindVertexArray function, which has two use cases. It binds a specified VAO or, when using 0 as the value of the array, the use of the VAO is terminated (the currently active VAO is unbound).

void glBindVertexArray(GLuint array);

Unused VAOs can then be deleted using the glDeleteVertexArrays function, which deletes the n VAOs specified in the arrays parameter. The released names of VAOs can be used again in the future. Note that this method does not release attached VBOs.

void glDeleteVertexArrays(GLsizei n, GLuint *arrays);

The glIsVertexArray function below is used to determine whether the given value represents an allocated (but not necessarily initialized) VAO. It returns GL_TRUE if the array is the name of a VAO generated by glCreateVertexArrays that has not yet been deleted. It returns GL_FALSE if the array is zero or a non-zero value that is not an identifier of any VAO.

GLboolean glIsVertexArray(GLuint array);

Processing of Attributes

For each vertex attribute, we must perform the following steps:

  1. Create VBOs with the data.
  2. Enable the appropriate attribute.
  3. Bind a buffer from which the data for this attribute will be taken.
  4. Set the format of how this data is stored in the buffer.
  5. Handle buffer and attribute binding.

Step 1 – Creating VBOs

We utilize the commands defined earlier to create one or multiple VBOs and upload data into them. You can choose to create separate VBOs for each attribute or combine multiple attributes into a single VBO. Below are a few examples demonstrating the creation of separate VBOs for positions, colors, and texture coordinates.

VBO with Positions
float positions[12] = {1.0f, };   // x,y,z coordinates for 4 triangles
GLuint vbo_pos;
glCreateBuffers(1, &vbo_pos);
glNamedBufferStorage(vbo_pos, sizeof(float) * 12, positions, 0);
VBO with Colors
uint8_t colors[12] = {255, };      // r,g,b colors for 4 triangles
GLuint vbo_colors;
glCreateBuffers(1, &vbo_colors);
glNamedBufferStorage(vbo_colors, sizeof(uint8_t) * 12, colors, 0);
VBO with Texture Coordinates
float UVs[8] = {1.0f, };     // u,v texture coordinates for 4 triangles
GLuint vbo_uvs;
glCreateBuffers(1, &vbo_uvs);
glNamedBufferStorage(vbo_uvs, sizeof(float) * 8, UVs, 0);

Step 2 – Enabling Attributes

Before using a vertex array for the first time in a program, you must enable the given attribute by calling the glEnableVertexArrayAttrib function. The vao parameter specifies the name of the vertex array object and the attribindex parameter specifies the index of the vertex attribute.

void glEnableVertexArrayAttrib(GLuint vao, GLuint attribindex);

If you no longer wish to use certain arrays, you can call the glDisableVertexArrayAttrib function, which accepts the same parameters as the previous function.

void glDisableVertexbArrayAttrib(GLuint vao, GLuint attribindex);

The attribute index is defined by the programmer and corresponds to the location in the shader. For example, you might choose to label the attributes as follows:

Attribute Indices
#version 450

 layout(location = 0) in vec3 position;
 layout(location = 1) in vec3 color;
 layout(location = 2) in vec2 uv;

 void main(){  }

This means that we need to enable the attribute indices 0, 1, and 2.

glEnableVertexArrayAttrib(vao, 0);
glEnableVertexArrayAttrib(vao, 1);
glEnableVertexArrayAttrib(vao, 2);

Step 3 – Buffer Binding

In this step, we specify which parts of our Vertex Buffer Objects (VBOs) we want to use for each of the attributes. This is accomplished through the use of virtual binding points. You can think of a binding point as a view of your data, containing only the required part. The binding is specified using glVertexArrayVertexBuffer function.

glVertexArrayVertexBuffer(GLuint vao, GLuint bindingindex, Gluint buffer, Glintptr offset, Glsizei stride);

In a simple example where we have one VBO per attribute, we simply bind the whole VBO to one binding point. This approach allows for straightforward access to each attribute.

Simple Bidning

In this example, we call the method three times. For each call, the offset is set to 0 since we always start from the beginning of the buffer. The stride corresponds to the number of values available for each given attribute (e.g., 3 float values for both positions and colors, and 2 float values for texture coordinates).

glVertexArrayVertexBuffer(vao, 0, vbo_positions, 0, 3 * sizeof(float));    // Positions
glVertexArrayVertexBuffer(vao, 1, vbo_colors, 0, 3 * sizeof(uint8_t));     // Colors
glVertexArrayVertexBuffer(vao, 2, vbo_uvs, 0, 2 * sizeof(float));          // Texture Coordinates

However, you may also decide to store data for multiple attributes in a single VBO. In this case, we need to split the buffer into virtual binding points to map them to the individual attributes. This approach is the primary reason for the existence of virtual binding points and why we cannot directly map VBOs to attributes in a shader.

More Complex Bidning

Here, you can notice that the colors are in the second part of the buffer. Therefore, we need to utilize the offset (in the second call) to inform OpenGL where the memory for colors starts. Note that, in this example, we assume that our array consists of float values for positions and uint8_t values for colors.

glVertexArrayVertexBuffer(vao, 0, vbo_positions_and_colors, 0, 3 * sizeof(float));
glVertexArrayVertexBuffer(vao, 1, vbo_positions_and_colors, vertex_count * 3 * sizeof(float), 3 * sizeof(uint8_t));

Step 4 – Attribute Format Setting

Every attribute can have a different format. For example, positions may be represented as float values, while colors can be represented as unsigned integers in the range [0-255]. The previous function, glVertexArrayVertexBuffer, describes where the data starts but does not provide any information about its content. Therefore, we need to tell OpenGL how to interpret the bytes we are sending by calling another function called glVertexArrayAttribFormat.

void glVertexArrayAttribFormat(GLuint vao, GLuint attribindex, GLint size, Glenum type, GLboolean normalized, Gluint relativeoffset);

This function specifies the following parameters:

Again, we can demonstrate the use of this function in a simple example where we have one VBO per attribute.

Simple Bidning

In this case, we need to call the method three times. You can observe that we use GL_FLOAT for positions and texture coordinates, but we use GL_UNSIGNED_BYTE for colors. The reason for this is that we have defined the original data as an array of uint8_t. If we had defined the original data for colors as an array of float values, we would then naturally use GL_FLOAT for the second attribute as well.

Additionally, we set the normalized parameter to GL_TRUE for colors. This is necessary because we are passing the colors in the range [0-255], but OpenGL represents colors internally in the range [0-1]. Therefore, we need to normalize the values. We would not have to do this if we used the float representation for colors. The size parameter depends on the number of values we have for each attribute.

glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);          // Positions
glVertexArrayAttribFormat(vao, 1, 3, GL_UNSIGNED_BYTE, GL_TRUE, 0);   // Colors
glVertexArrayAttribFormat(vao, 2, 2, GL_FLOAT, GL_FALSE, 0);          // Texture Coordinates

Above, you can notice that in all cases, the last parameter is 0. This is because the data is tightly packed, meaning there is no offset between the values for individual vertices. In other words, the information about one vertex position follows immediately after another.

To better understand the purpose of this parameter, we need to create a single VBO for multiple attributes and rearrange the data such that all values for one vertex are stored close to each other.

Interleaved Bidning

In this case, we map the entire buffer to a single binding point. Note that, in this example, we still assume that our array consists of float values for positions and uint8_t values for colors.

glVertexArrayVertexBuffer(vao, 0, vbo_positions_and_colors, 0, 3 * sizeof(float) + 3 * sizeof(uint8_t));

We can now use the relative offset in the glVertexArrayAttribFormat method to correctly separate the data. In the code below, you may notice that the offset is set to 0 for the position attribute. This is because the X, Y, and Z values are located in memory exactly where each vertex starts. However, for colors, we need to use 3 * sizeof(float) as the offset, because this is precisely where the R, G, and B values start relative to the beginning of each vertex.

glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
glVertexArrayAttribFormat(vao, 1, 3, GL_UNSIGNED_BYTE, GL_TRUE, 3 * sizeof(float));

Step 4 – Buffer and Attribute Linking

The final step is to link the virtual binding point (i.e., our data) with the attribute. This is accomplished using the glVertexArrayAttribBinding function, which takes two indices as parameters.

void glVertexArrayAttribBinding(GLuint vao, Gluint attribindex, GLuint bindingindex);

Working with Indices

In many cases, vertices in the geometry are repeated (see figure). In such instances, it is beneficial to use vertex indices instead of repeating the vertices themselves. The concept is to create an array of vertices and use indices that point into this array to define individual triangles. This approach allows us to repeat only one value instead of three.

Indices
float vertex_positions[8] = {
   -0.5, -0.5, 
    0.5, -0.5,
    0.5, 0.5,
   -0.5, 0.5
};
unsigned int indices[6] = {
   0,1,2,
   2,3,0
};

Indices are stored in a buffer object, which is created in the same manner as any other VBO.

glCreateBuffers(1, &indices_vbo);
glNamedBufferStorage(indices_vbo, 6 * sizeof(unsigned int), indices, 0);

However, since indices are somewhat special in that they are not vertex attributes but rather define triangles, OpenGL provides a custom function to bind them. Note that if we do not have indices or choose not to use them, this function is optional and does not have to be called.

void glVertexArrayElementBuffer(GLuint vao, GLuint buffer);

Rendering Geometry

The geometry can be rendered in two basic ways, depending on whether we have indices or not. The glDrawArrays method renders the geometry using a list of several consecutive vertices. This function selects the number of vertices (count) that are stored consecutively in the currently bound array (for example, as defined by the VAO). The function then creates a sequence of geometric primitives using array elements starting from first and ending at first + count - 1. This function does not utilize the index buffer.

void glDrawArrays(GLenum mode, GLint first, GLsizei count);

The glDrawElements method renders the geometry using a list of several vertices along with their respective indices. This function selects the number of vertices (count) from currently enabled arrays. The indices can be passed directly with this function (for cases where we want to use a C++ array) by providing a pointer to the array in the last parameter (indices). Alternatively, if we are using a VBO, the last parameter serves as an offset into this VBO. The type of this array is determined by the type parameter, which can take the values GL_UNSIGNED_BYTE, GL_UNSIGNED_SHORT, or GL_UNSIGNED_INT. The mode parameter specifies which geometric primitives are to be constructed from the selected vertices.

void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void *indices);

Let’s look at the following example, which again uses the triangle data created at the beginning of the lecture. The VAO is utilized in the display function for the final data rendering. The display function initially clears the color buffer and then binds the Vertex Array passed to it as a parameter. Drawing is performed using the glDrawArrays function, with the first parameter defining the type of primitive to be drawn from the vertices (GL_TRIANGLES).

void display(Gluint &vao){
    glClear(GL_COLOR_BUFFER_BIT);
    glBindVertexArray(vao);
    glDrawArrays(GL_TRIANGLES, 0, 12);
}

The figure below shows the resulting rendering of the input triangles.

Rendered Triangles

Instead of triangles, we can render individual points using the following call.

glDrawArrays(GL_POINTS, 0, 12);

Then, it is possible to change the size of the points in the vertex shader.

gl_PointSize = 10.0;
Rendered Points

The triangles drawn in the first example are filled with color by default. However, this can be altered by using the glPolygonMode function before drawing the geometry. For example, to draw only the outline of the triangles, use the following command:

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
glDrawArrays(GL_TRIANGLES, 0, 12);
Rendered Lines

Below are some additional examples of how we can use the glPolygonMode function to achieve different results. Note that these modes can be applied to the faces of the triangle separately for the front and back faces. This is achieved by using GL_FRONT, resp. GL_BACK instead of GL_FRONT_AND_BACK.

glPolygonMode(GL_FRONT_AND_BACK, GL_POINT); // Draws points
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);  // Draws outlines
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);  // Draws filled triangles
Polygon Modes