www.codeworx.org/opengl-tutorials/Tutorial 31: Milkshape 3D Modelle laden

Lektion 31: Milkshape 3D Modelle laden

Autor: Brett Porter (brettporter@yahoo.com)

Der Quellcode dieser Lektion stammt aus PortaLib3D, das dem Benutzer z.B. ermöglicht Objekte schnell und einfach zu laden und darzustellen. Aber warum sollte man eine Grafikbibliothek benutzen, wenn man den Code dahinter nicht auch selber verstehen und schreiben kann? Dieses Tutorial wird dabei helfen.

Die aus PortaLib3D entnommenen Teile stehen unter meinem Copyright, dürfen aber natürlich von jedem genutzt werden, solange die Copyright-Hinweise bestehen bleiben und ich in den Credits genannt werde. Sollte das Tutorial verstanden worden sein und jemand den Code neu implementiert (per cut-and-paste lernt man sowieso nichts!), dann kann man sich den Copyright Hinweis natürlich freiwillig setzen.

Der OpenGL Basiscode

befindet sich in Lesson31.cpp. Das meiste davon stammt noch aus Lektion 6, mit einigen kleineren Änderungen beim Texturladen und an der Ausgabefunktion. Die Änderungen werden später besprochen.

Milkshape 3D

Das genutzte 3D-Modell wurde mit Milkshape 3D erstellt, da das Dateifomat sehr einfach zu parsen und verstehen ist. Als nächstes plane ich ein ähnliches Programm zum Einlesen von Anim8or und 3DS-Dateien.

Natürlich ist das Dateiformat eher nebensächlich und wird hier nur kurz beschrieben. Die Daten müssen vorallem in die passenden Strukturen ( z.B. struct Vertex ) geladen werden, also wird auch mit diesen begonnen:

Datenstrukturen des 3D-Modells

Als erstes werden normale Punkte im 3-dimensionalen Raum benötigt, zu finden in der Klasse Model in Model.h:

// Vertex Structure
struct Vertex
{
   char m_boneID;// For Skeletal Animation
   float m_location[3];
};

// Vertices Used
int m_numVertices;
Vertex *m_pVertices;

m_boneID kann in diesem Tutorial noch ignoriert werden, es könnte aber eine weitere Lektion zum Thema geben. m_location repräsentiert die 3 Koordinaten (X, Y und Z). Die beiden Variablen speichern die Anzahl der Punkte und die Punkte selber in einem dynamischen Array, dessen Speicherplatz später alloziiert wird.

Die Punkte werden in Dreiecken angeordnet:

// Triangle Structure
struct Triangle
{
   float m_vertexNormals[3][3];
   float m_s[3], m_t[3];
   int m_vertexIndices[3];
};

// Triangles Used
int m_numTriangles;
Triangle *m_pTriangles;

3 Punkte die ein Dreieck bilden sind in m_vertexIndices gespeichert. Es handelt sich hierbei um Adressen in dem Punktarray m_pVertices. So muss jeder Punkt nur einmal gespeichert werden, was Speicher und Berechnungen bei späteren Bewegungen spart. (Bei "geschlossenen" Oberflächen ist es ja sehr wahrscheinlich das sich zwei nebeneinander liegende Dreiecke einen Punkt teilen, der dann nur einmal gespeichert wird, daher diese zwar umständliche aber sparende Methode.) m_s und m_t sind die Texturkoordinaten (S und T) für jeden der 3 Punkte. Die dafür genutzte Textur gehört zu einem bestimmten mesh (dazu gleich). m_vertexNormals speichert die drei Koordinaten des Normalenvektors für jeden der drei Punkte.

Ein mesh ist eine bestimmte Anzahl von Dreiecken, die sich das gleiche Material, also eine bestimmte Textur (mit Beleuchtung) teilen. Mehrere meshs bilden das fertige Modell.

// Mesh
struct Mesh
{
   int m_materialIndex;
   int m_numTriangles;
   int *m_pTriangleIndices;
};

// Meshes Used
int m_numMeshes;
Mesh *m_pMeshes;

