Loading Polygon Groups from an OBJ File

Now that you can draw more complex polygon models, it's getting to the point that it is too complicated to write Java code for all the polygons you want to create. In this section, you'll create a parser to read Alias|Wavefront OBJ files, a popular text-based format commonly used for 3D objects. Using a popular format such as the OBJ format means that 3D artists can create 3D objects in existing 3D design software and put them right in your game. Also, because the format is text based, people without access to this type of 3D design software can use a text editor to modify exiting OBJ files or create new ones. In the code in this tutorial, we actually support only a subset of the OBJ file specification, but the code will be capable of reading existing OBJ files without a problem. Do a search on the web for "obj file format" if you're curious about the complete file format specification.

The OBJ File Format

The OBJ format is a text format that contains one command per line. Lines beginning with # are comments, and blank lines are ignored. You'll support five keywords in your parser:

mtllib < filename>

Loads materials from an external .mtl file.

v <x> <y> <z>

Defines a vertex with floating-point coordinates (x,y,z).

f <v1> <v2> <v3> ...

Defines a new face. A face is a flat, convex polygon with vertices listed in counterclockwise order. The face can have any number of vertices. For each vertex, positive numbers indicate the index of the vertex that is defined in the file. Negative numbers indicate the vertex defined relative to the last vertex read. For example, 1 indicates the first vertex in the file, -1 is the last vertex read, and -2 is the vertex before last.

g < name>

Defines a new group by name. The subsequent faces are added to this group.

usemtl < name>

Uses the named material (loaded from an .mtl file) for subsequent faces.

The MTL format defines materials—or, in this case, textures. We talk about MTL files in a little bit, but first let's create an example OBJ file that's the shape of a cube, shown here in Listing 9.5.
Listing 9.5 cube.obj
# load materials mtllib textures.mtl
# define vertices v 16 32 16
v 16 32 -16
v 16 0 16
v 16 0 -16
v -16 32 16
v -16 32 -16
v -16 0 16
v -16 0 -16
# name the group g myCube
# define the material usemtl texture_A
# define the polygons f 1 3 4 2
f 6 8 7 5
f 2 6 5 1
f 3 7 8 4
f 1 5 7 3
f 4 8 6 2


This sample OBJ file lists the eight vertices and six polygons that make up a cube. You'll use an object like this in the demo at the end of the chapter. Now let's create an OBJ file parser. The first part of the parser, the ObjectLoader, is in Listing 9.6. It doesn't actually parse OBJ files yet, but it sets up a system of managing materials, vertices, and polygon groups. Also, it provides a basic parsing framework to ignore comments and blank lines, and send other lines to a separate line parser.

