Reading OBJ

How to 3D

Chapter 3: Meshes

Reading OBJ

Some models are neatly generated by algorithms. Others are more appropriately made by human artists using a 3D modeling program like Blender, Maya, or 3ds Max. These tools export meshes in many different formats. OBJ is a common but aging format. It was published by Wavefront Technologies in the 1980s as part of a graphics suite used to make movies like Disney's Aladdin. The suite was eventually integrated into what is known today as Autodesk Maya.

Despite its age, OBJ has two redeeming qualities: it is a plain text format that can be read by humans, and its simplest form is just a list of vertex positions and face indices. This is the OBJ file for a square in the xy-plane:

OBJ
v -1 -1 0
v  1 -1 0
v -1  1 0
v  1  1 0
f 1 2 3
f 2 4 3
v -1 -1 0
v  1 -1 0
v -1  1 0
v  1  1 0
f 1 2 3
f 2 4 3

Each vertex position is preceded by a v. Each triangular face is preceded by an f. The vertex indices are 1-based. The OBJ format does allow for additional information about texturing, normals, and materials, but that extra data is tedious to parse. We'll use OBJ for simple meshes and a more modern format for complex ones.

Let's assume that our OBJ files are fetchable assets. We'll fetch them just as we fetch our shader code. We put the code for loading and parsing OBJ files in the class TrimeshIo in a file named trimesh-io.ts:

class TrimeshIo {
  // ...
}
class TrimeshIo {
  // ...
}

To read in an OBJ file, we first fetch the file as text using our readText method. Then we rely on a helper method to do the actual parsing and construction of a Trimesh:

class TrimeshIo {
  static async readFromUrl(url: string): Trimesh {
    const objText = await fetchText(url);
    return TrimeshIo.readFromText(objText);
  }

  static readFromText(objText: string): Trimesh {
    // ...
  }
}
class TrimeshIo {
  static async readFromUrl(url: string): Trimesh {
    const objText = await fetchText(url);
    return TrimeshIo.readFromText(objText);
  }

  static readFromText(objText: string): Trimesh {
    // ...
  }
}

Because readFromText is separate from readFromUrl, we can use it to parse OBJ files from sources beyond the assets directory, like string literals or file uploads. The readFromUrl method must be an asynchronous function since it calls the asynchronous fetchText. We must await its results before parsing the text.

Inside readFromText, we split the text into lines, and each line we split into fields separated by whitespace:

class TrimeshIo {
  // ...
  static readFromText(objText: string): Trimesh {
    const positions = [];
    const faces = [];
    
    for (let line of objText.split(/\r?\n/)) {
      const fields = line.split(' ');
      // process fields
    }

    return new Trimesh(positions, faces);
  }
}
class TrimeshIo {
  // ...
  static readFromText(objText: string): Trimesh {
    const positions = [];
    const faces = [];
    
    for (let line of objText.split(/\r?\n/)) {
      const fields = line.split(' ');
      // process fields
    }

    return new Trimesh(positions, faces);
  }
}

If the OBJ is formatted correctly, the first field will be either "v" or "f". A vertex line has three floats in the remaining fields, and a face line has three 1-based indices. We parse these numbers and use them to populate the positions and faces arrays:

class TrimeshIo {
  // ...
  static readFromText(objText: string): Trimesh {
    const positions = [];
    const faces = [];
    
    for (let line of objText.split("\n")) {
      const fields = line.split(' ');
      if (fields.length > 0) {
        if (fields[0] === 'v' && fields.length === 4) {
          positions.push(new Vector3(
            parseFloat(fields[1]),
            parseFloat(fields[2]),
            parseFloat(fields[3])
          ));
        } else if (fields[0] === 'f' && fields.length === 4) {
          faces.push([
            parseInt(fields[1]) - 1,
            parseInt(fields[2]) - 1,
            parseInt(fields[3]) - 1
          ]);
        }
      }
    }

    return new Trimesh(positions, faces);
  }
}
class TrimeshIo {
  // ...
  static readFromText(objText: string): Trimesh {
    const positions = [];
    const faces = [];
    
    for (let line of objText.split("\n")) {
      const fields = line.split(' ');
      if (fields.length > 0) {
        if (fields[0] === 'v' && fields.length === 4) {
          positions.push(new Vector3(
            parseFloat(fields[1]),
            parseFloat(fields[2]),
            parseFloat(fields[3])
          ));
        } else if (fields[0] === 'f' && fields.length === 4) {
          faces.push([
            parseInt(fields[1]) - 1,
            parseInt(fields[2]) - 1,
            parseInt(fields[3]) - 1
          ]);
        }
      }
    }

    return new Trimesh(positions, faces);
  }
}

That's it. We now have an OBJ loader. Test that it works by exporting a simple and small model exported from Blender. Our parser doesn't handle normals, but we can still calculate them with computeNormals.

Our simple loader will fail to parse many of the OBJs we find online. Some OBJs have faces with more than three vertices. Others have texture and lighting data included in the f lines. To produce an OBJ file that we can load, open the mesh in Blender, and follow these steps:

Click File / Export / Wavefront (.obj).
Check Triangulated Mesh in Geometry, but uncheck all other options.
Uncheck all options in Materials and Grouping.
Export the file into your renderer's directory.

In your testing, make sure the model is small. The renderers we've been writing can only see triangles that fit within the unit cube. That will soon change as we add matrix transformations.

← Vertex NormalsReading glTF →