Tipu's iTiger: LightWave, COLLADA &
OpenGL ES on the iPhone

By Stephen Jayna, 27th October 2009

Tipu's Tiger is one of the V&A's most enduringly famous and fascinating objects. Everita was asked to bring it to life on the iPhone. You can now get closer to Tipu's Tiger than ever and hear this automaton. Here we describe how it was done.


A Render From LightWave Of Tipu's Tiger During Development
A Wireframe From LightWave Of Tipu's Tiger During Development

Background

Tipu's Tiger was commissioned in the 1790s by Tipu Sultan of Mysore, who kept the spectacular wooden semi-automaton in the music room of his palace. It's brought to life on the iPhone with the help of a three-dimensional model which you can view from nearly any angle. You can play the organ within the tiger's body and hear it roar while seeing how it would have originally moved. You can read more about Tipu Sultan and his tiger here.

How It Was Done

So, as you might of guessed, the crux of this project is the 3D model and its transition from LightWave through COLLADA and finally to OpenGL and an iPhone. My last project using OpenGL — for the Newton Virus — was a little less complicated in some ways! But at least I could be sure that that Tipu's iTiger was technically feasible. And that was somewhat refreshing.

Before I begin in earnest it'd be remiss of me not to thank Tom Windross of the V&A for entrusting me with this project and for spurring me on throughout. Not to mention coming up with the 2D graphics and helping with the user-interface. Very handy indeed.

A Shaded Render From LightWave
A Shaded Render From LightWave

How It Was Done: The Fine Detail

So first things first. We had a myriad of high-resolution photos of Tipu's Tiger from almost every angle. From those photos we had to produce a 3D model. It was clear that a professional model maker would be the best solution for the tiger itself.

3D Modeling

Enter stage right Ben Washington. With a penchant for sculpture and exquisite 3D modeling skills he clearly was going to make this work in a way I never could. Albeit using LightWave, his tool of choice. You can see the model being built up below, I think you'll agree it's all rather impressive. I can't recommend him highly enough.

Exporting from LightWave: COLLADA

With that under way it was up to me to work out just how to get the soon to be completed model exported from LightWave and imported to Objective-C / OpenGL ES.

LightWave offers various ways to export models, but COLLADA seemed like a shoo-in given it's just XML or tagged data as I prefer to refer to it. Specifically it's a, and I quote, "royalty-free XML schema that enables digital asset exchange within the interactive 3D industry". Lovely.

Just to prove it works if you happen to have Photoshop CS4 try loading your DAE file. I was impressed and more to the point encouraged. For that matter if you're running Snow Leopard, Preview will make a reasonable job of it too.

A Render From LightWave Of Tipu's Tiger During Development
A Wireframe From LightWave Of Tipu's Tiger During Development

Late Night Special: Massaging a COLLADA export

So here's where we get technical. I'll show you how to take your COLLADA export and turn it into something that OpenGL ES can consume.

So let us begin:

Fire up your copy of LightWave, I happen to be on the latest and greatest* version 9.6 at time of writing. Load your model. Ensure all your polygons are triangles, OpenGL ES only deals with triangles, converting quads to triangles on-the-fly is too expensive. Shift + T is what you're after. Now you're ready. Hit File -> Export -> Export COLLADA and presto you now have your model in a .DAE (Digital Asset Exchange) file. Or as plain-text tagged data to you or me full of positions (that make up vertices), texture coordinates and err no urm normals. Hmm. More on that later.

A Render Of The Top Of The Tiger
A Render Of The Top Of The Tiger

Fast Forward: What Are We Aiming For?

First let's start off with a snippet of OpenGL ES, this after a fashion, is what we're aiming at:

  #import "OpenGLCommon.h"

  const Vertex3D tigerBottomPositions[] = {
    {0.176567, 0.143711, 0.264963},
    {0.176567, 0.137939, 0.177312},
    {0.198811, 0.135518, 0.179324},
    …
  };
  const Vertex3D tigerBottomNormals[] = {
    {-0.425880, -0.327633, 0.350967},
    {-0.480159, -0.592888, 0.042138},
    {-0.113803, -0.991356, 0.065283},
    …
  };
  const GLfloat tigerBottomTextureCoords[] = {
    0.867291, 0.359728,
    0.779855, 0.359494,
    0.781798, 0.337223,
    …
  };
  const GLushort tigerBottomIndices[] = {
    0,1,2,
    3,0,4,
    1,5,6,
    …
  };

  glEnableClientState(GL_VERTEX_ARRAY);
  glEnableClientState(GL_NORMAL_ARRAY);
  glEnableClientState(GL_TEXTURE_COORD_ARRAY);    

  glBindTexture(GL_TEXTURE_2D, tigerTextures[5]);
  glVertexPointer(3, GL_FLOAT, 0, tigerBottomPositions);
  glNormalPointer(GL_FLOAT, 0, tigerBottomNormals);
  glTexCoordPointer(2, GL_FLOAT, 0, tigerBottomTextureCoords);
  glDrawElements(GL_TRIANGLES, 210, GL_UNSIGNED_SHORT, tigerBottomIndices);

  glDisableClientState(GL_VERTEX_ARRAY);
  glDisableClientState(GL_NORMAL_ARRAY);
  glDisableEnableClientState(GL_TEXTURE_COORD_ARRAY);    

