Parsing an OBJ wavefront file - Fish-In-A-Suit/Conquest GitHub Wiki

OBJ (or .OBJ) is a geometry definition open file format developed by Wavefront Technologies which has been widely adopted. An OBJ file defines the vertices, texture coordinates and polygons that compose a 3D model. It’s a relative easy format to parse since is text based and each line defines an element (a vertex, a texture coordinate, etc.).

Each line in .obj file starts with a token that identifies the type of element it holds:

token explanation example
# comment /
v vertex coordinates (x, y, z) v 0.155 0.211 0.32 1.0
vn vertex normal coordinates (x, y, z) vn 0.71 0.21 0.82
vt texture coordinates (x, y) vt 0.500 1
f polygonal face element (f v1 v2 v3 or f v1/t1/n1 v2/t2/n2 V3/t3/n3) f 6 3 1 or f 6/4/1 3/5/3 7/6/5

The token f defines a face. With information contained in these lines, the indices array, which is used to tell OpenGL in which order to connect vertices, can be constructed. Note that this .obj parser only handles faces as triangles (when exporting .obj models, one must select the checkbox "Triangulate faces").

(1) Read all of the lines of a file

(2) Initialize the following storage containers into which parsed values from lines are stored

  • List vertices = new ArrayList<>();
  • List textures = new ArrayList<>();
  • List normals = new ArrayList<>();
  • List faces = new ArrayList<>();

(3) Parse the lines based on the starting token

  • case "v": create a new Vector3f out of line data and add it to vertices
  • case "vt": create a new Vector2f out of line data and add it to textures
  • case "vn": create a new Vector3f out of line data and add it to normals
  • case "f": create a new Face out of three PFE tokens (v/vt/vn eg. 8/4/2) and add it to faces

(4) reorder vertices, textures, normals and faces and take care of duplicate vertices

Important notice: When creating a model inside a DCC (digital content creation) app, such as Blender, and when texturing that model, one has to mark ie. seams - edges which define where the mesh is "cut apart", so texture can be properly mapped. Vertices at these edges are assigned different texture coordinates, which has to be handled accordingly by the parser.

Three classes will be needed for parsing a .obj file:

  • OBJLoader, which uses the functionality of other two, provides the method to open a .obj file given by a String of text, read its content and rearrange it in such a way that it can be rendered
  • Vertex, which represents one vertex of a Mesh. This includes its position, texture coordinates and corresponding normal vector
  • ModelData, which is a class used to store ordered arrays which hold vertex positions, texture coordinates and normal vectors

OBJLoader contains the loadObjModel(String fileName, String textureName) method:¸

  • fileName: the name of the obj file in resources/models/ without the .obj extension
  • textureName: the name of the texture (.png) file in resources/textures/ without the .png extension

The rest of this explanation is going to deal with explaining how this method works, as it encompasses all of the above classes

First, all storage containers are intiialized:

  • List vertices: a List which holds unordered Vertex elements.
  • List textures: a List which holds unordered texture coordinates as _Vector2f_s
  • List normals: a List which holds unordered normal vectors as _Vector3f_s
  • List indices: a List which holds ordered indices. It is later just converted to an int array
  • float[] vertexPosArray: a float array which holds ordered vertex positions in component form (each vertex position is represented as a Vector3f which has three components)
  • float[] texturesArray: a float array which holds ordered texture coordinates in component form
  • float[] normalsArray: a float array which holds ordered normal vectors in component form
  • int[] indicesArray: an int array which holds indices

Then, a try-catch clause is introduced, which reads the file and stores the contents inside corresponding containers. First, a new BufferedReader instance is creates from the file path, which is responsible for reading a file line by line. Each such line is split into a String array based on spacebar characters. - If a line begins with a v, then a new Vector3f called vertexPos is created out of the data of the String array created from the String representation of the current line: Vector3f vertex = new Vector3f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]), Float.parseFloat(splitLine[3]));. Then a new Vertex instance (called newVertex) is created based on the size of vertices List (which actually acts as an index of that Vertex instance inside vertices List) and vertexPos: Vertex newVertex = new Vertex(vertices.size(), vertexPos);. vertices.size() is assigned to Vertex.index and vertexPos is assigned to Vertex.position (inside the constructor). Then, newVertex is added to vertices List. - If a line begins with vt, a Vector2f called texture is created out of data of that line. This Vector2f (which holds two texture coordinates), is then added to textures List. - If a line begins with vn, a Vector3f called normal is created out of the data of that line. This Vector3f is then added to normals List.

