Design patterns in game development: parsing OBJ files
Today we will take a look at a common task in game development: parsing asset files – from a code design standpoint.
Using the example of Wavefront OBJ files, we will explore the usefulness of thinking about our code using design patterns.
Our goal will not be to use design patterns to create code. Instead, I would like to highlight how much of our code is full of such patterns – whether we think about them or not – and how realising this can help us communicate more efficiently with other programmers.
This post is part of a series on design patterns in game programming.
Design patterns play an important role in computer programming. Not every problem can be solved with a pattern, and not every pattern is useful in all circumstances. However, they can be powerful thinking tools when applied to the right kinds of problem and help us understand and design solutions quickly and without reinventing the wheel every time. Similarly, they can aid us in communicating our ideas efficiently to others.
The OBJ file format
The Wavefront OBJ file format is a well known and well supported clear-text file format for storing 3D models and scenes.
We will use it as an example because of it’s relative simplicity, and because its clear-text nature makes it more convenient to write about. Anything in this post can also be applied to binary formats, and to most other sorts of asset files.
Note that for the sake of brevity we will constrict ourselves to a subset of the file format. We will only load singular meshes comprised of vertices with positions – and optionally normals and texture coordinates – and faces connecting these vertices. We will also gloss over several of the file formats other features and subtleties, in order to focus on the essentials.
The way these different elements are defined is very simple. Each line of a OBJ file begins with a short word followed by a space that signifies what kind of data the line contains. This word is then followed by space-separated numbers, or groups of numbers giving the parameter for the represented element.
For example, to specify a vertex position, a line would say v 1.5 3 0.25
. Similarly, texture coordinates are indicated with vt
and normals with vn
.
Faces are defined by lines starting with f
, followed by slash separated tuples of indices. These indices indicate which position, texture coordinate, and normal to combine into a vertex for the face.
For example, the line f 0/0/0 1/1/1 2/0/1
represents a triangle spanning three vertices as follows:
The first vertex (0/0/0
) has the first specified position, texture coordinates, and normals in the file. The next vertex (1/1/1
) uses the second of each, and the third vertex (2/0/1
) combines the third position, first texture coordinates, and second normal definitions in the file.
For the full specifications of the file format, please see the Wikipedia article on the topic.
How to load an OBJ file
From the above specifications, it is clear that we can parse our OBJ files simply line by line. In each line we can then check the identifying word in the beginning to decide how the line should be parsed.
We then split the line by spaces to obtain its parameters, and possibly further split the parameters by slashes. Having done so, we can parse the line appropriately and add the contained data to the correct list.
We repeat this for every line of the file, until the mesh is fully loaded.
In rough pseudo code, this process might look something like this:
positions = new list of positions
uvs = new list of texture coordinates
normals = new list of normals
faces = new list of faces
foreach(line in file)
{
splitBySpaces = line split by spaces
if(splitBySpaces[0] == "v")
positions.Add(parsePosition(splitBySpaces))
if(splitBySpaces[0] == "vt")
uvs.Add(parseUVs(splitBySpaces))
if(splitBySpaces[0] == "vn")
normals.Add(parseNormal(splitBySpaces))
if(splitBySpaces[0] == "f")
faces.Add(parseFace(splitBySpaces))
}
Implementation
To easily keep track of the loaded data, let us create a few helper types.
Builder class
First, we will create a class that can hold all the different lists we need for us. In my sometimes all but subtle fashion, I will call this class Builder
, since we will be using it to build our mesh, one step at a time.
The class is very simple:
class Builder
{
private List<Vector4> positions = new List<Vector4>();
private List<Vector3> uvs = new List<Vector3>();
private List<Vector3> normals = new List<Vector3>();
private List<Face> faces = new List<Face>();
public void AddPosition(Vector4 vertex)
{
this.positions.Add(vertex);
}
public void AddUV(Vector3 uv)
{
this.uvs.Add(uv);
}
public void AddNormal(Vector3 normal)
{
this.normals.Add(normal);
}
public void AddFace(Face face)
{
this.faces.Add(face);
}
public ObjFileMesh Build()
{
/* return new ObjFileMesh created from the loaded data */
}
}
Note how I keep the actual lists private. This encapsulation allows us to change the way the builder class works, should we ever need to do so.
While in this case it is unlikely that we would need to change this code in the future, more complicated – and especially changing – file formats may require additional features like data validation, or processing into a different format, which can all be hidden inside this class. The rest of our code will not have to know about these changes and can continue using our builder class.
The code uses a Face
type to represent each face as specified in the file that we will not go into here. Suffice it to say, it corresponds to a face-definition line of an OBJ file and can be parsed from it directly without any additional information, just like a position or normal can be.
Parsing line by line
With these helper types out of the way, we can start implementing the actual code that will load our 3D mesh.
We will start by creating a builder object and then opening and enumerating the file line by line.
static ObjFileMesh FromFile(string filename)
{
var builder = new Builder();
using (var stream = File.OpenRead(filename))
{
var reader = new StreamReader(stream);
string line;
while ((line = reader.ReadLine()) != null)
{
parseLine(builder, line);
}
}
return builder.Build();
}
Deciding what to parse
We can now focus on parsing a single line. As our pseudo code above shows, we will want to split the line by spaces first, and then decide on what to do depending on the first word.
static void parseLine(Builder builder, string line)
{
var splitLine = line.Split(' ');
var keyword = splitLine[0];
switch (keyword)
{
case "v":
{
builder.AddPosition(parsePosition(splitLine));
break;
}
case "vt":
{
builder.AddUV(parseUV(splitLine));
break;
}
case "vn":
{
builder.AddNormal(parseNormal(splitLine));
break;
}
case "f":
{
builder.AddFace(parseFace(splitLine));
break;
}
}
}
I will not go further into the implementation of the parse methods used here. If you are interested in the details, feel free to take a look at my full implementation of OBJ file loading as part of my open source graphics library.
Thinking in patterns
Strategy pattern
Note how similar the four case
blocks in the last code snippets above are to each other.
This is of course not surprising, as each of them performs a very similar operation:
- parse the split line using a particular method;
- add the result to the builder using the correct Add method.
While this similarity is not strictly speaking necessary, it illustrates we used – maybe without thinking about it – well: we can think of each case
block as a strategy for how to parse a line and add its results to a builder.
As such, even though we are not making use of inheritance or polymorphism, this piece of code is an example use of the strategy pattern.
We could in fact refactor this method to be entirely ignorant of how to parse specific kind of lines in particular, and instead have it store appropriate actions in a dictionary.
static Dictionary<string, Action<Builder, string[]>> parse =
new Dictionary<string, Action<Builder, string[]>>
{
{ "v", (b, l) => b.AddPosition(parsePosition(l)) },
{ "vt", (b, l) => b.AddUV(parseUV(l)) },
{ "vn", (b, l) => b.AddNormal(parseNormal(l)) },
{ "f", (b, l) => b.AddFace(parseFace(l)) },
};
static void parseLine(Builder builder, string line)
{
var splitLine = line.Split(' ');
var keyword = splitLine[0];
parse[keyword](builder, line);
}
I would argue that in this example we are taking things to an extreme – and possibly to an extreme that is not worth it when writing production code.
However, note how flexible this solution has become. Nothing would prevent us from adding more parsing methods, or switching out existing ones at will – and at runtime – should we need to.
In situations where we require such flexibility, we can potentially save a lot of work, and end up with much more readable code if we go the extra mile to adapt our system to this requirements of flexibility.
However, even as it stands, this code illustrates even better than our switch
statement that we are using the strategy pattern. Even if we end up using the switch
statement as above – and I did when implementing this for my library – considering this different approach made it much easier for me to think and talk about this piece of code.
Builder pattern
The other big pattern hiding – albeit in plain sight – in the code snippets above is the builder pattern.
Using a builder object comes in handy for two reasons.
First, it allows us to keep the entire state of our loading process in one single place, which makes book-keeping significantly easier – we only have to pass a single object around.
It also allows us build our result step by step, no matter what kind of immutability requirements or constraints our final result may have. By building our result one piece at a time, and only compiling a final object to return as a last step – in the Build()
method – we make sure to never have a seemingly final object containing invalid data because it was not fully constructed.
Secondly, by abstracting and encapsulating the building process inside a specialised objects, we split this responsibility from that of parsing the file itself.
As mentioned above this can become invaluable, should we want to add additional features to our building process. We do not need to change the code that deals with loading and parsing data but instead only have to modify the building code itself.
In fact – if there are multiple ways of building an object after, or while, parsing it, we could again use the strategy pattern to use one of a number of different builders, each with its unique behaviour.
Conclusion
While I would never argue that design patterns are the only valid way of thinking and talking about code, I find them an invaluable tool myself.
I hope this post has been an interesting read, and that it motivates you to think more about the patterns you may be using every day without even noticing.
Enjoy the pixels!
Reference: | Design patterns in game development: parsing OBJ files from our NCG partner Paul Scharf at the GameDev<T> blog. |