For the sake of brevity I've shown only mere handful of vertices: you get the idea. It's also assumed you've already loaded your texture into memory with glTexImage2D referenced by tigerTextures in the glBindTexture line.

There you have it I guess. We need a texture, some positions, some normals, and some texture coordinates and a single set of indices. glDrawElements only takes one. Which, while I understand why, is a pain once you've understood the contents of your spangly new DAE file.

Dissection

Some background first. I suppose it's about time we actually looked at the contents of a DAE file isn't it? I've been putting it off. A DAE file can contain all manner of things from information pertaining to animation, cameras and lights. For now I'm just going to stick purely to getting a textured model into OpenGL. Animation, skin, bones, cameras and lighting are for another day.

To that end, and some of you will think this is poor form, I've slashed down the DAE file to the part that I'm interested in. What lies between the <library_geometries> tag. Pretend it's half past eight and you've got to go home cos he's sitting on his own again this evening.

<library_geometries>
  <geometry id="Mesh_Object_lib" name="Mesh_Object">
    <mesh>
      <source id="Mesh_Object_positions" name="position">
        <float_array id="Mesh_Object_positions_array" count="111">
          0.201407 0.24078 0.267607
          0.201407 0.234864 0.174484
          0.194999 0.222011 -0.00271141
          …
        </float_array>
        <technique_common>
          <accessor count="37" offset="0" source="#Mesh_Object_positions_array" stride="3">
            <param name="X" type="float"></param>
            <param name="Y" type="float"></param>
            <param name="Z" type="float"></param>
          </accessor>
        </technique_common>
      </source>
      <source id="bottom shelf_uvmap" name="uvmap">
        <float_array id="bottom shelf_uvmap_array" count="186">
          0.781798 0.337223
          0.779855 0.359494
          0.867291 0.359728
          …
        </float_array>
        <technique_common>
          <accessor count="93" offset="0" source="#bottom shelf_uvmap_array" stride="2">
          <param name="S" type="float"></param>
          <param name="T" type="float"></param>
          </accessor>
        </technique_common>
      </source>
      <vertices id="Mesh_Object_vertices">
        <input semantic="POSITION" source="#Mesh_Object_positions"></input>
      </vertices>
      <polylist count="70" material="bottom_shelf">
        <input offset="0" semantic="VERTEX" source="#Mesh_Object_vertices"></input>
        <input offset="1" semantic="TEXCOORD" source="#bottom shelf_uvmap" set="0"></input>
        <vcount>3 3 3…</vcount>
        <p>
          34 2 26 1 25 0…
        </p>
      </polylist>
    </mesh>
  </geometry>
</library_geometries>

XML Gives You More Than Enough Rope To Hang Yourself

You can see that two indices are encoded in one <p> tag. Unnecessarily complicated if you ask me. Why not another tag altogether? I don't know. It upsets me how XML is wielded at times. I'm not alone, there are many threads with people confused about how to interpret COLLADA files.

If we banned attributes in XML contrivances like this would be less likely to occur. Offsets — I mean really — this isn't assembly language, nor is it 1982 (from whence I have travelled by the way, thanks XCDE). To be fair this is a very simple example and I've barely explored COLLADA, there's surely a very good reason.

A Render From LightWave: Pretty Isn't It?
A Render From LightWave: Pretty Isn't It?

Two Becomes One

So we've got a set of indices for the vertices and a set for the texture coordinates. Bother. We'd have one for the normals too had LightWave exported them.

We need to rearrange the vertex index so that each vertex will reference the corresponding position, normal and texcoord with the same index number.