Die zu einem mesh zugehörigen Dreiecke werden in m_pTriangleIndices gespeicht, genauso wie weiter oben die Punkte der einzelnen Dreiecke. Da die Anzahl der Dreiecke im mesh (m_numTriangles) erst beim eigentlichen Laden bekannt ist, wird auch hier wieder Speicher dynamisch alloziiert. m_materialIndex spezifiziert das verwendete Material:

// Material Properties
struct Material
{
   float m_ambient[4], m_diffuse[4], m_specular[4], m_emissive[4];
   float m_shininess;
   GLuint m_texture;
   char *m_pTextureFilename;
};

// Materials Used
int m_numMaterials;
Material *m_pMaterials;

Die für OpenGL typischen Beleuchtungswerte (ambient, diffuse, specular, emissive und shininess) gehören zu einem Material, genauso wie die Textur (m_texture) und deren Dateiname (auch dynamisch), damit diese bei Bedarf geladen werden kann.

Das 3D-Modell wird geladen

Der virtuellen Funktion loadModelData wird der Dateiname des zu ladenen Modells übergeben. Die von "Model" abgeleitete Klasse "MilkshapeModel" (MilkshapeModel.h) enthält diese Funktion.

bool MilkshapeModel::loadModelData( const char *filename )
{
   ifstream inputFile( filename, ios::in | ios::binary | ios::nocreate );
   if ( inputFile.fail())
      return false;                  // "Couldn't Open The Model File."

Zuerst wird die Milkshape-Datei geöffnet, die im Binärfomat (ios::binar) vorliegt. Sollte diese nicht gefunden werden, bricht die Funktion ab und es wird false zurückgegeben.

   inputFile.seekg( 0, ios::end );
   long fileSize = inputFile.tellg();
   inputFile.seekg( 0, ios::beg );

Die Größe der Datei in Byte wird ermittelt.

   byte *pBuffer = new byte[fileSize];
   inputFile.read( pBuffer, fileSize );
   inputFile.close();

Die Datei wird komplett in einen Puffer geladen.

   const byte *pPtr = pBuffer;
   MS3DHeader *pHeader = ( MS3DHeader* )pPtr;
   pPtr += sizeof( MS3DHeader );
   if ( strncmp( pHeader->m_ID, "MS3D000000", 10 ) != 0 )
      return false;// "Not A Valid Milkshape3D Model File."

   if ( pHeader->m_version < 3 || pHeader->m_version > 4 )
      return false;// "Unhandled File Version. Only Milkshape3D Version 1.3 And    1.4 Is Supported."

pPtr ist ein Pointer der auf die aktuelle Position in der Datei zeigt. Ein Pointer auf den Dateiheader wird gespeichert und pPtr hinter den Header gesetzt um die eigentlichen Daten einzulesen. Es werden mehrmals Strukturen mit MS3D am Anfang genutzt, diese werden in MilkshapeModel.cpp deklariert und stammen direkt aus der Spezifikation für Milkshape-Dateien. Die ausgelesenen Header-Daten werden dann darauf überprüft, ob das Format und die Version stimmt.

   int nVertices = *( word* )pPtr;
   m_numVertices = nVertices;
   m_pVertices = new Vertex[nVertices];
   pPtr += sizeof( word );
   int i;
   for ( i = 0; i < nVertices; i++ )
   {
      MS3DVertex *pVertex = ( MS3DVertex* )pPtr;
      m_pVertices[i].m_boneID = pVertex->m_boneID;
      memcpy( m_pVertices[i].m_location, pVertex->m_vertex, sizeof( float )*3 );
      pPtr += sizeof( MS3DVertex );
   }

Der obere Code liest die gespeicherten Punkte ein, für die mit "new" Speicher alloziiert wird. Mithilfe von pPtr werden diese Schritt für Schritt eingelesen und mit memcpy in das Array m_pVertices kopiert. m_boneID kann ignoriert werden und wird nur bei skelettbasierenden Animationen wichtig.

   int nTriangles = *( word* )pPtr;
   m_numTriangles = nTriangles;
   m_pTriangles = new Triangle[nTriangles];
   pPtr += sizeof( word );
   for ( i = 0; i < nTriangles; i++ )
   {
      MS3DTriangle *pTriangle = ( MS3DTriangle* )pPtr;
      int vertexIndices[3] = { pTriangle->m_vertexIndices[0], pTriangle->m_vertexIndices[1],    pTriangle->m_vertexIndices[2] };
      float t[3] = { 1.0f-pTriangle->m_t[0], 1.0f-pTriangle->m_t[1], 1.0f-pTriangle->m_t[2]    };
      memcpy( m_pTriangles[i].m_vertexNormals, pTriangle->m_vertexNormals, sizeof(    float )*3*3 );
      memcpy( m_pTriangles[i].m_s, pTriangle->m_s, sizeof( float )*3 );
      memcpy( m_pTriangles[i].m_t, t, sizeof( float )*3 );
      memcpy( m_pTriangles[i].m_vertexIndices, vertexIndices, sizeof( int )*3 );
      pPtr += sizeof( MS3DTriangle );
   }

Ähnlich wie bei den Punkten liest dieser Teil der Funktion die Dreiecke aus. Die meisten Werte werden 1 zu 1 übernommen, die Punktindizes werden vom Typ word zu int konvertiert, um später ohne Konvertierungen darauf zugreifen zu können. Die m_t-Werte werden in die Form 1.0-Originalwert umgewandelt, da OpenGL ein anderes Koordinatensystem als Milkshape nutzt (Der Koordinatenursprung liegt bei OpenGL links unten und bei Milkshape links oben, die Y-Koordinate muss also umgedreht werden.)

   int nGroups = *( word* )pPtr;
   m_numMeshes = nGroups;
   m_pMeshes = new Mesh[nGroups];
   pPtr += sizeof( word );

   for ( i = 0; i < nGroups; i++ )
   {
      pPtr += sizeof( byte );// Flags
      pPtr += 32;// Name
      word nTriangles = *( word* )pPtr;
      pPtr += sizeof( word );
      int *pTriangleIndices = new int[nTriangles];
      for ( int j = 0; j < nTriangles; j++ )
      {
         pTriangleIndices[j] = *( word* )pPtr;
         pPtr += sizeof( word );
      }

      char materialIndex = *( char* )pPtr;
      pPtr += sizeof( char );
      m_pMeshes[i].m_materialIndex = materialIndex;
      m_pMeshes[i].m_numTriangles = nTriangles;
      m_pMeshes[i].m_pTriangleIndices = pTriangleIndices;
   }

Der obere Code speichert die mesh-Strukturen, die in Mikshape als "groups" bezeichnet werden. Da die Anzahl der Dreiecke variiert, kann hier von keiner festen Größe ausgegangen werden, es muss also Stück für Stück einzeln gelesen werden. Der Speicher für die Indices der Dreiecke wird wieder mit new reserviert.

   int nMaterials = *( word* )pPtr;
   m_numMaterials = nMaterials;
   m_pMaterials = new Material[nMaterials];
   pPtr += sizeof( word );

   for ( i = 0; i < nMaterials; i++ )
   {
      MS3DMaterial *pMaterial = ( MS3DMaterial* )pPtr;
      memcpy( m_pMaterials[i].m_ambient, pMaterial->m_ambient, sizeof( float )*4    );
      memcpy( m_pMaterials[i].m_diffuse, pMaterial->m_diffuse, sizeof( float )*4    );
      memcpy( m_pMaterials[i].m_specular, pMaterial->m_specular, sizeof( float    )*4 );
      memcpy( m_pMaterials[i].m_emissive, pMaterial->m_emissive, sizeof( float    )*4 );
      m_pMaterials[i].m_shininess = pMaterial->m_shininess;
      m_pMaterials[i].m_pTextureFilename = new char[strlen( pMaterial->m_texture    )+1];
      strcpy( m_pMaterials[i].m_pTextureFilename, pMaterial->m_texture );
      pPtr += sizeof( MS3DMaterial );
   }

   reloadTextures();

Zuletzt werden die Materialinformationen aus dem Puffer gelesen. Dies passiert prinzipiell wie oben. Die benötigten Texturen werden geladen und an die üblichen OpenGL Texturobjekte gebunden. Diese Funktion wird aber gleich beschrieben.

   delete[] pBuffer;
   return true;
}


