Renderer OBJ and MTL Files Using OpenGL and OpenCV
Parsing OBJ and MTL Geometry and Materials
OBJ models may contain faces with variable vertex counts within a single file. Instead of assuming triangular or quadrilateral polygons, use GL_POLYGON inside a glBegin/glEnd block when drawing each face. This handles arbitrary polygon order correctly.
Material associations are driven by usemtl directives in the OBJ file. After a usemtl material_name line, all subsequent faces inherit that material until the next usemtl appears. The MTL file defines each material with newmtl material_name and may specify a diffuse texture via map_Kd texture_file_path. Textures are stored separately and mapped through material definitions.
Face declarations f v/vt/vn might use negative indices, which reference vertices relative to the current position in the file. These are intentionally skipped in this implementation for simplicity.
Handling Texture Coordinates
OpenCV loads images with the origin at the top‑left corner (pixel (0,0) at the top). OpenGL expects texture‑coordinate space where (0,0) corresponds to the bottom‑left corner. To reconcile the mismatch, flip the image vertically while copying pixel data from cv::Mat into the raw pixel buffer before uploading to OpenGL.
Older OpenGL implementations often require texture dimensions to be powers of two. Resize every loaded texture to 1024×1024 pixels with cv::resize to guarantee compatibility.
Loading Texture Images
A dedicated load_texture routine reads an image file with OpenCV, resizes it to 1024×1024, and vertically flips the pixel rows. The raw GLubyte data is then uploaded using glTexImage2D with format GL_BGR_EXT. A new texture ID is generated, bound, and stored in a texture list.
void Material::load_texture(const char* path)
{
cv::Mat source = cv::imread(path);
cv::resize(source, source, cv::Size(1024, 1024));
int rows = source.rows;
int cols = source.cols;
int totalPixels = rows * cols;
GLubyte* pixelBuffer = (GLubyte*)malloc(totalPixels * 3);
for (int i = 0; i < rows; ++i) {
int flippedRow = rows - 1 - i;
for (int j = 0; j < cols; ++j) {
cv::Vec3b bgr = source.at<cv::Vec3b>(flippedRow, j);
int base = (i * cols + j) * 3;
pixelBuffer[base] = bgr[0];
pixelBuffer[base + 1] = bgr[1];
pixelBuffer[base + 2] = bgr[2];
}
}
GLuint texId;
glGenTextures(1, &texId);
if (texId == 0) {
free(pixelBuffer);
return;
}
GLint previousTex;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previousTex);
glBindTexture(GL_TEXTURE_2D, texId);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, cols, rows, 0,
GL_BGR_EXT, GL_UNSIGNED_BYTE, pixelBuffer);
glBindTexture(GL_TEXTURE_2D, previousTex);
free(pixelBuffer);
this->texture_list.push_back(texId);
}
Parsing the MTL File
The loadFile method for materials iterates through the MTL file. When encountering newmtl, a new material struct is populated. Lines starting with Ns, d, Ni, illum, Ka, Kd, Ks, and map_Kd are parsed accordingly. Textures referenced by map_Kd trigger load_texture and the returned index is stored as texture_id.
void Material::loadFile(const char* mtlPath)
{
std::ifstream in(mtlPath);
std::string line;
while (std::getline(in, line)) {
if (line.rfind("newmtl", 0) == 0) {
std::istringstream iss(line);
std::string kw, matName;
iss >> kw >> matName;
material current;
current.material_name = matName;
while (std::getline(in, line) && !line.empty()) {
if (line.find("Ns ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.Ns;
} else if (line.find("d ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.d;
} else if (line.find("Ni ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.Ni;
} else if (line.find("illum ") == 0) {
std::istringstream ls(line);
std::string k; int v; ls >> k >> v; current.illum = v;
} else if (line.find("Ka ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.Ka[0] >> current.Ka[1] >> current.Ka[2];
} else if (line.find("Kd ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.Kd[0] >> current.Kd[1] >> current.Kd[2];
} else if (line.find("Ks ") == 0) {
std::istringstream ls(line);
std::string k; ls >> k >> current.Ks[0] >> current.Ks[1] >> current.Ks[2];
} else if (line.find("map_Kd ") == 0) {
std::istringstream ls(line);
std::string k, texFile; ls >> k >> texFile;
this->load_texture(texFile.c_str());
current.texture_id = this->texture_list.size() - 1;
}
}
this->material_list.push_back(current);
}
}
}
Parsing the OBJ File
OBJ parsing is split into two passes over the file. The first pass extracts vertex positions (v), normals (vn), and texture coordinates (vt). The second pass collects face definitions and records the active material name for each face. Face data (f) is decomposed into groups of three integers (vertex, texture, normal indices). These triplets are pushed into a flat vector per face.
void Model::loadFile(const char* objPath)
{
std::ifstream in(objPath);
std::string line;
// First pass: geometry attributes
while (std::getline(in, line)) {
if (line.rfind("v ", 0) == 0) {
float x, y, z;
sscanf_s(line.c_str(), "v %f %f %f", &x, &y, &z);
vertex_list.insert(vertex_list.end(), {x, y, z});
} else if (line.rfind("vn ", 0) == 0) {
float nx, ny, nz;
sscanf_s(line.c_str(), "vn %f %f %f", &nx, &ny, &nz);
normal_list.insert(normal_list.end(), {nx, ny, nz});
} else if (line.rfind("vt ", 0) == 0) {
float u, v;
sscanf_s(line.c_str(), "vt %f %f", &u, &v);
texture_list.push_back(u);
texture_list.push_back(v);
}
}
// Second pass: faces and materials
in.clear();
in.seekg(0);
std::string activeMaterial;
while (std::getline(in, line)) {
if (line.rfind("usemtl ", 0) == 0) {
std::istringstream iss(line);
std::string k; iss >> k >> activeMaterial;
} else if (line.rfind("f ", 0) == 0) {
std::istringstream iss(line.substr(2));
std::vector<int> indices;
int vi, ti, ni;
char slash;
while (iss >> vi >> slash >> ti >> slash >> ni) {
indices.push_back(vi);
indices.push_back(ti);
indices.push_back(ni);
}
face_list.push_back(indices);
face_material.push_back(activeMaterial);
}
}
}
Rendering with Materials and Textures
During display, iterate over each face. Find the corresponding material by matching face_material[i] with material names. If the material has a valid texture_id, bind its texture and enable texture coordinates; otherwise apply material colors via glMaterialfv. A polygon is emitted using GL_POLYGON; for each vertex triplet, set normal from normal_list, texture coordinates from texture_list, and position from vertex_list. The texture coordinate index uses the second element of each group (vt), while the normal is taken from the third group (vn).
for (size_t i = 0; i < model_object.face_list.size(); ++i) {
auto& indices = model_object.face_list[i];
const std::string& mtlName = model_object.face_material[i];
material activeMat;
for (auto& m : material_object.material_list) {
if (m.material_name == mtlName) {
activeMat = m;
break;
}
}
if (activeMat.texture_id != -1) {
glBindTexture(GL_TEXTURE_2D, material_object.texture_list[activeMat.texture_id]);
glBegin(GL_POLYGON);
for (size_t j = 0; j < indices.size(); j += 3) {
int tIdx = indices[j + 1] - 1;
int nIdx = indices[j + 2] - 1;
int vIdx = indices[j] - 1;
glTexCoord2f(model_object.texture_list[2 * tIdx],
model_object.texture_list[2 * tIdx + 1]);
glNormal3f(model_object.normal_list[3 * nIdx] + dx,
model_object.normal_list[3 * nIdx + 1] + dy,
model_object.normal_list[3 * nIdx + 2]);
glVertex3f(model_object.vertex_list[3 * vIdx] + dx,
model_object.vertex_list[3 * vIdx + 1] + dy,
model_object.vertex_list[3 * vIdx + 2]);
}
glEnd();
} else {
glMaterialfv(GL_FRONT, GL_SPECULAR, activeMat.Ks);
glMaterialfv(GL_FRONT, GL_DIFFUSE, activeMat.Kd);
glLightModelfv(GL_LIGHT_MODEL_AMBIENT, activeMat.Ka);
glBegin(GL_POLYGON);
for (size_t j = 0; j < indices.size(); j += 3) {
int nIdx = indices[j + 2] - 1;
int vIdx = indices[j] - 1;
glNormal3f(model_object.normal_list[3 * nIdx] + dx,
model_object.normal_list[3 * nIdx + 1] + dy,
model_object.normal_list[3 * nIdx + 2]);
glVertex3f(model_object.vertex_list[3 * vIdx] + dx,
model_object.vertex_list[3 * vIdx + 1] + dy,
model_object.vertex_list[3 * vIdx + 2]);
}
glEnd();
}
}