Introduction
Hello! In our last lesson, you built a C function to parse an OBJ file and load all the vertex positions (v), texture coordinates (vt), and normals (vn) into separate arrays. You now have the raw ingredients for a 3D model, but they are like three separate piles of parts with no assembly instructions.
Today, we'll tackle those instructions. The goal of this lesson is to process the face data (f) from the OBJ file to generate an indexed vertex buffer for rendering. We will parse the f lines to understand how the vertices connect and solve a key problem: the mismatch between how OBJ files and modern graphics APIs like OpenGL define a vertex.
By the end of this lesson, you will have a process to convert the separate data pools from an OBJ file into a unified, optimized format that the GPU can render efficiently.
1. Understanding the Face (f) Line
First, let's look closely at the f lines we ignored in the last lesson. A face is typically a triangle, defined by three vertices. In an OBJ file, each of those vertices is described by a set of indices.
# f vertex_index / texture_coord_index / normal_index
f 1/1/1 2/2/1 3/3/1
This line defines one triangle.
- The first vertex uses position
1, texture coordinate1, and normal1. - The second vertex uses position
2, texture coordinate2, and normal1. - The third vertex uses position
3, texture coordinate3, and normal1.
It's crucial to remember from last time that these are 1-based indices, so we'll need to subtract 1 to use them in our 0-based C arrays.
The format can also vary. Sometimes texture coordinates or normals are omitted.
The Scratchapixel article on the OBJ format provides a concise overview of the different ways face data can be represented. This will help you build a more robust parser.
Read the short section titled "The OBJ File Format". Focus on the four different syntaxes for the f line it presents. This will prepare you for parsing various OBJ files you might encounter.
2. The Core Problem: Mismatched Vertex Definitions
Here is the central challenge of this lesson:
- An OBJ file defines a vertex corner using up to three separate indices (
v/vt/vn). - OpenGL defines a vertex using a single index. A vertex in OpenGL is the complete bundle of attributes: position + color + normal + texture coordinate, etc.
This means we can't simply use the vertex position indices from the OBJ file as our final index buffer. Why? Consider a cube. Two faces can share a vertex position, but they will have different normal vectors to create a hard edge. From OpenGL's perspective, these are two different vertices because their attribute bundles (specifically, the normals) are different.
Our job is to translate from the OBJ's flexible but separate indexing scheme to OpenGL's strict, unified one.
Loading 3D Models - Vulkan Game Engine Tutorial 17
This video by Brendan Galea, although for Vulkan, explains this exact problem with exceptional clarity. The concepts are identical for OpenGL.
Watch the clip from 15:21 to 16:54. The presenter explains why a simple OBJ face-to-index-buffer conversion doesn't work and outlines the strategy we need to adopt: creating a list of unique vertices and an index buffer that points into it.
3. The Solution: De-duplication
As the video explained, the solution is to build two new lists:
- A vertex buffer containing only unique vertices. A vertex is unique if its combination of position, normal, and texture coordinate is unique.
- An index buffer that contains integers pointing to the vertices in our new vertex buffer, defining the triangles.
The algorithm to do this is as follows:
- Read each corner of each face from the OBJ file (e.g., the
1/1/1part). - Use the indices to fetch the corresponding position, texcoord, and normal from the arrays we loaded in the last lesson.
- Combine these into a single
Vertexstruct. - Check if we have seen this exact
Vertexstruct before. - If we have not, add it to our list of unique vertices. The index of this new vertex is simply the current size of the list. Add this new index to our index buffer.
- If we have seen it before, find its existing index in the unique vertex list. Add that existing index to our index buffer.
To perform the check in step 4 efficiently, a hash map (like Python's dict or C#'s Dictionary) is the ideal data structure. It provides very fast lookups.
Loading 3D Models - Vulkan Game Engine Tutorial 17
The same video goes on to implement this exact logic using a C++ std::unordered_map. While we will be coding in C, watching this will make the algorithm crystal clear. Your experience with object-oriented programming will make this easy to follow.
Watch from 16:54 to 21:59. Don't worry about the C++-specific syntax like custom hash functions. Focus on the core logic inside the loadModel function: An unordered_map called uniqueVertices is created. It loops through the model's indices. It builds a vertex struct. It checks if the vertex is already in the map (uniqueVertices.count(vertex)). If not, it adds the vertex to the vertex buffer and the map. Finally, it pushes the vertex's index (from the map) to the index buffer. This is the exact algorithm we will now implement in C.
4. Implementing the Indexing Logic in C
Now, let's adapt this logic for our C parser. We'll extend the load_obj function from the previous lesson.
First, let's update our data structures. We need a struct for the final, unified vertex and we need to add arrays for our final vertex and index buffers to our main model struct.
// In a suitable header, e.g., "model.h"
// A complete vertex with all its attributes
typedef struct {
vec3 position;
vec2 texcoord;
vec3 normal;
} Vertex;
// The final model data, now including the indexed buffers
typedef struct {
// Temporary pools of data loaded from OBJ
vec3 *positions;
vec3 *normals;
vec2 *texcoords;
// Final, render-ready buffers
Vertex *vertices; // Array of unique vertices
unsigned int *indices; // Index buffer
int n_vertices;
int n_indices;
} model_data;
Now, we'll modify the load_obj function. Implementing a hash map from scratch in C is a significant task that would distract from our main goal. Instead, for this lesson, we will implement the lookup (step 4 of our algorithm) with a simple linear search. This is less performant for very large models but is much simpler to write and perfectly demonstrates the logic.
The overall parsing flow now becomes:
- First Pass: Count the number of positions, normals, texcoords, and faces (
f) in the file. - Memory Allocation:
- Allocate memory for the temporary
positions,normals, andtexcoordsarrays, just like last lesson. - Allocate memory for the final
indicesarray. We know its size will beface_count * 3. - We can't know the number of unique vertices ahead of time. So we'll start with a reasonable guess and use
reallocto grow theverticesarray if needed. This is a practical compromise.
- Allocate memory for the temporary
- Second Pass:
- Fill the temporary
positions,normals,texcoordsarrays. - Process each
fline, parse the indices, and perform the de-duplication logic to populate the finalverticesandindicesarrays.
- Fill the temporary
Here is what the core logic for processing faces in the second pass looks like:
// Inside your load_obj function, during the second pass...
// ... after parsing v, vt, vn lines ...
else if (strncmp(line, "f ", 2) == 0) {
int pos_idx[3], tex_idx[3], norm_idx[3];
// Parse the 9 indices from the 'f' line
// This handles the v/vt/vn format
int matches = sscanf(line, "f %d/%d/%d %d/%d/%d %d/%d/%d",
&pos_idx[0], &tex_idx[0], &norm_idx[0],
&pos_idx[1], &tex_idx[1], &norm_idx[1],
&pos_idx[2], &tex_idx[2], &norm_idx[2]);
if (matches != 9) {
// Handle other formats or error
// For now, we'll just support this one
continue;
}
// For each of the 3 vertices of the face
for (int i = 0; i < 3; ++i) {
// Assemble a complete vertex from the OBJ indices
Vertex current_vertex;
// Remember to convert from 1-based to 0-based index!
current_vertex.position = temp_positions[pos_idx[i] - 1];
current_vertex.texcoord = temp_texcoords[tex_idx[i] - 1];
current_vertex.normal = temp_normals[norm_idx[i] - 1];
// --- Linear search for de-duplication ---
int existing_index = -1;
for (int j = 0; j < out_model->n_vertices; ++j) {
// memcmp is a C function to compare blocks of memory.
// If the memory for the two structs is identical, they are the same vertex.
if (memcmp(&out_model->vertices[j], ¤t_vertex, sizeof(Vertex)) == 0) {
existing_index = j;
break;
}
}
if (existing_index != -1) {
// If we found an existing vertex, use its index
out_model->indices[out_model->n_indices++] = existing_index;
} else {
// If it's a new vertex, add it to the vertex buffer
// (You would add realloc logic here if capacity is reached)
out_model->vertices[out_model->n_vertices] = current_vertex;
// And add its new index to the index buffer
out_model->indices[out_model->n_indices++] = out_model->n_vertices;
out_model->n_vertices++;
}
}
}
This C code block is the direct implementation of the de-duplication algorithm. It iterates through each face corner, assembles a Vertex, searches for it, and adds either an old or a new index to the indices buffer.
5. Using the Indexed Data with OpenGL
So, why did we go through all this trouble? Because the two arrays we just createdโvertices and indicesโmap perfectly to OpenGL's rendering model.

