OBJLoader - Fish-In-A-Suit/Conquest GitHub Wiki
This class is responsible for reading geometry data from a wavefront .obj file and creating a GameEntity instance out of it. To perform this task, methodloadObjModel(String fileName, String texturePath) is used.
-
fileName corresponds to the name of the .obj file in the resources/models/ folder. Example: The fileName for file
resources/models/cube.obj
iscube
-
texturePath corresponds to the full path to the corresponding texture image (it's preferred to keep it stored inside
resources/textures
)
To put it shortly, this method reads all of the lines of a .obj file, stores data out of each line into corresponding lists based on line qualifiers at the beginning of each line, reorders these lists and stores reordered values in arrays, so these values can be rendered by OpenGL and finally stores all of this ordered information about a model as a ModelData instance and passes this to the constructor for GameEntity(ModelData data, String texturePath);
First, a full String for a texture (.png image) is created out of textureName (do note that textures are expected to be located inside resources/textures/
)
Then some lists and arrays are defined, which are used throughout the method to store or sort data read out of .obj file:
List<Vertex> vertices = null;
List<Vector2f> textures = null;
List<Vector3f> normals = null;
List<Integer> indices = null;
String line;
float[] vertexPosArray = null;
float[] texturesArray = null;
float[] normalsArray = null;
int[] indicesArray = null;
The _List_s will be used as storage for the data which is read from the .obj file. Why use Lists you ask? Data is read out of .obj file dynamically - the program at the start doesn't know the number of the texture coordinates or normal vectors in a file. Arrays have to be initialized with a fixed size (number of elements which an array can accept), but how to do that if the number of elements (ex texture coordinates) isn't known? Lists, however, can be added data dynamically and are therefore a perfect choice.
An additional String called line is added, which is later used by a BufferedReader instance to read through the file.
Here, the term Vertex first appears: List<Vertex> vertices
. Each Vertex instance corresponds to a single point (vertex) if a 3D mesh (model). Each such point MUST have an associated position and it can also possibly have two texture coordinates and a normal vector (but it's not necessery). Position, texture coordinates and normal vector of vertices are already stored in Lists. Therefore, specifying them once again using "raw form" (ie Vectors) isn't the smartest idea, since date would be duplicated. Therefore, they are specified with numbers, which tell where in the corresponding array a vertex positional vector, texture coordinates or a normal vector is located. In other words, these numbers specify an index of a specific element and are therefore called indices. Variables position, textureIndex and normalIndex (which comprise a Vertex instance) all refer to a specific element in a corresponding List (of positional vectors, texture coordinates or normal vectors). This code represents Vertex class:
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 Vertex(int index, Vector3f position) {
this.index = index;
this.position = position;
this.length = position.length();
}
public int getIndex() {
return index;
}
public float getLength() {
return length;
}
public boolean isSet() {
return textureIndex != NO_INDEX && normalIndex != NO_INDEX;
}
public boolean hasSameTextureAndNormal(int textureIndexOther, int normalIndexOther) {
return textureIndexOther == textureIndex && normalIndexOther == normalIndex;
}
public void setTextureIndex(int textureIndex) {
this.textureIndex = textureIndex;
}
public void setNormalIndex(int normalIndex) {
this.normalIndex = normalIndex;
}
public Vector3f getPosition() {
return position;
}
public int getTextureIndex() {
return textureIndex;
}
public int getNormalIndex() {
return normalIndex;
}
public Vertex getDuplicateVertex() {
return duplicateVertex;
}
public void setDuplicateVertex(Vertex duplicateVertex) {
this.duplicateVertex = duplicateVertex;
}
}
Note #1: If a Vertex instance has no texture coordinates or a normal vector, then the value of textureIndex or normalIndex is -1
.
Note #2: Vertices that are part of a texture seam (ie, a line which breaks up a 3D model into bunches of 2D shapes, which is done in a DCC app such as Blender to perform UV mapping) have to be duplicated in order for texture mapping to work correctly, due to the face that vertices which are a part of a texture seam hold different texture coordinates. To keep track of these duplicate vertices, each Vertex instance has a field called duplicateVertex, which is assigned a Vertex instance if a vertex is a part of a texture seam.
Then, the code steps into a "big hefty" try loop. The following steps will analyse the code line by line:
- Create a new FileReader instance based on the specified file name:
FileReader fr = new FileReader(new File("resources/models/" + fileName + ".obj"));
- Create a new BufferedReader instance using FileReader instance from (1)
- Initialize variables:
vertices = new ArrayList<>();
textures = new ArrayList<>();
normals = new ArrayList<>();
indices = new ArrayList<>();
- Read vertex position vectors, vertex texture coordinates and vertex normal elements of the file. If a line is an empty line ("") or it begins with a hashtag (#), then ignore this line. If not, then split the lines based on spacebar characters:
String[] splitLine = line.split(" ");
Then, the first Steing in the array of Strings obtained from the split line is evaluated:switch(splitLine[0])
. If it equals tov
(the line represents vertex position), then a new Vector3f called vertexPos is created out of line data:Vector3f vertexPos = new Vector3f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]), Float.parseFloat(splitLine[3]));
. Then, a new Vertex instance is created based on the current size of the vertices List (vertices.size()
returns an int value which is equal to the number of elements inside this List. This number is used as an index which specifies the position of the newly added Vertex instance to the end of vertices List. If the line starts withvt
(it contains a pair of texture coordinates), create a Vector2f out of these two coordinates and then add it to textures List. If a line starts withvn
(it contains normal vector data), then create a Vector3f out of the data found in that line and add it to normals List.
while((line = br.readLine()) != null) {
if (!line.equals("") || !line.startsWith("#")) {
String[] splitLine = line.split(" ");
switch(splitLine[0]) {
case "v":
Vector3f vertexPos = new Vector3f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]), Float.parseFloat(splitLine[3]));
Vertex newVertex = new Vertex(vertices.size(), vertexPos); //vertices.size() equals to how much there are currently elements in vertices --> index of the vertex
vertices.add(newVertex);
break;
case "vt":
Vector2f texture = new Vector2f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]));
textures.add(texture);
break;
case "vn":
Vector3f normal = new Vector3f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]), Float.parseFloat(splitLine[3]));
normals.add(normal);
break;
- First, a new line is read as the condition to the while loop. If the end of the file is reached, the line variable will be assigned value null and the while loop will be terminated:
while((line = br.readLine()) != null)
- Then it is checked whether the line is not empty and that it doesn't start with a hashtag. If the condition is met (the line isn't empty and the line doesn't start with a hashtag), the contents of the if loop are executed:
if (!line.equals("") || !line.startsWith("#"))
. If the above condition is met, the following steps are executed:-
Split the line wherever there a spacebar characters:
String[] splitLine = line.split(" ");
Each split part of the line (represented as a String) can be accessed in the splitLine String array:String[] splitLine = line.split(" ");
. For example, say we have a line"I am a fisherman."
represented by a String called line1. Then, if this string was to be split at spacebar characters (String[] splitLine1 = line1.split(" ");
), it would be split into the following Strings:"I"
,"am"
,"a"
,"fisherman"
. The array representing the split line (splitLine1
) would have 4 Strings. Index 0 of this array refers to"I"
, index 1 to"am"
and so on. - Based on the first String in splitLine (accessed by index 0), determine whether to execute code for
v
,vt
orvn
:switch(splitLine[0])
- If the line begins with
v
(case "v":
), then create a Vector3f instance called vertex out of the data (three numbers) that follows thev
in line. These three numbers are initially represented as Strings (accessed by a corresponding index in splitLine array), so they have to be parsed to floats prior to being sent to the constructor for Vector3f:Vector3f vertex = new Vector3f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]), Float.parseFloat(splitLine[3]));
. Then, vertex is stored in the vertices ArrayList:vertices.add(vertex);
- If the line begins with
vt
(case "vt":
), then create a Vector2f instance called texture (which represents u and v texture coordinates) out of the data (two numbers) that follows thevt
in line. As above, these two numbers are initially Strings, so they have to be parsed to floats:Vector2f texture = new Vector2f(Float.parseFloat(splitLine[1]), Float.parseFloat(splitLine[2]));
. Then, add texture to the textures ArrayList:textures.add(texture);
- If the line begins with
vn
(case "vn":
), then create a Vector3f instance called normal (which represents a normal vector) out of the data (three numbers) that follow thevn
in line. Parse each "String number" to float prior to sending it to the constructor for Vector3f. Then, add normal to the normals ArrayList:normals.add(normal);
-
Split the line wherever there a spacebar characters:
- /end of while loop/
Now that vertex coordinates (positions), texture coordinates and normal vectors for each vertex are extracted from the file:
- The number of vertices comprising the model can be determined:
int numVertices = vertices.size();
. Note that the vertices ArrayList stores vertices represented as Vector3f models. If it stored individual components of each vertex, then the size of the vertices array would have to be divided by three, as each vertex is represented by three float components. - The size of the textures array can be determined:
texturesArray = new float[numVertices*2];
. Note that the range of texturesArray has to be twice as big as the number of vertices, since each vertex has two associated vertex coordinates.
Then, another while loop is introduced, the task of which is to read and process the lines starting with f
: while((line = br.readLine()) != null)
. This is the process which is repeated for each iteration.
- Check if the line starts with an f. If so, continue execution.
- Split the line into an array of Strings where there are spacebar characters:
String[] splitLine = line.split(" ");
- Split each of the polygonal face elements (i.e
8/4/2
) based on the slash characters to extract three additional Strings:String[] v1 = splitLine[1].split("/");
,String[] v2 = splitLine[2].split("/");
,String[] v3 = splitLine[3].split("/");
. Each of this polygonal face elements describes the vertex which is to be used, the corresponding texture coordinate and the corresponding normal. For example8/4/2
means: use the 8th vertex, accompany it by the 4th texture coordinate and 2nd normal vector. - Process each individual polygonal face element using
processVertex(String[] vertexData, List<Integer> indices, List<Vector2f> textures, List<Vector3f> normals, float[] textureArray, float[] normalsArray)
. - Read another line
This is a description of processVertex(String[] vertexData, List indices, List textures, List normals, float[] textureArray, float[] normalsArray) method.
-
String[] vertexData refers to the polygonal face element data which is extracted from a line beginning with an
f
. This is a line in a .obj file starting withf
:f 8/4/2 6/5/2 5/6/2
. vertexData refers to either8/4/2
,6/5/2
or5/6/2
. -
List<Integer> indices
refers to indices ArrayList, which will be used to store the position vertex indices (which tells OpenGL how vertices with corresponding texture coordinates should be bound together) -
List<Vector2f> textures
represents the textures ArrayList and is used to retrieve a Vector2f , representing a specific set of texture coordinates (u, v) which is determined by the second String of the polygonal face element (and that second polygonal face element String is accessed through vertexData) -
List<Vector3f>
normals represents the normals ArrayList and is used to retrieve a Vector3f, representing a specific normal vector which is determined by the third String of the polygonal face element (and that third polygonal face element String is accessed through vertexData) - float[] textureArray represents the final and ordered array of texture coordinates, into which texture coordinates from textures ArrayList are stored. These texture coordinates are stored at index specified by the first polygonal face element String (representing a vertex index), so that the vertices and textures are sorted inside their corresponding arrays is the correct order
- float[] normalsArray represents the final and ordered array of vertex normal components, into which normal vector components from normals ArrayList are stored. These normal vector components are stored at index specified by the first polygonal face element String (representing a vertex index), so that the vertices and normal vectors are sorted inside their corresponding arrays in correct order
For the sake of explanation, the cube.obj file is included:
# 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
).
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".
//elaborate explanation here The full data for a model is represented using three arrays: the vertex positions array, the textures array (texturesArray) and the normal vectors array (normalsArray). The vertex positions array is already read out of the file and won't need to be rearranged. The texture coordinates and normal vectors have been read out of the file, but aren't yet arranged. Why arrange though? When rendering, glDrawElements uses indices stored in the element array buffer to render the model. If mode GL_TRIANGLES is selected, it pulls three indices at a time. For example, let's say that it uses the 4th, 5th and 6th index to draw the triangle. Now let's focus on the 4th index. This index refers to the 4th vertex in the vertices array (vertex array buffer (vbo) to be precise) and the 4th texture coordinate set in the texture vbo. If the texture coordinates aren't ordered correctly in their array (they have to be ordered with regards to how the vertices are ordered), then the texture buffer object will have wrong texture coordinates at wrong indices and the rendering won't be performed correctly.
When a set of vertex data is sent to processVertex method, it first converts the first element of the PFE to an integer and then substracts 1 (to convert it to "java index"). It basically reads the ".obj" index of the vertex position out of PFE and converts it to "java index". Then, it adds this new index to the indices ArrayList:
int currentVertexPointer = Integer.parseInt(vertexData[0]) - 1;
indices.add(currentVertexPointer);
If we take the above example (8/4/2
), this piece of code converts "8" to integer (8) and then substracts 1 to get 7 and sends this to the indices array.
Then, it assigns a Vector2f representing a texture coordinate set to a variable of type Vector2f named currentTex (representing the texture coordinate set of the current PFE used), which can be accessed by substracting 1 from the second PFE String and using this value as index to the textures ArrayList:
Vector2f currentTex = textures.get(Integer.parseInt(vertexData[1]) - 1);
If we take the example 8/4/2
: "4" is converted to integer (4) and this is substracted 1 to get 3. This value (3) is the index which tells which texture coordinate set from the textures ArrayList belongs to the 8th vertex position. The Vector2f at index 3 of the ArrayList textures is assigned to currentTex variable.
Then, the first texture coordinate is added to the index in the textureArray that is twice the index of a position vector index (aka the first index in the PFE), because each vertex has two associated texture coordinates. The second texture coordinate is twice the index of the position vector index + 1. For our example 8/4/2, the position vector index is 7, the first texture coordinate index is 7*2 = 14
, the second texture coordinate index is 7*2+1 = 13
:
textureArray[currentVertexPointer*2] = currentTex.x;
textureArray[currentVertexPointer*2 + 1] = 1 - currentTex.y;
todo the above explanation similarly for vertex normals
Then, after the end of the line is reached, the while loop which is responsible for reading PFEs is terminated and the reader is closed, any exceptions are dealt with in the catch clause.
After that, the vertex positions array is constructed (vertexPosArray), which is three times the size of vertices ArrayList, since the Mesh constructor requires vertex coordinates be passed as floats rather than Vector3f's (and each Vector3f inside vertices ArrayList represents three floats): vertexPosArray = new float[vertices.size()*3];
Then, indicesArray is constructed based on the size of the indices ArrayList: indicesArray = new int[indices.size()];
After that, the vertexPosArray is populated with data from vertices ArrayList:
int i = 0;
for(Vector3f vertex : vertices) {
vertexPosArray[i++] = vertex.x;
vertexPosArray[i++] = vertex.y;
vertexPosArray[i++] = vertex.z;
}
Then, indicesArray is populated with cdata from indices ArrayList:
for(int j = 0; j<indices.size(); j++) {
indicesArray[i] = indices.get(i);
}
Then, vertex position data (vertexPosArray), index data (indicesArray), texture coordinates data (texturesArray) and texturePath are sent to the constructor for GameEntity.