Das letzte Stück löscht den angelegten Puffer und liefert true zurück wenn alles geklappt hat.

Jetzt sind die Datenstrukturen des 3D-Modells gefüllt und fast fertig zum rendern, der untere Code liest die Texturen für jedes verwendete Material ein:

void Model::reloadTextures()
{
   for ( int i = 0; i < m_numMaterials; i++ )
      if ( strlen( m_pMaterials[i].m_pTextureFilename ) > 0 )
         m_pMaterials[i].m_texture = LoadGLTexture( m_pMaterials[i].m_pTextureFilename );

      else
         m_pMaterials[i].m_texture = 0;
 }

Die Texturdateien werden mit dem üblichen und leicht veränderten Nehe-Code eingelesen, sollte der String des Dateinames leer sein, wird der Texturbezeichner auf 0 gesetzt.

Die Ausgabe

Das 3D-Modell wird ausgegeben, was allerdings weniger tragisch sein sollte, da alle Datenstrukturen einfach zu behandeln sind.

void Model::draw()
{
   GLboolean texEnabled = glIsEnabled( GL_TEXTURE_2D );

Hier wird der momentane Status des Texture Mappings gespeichert (true oder false), um diesen zum Schluss wieder herstellen zu können.

Die meshs werden eins nach dem anderen durchgegangen und ausgegeben:

   // Draw By Group
   for ( int i = 0; i < m_numMeshes; i++ )
   {

m_pMeshes[i] ist das aktuell auszugebene mesh, dessen Materialeigenschaften an OpenGL übermittelt werden. Sollte der Materialindex -1 sein, ist kein Material für dieses mesh vorhanden und es wird mit dem gerade aktuellen OpenGL Status ausgegeben

   int materialIndex = m_pMeshes[i].m_materialIndex;

   if ( materialIndex >= 0 )
   {
      glMaterialfv( GL_FRONT, GL_AMBIENT, m_pMaterials[materialIndex].m_ambient );
      glMaterialfv( GL_FRONT, GL_DIFFUSE, m_pMaterials[materialIndex].m_diffuse );
      glMaterialfv( GL_FRONT, GL_SPECULAR, m_pMaterials[materialIndex].m_specular    );
      glMaterialfv( GL_FRONT, GL_EMISSION, m_pMaterials[materialIndex].m_emissive    );
      glMaterialf( GL_FRONT, GL_SHININESS, m_pMaterials[materialIndex].m_shininess    );

      if ( m_pMaterials[materialIndex].m_texture > 0 )
      {
         glBindTexture( GL_TEXTURE_2D, m_pMaterials[materialIndex].m_texture );
         glEnable( GL_TEXTURE_2D );
      }

      else
         glDisable( GL_TEXTURE_2D );
   }

   else
   {
      glDisable( GL_TEXTURE_2D );
   }

Auch die zu dem mesh gehörende Textur wird am Ende des oberern Codes eingebunden, allerdings nur wenn der Texturbezeichner größer als 0 ist. Andernfalls wird keine Textur gebunden und das Texture Mapping abgeschaltet.

   glBegin( GL_TRIANGLES );
   {
      for ( int j = 0; j < m_pMeshes[i].m_numTriangles; j++ )
      {
         int triangleIndex = m_pMeshes[i].m_pTriangleIndices[j];
         const Triangle* pTri = &m_pTriangles[triangleIndex];

         for ( int k = 0; k < 3; k++ )
         {
            int index = pTri->m_vertexIndices[k];
            glNormal3fv( pTri->m_vertexNormals[k] );
            glTexCoord2f( pTri->m_s[k], pTri->m_t[k] );
            glVertex3fv( m_pVertices[index].m_location );
         }
      }
   }

   glEnd();
   }

Der vorherige Code rendert die Dreiecke des Modells. Die drei Punkte des Dreiecks (m_pVertices[index].m_location) werden durch die innere for-schleife mit ihrer Normalen (m_vertexNormals[k]) und den Textur Koordinaten (m_s[k], m_t[k]) übergeben. pTri ist ein Pointer auf das aktuelle Dreieck, um den Zugriff zu erleichtern und den Code zu vereinfachen. Die Punkte des Dreiecks an sich wurden ja nur als Indices ( TriangleIndices ) im aktuellen Mesh gespeichert und stehen im Punkt-Array des models.

   if ( texEnabled )
      glEnable( GL_TEXTURE_2D );

   else
      glDisable( GL_TEXTURE_2D );
}