The article from OpenGLBook.com provides C code that shows exactly how these arrays are sent to the GPU.
Chapter 3: Index Buffer Objects and Primitive Types
This article demonstrates how to use an Index Buffer Object (IBO), which is OpenGL's term for the index buffer we just created.
Read the sections "Enter Index Buffers", "Step-By-Step", "Modifying Vertices and Adding Indices", "Index Buffer Generation...", and "Rendering the Indexed Vertex Buffer Object". Focus on these key C functions: glGenBuffers, glBindBuffer, and glBufferData are used to upload our vertices array to a GL_ARRAY_BUFFER. The same functions are used to upload our indices array to a GL_ELEMENT_ARRAY_BUFFER. glDrawElements is the function that finally draws the model, using our index buffer to pull vertices in the correct order. This will show you the end-goal of our parsing work.
The process described in the article is the final step: you take the model.vertices array and send it to a VBO, and the model.indices array and send it to an EBO. Then, a single glDrawElements call can render your entire complex model efficiently.
Conclusion
This was a conceptually challenging but essential lesson. You have bridged the gap between a common 3D model file format and the data structure required by modern graphics hardware.
Key Takeaways:
- OBJ files use separate indices for position, texture, and normal data (
v/vt/vn), while OpenGL uses a single index for a complete vertex bundle. - To resolve this, we must perform a de-duplication step to create a list of unique vertices.
- The standard algorithm for this involves iterating through OBJ faces and using a fast lookup structure (like a hash map) to check if a vertex has been seen before.
- The final output is two buffers: a vertex buffer with unique, interleaved vertex data, and an index buffer that defines the triangles.
- These two buffers map directly to OpenGL's Vertex Buffer Objects (VBOs) and Element Buffer Objects (EBOs) and are rendered with
glDrawElements.
Preview of the Next Lesson:
Our model_data struct currently holds pointers to our final vertex and index data. However, to render it, we also need to manage the OpenGL buffer objects (the VBO and EBO) and the Vertex Array Object (VAO) that ties them together. In the next lesson, "Create a Mesh struct to encapsulate the VAO, VBO, EBO, and vertex count for a model," we will create a dedicated Mesh struct to manage both the data and its corresponding GPU resources, creating a clean, reusable component for our engine.