So far we’ve been dealing exclusively with a
gallery of parametric surfaces. They make a great teaching tool, but
parametric surfaces probably aren’t what you’ll be rendering in your app.
More likely, you’ll have 3D assets coming from artists who use modeling
software such as Maya or Blender.The first thing to decide on is the file format
we’ll use for storing geometry. The COLLADA format was devised to solve the
problem of interchange between various 3D packages, but COLLADA is quite
complex; it’s capable of conveying much more than just geometry, including
effects, physics, and animation.
A more suitable format for our modest purposes
is the simple OBJ format, first developed by Wavefront Technologies in the
1980s and still in use today. We won’t go into its full specification here
(there are plenty of relevant sources on the Web), but we’ll cover how to
load a conformant file that uses a subset of OBJ features.
Warning:
Even though the OBJ format is simple and
portable, I don’t recommend using it in a production game or
application. The parsing overhead can be avoided by inventing your own
raw binary format, slurping up the entire file in a single I/O call, and
then directly uploading its contents into a vertex buffer. This type of
blitz loading can greatly improve the start-up time of your iPhone
app.
Note:
Another popular geometry file format for the
iPhone is PowerVR’s POD format. The PowerVR Insider SDK includes tools and code samples for
generating and reading POD files.
Without further ado, Example 1 shows an example OBJ file.
Example 1. Insanely simple OBJ file
# This is a comment.
v 0.0 1.0 1.0 v 0.0 -1.0 1.0 v 0.0 -1.0 -1.0 v -1.0 1.0 1.0
f 1 2 3 f 2 3 4
|
Lines that start with a v
specify a vertex position using three floats separated by spaces. Lines
that start with f specify a “face” with a list of
indices into the vertex list. If the OBJ consists of triangles only, then
every face has exactly three indices, which makes it a breeze to render
with OpenGL. Watch out, though: in OBJ files, indices are one-based, not
zero-based as they are in OpenGL.
OBJ also supports vertex normals with lines
that start with vn. For a face to refer to a vertex
normal, it references it using an index that’s separate from the vertex
index, as shown in Example 2. The slashes are doubled
because the format is actually f v/vt/vn; this example
doesn’t use texture coordinates (vt), so it’s
blank.
Example 2. An OBJ file with vertex normals
v 0.0 1.0 1.0 v 0.0 -1.0 1.0 v 0.0 -1.0 -1.0 vn 1 0 0 f 1//1 2//1 3//1
|
One thing that’s a bit awkward about this (from
an OpenGL standpoint) is that each face specifies separate position
indices and normal indices. In OpenGL ES, you only specify a single list
of indices; each index simultaneously refers to both a normal and a
position.
Because of this complication, the normals found
in OBJ files are often ignored in many tools. It’s fairly easy to compute
the normals yourself analytically, which we’ll demonstrate soon.
3D artist Christopher Desse has graciously
donated some models to the public domain, two of which we’ll be using in
ModelViewer: a character named MicroNapalm (the
selected model in Figure 1) and a ninja character
(far left in the tab bar). This greatly enhances the cool factor when you
want to show off to your 4-year-old; why have cones and spheres when you
can have ninjas?
Note:
I should also mention that I processed
Christopher’s OBJ files so that they contain only v
lines and f lines with three indices each and that I
scaled the models to fit inside a unit cube.
1. Managing Resource Files
Note that we’ll be loading resources from
external files for the first time. Adding file resources to a project is
easy in Xcode. Download the two files
(micronapalmv2.obj and
Ninja.obj) from the examples site, and put them on
your desktop or in your Downloads folder.
Create a new folder called
Models by right-clicking the
ModelViewer root in the
Overview pane, and choose Add→New Group. Right-click the new folder, and
choose Add→Existing Files. Select the
two OBJ files by holding the Command key, and then click
Add. In the next dialog box, check the box labeled
“Copy items...”, accept the defaults, and then click Add. Done!
The iPhone differs from other platforms in
how it handles bundled resources, so it makes sense to create a new
interface to shield this from the application engine. Let’s call it
IResourceManager, shown in Example 3. For now it has a single method that
simply returns the absolute path to the folder that has resource files.
This may seem too simple to merit its own interface at the moment, such as loading image files. Add these
lines, and make the change shown in bold to
Interface.hpp.
Example 3. Adding IResourceManager to Interface.hpp
#include <string> using std::string;
// ... struct IResourceManager { virtual string GetResourcePath() const = 0; virtual ~IResourceManager() {} };
IResourceManager* CreateResourceManager();
IApplicationEngine* CreateApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager);
// ...
|
We added a new argument to
CreateApplicationEngine to allow the
platform-specific layer to pass in its implementation class. In our case
the implementation class needs to be a mixture of C++ and Objective-C.
Add a new C++ file to your Xcode project called
ResourceManager.mm (don’t create the corresponding
.h file), shown in Example 4.
Example 4. ResourceManager implementation
#import <UIKit/UIKit.h> #import <QuartzCore/QuartzCore.h> #import <string> #import <iostream> #import "Interfaces.hpp"
using namespace std;
class ResourceManager : public IResourceManager { public: string GetResourcePath() const { NSString* bundlePath = [[NSBundle mainBundle] resourcePath]; return [bundlePath UTF8String]; } };
IResourceManager* CreateResourceManager() { return new ResourceManager(); }
|
The resource manager should be instanced
within the GLView class and passed to the application
engine. GLView.h has a field called
m_resourceManager, which gets instanced somewhere in
initWithFrame and gets passed to
CreateApplicationEngine. (This is similar to how
we’re already handling the rendering engine.) So, you’ll need to do the
following:
In GLView.h, add the
line IResourceManager* m_resourceManager; to the
@private section.
In GLView.mm, add
the line m_resourceManager =
CreateResourceManager(); to
initWithFrame (you can add it just above the line
if (api == kEAGLRenderingAPIOpenGLES1). Next, add
m_resourceManager as the second argument to
CreateApplicationEngine.
Next we need to make a few small changes to
the application engine per Example 5.
The lines in bold show how we’re reusing the ISurface
interface to avoid changing any code in the rendering engine.
Modified/new lines in ApplicationEngine.cpp are
shown in bold (make sure you replace the existing assignments to
surfaces[0] and
surfaces[0] in
Initialize):
Example 5. Consuming IResourceManager from ApplicationEngine
#include "Interfaces.hpp" #include "ObjSurface.hpp"
...
class ApplicationEngine : public IApplicationEngine { public: ApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager); ... private: ... IResourceManager* m_resourceManager; }; IApplicationEngine* CreateApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager) { return new ApplicationEngine(renderingEngine, resourceManager); }
ApplicationEngine::ApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager) : m_spinning(false), m_pressedButton(-1), m_renderingEngine(renderingEngine), m_resourceManager(resourceManager) { ... }
void ApplicationEngine::Initialize(int width, int height) { ...
string path = m_resourceManager->GetResourcePath(); surfaces[0] = new ObjSurface(path + "/micronapalmv2.obj"); surfaces[1] = new ObjSurface(path + "/Ninja.obj"); surfaces[2] = new Torus(1.4, 0.3); surfaces[3] = new TrefoilKnot(1.8f); surfaces[4] = new KleinBottle(0.2f); surfaces[5] = new MobiusStrip(1);
... }
|
2. Implementing ISurface
The next step is creating the
ObjSurface class, which implements all the
ISurface methods and is responsible for parsing the
OBJ file. This class will be more than just a dumb loader; recall that
we want to compute surface normals analytically. Doing so allows us to
reduce the size of the app, but at the cost of a slightly longer startup
time.
We’ll compute the vertex normals by first
finding the facet normal of every face and then averaging together the
normals from adjoining faces. The C++ implementation of this algorithm
is fairly rote, and you can get it from this website
(http://oreilly.com/catalog/9780596804831); for
brevity’s sake, Example 6 shows the
pseudocode.
Example 6. Pseudocode to compute vertex normals from facets
ivec3 faces[faceCount] = read from OBJ vec3 positions[vertexCount] = read from OBJ vec3 normals[vertexCount] = { (0,0,0), (0,0,0), ... }
for each face in faces: vec3 a = positions[face.Vertex0] vec3 b = positions[face.Vertex1] vec3 c = positions[face.Vertex2] vec3 facetNormal = (a - b) × (c - b)
normals[face.Vertex0] += facetNormal normals[face.Vertex1] += facetNormal normals[face.Vertex2] += facetNormal
for each normal in normals: normal = normalize(normal)
|
The mechanics of loading face indices and
vertex positions from the OBJ file are somewhat tedious, so you should
download ObjSurface.cpp and
ObjSurface.hpp from this website and add them
to your Xcode project. Example 7 shows the
ObjSurface constructor, which loads the vertex
indices using the fstream facility in C++. Note that
I subtracted one from all vertex indices; watch out for the one-based
pitfall!
Example 7. ObjSurface constructor
ObjSurface::ObjSurface(const string& name) : m_name(name), m_faceCount(0), m_vertexCount(0) { m_faces.resize(this->GetTriangleIndexCount() / 3); ifstream objFile(m_name.c_str()); vector<ivec3>::iterator face = m_faces.begin(); while (objFile) { char c = objFile.get(); if (c == 'f') { assert(face != m_faces.end() && "parse error"); objFile >> face->x >> face->y >> face->z; *face++ -= ivec3(1, 1, 1); } objFile.ignore(MaxLineSize, '\n'); } assert(face == m_faces.end() && "parse error"); }
|