Milkshape 3D Animation using C# and OpenGL

Download the project

There are plenty of great 3D packages on the web with proprietary and open source 3D file formats. No package is arguably easier to learn or more affordable as Chumbalum Soft (love that name!) Milkshape 3D. This tutorial will cover the Milkshape 3D (MS3D) file format, and show you how to load and render it, in all of its animated and texture mapped glory, using OpenGL. We’ll even write a simple animation configuration file to make playing animations nice and easy.

Before we begin, you will need to have the Glut library on your system. I've included it in the source download. Simply copy Glut32.dll to your %WINDIR%\System32 folder and you'll be all ready to go.

For this example I'm using the nifty Tao OpenGL library from www.randyridge.com. Most of the MS3D code in this tutorial was converted to C# from C classes found on the web which don't appear to have any particular author specified. You can find the original site here (sorry link dead - this is 6 years old!).

Reading the Data

The MS3D file format consists of just 6 data structures. As with any file format, all you need to know are the data structures which make up the file, and the order in which you need to load them. The data structure declarations for reading an MS3D file are as follows:

/// <summary>
/// MS3D File header
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DHeader
{
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=10)]
  public char[] ID;
  public int version;
}

/// <summary>
/// MS3D Vertex information
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DVertex
{
  public byte flags;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] vertex;
  public char boneID;
  public byte refCount;
}

/// <summary>
/// MS3D Triangle information
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DTriangle
{
  public short flags;
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=3)]
  public short[] vertexIndices;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=9)]
  public float[] vertexNormals; //[3],[3]
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] s;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] t;
  public byte smoothingGroup;
  public byte groupIndex;
}

/// <summary>
/// MS3D Material information
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DMaterial
{
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=32)]
  public char[] name;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=4)]
  public float[] ambient;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=4)]
  public float[] diffuse;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=4)]
  public float[] specular;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=4)]
  public float[] emissive;
  [MarshalAs(UnmanagedType.R4)]
  public float shininess; // 0.0f - 128.0f
  [MarshalAs(UnmanagedType.R4)]
  public float transparency; // 0.0f - 1.0f
  public char mode; // 0, 1, 2 is unused now
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=128)]
  public char[] texture;
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=128)]
  public char[] alphamap;
}

/// <summary>
/// MS3D Joint information
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DJoint
{
  public byte flags;
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=32)]
  public char[] name;
  [MarshalAs(UnmanagedType.ByValArray, SizeConst=32)]
  public char[] parentName;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] rotation;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] translation;
  public short numRotationKeyframes;
  public short numTranslationKeyframes;
}

/// <summary>
/// MS3D Keyframe data
/// </summary>
[StructLayout( LayoutKind.Sequential, Pack=1, CharSet=CharSet.Ansi)]
public struct MS3DKeyframe
{
  [MarshalAs(UnmanagedType.R4)]
  public float time;
  [MarshalAs(UnmanagedType.ByValArray, ArraySubType=UnmanagedType.R4, SizeConst=3)]
  public float[] parameter;
} 

When reading the data in an MS3D file, we have to read everything in a special sequence so that we end up with valid information about the model, it's textures, joints, animation etc. After each set of data structures, MS3D files have a single 2 byte value which tells you how many of the next structure (bones, vertices etc) need to be loaded. The steps we take during the LoadModelData(string Name) method are:

  1. Read the MS3DHeader
  2. Read the Number of Vertices in the model
  3. Read each MS3DVertex
  4. Read the number of Triangles in the model
  5. Read each MS3DTriangle
  6. Read the number of mesh groups in the model
  7. Read each mesh group.
  8. Read the number of Materials
  9. Read each MS3DMaterials
  10. Read animation information: Frames Per Section, Current Time and Total Frames
  11. Read the number of Joints in the model
  12. For each MS3DJoint: ol
  13. Firstly read the MS3DJoint structure. This gives you the number of rotation and translation key frames (MS3DKeyframe).
  14. Then read all of the MS3DKeyframe structures until you have read all of the indicated key frames for the joint.
  15. Keep repeating from 12.1 until all of the Joints and the key frames of each joint have been read.
  16. /li>

How a programmer chooses to store and process 3d information is typically up their own discretion and personal preference. In this example I've converted a classic C Model class which I believe is quite popular for OpenGL lessons, I think it was originally taken from www.gamedev.net.

Animating

When we load the MS3D file, you may recall that MS3DJoint and MS3DKeyFrame structures were loaded in. If we were to render the data that we have directly after reading all of the data, we would see that the model is in its default binding pose, the arms and legs would be pointing straight out, head and toes facing forward, that sort of thing. For this reason, we have to translate and rotate all of the vertex information from this default binding pose so that it is bound to the bone information for the first frame of our animation. We do this in the MilkshapeModel class by calling SetupJoints().

As with all good animation systems, our MilkshapeModel class uses time-based interpolation to calculate movement. In other words, we don't try and render every single key frame regardless of how slow the user's PC is.