The COLLADA site has a number of 'conditioners' that will process DAE files and do something to them. Something includes, for example, changing all polygons to triangles. But don't take my word for it. As you might guess I was interested in the Deindexer. If I was a real geek I would have worked out the algorithm myself but heh.

So download yourself a copy of the COLLADA Refinery. This drives your conditioner.

Much to my chagrin I ended up using the Windows binary. Ahem. SVN for those of you who'd rather not is here.

One thing to note. The COLLADA Refinery will crash if there are any files referenced in the .DAE that have a space in the name. In my case they were referencing the texture. To make it work lose the spaces. It was a complete fluke I managed to figure this out: some of my models had textures with spaces in the filenames some without which was my saving grace. That and a repulsion to filenames with spaces, they seem to trip everyone up.

COLLADA Refinery: Deindexer
COLLADA Refinery: Deindexer

It's pretty self-explanatory to use. Double-click on 'Input 1', pick your existing DAE file, double-click 'Output 1', pick an output file, and hit 'Execute'. VoilĂ !

If you've got many models this will get tedious, I'm sure you could batch the process, I only had a half dozen so this worked just fine.

It's A Wonderful Thing, Baby

<library_geometries>
  <geometry id="Mesh_Object_lib" name="Mesh_Object">
    <mesh>
      <source id="Mesh_Object_positions" name="position">
        <float_array id="Mesh_Object_positions_array" count="279">
          0.176567 0.143711 0.264963
          0.176567 0.137939 0.177312
          0.198811 0.135518 0.179324
          …
        </float_array>
        <technique_common>
          <accessor count="93" source="#Mesh_Object_positions_array" stride="3">
            <param name="X" type="float"/>
            <param name="Y" type="float"/>
            <param name="Z" type="float"/>
          </accessor>
        </technique_common>
      </source>
      <source id="bottom_shelf_uvmap" name="uvmap">
        <float_array id="bottom_shelf_uvmap_array" count="186">
          0.867291 0.359728
          0.779855 0.359494
          0.781798 0.337223
          …
        </float_array>
        <technique_common>
          <accessor count="93" source="#bottom_shelf_uvmap_array" stride="2">
            <param name="S" type="float"/>
            <param name="T" type="float"/>
          </accessor>
        </technique_common>
      </source>
      <vertices id="Mesh_Object_vertices">
        <input semantic="POSITION" source="#Mesh_Object_positions"/>
      </vertices>
      <polylist count="70" material="bottom_shelf">
        <input offset="0" semantic="VERTEX" source="#Mesh_Object_vertices"/>
        <input offset="0" semantic="TEXCOORD" source="#bottom_shelf_uvmap" set="0"/>
        <vcount>3 3 3…</vcount>
        <p>
          0 1 2 3 0 4 1 5 6…
        </p>
      </polylist>
    </mesh>
  </geometry>
</library_geometries>

And there you have it. A single set of indices that reference positions, normals and texture coordinates. You're very nearly ready for OpenGL ES.

On Laziness

You could argue I've been rather light on detail here having somewhat glazed over the use of indices and the whys and wherefores. It's a nasty habit of mine, but I shan't apologise for it. Sometimes it's enough that a process works and to move on. The intricacies of it all will take up your time like some cheap magazine when you could have been learning something, you know what I mean?

Converting It Into Code

Remind yourself what we're aiming at above. Take a look at what we've currently got. It's pretty obvious what goes where. What we need now is some text munging. Some of you at this point will reach for your favourite XML parser. Me, well, I reached for Perl and some crude regular expressions. Technically it's still parsing XML, kind of.

No point boring you with the details, just apply a liberal spattering of commas, group the positions into sets of three and surround with braces.

The value of 210 that is passed to glDrawElements is simply the polylist count * 3.

glDrawElements(GL_TRIANGLES, 70*3, GL_UNSIGNED_SHORT, tigerBottomIndices);

It's Not Normal

You might have noticed me alluding to the the lack of normals in the COLLADA export? Normals for those of you who don't know are needed in order for OpenGL ES to determine a surface's orientation toward a light source. Without them your model isn't going to be as pretty as it could be as it won't be lit. I believe they can be generated on-the-fly by regular OpenGL but it's computationally expensive. Not something you want an iPhone doing thirty times a second even if it could.

It seems that in LightWave 9.6 — and I can't vouch for other versions — normals aren't exported. This appears to be a bug but as always I'm happy to be proved wrong. So we're left to compute them ourselves.

Computing Normals