Listing 9.6 ObjectLoader.java
package com.brackeen.javagamebook.math3D;
import java.io.*;
import java.util.*;
import com.brackeen.javagamebook.graphics3D.texture.*;
/**
 The ObjectLoader class loads a subset of the
 Alias|Wavefront OBJ file specification.
*/
public class ObjectLoader {
 /**
 The Material class wraps a ShadedTexture.
 */
 public static class Material {
 public File sourceFile;
 public ShadedTexture texture;
 }
 /**
 A LineParser is an interface to parse a line in a text
 file. Separate LineParsers are used for OBJ and MTL
 files.
 */
 protected interface LineParser {
 public void parseLine(String line) throws IOException,
 NumberFormatException, NoSuchElementException;
 }
 protected File path;
 protected List vertices;
 protected Material currentMaterial;
 protected HashMap materials;
 protected List lights;
 protected float ambientLightIntensity;
 protected HashMap parsers;
 private PolygonGroup object;
 private PolygonGroup currentGroup;
 /**
 Creates a new ObjectLoader.
 */
 public ObjectLoader() {
 materials = new HashMap();
 vertices = new ArrayList();
 parsers = new HashMap();
 parsers.put("obj", new ObjLineParser());
 parsers.put("mtl", new MtlLineParser());
 currentMaterial = null;
 setLights(new ArrayList(), 1);
 }
 /**
 Sets the lights used for the polygons in the parsed
 objects. After calling this method, calls to loadObject
 use these lights.
 */
 public void setLights(List lights,
 float ambientLightIntensity)
 {
 this.lights = lights;
 this.ambientLightIntensity = ambientLightIntensity;
 }
 /**
 Loads an OBJ file as a PolygonGroup.
 */
 public PolygonGroup loadObject(String filename)
 throws IOException
 {
 File file = new File(filename);
 object = new PolygonGroup();
 object.setFilename(file.getName());
 path = file.getParentFile();
 vertices.clear();
 currentGroup = object;
 parseFile(filename);
 return object;
 }
 /**
 Gets a Vector3D from the list of vectors in the file.
 Negative indices count from the end of the list, positive
 indices count from the beginning. 1 is the first index,
 -1 is the last. 0 is invalid and throws an exception.
 */
 protected Vector3D getVector(String indexStr) {
 int index = Integer.parseInt(indexStr);
 if (index < 0) {
 index = vertices.size() + index + 1;
 }
 return (Vector3D)vertices.get(index-1);
 }
 /**
 Parses an OBJ (ends with ".obj") or MTL file (ends with
 ".mtl").
 */
 protected void parseFile(String filename)
 throws IOException
 {
 // get the file relative to the source path
 File file = new File(path, filename);
 BufferedReader reader = new BufferedReader(
 new FileReader(file));
 // get the parser based on the file extension
 LineParser parser = null;
 int extIndex = filename.lastIndexOf('.');
 if (extIndex != -1) {
 String ext = filename.substring(extIndex+1);
 parser = (LineParser)parsers.get(ext.toLowerCase());
 }
 if (parser == null) {
 parser = (LineParser)parsers.get("obj");
 }
 // parse every line in the file
 while (true) {
 String line = reader.readLine();
 // no more lines to read
 if (line == null) {
 reader.close();
 return;
 }
 line = line.trim();
 // ignore blank lines and comments
 if (line.length() > 0 && !line.startsWith("#")) {
 // interpret the line
 try {
 parser.parseLine(line);
 }
 catch (NumberFormatException ex) {
 throw new IOException(ex.getMessage());
 }
 catch (NoSuchElementException ex) {
 throw new IOException(ex.getMessage());
 }
 }
 }
 }
}


The ObjectLoader class has an inner interface called LineParser. This interface provides a method to parse a line in a file, and it has two implementing classes: ObjLineParser and MtlLineParser. The parseFile() method parses either an OBJ file or an MTL file one line at a time, ignoring blank lines and comments. It uses the appropriate LineParser to parse each line, depending on the extension of the filename (either .obj or .mtl). The getVector() method is a convenience method to get the vertex at the specified index. Remember, indices start at 1, and negative indices count backward from the last vertex. Finally, the loadObject() method is designed to load an OBJ file and return a PolygonGroup. Of course, none of this works without ObjLineParser, shown here in Listing 9.7.