Reading lines that begin with f is more complicated. Lines beginning with f represent (triangular) faces by specifying groups of indices, which refer which vector position, texture coordinate(s) and normal vector to use in order to construct a vertex (three vertices needed for a face). Since the current OBJLoader has support only for triangulated faces, there will be three index groups of three (if the model is textured and contains normal vectors).

When exporting a .obj model from Blender, make sure to have checkboxes "Include normals", "Include UVs", "Triangulate Faces" and "Objects as OBJ Objects" checked. Also, make sure that "-Z Forward" and "Y Up" are selected.

This is an example of a .obj file:

# Blender v2.78 (sub 0) OBJ File: 'cube.blend'
# www.blender.org
o Cube
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 -0.999999
v 0.999999 1.000000 1.000001
v -1.000000 1.000000 1.000000
v -1.000000 1.000000 -1.000000
vt 0.2766 0.2633
vt 0.5000 0.4867
vt 0.2766 0.4867
vt 0.7234 0.4867
vt 0.9467 0.2633
vt 0.9467 0.4867
vt 0.0533 0.4867
vt 0.0533 0.2633
vt 0.2766 0.0400
vt 0.5000 0.2633
vt 0.0533 0.7100
vt 0.7234 0.2633
vt 0.0533 0.0400
vt 0.2766 0.7100
vn 0.0000 -1.0000 0.0000
vn 0.0000 1.0000 0.0000
vn 1.0000 -0.0000 0.0000
vn 0.0000 -0.0000 1.0000
vn -1.0000 -0.0000 -0.0000
vn 0.0000 0.0000 -1.0000
s off
f 2/1/1 4/2/1 1/3/1
f 8/4/2 6/5/2 5/6/2
f 5/7/3 2/1/3 1/3/3
f 6/8/4 3/9/4 2/1/4
f 3/10/5 8/4/5 4/2/5
f 1/3/6 8/11/6 5/7/6
f 2/1/1 3/10/1 4/2/1
f 8/4/2 7/12/2 6/5/2
f 5/7/3 6/8/3 2/1/3
f 6/8/4 7/13/4 3/9/4
f 3/10/5 7/12/5 8/4/5
f 1/3/6 4/14/6 8/11/6

Now, let's take the 8/4/2 PFE (polygonal face element) as example. It refers to the 8th position vertex from the top (v -1.000000 1.000000 -1.000000). This vertex position should be coupled with the 4th texture coordinate set (vt 0.7234 0.4867) and the second normal vector (vn 0.0000 1.0000 0.0000). When applying textures in Blender, however, texture seams are used. Vertices which are part of a seam are assigned different texture coordinates and should therefore be duplicated.

Lines that begin with f are split further by slash character / (they are already split by spacebar at the beginning), so each of the polygonal face elements is split into a String array. If v/vt/vn paradigm is used, then each String array will have three elements. The first String in the array refers to the index which refers to the vertex position, the second String refers to the index which refers to the corresponding vertex coordinate and the third String refers to the index which in turn refers to the normal vector of that vertex. For example, 8/4/2 is broken into 8, 4 and 2; this vertex is comprised of the 8th positional vector, 4th texture coordinate (represented as Vector2f) and second normal vector.

Each String array, which holds individual elements of a face (ie. a String that holds 8, 4 and 2), is then sent to the processVertex(String[] vertexData, List vertices, List indices) method:

  • vertexData: one polygonal face element (PFE) represented as a String array (must be split at slashes!)
  • vertices: a List of Vertex instances (not yet duplicated)
  • indices: an empty List at this point, into which indices are stored