The reason for this is fairly obvious. If we're animating on a slow system, the desired behaviour is not to have your animation run slower, but to have the software skip the in-between frames so that the length of your animation is not affected by the machine's speed. Theoretically, when using this method, if you put a slow and a fast computer side-by-side, you can end up with your animation starting and ending at the same time on both systems. The end result is that the faster PC simply renders a far smoother looking motion.

In the MilkshapeModel class I've also provided a simple animation managing tool which will allow you to name a range of key frames for an MS3D animation file. This is not that different from how you might set up a Quake 3 .cfg file.

If we open the poogle_anim.txt example, we're presented with the following:

#
# Configuration
#
# ScaleFactor = 1.0   # Number between 0 and 1
# OffsetY = -4    # Any +/- number to position on the Y axis

#
# Animations
#
# Anim  Start End
Wave  1 19
Idle  20 39
Walk  40 79

#
# Dialogue
#
#Name  Time(sec) Sentence
Welcome  10  Hello,
my name is Poogle. I'm actually a cow, but I don't look like one.\n\nDo
you believe me? No, I didn't think so.
... 

As you can see, all you'd have to do for your own model is populate the sections with info however you like. The code to load and parse this animation configuration file is so simple that I won't bother to go in to more detail.

Rendering

The MilkshapeModel.DrawModel() method handles the actual OpenGL output. It goes something like this:

internal void DrawModel()
{
if (Animate)
AdvanceAnimation();

// Draw by group
for ( int i = 0; i < mod.numMeshes; i++ )
{
int materialIndex = mod.Meshes[i].materialIndex;
if ( materialIndex != 255 )
{
Gl.glMaterialfv( Gl.GL_FRONT, Gl.GL_AMBIENT, mod.Materials[materialIndex].ambient );
Gl.glMaterialfv( Gl.GL_FRONT, Gl.GL_DIFFUSE, mod.Materials[materialIndex].diffuse );
Gl.glMaterialfv( Gl.GL_FRONT, Gl.GL_SPECULAR, mod.Materials[materialIndex].specular );
Gl.glMaterialfv( Gl.GL_FRONT, Gl.GL_EMISSION, mod.Materials[materialIndex].emissive );
Gl.glMaterialf( Gl.GL_FRONT, Gl.GL_SHININESS, mod.Materials[materialIndex].shininess );

if ( mod.Materials[materialIndex].texture > 0 )
{
  Gl.glBindTexture( Gl.GL_TEXTURE_2D, (int)mod.Materials[materialIndex].texture );
  Gl.glEnable( Gl.GL_TEXTURE_2D );
}
} 

“mod” is a Model class that we stored all of the model data in after we read it from the MS3D file. At this point, we've simply set up a few OpenGL texture shading options as defined by our model.

Here's where the real fun starts:

Gl.glBegin( Gl.GL_TRIANGLES );

for ( int j = 0; j < mod.Meshes[i].numTriangles; j++ )
{
int triangleIndex = mod.Meshes[i].TriangleIndices[j];
Triangle pTri = mod.Triangles[triangleIndex];

for ( int k = 0; k < 3; k++ )
{
  int index = pTri.vertexIndices[k]; 

Since MS3D tessellates models using triangles, we indicate to OpenGL that we're going to begin drawing some triangle shapes.

Then for each triangle in the mesh, we set up its texture mapping normals and coordinates.

// If no bones render normaly
if ((int)mod.Vertices[index].boneID == 255)
  {
   int foo = (k*3);
   float[] norm = new float[3]{pTri.vertexNormals[0+foo], pTri.vertexNormals[1+foo], pTri.vertexNormals[2+foo]};
   Gl.glNormal3fv( norm );
   Gl.glTexCoord2f( pTri.s[k], pTri.t[k] );
   Gl.glVertex3fv( mod.Vertices[index].location );
  }
  else
  {

    // otherwise rotate according to transformation matrix
    Matrix final = new Matrix(mod.Joints[mod.Vertices[index].boneID].mat_final);

   Gl.glTexCoord2f( pTri.s[k], pTri.t[k] );

   int foo = (k*3);
   float[] norm = new float[3]{pTri.vertexNormals[0+foo], pTri.vertexNormals[1+foo], pTri.vertexNormals[2+foo]};

   Vector newNormal = new Vector(norm);
   newNormal.Transform3(final);
   newNormal.Normalize();
   Gl.glNormal3fv(newNormal.getVector());

   Vector newVertex = new Vector( mod.Vertices[index].location ); 

We use a little transformation code when dealing with the vertices with bones. I don't pretend to know how to explain or fully understand the mathematics behind it, but as far I can tell it's all fairly standard coordinate and rotation calculations being performed.

  newVertex.Transform( final );
   Gl.glVertex3fv( newVertex.getVector());
 }
}
} 

We then draw the actual vertex which is when OpenGL will actually show us a shape. We repeat this process about a hundred times per second as we draw each individual triangle in the MS3D model.

Gl.glEnd();
}
} 

Finally after all of the drawing is done for the current frame, we tell OpenGL that we're finished with our GL_TRIANGLES operation.

The example application is a bit of an experiment in that I'm also rendering to an Image and tracing it to create a shaped form region. The result is that you end up with a model that appears to be draw directly on your desktop.