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