Here's a rather helpful bit of code in Objective-C that generates the normals for you and outputs them on the console. I borrowed it from this tutorial and then bastardized it for my own ends. The entire series is very good and helped me greatly, it's far more detailed than what I've described here with respect to OpenGL ES. Highly recommended.

However I don't recommend doing it this way if you've more than a handful of models to process. I'm endeavoring to show you what needs to be done, not necessarily how to go about it.

#import <Foundation/Foundation.h>
#import "OpenGLCommon.h"

int main (int argc, const char * argv[]) {
  NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
  
  const Vertex3D tigerBottomPositions[] = {
    {0.176567,0.143711,0.264963},
    {0.176567,0.137939,0.177312},
    {0.198811,0.135518,0.179324},
    ...
  };

  const GLushort tigerBottomIndices[] = {
    0,1,2,
    3,0,4,
    1,5,6,
    ...
  };
  
  int positionCount = 93;
  int indexCount = 70;
  
  NSMutableString *result = [NSMutableString string];
  
  Vector3D *surfaceNormals = calloc(indexCount, sizeof(Vector3D));
  
  for (int i = 0; i < indexCount; i++) {
    Vertex3D vertex1 = tigerBottomPositions[tigerBottomIndices[(i*3)]];
    Vertex3D vertex2 = tigerBottomPositions[tigerBottomIndices[(i*3)+1]];
    Vertex3D vertex3 = tigerBottomPositions[tigerBottomIndices[(i*3)+2]];
    Triangle3D triangle = Triangle3DMake(vertex1, vertex2, vertex3);
    Vector3D surfaceNormal = Triangle3DCalculateSurfaceNormal(triangle);
    Vector3DNormalize(&surfaceNormal);
    surfaceNormals[i] = surfaceNormal;
  }
  
  Vertex3D *normals = calloc(positionCount, sizeof(Vertex3D));
  [result appendString:@"const Vector3D tigerBottomNormals[] = {\n"];
  
  for (int i = 0; i < positionCount; i++) {
    int faceCount = 0;
    
    for (int j = 0; j < indexCount; j++) {
      BOOL contains = NO;
      for (int k = 0; k < 3; k++) {
        if (tigerBottomIndices[(j * 3) + k] == i){
          contains = YES;
        }
      }
      if (contains) {
        faceCount++;
        normals[i] = Vector3DAdd(normals[i], surfaceNormals[j]);
      }
    }
    
    normals[i].x /= (GLfloat)faceCount;
    normals[i].y /= (GLfloat)faceCount;
    normals[i].z /= (GLfloat)faceCount;
    
    [result appendFormat:@"\t{%f, %f, %f},\n",
                                      normals[i].x,
                                      normals[i].y,
                                      normals[i].z];
  }
  
  [result appendString:@"};\n"];
  
  NSLog(result);
  
  [pool drain];
  return 0;
}

Wonderful. You've now all the ingredients you need to render your textured LightWave model in OpenGL ES.

A Render From LightWave Of Tipu's Tiger During Development
A Screenshot From Tipu's iTiger

Conclusion

I wasn't able to find this process described end-to-end anywhere else so I do hope that it's helpful. Most books appear to be aimed at either graphic artists or OpenGL programmers with few describing that which binds them. Perhaps I was just looking in the wrong place.

So there you have it. A few very late nights for a fantastic result which showcases just how beautiful graphics can be on the iPhone.

Purchase Tipu's iTiger from the App Store

Some Images © 2009 VAE Limited.



Your Comments

THANK YOU

wooooooooooo una obra de arte the best increible


Normals not normal

Thanks for documenting. I'm using Blender and are able to export the normals using the Collada 1.4.0 plugin. It exports an additional float array with a numer of normals per mesh and then the order in which to use them is given in the polylist (?)


Collada / iphone

Some great nuggets of information here on how to get dae models working on the iPhone...Very useful, thanks for sharing.


Post new comment

The content of this field is kept private and will not be shown publicly.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Lines and paragraphs break automatically.

More information about formatting options

Having MySQL performance issues?

We're experts at tuning MySQL and offer a MySQL performance consulting service.

LAMP stack not performing as you'd hoped?

Everita is experienced at getting the most out of your Linux, Apache, MySQL and Perl, PHP or Python setup. We're Drupal Experts.

Client Testimonials

Steve was knowledgeable and diligent in helping us identify application characteristics which were impacting MySQL's efficiency.

I would recommend him to anyone needing help optimising MySQL server and look forward to working with him in the future.

Richard Ainley
Performance Tester
WorkPlace Systems PLC

Next »