Since the first String in vertexData refers to the position of a vertex and OpenGL works by first taking indexes which refer to positions (and only then are texture coordinates and normal vectors for that position processed), the index which refers to this position could be dubbed as the index which refers to the vector as itself. This value is stored in variable index: int index = Integer.parseInt(vertexData[0]) - 1;

Note: indices in .obj begin with number 1, whereas in Java the array indices begin with 0. Therefore, when specifying indices, we need to substract 1 from the ".obj index" to get the correct "java index".

Then, the Vertex instance (currentVertex) is retrieved from vertices list by using index. After that, texture and normal indexes are parsed from the elements at indexes 1 and 2 of the current PFE:

int textureIndex = Integer.parseInt(vertexData[1]) - 1;
int normalIndex = Integer.parseInt(vertexData[2]) - 1;

Then the program checks whether the current Vertex instance already has an associated texture index and normal index. If they aren't set yet, the textureIndex and normalIndex are simply "set to the instance of the current Vertex" and index is added to indices:

//Vertex.java - the part that checks if two fields are set
public class Vertex {
    private static final int NO_INDEX = -1;
    private Vector3f position;
    private int textureIndex = NO_INDEX;
    private int normalIndex = NO_INDEX;
    private Vertex duplicateVertex = null;
    private int index;
    private float length;

    ...