Listing 9.7 ObjLineParser Inner Class of ObjectLoader
/**
 Parses a line in an OBJ file.
*/
protected class ObjLineParser implements LineParser {
 public void parseLine(String line) throws IOException,
 NumberFormatException, NoSuchElementException
 {
 StringTokenizer tokenizer = new StringTokenizer(line);
 String command = tokenizer.nextToken();
 if (command.equals("v")) {
 // create a new vertex
 vertices.add(new Vector3D(
 Float.parseFloat(tokenizer.nextToken()),
 Float.parseFloat(tokenizer.nextToken()),
 Float.parseFloat(tokenizer.nextToken())));
 }
 else if (command.equals("f")) {
 // create a new face (flat, convex polygon)
 List currVertices = new ArrayList();
 while (tokenizer.hasMoreTokens()) {
 String indexStr = tokenizer.nextToken();
 // ignore texture and normal coords
 int endIndex = indexStr.indexOf('/');
 if (endIndex != -1) {
 indexStr = indexStr.substring(0, endIndex);
 }
 currVertices.add(getVector(indexStr));
 }
 // create textured polygon
 Vector3D[] array =
 new Vector3D[currVertices.size()];
 currVertices.toArray(array);
 TexturedPolygon3D poly =
 new TexturedPolygon3D(array);
 // set the texture
 ShadedSurface.createShadedSurface(
 poly, currentMaterial.texture,
 lights, ambientLightIntensity);
 // add the polygon to the current group
 currentGroup.addPolygon(poly);
 }
 else if (command.equals("g")) {
 // define the current group
 if (tokenizer.hasMoreTokens()) {
 String name = tokenizer.nextToken();
 currentGroup = new PolygonGroup(name);
 }
 else {
 currentGroup = new PolygonGroup();
 }
 object.addPolygonGroup(currentGroup);
 }
 else if (command.equals("mtllib")) {
 // load materials from file
 String name = tokenizer.nextToken();
 parseFile(name);
 }
 else if (command.equals("usemtl")) {
 // define the current material
 String name = tokenizer.nextToken();
 currentMaterial = (Material)materials.get(name);
 if (currentMaterial == null) {
 System.out.println("no material: " + name);
 }
 }
 else {
 // unknown command - ignore it
 }
 }
}


The ObjLineParser class uses a StringTokenizer to get space-delimited words from the line you're parsing. Parsing the line is pretty straightforward. The v command creates a new vertex, the f command creates a new polygon (with a ShadedSurface), the g command creates a new PolygonGroup, and the usemtl command sets the current material. The mtllib command actually triggers another call to parseFile() to load an MTL file. Something to note about the f command is that for each vertex, it ignores any "/" symbols and the characters that follow. This is because the OBJ file specification uses additional vertices after the slash to define a polygon normal or texture coordinates. We're not using these extra vertices here, so we ignore the "/" symbol and the following characters for each word. This way we can read OBJ files that include this feature. There's one more thing to do to complete the OBJ file reader: Create an MTL file reader.

The MTL File Format

The MTL file format is designed to define materials such as solid colors, reflective surfaces (such as shiny objects or mirrors), bump mapping (simulating material depth), and textures. It has all sorts of options and commands, but in this case, you're going to use only texture mapping. Here are the commands you'll use from the MTL file:

newmtl < name>

Defines a new material by name

map_Kd < filename>

Gives the material a texture map

No problem here. The MtlLineParser you create will read textures and create new Material objects (see Listing 9.8).
Listing 9.8 MtlLineParser Inner Class of ObjectLoader
/**
 Parses a line in a material MTL file.
*/
protected class MtlLineParser implements LineParser {
 public void parseLine(String line)
 throws NoSuchElementException
 {
 StringTokenizer tokenizer = new StringTokenizer(line);
 String command = tokenizer.nextToken();
 if (command.equals("newmtl")) {
 // create a new material if needed
 String name = tokenizer.nextToken();
 currentMaterial = (Material)materials.get(name);
 if (currentMaterial == null) {
 currentMaterial = new Material();
 materials.put(name, currentMaterial);
 }
 }
 else if (command.equals("map_Kd")) {
 // give the current material a texture
 String name = tokenizer.nextToken();
 File file = new File(path, name);
 if (!file.equals(currentMaterial.sourceFile)) {
 currentMaterial.sourceFile = file;
 currentMaterial.texture = (ShadedTexture)
 Texture.createTexture(file.getPath(),true);
 }
 }
 else {
 // unknown command - ignore it
 }
 }
}


That's it for loading OBJ files. You've created an OBJ loader that reads only a subset of the OBJ format, but it will be just what you need for the 3D objects in this tutorial. If you want to extend the OBJ loader to read a broader set of OBJ commands, search the web for "OBJ file format," and you'll find plenty of resources to get you started.

Screenshot


   
Comments