Skip to content

OBJ & BMP Parser

Loading real 3D objects from files — no libraries, just file reading.


The goal

Up to this point, objects were cubes defined by hand with hardcoded vertices. To render real models, we need to read them from files. The standard format for geometry is .obj paired with .mtl for material definitions. For textures, I chose .bmp — a simple binary format with no compression, far easier to parse from scratch than PNG or JPEG — no compression, no color space transformations, just raw bytes.

The OBJ format

An .obj file is plain text. Each line starts with a keyword that tells you what kind of data follows:

v   0.5  1.0 -0.3       # vertex position
vt  0.25 0.75           # UV coordinate
vn  0.0  1.0  0.0       # normal vector
f   1/1/1 2/2/2 3/3/3   # face: v/vt/vn indices per vertex

Face indices start at 1, not 0 — that's the OBJ convention. Each v/vt/vn group references a vertex position, a UV coordinate, and a normal.

Parsing line by line

The parser reads the file line by line with std::getline, then reads each line token by token using std::istringstream. A token is any string of characters separated by spaces — >> reads one token at a time:

std::ifstream objeto(filename);
std::string line;

while (std::getline(objeto, line)) {
    std::istringstream iss(line);
    std::string word;
    iss >> word;  // first token — the keyword ("v", "vt", "f", etc.)

    if (word == "v") {
        iss >> word; float v1 = std::stof(word);
        iss >> word; float v2 = std::stof(word);
        iss >> word; float v3 = std::stof(word);
        vertices.push_back({v1, v2, v3});
    }
    if (word == "vt") { /* same for UV coords */ }
    if (word == "vn") { /* same for normals   */ }
    if (word == "f")  { /* parse face indices */ }
}

For faces, each group 1/2/3 needs a second istringstream using / as the delimiter:

while (iss >> token) {
    std::istringstream iss2(token);
    std::string index;

    std::getline(iss2, index, '/');  // vertex index
    face.v_indices.push_back(std::stoi(index));

    std::getline(iss2, index, '/');  // UV index
    face.uv_indices.push_back(std::stoi(index));

    std::getline(iss2, index);       // normal index
    face.n_indices.push_back(std::stoi(index));
}

Fan triangulation

OBJ files can have faces with more than 3 vertices — quads, pentagons, anything. The rasterizer only handles triangles. The solution is fan triangulation: pick the first vertex as a fixed anchor, then connect it to each consecutive pair of the remaining vertices:

for (int i = 1; i < face.v_indices.size() - 1; i++) {
    // triangle: vertex 0, vertex i, vertex i+1
    Vec3 v1 = vertices[face.v_indices[0] - 1];   // -1: OBJ indices start at 1
    Vec3 v2 = vertices[face.v_indices[i] - 1];
    Vec3 v3 = vertices[face.v_indices[i+1] - 1];
}

The MTL file

The .mtl file accompanies the .obj and defines the material properties for each named surface — including the path to the texture image and the Phong constants:

newmtl skull_texture
map_Kd textures/skull.bmp   # path to diffuse texture
Ns  96.0                     # shininess
Ka  0.1 0.1 0.1             # ambient color
Kd  0.8 0.8 0.8             # diffuse color
Ks  0.5 0.5 0.5             # specular color

The parser reads the MTL to find the texture path for each material name, then loads the corresponding BMP. Each face in the OBJ references a material by name — that's how the renderer knows which texture to sample for each triangle.

The BMP parser

BMP is a binary format — you can't read it line by line. The file starts with a header that contains metadata at fixed byte positions:

  • Bytes 10–13: offset where the pixel data starts
  • Bytes 18–21: image width
  • Bytes 22–25: image height

These values are stored in little-endian format — the least significant byte comes first. To read a 4-byte integer, you need to combine the bytes manually:

uint32_t start =
    static_cast<uint8_t>(buffer[10]) |
    (static_cast<uint8_t>(buffer[11]) << 8)  |
    (static_cast<uint8_t>(buffer[12]) << 16) |
    (static_cast<uint8_t>(buffer[13]) << 24);

Each byte is shifted into its correct position and OR'd together. Without the uint8_t cast, sign extension would corrupt the higher bits.

Three quirks of the BMP format:

  • Colors are stored in BGR order, not RGB
  • Rows are stored bottom to top — the first row in the file is the last row of the image
  • Each row is padded to a multiple of 4 bytes
int padding = (4 - ((width * 3) % 4)) % 4;

for (int j = height - 1; j >= 0; j--) {       // bottom to top
    for (int i = 0; i < width * 3; i += 3) {  // 3 bytes per pixel (BGR)
        int idx = j * (width * 3 + padding) + i + start;
        colores[(height-1-j) * width + i/3].b = buffer[idx];
        colores[(height-1-j) * width + i/3].g = buffer[idx + 1];
        colores[(height-1-j) * width + i/3].r = buffer[idx + 2];
    }
}

The result is stored in a std::vector<Col> of size width × height — a flat array of RGB pixels, exactly like the framebuffer but for the texture. This vector is what the rasterizer samples when it needs the color at a given UV coordinate.


Bugs

BUG Nothing renders — black window
What happened The window opened but showed nothing. The model wasn't loading at all.
Cause The working directory — the folder CLion uses as the base for relative paths — wasn't set to the project root. So obj/skull/model.obj resolved to a path that didn't exist. This is a common pitfall when opening files with relative paths: the program runs fine in the IDE but can't find anything because it's looking in the wrong place.
Fix Set the working directory explicitly in CLion's run configuration to the project root. All relative paths then resolve correctly.
BUG Textures completely broken — geometry destroyed
What happened The model loaded but the geometry was completely distorted — vertices in the wrong places, faces twisted.
Cause OBJ indices start at 1, C++ vectors start at 0. Accessing vertices[index] without subtracting 1 reads the wrong vertex every time.
Fix vertices[face.v_indices[i] - 1] — subtract 1 from every index when accessing the arrays.

Broken textures from wrong indices

Wrong vertex indices — geometry completely distorted.


Result

The visual results of the parser — the Minecraft cube with texture — appear in the next section, UV Texturing, where sampling the texture is fully implemented.