    public boolean isSet() {
	return textureIndex != NO_INDEX && normalIndex != NO_INDEX;
    }
//OBJLoader.processVertex
    private static void processVertex(String[] vertexData, List<Vertex> vertices, List<Integer> indices) {
        int index = Integer.parseInt(vertexData[0]) - 1;
        Vertex currentVertex = vertices.get(index);
        int textureIndex = Integer.parseInt(vertexData[1]) - 1;
        int normalIndex = Integer.parseInt(vertexData[2]) - 1;
        if (!currentVertex.isSet()) {
            currentVertex.setTextureIndex(textureIndex);
            currentVertex.setNormalIndex(normalIndex);
            indices.add(index);
        } else {
            dealWithAlreadyProcessedVertex(currentVertex, textureIndex, normalIndex, indices,
                    vertices);
        }
    }

If a Vertex instance already has associated index which refers to texture coordinates and index which refers to the normal vector, then dealWithAlreadyProcessedVertex(Vertex previousVertex, int newTextureIndex, int newNormalIndex, List indices, List vertices) is called. Note that the currentVertex now already has an associated texture coordinates and normal vector. currentVertex is in the context of this method dubbed processedVertex, since it has been already processed and assigned textureIndex and normalIndex.

In dealWithAlreadyProcessedVertex it is first checked if the "new" texture coordinate and normal vector match with the texture coordinate and normal vector of the vertex which had already been processed (ie. assigned texture coordinates and normal indexes) - processedVertex. If yes, then it is no point creating a cloned vertex, since the texture coord and normal indexes of the vertex which had already been processed match completely with the "new" texture coordinate and normal indexes --> index of the processedVertex is added to indices:

if (processedVertex.hasSameTextureAndNormal(newTextureIndex, newNormalIndex)) {
            indices.add(processedVertex.getIndex());
        } else {
            Vertex anotherVertex = processedVertex.getDuplicateVertex();
            if (anotherVertex != null) {
                dealWithAlreadyProcessedVertex(anotherVertex, newTextureIndex, newNormalIndex,
                        indices, vertices);
            } else {
                Vertex duplicateVertex = new Vertex(vertices.size(), processedVertex.getPosition());
                duplicateVertex.setTextureIndex(newTextureIndex);
                duplicateVertex.setNormalIndex(newNormalIndex);
                processedVertex.setDuplicateVertex(duplicateVertex);
                vertices.add(duplicateVertex);
                indices.add(duplicateVertex.getIndex());
            }
 
        }

Then it is checked whether processedVertex already has a duplicate Vertex instance (every Vertex instance has a field called duplicateVertex which should refer to the duplicate Vertex instance of that vertex). Inside dealWithAlreadyProcessedVertex, a duplicate Vertex instance of processedVertex is acquired by calling getDuplicateVertex() method. The returning value is assigned to a variable called anotherVertex. If anotherVertex is existent, then it is obviously logical to check whether the current texture coordinates index and normal vector index match the texture coordinates index and normal vector index of anotherVertex. Since this is matching is checked at the start of dealWithAlreadyProcessedVertex method, a practice known as recursion is used. anotherVertex is passed along with the new texture and normal indexes (and Lists indices and vertices) to the same method. [elaborate explanation here]. If anotherVertex is not existent (processedVertex doesn't have a duplicate Vertex instance), then create a new Vertex instance (called duplicateVertex) with the current size of vertices List as index and the position of processedVertex. Then, assign the new texture index(which refers to texture coordinates) and new normal index (which refers to the normal vector) to corresponding fields of duplicateVertex (textureIndex and normalIndex). Then, set this newly created duplicateVertex as a vertex duplicate of processedVertex. Then, add duplicateVertex to vertices List and add its index to indices List.

After all PFEs are processed, all unused Vertex instances (ie. Vertex instances that have no assigned textureIndex and normalIndex) are removed by removeUnusedVertices(vertices);.

Then, all of the "final" float arrays for vertex positions, texture coordinates and normal vector components are created. Do note that, for example, the size of the array which stores vertex positions should be three times the size of vertices List, since vertices stores _Vector3f_s. There is a need to convert _Vector3f_s from vertices List to floats since OpenGL expects float values, not _Vector3f_s:

vertexPosArray = new float[vertices.size() * 3];
texturesArray = new float[vertices.size() * 2];
normalsArray = new float[vertices.size() * 3];

After that, convertDataToArrays is called to convert data from vertices, textures and normals Lists to corresponding float arrays (vertexPosArray, texturesArray, normalsArray). The method iterates through all Vertex instances of a model (which are stores inside vertices List). Vector3f position, Vector2f textureCoord and Vector3f normalVector are extracted from each Vertex instance. Then, the components of those vectors are set to corresponding indexes in the float arrays:

    private static void convertDataToArrays(List<Vertex> vertices, List<Vector2f> textures,
    		List<Vector3f> normals, float[] verticesArray, float[] texturesArray, float[] normalsArray) {
    	for (int i = 0; i < vertices.size(); i++) {
            Vertex currentVertex = vertices.get(i);
            Vector3f position = currentVertex.getPosition();
            Vector2f textureCoord = textures.get(currentVertex.getTextureIndex());
            Vector3f normalVector = normals.get(currentVertex.getNormalIndex());
            verticesArray[i * 3] = position.x;
            verticesArray[i * 3 + 1] = position.y;
            verticesArray[i * 3 + 2] = position.z;
            texturesArray[i * 2] = textureCoord.x;
            texturesArray[i * 2 + 1] = 1 - textureCoord.y;
            normalsArray[i * 3] = normalVector.x;
            normalsArray[i * 3 + 1] = normalVector.y;
            normalsArray[i * 3 + 2] = normalVector.z;
    	}
    }

Then, indices List is converted to float array (indicesArray) with the method convertIndicesListToArray:

    private static int[] convertIndicesListToArray(List<Integer> indices) {
        int[] indicesArray = new int[indices.size()];
        for (int i = 0; i < indicesArray.length; i++) {
            indicesArray[i] = indices.get(i);
        }
        return indicesArray;
    }

Finally, all of the float arrays which are required to represent (and render) a model are stored as a ModelData instance (data). ModelData class is basically a storage for data which is used to render a 3D model. This includes a float array of vertex positional vector components, texture coordinates, normal vector components and an int array of indices.

ModelData data = new ModelData(vertexPosArray, texturesArray, normalsArray, indicesArray);

Then, a new GameEntity instance is returned using the constructor GameEntity(ModelData data, String texturePath):

        //the constructor for GameEntity related to ModelData:
	public GameEntity(ModelData data, String texturePath) throws Exception {
		modelTexture = new renderEngine.Texture(texturePath);
		mesh = new Mesh(data.getVertices(), data.getIndices(), data.getTextureCoordinates(), modelTexture);
		setDefaultPRS();
	}

        //call to the constructor from loadObjModel:
        return new GameEntity(data, texturePath);
⚠️ **GitHub.com Fallback** ⚠️