Jetzt wird nur noch der Status des Texture Mappings auf den Ausgangswert zurückgesetzt.

Interessant könnten noch der constructor und destructor der Klasse sein, diese erklären sich aber im Prinzip von selber. Der constructor setzt alle seine Member Variablen auf 0, oder NULL bei Pointern, der destructor gibt den dynamisch reservierten Speicher wieder frei. loadModelData sollte pro Objekt auch nur genau einmal aufgerufen werden, sonst kommt es zu Fehlern.

Alle nötigen Veränderungen im Basiscode sollen noch kurz erläutert werden um die erstellte Klasse zu nutzen.

Model *pModel = NULL;    // Holds The Model Data

Am Anfang von Lesson31.cpp wird das genutzte model deklariert, aber erst in WinMain initialisiert.

pModel = new MilkshapeModel();

if ( pModel->loadModelData( "data/model.ms3d" ) == false )
{
   MessageBox( NULL, "Couldn't load the model data/model.ms3d", "Error", MB_OK | MB_ICONERROR );
   return 0;// If Model Didn't Load, Quit
}

Das model sollte besser hier und nicht in InitGL initiallisiert werden, da InitGL mehrmals aufgerufen werden könnte. In InitGL müssen bei Veränderungen am Ausgabemodus die Texturen neu geladen werden, was der nächste Codeschnipsel ebewirkt:

pModel->reloadTextures();

Ganz zum Schluß noch die veränderte DrawGLScene Funktion:

int DrawGLScene(GLvoid)// Here's Where We Do All The Drawing
{
   // Clear The Screen And The Depth Buffer
   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
   glLoadIdentity();               // Reset The View
   gluLookAt( 75, 75, 75, 0, 0, 0, 0, 1, 0 );
   glRotatef(yrot,0.0f,1.0f,0.0f);
   pModel->draw();
   yrot+=1.0f;
   return TRUE;                    // Keep Going
}

Der color und depht buffer wird geleert und mit gluLookAt wird die Blickrichtung eingestellt. Der Betrachter befindet sich jetzt am Punkt (75, 75, 75) blickt auf den Punkt (0, 0, 0), der letzte Vektor (0, 1, 0) legt fest wo "oben" ist.

Um das Ganze etwas interessanter zu machen, rotiert die Szene ständig mittels glRotatef(...) um die Y-Achse.

Von hier aus wird auch die Ausgabefunktion des Modells aufgerufen, soll das Objekt also verschoben oder in weitere Richtungen gedreht werden, müssen die entsprechenden Funktionen vorher aufegerufen werden.

Das wärs, also ran an Milkshape ;-)

Ein zukünftiges Tutorial

für die Nehe-Reihe wird die Modell-Klasse erweitern um auch skeletal animation nutzen zu können. Der Schritt dorthin ist nicht allzu kompliziert, allerdings kommt mehr Mathe ins Spiel. Außerdem könnten einige Ladefunktionen für weitere Dateiformate folgen.

Bis dann.

Einige Informationen zu Brett Porter. Besagter wurde in Australien geboren und hat an der University of Wollongong studiert. Er hat mit 12 auf einem C64 Klon (VZ300) angefangen BASIC zu programmieren, ist aber sehr schnell zu Pacsal, Assembler, C++ und Java gekommen. Zur Zeit beschäftigt er sich vorallem mit OpenGL. Mehr unter http://rsn.gamedev.net .

Ergänzung: Die Fortsetzung des Tuts ist hier zu finden.

Brett Porter

Jeff Molofee (NeHe) http://nehe.gamedev.net

Die Source Codes und Ausführbaren Dateien zu den Kursen liegen auf der Neon Helium Website

codeworx.

Übersetzt und modifiziert von Hans-Jakob Schwer 29.09.2k3, www.codeworx.org