Modern OpenGL: Part 11 Creating a basic Obj loader and loading models into Buffers
Here we go
Get ready, because this is going to be a bit of a longer post than usual because we are going to be writing an obj loader from scratch. This will involve us diving into some of the finer details of understanding vertex and polygon data.
Why write another obj loader?
You might be asking why we need to do this and why aren’t we using a library to load meshes because it’s been done a thousand times already. You might also be frustrated that I am not suggested we use glTF as the format of choice. You are right on all counts. You should use glTF and you should use a library. That doesn’t mean that learning how to write an obj loader isn’t useful. It serves a purpose.
obj is a very odd format in the way that it stores its data. By learning how to read data, interpret it and store it into your own c++ data structures, you will learn some useful skills in data processing and it will add to your arsenal of mental tools in how you approach thinking about 3D data.
I also feel that writing an obj loader is a kind of ‘rite of passage’ of graphics programming. It’s one of those things that everyone seems to want to do and it is accessible enough that anyone with a small knowledge of programming can achieve.
What do we need to do?
One of the issues with obj is that the data stored in the format is not in a layout that is ready to be ingested by OpenGL in its raw form. It will need to be read, analysed, processed and prepared into buffers for OpenGL to read.
First we need to define the final vertex format that we will want to be ended up with. We have the power to decide what the requirements are for the meshes we want to render. We are going to start with just 3 kinds of attributes. positions, normals and texture coordinates. These 3 things alone will be enough for us to get quite far and be able to load meshes, light them and apply textures.
We will define a data structure in our c++ code that we want to load the obj data into for ingesting by OpenGL. Let’s define a struct called vertex3D that has 3 members in it…..
#pragma once #include <cstdio> #include <fmt/core.h> #include <vector> #include "glm/glm.hpp" struct vertex3D { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; };
We will be parsing the obj file and processing then loading each vertex’s data into one of these structs. There are many ways we could layout out our data (interleaved vs separate arrays) but we will choose to interleave here. We can always transform the data later on. This struct definition is public to other cpp files because they will use it also. We will also need std vector so lets include that now.
Next we will open a new namespace and put all of our reading code in it.
namespace objLoader { using namespace std;
Next we will want to have a temporary store of data that will simply read in the data from the obj to get it into memory. This is even before we do the interleaving and store the data in the custom vertex3D struct we defined before. This is just getting the obj file data in to c++ vectors for now.
struct RawMeshData { RawMeshData() : positions(1), normals(1), textureCoords(1) { } // dummy value at 0. removes the need for subtracting 1 from obj file std::vector<glm::vec3> positions; std::vector<glm::vec3> normals; std::vector<glm::vec2> textureCoords; std::vector<glm::ivec3> faceIndices; };
Here is it very important that we initialize the vectors with a dummy value by giving them a size of 1 from the get go. This is a nifty trick that I’d learned in a forum somewhere. The reason is because of the fact that obj indices start at 1 ( they are not zero index based) and this will remove the need for us to have to subtract 1 later.
So now we will write the code to read in the data from the file and store it in the RawMeshData structure. The basic list of things
- Loop through each line of the obj
- Figure out the line length int erms of how many characters there are
- find out what kind of data is on that line
- Find out where the spaces are located in the line, store those locations and replace them with a special end line character (for reasons I will explain)
- based on the kind of data stored on that line, take the values separated by spaces and store it in one of the vector in the raw mesh data
We are starting off by writing a function called readObjRaw() that takes a file path that is the path to the obj file on disk and returns one of these raw mesh structures that has all the data loaded into it…
RawMeshData readObjRaw(const std::string& filePath) {
So now within this function we will begin by declaring an empty RawMeshData for us to load…
RawMeshData meshData;
Then we will use some C functions to open the file. I did consider using c++ all the way for this part of the code, but as much as I try and defend c++ from its shortcomings, in this case using c++ iostreams are simply slower than straight C functions so I’ll just going to use the tool that isn’t slow by default. The code is slightly uglier, but I can live with that.
FILE* fp = fopen(filePath.c_str(), "r"); if (!fp) { fmt::print(stderr, "Error opening file\n"); }
Next we need to create a small buffer in the form of a character array that we will read each line of the file into. This needs to be big enough to contain the whole line. I am going to choose 128 as the max length of the line. There might very well be obj files out there that contain 128 characters or more per line, but this should cover most cases and is enough for our purposes. We are also going to be calculating the length of the string after it has been read into the buffer, so we declare a variable that we can reuse for that purpose.
char line[128]; size_t line_size;
Then we want to have a temporary dynamic buffer of index positions that store where in the line of text that spaces occur. This allows us to track where the values in the text are stores so we can easily parse them into variables. We are also going to have a char pointer variable called end which is going to track the last character in the line that we were reading so we can pass that pointer into the next parsing function to start from…
std::vector<int> spacePositions(8); char* end; uint32_t key;
We also add this key variable which is going to store which type of line the current line is.
Now we can start the code that loops through each line of the text…
while (fgets(line, 128, fp)) {
This line starts a while loop and calls the C function fgets() which takes the buffer that we want to fill with the line, how many characters to read into that buffer (we just choose the same length as the buffer in this case) and the pointer to the file handle that we opened earlier. This while loop will continue to loop until the end of the file.
Now we are going to do some setup work at the beginning of each loop iteration once the line has been read into the buffer…
{ // setup line_size = strlen(line); spacePositions.clear(); key = packCharsToIntKey(line[0], line[1]); }
What we are doing here is reading how many characters were actually read because each line can be of arbitrary length. Then we are going to clear the vector that stores the positions of where the spaces are in the line. We do this because std vectors are dynamic memory and we want to reuse the memory that has already been allocated and prevent having to reallocate every loop iteration. This is a common pattern in computer graphics where you need to track multiple things each loop iteration where the count can change per iteration.
Then we calling a packCharsToIntKey() function (which we will show how to write next) that reads what the first two characters that are in the line and returns a special ‘key’ that we can use in a switch case statement (we are passing in the first two characters by indexing directly into position 0 and 1 of the line buffer). Switch case mechanisms in c and c++ are useful constructs to be able to branch on multiple values and jump to the right bit of code depending on what the value is. The reason we use this special key conversion is because switch case doesn’t work on multiple characters. We are lucky that two chars can be encoded into a single integer value by using some bit shifting. Then we can shift on the integer.
Here is how we write that encoding packCharsToIntKey() function (which we can put at the top of this file within the namespace)…
constexpr uint32_t packCharsToIntKey(char a, char b) { return (static_cast<uint32_t>(a) << 8) | static_cast<uint32_t>(b); }
This function now gives us a key to switch on, but what do we put in the switch statement as the values of the key? For example, if the line in the obj file starts with a “vn” then we want to switch on the key that is the integer corresponding to those letters. We can use some predetermined values for those keys and then just refer to those values without having to know what the keys actually are.
constexpr uint32_t v = packCharsToIntKey('v', ' '); constexpr uint32_t vn = packCharsToIntKey('v', 'n'); constexpr uint32_t vt = packCharsToIntKey('v', 't'); constexpr uint32_t vp = packCharsToIntKey('v', 'p'); constexpr uint32_t f = packCharsToIntKey('f', ' ');
Before we start our switching on values, we have to first detect in the current line where the spaces exist. This bit of code does that for us.
// spaces after the first will always be after 3 for (auto i = 0u; i < line_size; ++i) { if (line[i] == ' ') { line[i] = '\0'; spacePositions.push_back(i + 1); } } spacePositions.push_back((int)line_size));
What this does is loop though the line and when it detects a space, it replaces it with a null character (‘\0’). The reason for this will be made clear soon. It also stores the position of that space which we will use as the starting position for parsing the data. Then we also store the end position which gives us a way later to check wether we are reading a triangle or a quad polygon in the obj file. If there are a particular number of spaces (5), then we know its a quad.
Now lets start our switch case within the while loop on the key that we retrieved in the setup section previously…
switch (key) {
and for the first case we want to handle when the line is a vertex, (which is denoted by the letter ‘v’)
case v: { meshData.positions.emplace_back( std::strtof(&line[spacePositions[0]], nullptr), std::strtof(&line[spacePositions[1]], nullptr), std::strtof(&line[spacePositions[2]], nullptr)); break; }
This will store the data of the 3 coordinates of the position of the vertex in the positions vector in the meshData structure. The emplace_back function is a function very similar to push_back on the std::vector, but allows us to pass the arguments of the vec3 constructor directly. We could have called push_back, but that would mean we have to construct a vec3 first which could incur a copy.
I will explain the strtof() function as well. This is one of the C functions that can read text and convert to binary floating point values. It takes the position in the line of text where the spaces occur given to us by the spacePositions indices we stores previously and parses the text until it meets a null character. This is why we previously injected null characters into the spaces so that this function would know when to end its parsing between spaces.
The next step is to create the case for a vertex normal, denoted by “vn” at the start of the line. We use the vn variable which is the key associated with those two characters.
case vn: { meshData.normals.emplace_back( std::strtof(&line[spacePositions[0]], nullptr), std::strtof(&line[spacePositions[1]], nullptr), std::strtof(&line[spacePositions[2]], nullptr)); if (!startGroupTracking) { startGroupTracking = true; } break; }
and then we do the same for the vertex texture coordinates…
case vt: { meshData.textureCoords.emplace_back( std::strtof(&line[spacePositions[0]], nullptr), std::strtof(&line[spacePositions[1]], nullptr)); break; }
That takes care of all of the individual point data. Now we move onto the case that reads the lines in the obj that describe the polygons which are lists of the point indices.
case f: { // is face int a = std::strtol(&line[spacePositions[0]], &end, 10); int b = std::strtol(end + (*end == '/'), &end, 10); int c = std::strtol(end + (*end == '/'), &end, 10); meshData.faceIndices.emplace_back(a, b, c); int d = std::strtol(&line[spacePositions[1]], &end, 10); int e = std::strtol(end + (*end == '/'), &end, 10); int f = std::strtol(end + (*end == '/'), &end, 10); meshData.faceIndices.emplace_back(d, e, f); int g = std::strtol(&line[spacePositions[2]], &end, 10); int h = std::strtol(end + (*end == '/'), &end, 10); int i = std::strtol(end + (*end == '/'), &end, 10); meshData.faceIndices.emplace_back(g, h, i); if (spacePositions.size() == 5) { // face 0 meshData.faceIndices.emplace_back(a, b, c); // face 2 meshData.faceIndices.emplace_back(g, h, i); // reuse def as those temps aren't needed d = std::strtol(&line[spacePositions[3]], &end, 10); e = std::strtol(end + (*end == '/'), &end, 10); f = std::strtol(end + (*end == '/'), &end, 10); meshData.faceIndices.emplace_back(d, e, f); } break; }
You can think of the polygons in an obj file as a game of ‘join the dots’. We look at the line and each index triple is made up of indices into the points we have read, and is in the format position/normal/textureCoordinate.. If the polygon is made up of 3 triples, then it is a triangle, if it is 4 then it is a quad. If it is 5 then you need to talk to your modeller about their life choices.
So what we do in the case of the polygon being a quad is that we first make a triangle from 3 of the 4 vertices, effectively splitting the quad into two triangles. Then we detect the case that it is a quad (with the spacePositions.size() == 5 check, because if there are 5 space, it means there a 4 triples)., and if it is, then we create the other triangle that makes up the quad by emplacing back 3 more vertices. We have to be careful with the order here though to make sure that the winding order is correct.
It is also worth quickly explaining the use of the ‘end’ variable inside of the strtol function. The strtol function converts a string to a long integer data type. It takes the starting position as a pointer and also takes another variable which is an ‘out’ parameter that the function will set to where it detected the end of the string. Then in the next use of the function to get the next index, we use that end variable but add on a value that results in either 0 or 1 depending on the (*end == ‘/’) equality test. This is there so that we can skip the ‘/’ character if it exists. The 10 is the last argument which specifies that we are using base 10 which is the base of everyday numbers that we use on a day to day basis.
Finally we need to provide a default case for the types of lines in obj files that we aren’t reading just yet ( we aren’t supporting comments, groups, parameter space vertices, lines or materials).
default: { } } }
Now we can simply return the mesh data out of the function to the caller. We will revisit this function in later posts and add support for other parts of the obj format.
return meshData; }
So we have loaded all of the point data, but its not in the right order, its just a bunch of points. Also those points are going to be reused by multiple triangle so we need to flatten out the points into a list of triangles that OpenGL understands. For that, we have the face/polygon indices that are a list of triangle triple indexes. But because they are a list of indices, we need to loop through them and turn them into into vertices. First we will define a new struct that we can use as a type that we can return from a function that we will write.
struct MeshDataSplit { std::vector<vertex3D> vertices; }
Now we will write a function that will get the raw obj data and process them into triangle vertex data. It will take the filePath as its only argument. (Why not just return a vector<vertex3D> you might ask? We will be adding to this struct in later posts).
MeshDataSplit readObjSplit(const std::string& filePath) { auto rawMeshData = readObjRaw(filePath); MeshDataSplit meshData; meshData.vertices.resize(rawMeshData.faceIndices.size()); if (rawMeshData.textureCoords.size() == 0) { rawMeshData.textureCoords.resize(rawMeshData.faceIndices.size()); } if (rawMeshData.normals.size() == 0) { rawMeshData.normals.resize(rawMeshData.faceIndices.size()); } #pragma omp parallel for for (int i = 0u; i < rawMeshData.faceIndices.size(); ++i) { meshData.vertices[i] = { rawMeshData.positions[rawMeshData.faceIndices[i].x], rawMeshData.normals[rawMeshData.faceIndices[i].z], rawMeshData.textureCoords[rawMeshData.faceIndices[i].y]}; } return meshData; }
Immediately in this function, we call the previous functionreadObjRaw() we just wrote to get the raw mesh data using the file path. Then we create a blank MeshDataSplit which is what we will return out of the function. Then we resize the std::vectors inside the meshData and also the raw mesh data so that the data is reserved and we know that we can index into them. The vertices we will be writing into and the texture coords and normals we will be reading from. The reason we check the size before resizing is that we might have read an obj file that didn’t contain coords or normals so we are resizing so that if they were empty, we get some default values.
Then we can simply loop through the vertices and set their values using the indices stored in the faceIndices triples. It might be a bit hard to see whats going on here, but we are basically looking up the vertex data by index and pulling the data into the right location. Another great benefit of this approach is that because we pre reserved all the data, we can make this step be done in parallel by adding an openmp pragma to the loop. Thats nice!
Now lets go and write our c++ file to be able to load and draw the mesh. A lot of this will be the same as previous chapters, but we will be modifying our vertex binding to be able to read the vertex format (Vertex3D) we have decided to use.
… previous code (click to expand)#include "error_handling.hpp" #include "obj_loader_simple.hpp" #include <array> #include <chrono> // current time #include <cmath> // sin & cos #include <cstdlib> // for std::exit() #include <fmt/core.h> // for fmt::print(). implements c++20 std::format #include <unordered_map> // this is really important to make sure that glbindings does not clash with // glfw's opengl includes. otherwise we get ambigous overloads. #define GLFW_INCLUDE_NONE #include <GLFW/glfw3.h> #include <glbinding/gl/gl.h> #include <glbinding/glbinding.h> #include <glbinding-aux/debug.h> #include "glm/glm.hpp" #include <glm/gtc/matrix_transform.hpp> #include <glm/gtc/type_ptr.hpp> using namespace gl; using namespace std::chrono; int main() { auto startTime = system_clock::now(); const int width = 900; const int height = 900; auto windowPtr = [&]() { if (!glfwInit()) { fmt::print("glfw didnt initialize!\n"); std::exit(EXIT_FAILURE); } glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5); /* Create a windowed mode window and its OpenGL context */ auto windowPtr = glfwCreateWindow(width, height, "Chapter 11 - Loading Data from Disk", nullptr, nullptr); if (!windowPtr) { fmt::print("window doesn't exist\n"); glfwTerminate(); std::exit(EXIT_FAILURE); } glfwSetWindowPos(windowPtr, 480, 90); glfwMakeContextCurrent(windowPtr); glbinding::initialize(glfwGetProcAddress, false); return windowPtr; }(); // debugging { glEnable(GL_DEBUG_OUTPUT); glDebugMessageCallback(errorHandler::MessageCallback, 0); glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS); glDebugMessageControl(GL_DEBUG_SOURCE_API, GL_DEBUG_TYPE_OTHER, GL_DEBUG_SEVERITY_NOTIFICATION, 0, nullptr, false); }
Previously we used lambdas to create the vertex and fragment shaders and combine them into a file. I’ve now done a similar thing, except I’ve taken the shader text creation out of the lambda and made them arguments to the lambda function. The lambda now takes two strings which are the source text for the vertex and fragment shaders. This makes the lambda reusable.
auto createProgram = [](const char* vertexShaderSource, const char* fragmentShaderSource) -> GLuint { auto vertexShader = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr); glCompileShader(vertexShader); errorHandler::checkShader(vertexShader, "Vertex"); auto fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr); glCompileShader(fragmentShader); errorHandler::checkShader(fragmentShader, "Fragment"); auto program = glCreateProgram(); glAttachShader(program, vertexShader); glAttachShader(program, fragmentShader); glLinkProgram(program); return program; };
Next, we are going to be using the same fragment shader for drawing the gradient background and also for the mesh drawing. It is a simple shader that simply takes a colour attribute as input and outputs a pixel value of that interpolated vertex colour….
const char* fragmentShaderSource = R"( #version 450 core in vec3 colour; out vec4 finalColor; void main() { finalColor = vec4(colour, 1.0); } )";
Then, we are going to use our lambda and as the first argument for the vertex shader, pass it an in place string which is the same that we have been using for the gradiwent background. Notice we pass in the fragmentShaderSource variable as the second argument.
auto programBG = createProgram(R"( #version 450 core out vec3 colour; const vec4 vertices[] = vec4[]( vec4(-1.f, -1.f, 0.0, 1.0), vec4( 3.f, -1.f, 0.0, 1.0), vec4(-1.f, 3.f, 0.0, 1.0)); const vec3 colours[] = vec3[](vec3(0.12f, 0.14f, 0.16f), vec3(0.12f, 0.14f, 0.16f), vec3(0.80f, 0.80f, 0.82f)); void main(){ colour = colours[gl_VertexID]; gl_Position = vertices[gl_VertexID]; } )", fragmentShaderSource);
For our mesh drawing shader program, we want to have a slightly different vertex shader. This is going to take in the attributes from our vertex buffers and interpret the normals as colours and pass them to the fragment shader. The reason we do this is because this shader does a remap operation that shifts the colours from a range of (-1 -> 1) to (0 ->1). This makes the normals easier to visualize. Also see that we are reusing the fragment shader source. This is a powerful feature, being able to reuse shader stages in different shader programs.
auto program = createProgram(R"( #version 450 core layout (location = 0) in vec3 position; layout (location = 1) in vec3 normal; out vec3 colour; vec3 remappedColour = (normal + vec3(1.f)) / 2.f; void main(){ colour = remappedColour; gl_Position = vec4((position * vec3(1.0f, 1.0f, -1.0f)) + (vec3(0, -0.5, 0)), 1.0f); } )", fragmentShaderSource);
Finally we get to use our obj loader now! Lets read an obj file into our program!
auto meshData = objLoader::readObjSplit("rubberToy.obj");
That was easy wasn’t it?! We now have a meshData variable which has our vertices in a std::vector ready to use directly. Now lets get that data into a buffer…
This block of code is another one of our creation lambdas that does something for us and returns the result.
auto createBuffer = [&program](const std::vector<vertex3D>& vertices) -> GLuint { GLuint bufferObject; glCreateBuffers(1, &bufferObject); // upload immediately glNamedBufferStorage(bufferObject, vertices.size() * sizeof(vertex3D), vertices.data(), GL_MAP_WRITE_BIT | GL_DYNAMIC_STORAGE_BIT); return bufferObject; }; auto meshBuffer = createBuffer(meshData.vertices);
In this case, it takes in a vector of our vertices and sends the data from it into a buffer on the GPU.
Next we need to create a vertex array object and set up our binding mapping that will set up how the shader will read and interpret the data from the buffer. This doesn’t actually connect the buffer with the vaertex array object just yet (we will do that next), it just sets up the mapping.
auto createVertexArrayObject = [](GLuint program) -> GLuint { GLuint vao; glCreateVertexArrays(1, &vao); glEnableVertexArrayAttrib(vao, 0); glEnableVertexArrayAttrib(vao, 1); glVertexArrayAttribBinding(vao, glGetAttribLocation(program, "position"), /*buffer index*/ 0); glVertexArrayAttribBinding(vao, glGetAttribLocation(program, "normal"), /*buffer index*/ 0); glVertexArrayAttribFormat(vao, 0, glm::vec3::length(), GL_FLOAT, GL_FALSE, offsetof(vertex3D, position)); glVertexArrayAttribFormat(vao, 1, glm::vec3::length(), GL_FLOAT, GL_FALSE, offsetof(vertex3D, normal)); return vao; }; auto meshVao = createVertexArrayObject(program);
What this does is…
- enables 2 attributes with the call to glEnableVertexArrayAttrib()
- tells the vertex array object that the two attribute locations will be getting their data from a single buffer at a buffer index of 0 with glVertexArrayAttribBinding()
- Lets the vao know what the format of each attribute is at attribute locations 0 and 1 and tells it where in each vertex the data is as a byte offset with glVertexArrayAttribFormat()
https://dokipen.com/modern-opengl-part-5-feeding-vertex-data-to-shaders/
https://dokipen.com/modern-opengl-part-6-multiple-vertex-buffers/
glVertexArrayVertexBuffer(meshVao, 0, meshBuffer, /*offset*/ 0, /*stride*/ sizeof(vertex3D));
This is the function that tells opengl how to associate the vertex array object with actual buffer data. Remember we could keep using the vertex array object that matches our vertex format ands shader program attribute binding and use this to switch out to a completely different buffer. (as a side note, it’s even possible to use a shader program and switch out a whole vertex array object and buffer setup that uses completely different vertex formats and layout. Thats outside of the scope of this series though).
glBindVertexArray(meshVao); glEnable(GL_DEPTH_TEST); std::array<GLfloat, 4> clearColour{0.f, 0.f, 0.f, 1.f}; GLfloat clearDepth{1.0f}; while (!glfwWindowShouldClose(windowPtr)) { glClearBufferfv(GL_DEPTH, 0, &clearDepth); glUseProgram(programBG); glDrawArrays(GL_TRIANGLES, 0, 3); glUseProgram(program); glDrawArrays(GL_TRIANGLES, 0, (gl::GLsizei)meshData.vertices.size()); glfwSwapBuffers(windowPtr); glfwPollEvents(); } glfwTerminate(); }
All we do now is bind our vao as its the only one we will be using, and do our rendering as usual. In between draw calls, we simply switch the shader program used and draw a different number of triangles each time. And we are done! I hope that was fun for you.
In the next chapter, we will be learning about shader transforms so that we can start to draw our meshes in perspective views and move them around the screen!