Nachdem ihr nun euer Projekt eingerichtet habt und euer erstes Polygon gezeichnet habt, wird es Zeit ein echtes 3D-Modell zu laden und darzustellen. Diese Modelle werden auch „Meshes“ genannt, während eine Ansammlung dieser Objekte mitsamt Beleuchtung und Kamera oftmals als „Szene“ bezeichnet wird.
Solch eine Szene könnt ihr mit diversen 3D-Modelling-Tools wie etwa Autodesk Maya, 3D Studio Max oder Blender erstellen. Diese liefern euch dabei die verschiedensten Formate, für die es allesamt einen Importer zu programmieren gäbe. Zum Glück hat das aber bereits jemand getan und somit werde ich euch die Verwendung der ASSIMP-Library vorzeigen, mit der der gesamte Prozess fast ein Kinderspiel ist. Denn abgesehen davon, dass diese Library Open-Source ist, unterstützt sie auch fast alle gängigen Formate für 3D-Modelle und liefert sie einheitlich zurück.
Doch zunächst einmal widmen wir uns der perspektivischen Ansicht für unsere Kamera und der Beleuchtung.
OpenGL.cpp: Perspektivische Ansicht erstellen
Zunächst müsst ihr dafür einen Viewport definieren. Der Viewport stellt quasi das „Fenster“ dar, durch welches ihr auf die Szene blickt. Außerdem aktiviert ihr den Tiefentest, damit die Sichtbarkeit der Objekte stimmt (Vordergrund verdeckt Hintergrund). Hierzu geht ihr in eure OpenGL.cpp vom letzten Tutorialprojekt und fügt am ganz Ende der Init()-Methode folgendes ein:
//Aktiviert Tiefentest glEnable(GL_DEPTH_TEST); //Setze Viewport fest glViewport( 0, 0, width, height );
Als nächstes benötigt ihr die Projektions-Matrix mit einer perspektivischen Ansicht und die ModelView-Matrix. Erstere sorgt dafür, dass ihr eine 3D-Szene auf die 2D-Oberfläche eures Monitors projeziert bekommt. Die zweite definiert den Raum in dem sich die Modelle befinden für eure Kamera. Folglich fügt ihr nun noch diesen Code am Ende der Init()-Methode hinzu:
//Lade Projektionsmatrix glMatrixMode(GL_PROJECTION ); glLoadIdentity(); //Lege Perspektive fest gluPerspective(65, (GLfloat)width/(GLfloat)height, 0.1f, 100.0f); //Lade ModelView Matrix glMatrixMode(GL_MODELVIEW); glLoadIdentity(); //Verschiebt die Modelle nach vorne, sodass ihr sie sehen könnt glTranslatef(0,0, -5.0f);
Der Wert 65 steht im Übrigen für das „Field of View“. Es handelt sich dabei um den Sichtwinkel. 120 wäre zum Beispiel bereits eine extreme Weitwinkel-Ansicht.
OpenGL.cpp: Beleuchtung
Um ein wenig mehr Realismus in die Sache zu bringen, widmet ihr euch am besten auch gleich der Beleuchtung. Immerhin sollen die importierten 3D-Modelle auch hübsch anzusehen sein.
Dafür legt man üblicherweise vier verschiedene Eigenschaften für das Licht fest:
- Ambient: Die standardmäßige „Hintergrundbeleuchtung“. Alle Objekte, egal an welcher Position, werden immer vom Ambient-Licht beleuchtet.
- Diffuse: Der eigentliche Schein des Lichts.
- Specular: Die Reflektion des Lichts.
- Position: Die Position des Lichts von dem Diffuse und Specular ausgehen. Der vierte Wert bestimmt, ob es sich bei dem Licht um ein Pointlight (bei 1) oder um ein Directional Light (bei 0) handelt.
Dies könnt ihr zum Beispiel folgendermaßen in eurer Init()-Methode zwischen glClearColor() und glViewport() programmieren:
... //Legt die Hintergrundfarbe fest glClearColor( 0.2f, 0.2f, 0.2f, 0.2f ); //Aktiviert Tiefentest glEnable(GL_DEPTH_TEST); //Füge Beleuchtung hinzu //Erstelle Eigenschaftswerte für Licht GLfloat ambientLight[] = {0.1f, 0.1f, 0.1f, 1.0f}; GLfloat diffuseLight[] = {0.8f, 0.8f, 0.8f, 1.0f}; GLfloat specularLight[] = {0.5f, 0.5f, 0.5f, 1.0f}; GLfloat position[] = {0.3f, 1.0f, 0.5f, 1.0f}; //Weise die erstellten Werte dem Licht zu glLightfv(GL_LIGHT0, GL_AMBIENT, ambientLight); glLightfv(GL_LIGHT0, GL_DIFFUSE, diffuseLight); glLightfv(GL_LIGHT0, GL_SPECULAR, specularLight); glLightfv(GL_LIGHT0, GL_POSITION, position); //Aktiviere Beleuchtung glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); //Glättet das Licht glShadeModel(GL_SMOOTH); //Setze Viewport fest glViewport( 0, 0, width, height ); ...
Es können natürlich auch mehrere Lichter definiert werden. Hierfür könnt ihr einfach weitere Lichtquellen wie mit GL_LIGHT0 definieren und dabei GL_LIGHT1, GL_LIGHT2, usw. verwenden. Zusätzlich wird das Licht mittels „glShadeModel(GL_SMOOTH)“ geglättet.
Main.h: Header für string und vector hinzufügen
Damit wir im folgenden Verlauf des Tutorials auf diese beiden C++-Typen zurückgreifen können, müssen wir diese in der Main.h einbinden. Außerdem müssen wir den Namespace „std“ für die Operationen mit diese Variablen festlegen.
Eure Main.h sollte folglich danach so aussehen:
//Main.h - Beinhaltet externe includes, etc. für das Programm. #ifndef MAIN_H_INCLUDED #define MAIN_H_INCLUDED #include <string> #include <vector> #include <stdlib.h> #include <GL\glfw.h> using namespace std; #endif
ASSIMP Library einbinden
Nun ist es endlich an der Zeit, dass wir uns der Import Library zuwenden. Ladet euch zunächst die Version 2.0 der Assimp Bibliothek herunter.
Praktischerweise befinden sich bereits vorkompilierte Dateien für Windows im Download. Im Ordner lib/assimp_debug-dll_win32 findet ihr die statische assimp.lib sowie im Ordner bin/assimp_release-dll_win32 findet ihr die dazugehörige Assimp32.dll. Die Header-Files sind im include-Ordner abgelegt. Bindet all diese Dateien jetzt in euer Projekt ein. Falls dieses Gebiet neu für euch ist, habe ich zum Glück bereits vor einiger Zeit ein Tutorial verfasst, nur dass ihr statt der FreeImage-Bibliothek einfach die Assimp-Library integriert.
Mesh.h: Klasse für Meshes im Header definieren
Als nächstes benötigt ihr eine Klasse, die all die Daten und Verweise zu euren einzelnen Meshes beinhaltet, um den gesamten Ablauf objektorientiert und übersichtlich zu programmieren. Fügt also zu eurem Projekt eine neue Klasse namens „Mesh“ hinzu oder legt euch selbst die Mesh.h und Mesh.cpp an.
ASSIMP liefert uns die Modelle in einer Face-Vertex-Struktur zurück. Daher solltet ihr neben den Vertices, Normals und Texturkoordinaten (für später) auch die einzelnen Indices speichern, die eure Polygone definieren. Außerdem wird später die Anzahl der Vertices und Indices benötigt, weshalb wir auch diese speichern.
Da die Vertices, Normals und Texturekoordinaten als Vektoren festgelegt sind, bietet ASSIMP auch gleich ein eigenes Format für diese: aiVector3D. Außerdem müsst ihr zunächst die Indices aus den einzelnen Faces, die euch vom Importer als aiFace zurückgegeben werden, extrahieren.
Um all diese Werte praktischerweise über eine Methode festzulegen, geschieht dies am besten gleich über den Konstruktor. Daher fällt dieser im folgenden Codestück etwas länger aus.
//Mesh.h - Definiert die Mesh-Klasse für die Speicherung //und den Zugriff auf die Daten eines Meshes. #ifndef MESH_H_INCLUDED #define MESH_H_INCLUDED #include "main.h" #include <assimp.hpp> #include <aiScene.h> class Mesh { public: //Konstruktor, der alle wichtigen Werte des Meshes übernimmt. Mesh(unsigned int numVertices, aiVector3D* vertices, aiVector3D* normals, unsigned int numFaces, aiFace* faces); //Destruktor, der am Ende des Programms aufräumt. ~Mesh(void); //Die geladenen Vertices, Normals und Texturkoordinaten als Vektoren aiVector3D* vertices; aiVector3D* normals; aiVector3D* textureCoords; GLuint* indices; //Die Anzahl der Vertices bzw. Indices GLuint numVertices; GLuint numIndices; }; #endif
Mesh.cpp: Klasse für Meshes implementieren
Im Prinzip werden hier nur die Werte vom Konstruktor an die eigenen Variablen übergeben. Ausnahme bilden hierbei die Indices, welche ihr erst aus den einzelnen Faces extrahieren müsst. Die Implementierung der Klasse sieht daher folgendermaßen aus:
//Mesh.cpp - Implementiert die Mesh-Klasse und dessen Konstruktor //und Destruktor. #include "mesh.h" //übergibt die Werte des Meshes an die eigenen Variablen //und extrahiert die Indices aus den einzelnen Faces. Mesh::Mesh(unsigned int numVertices, aiVector3D* vertices, aiVector3D* normals, unsigned int numFaces, aiFace* faces) { this->numVertices = numVertices; this->vertices = vertices; this->normals = normals; this->numIndices = numFaces*3; this->indices = new GLuint[this->numIndices]; for(unsigned int i = 0; i < numFaces; i++) { indices[i*3] = (GLuint)faces[i].mIndices[0]; indices[i*3+1] = (GLuint)faces[i].mIndices[1]; indices[i*3+2] = (GLuint)faces[i].mIndices[2]; } } //Löscht die gespeicherten Indices Mesh::~Mesh(void) { delete [] indices; }
OpenGL.h: ImportScene()-Methode und die neue Draw()-Methode
Den Import und die Erstellung des Mesh-Objekts geschieht am besten über eine eigene ImportScene()-Methode, welcher ihr den Dateipfad zur importierenden 3D-Szene übergebt. Weiters wandelt ihr geschickterweise die Draw()-Methode etwas ab, um ihr einzelne Meshes zum Zeichnen zu übergeben, anstatt wild drauflos zu rendern. Schlussendlich benötigt ihr auch noch die Variablen für die Assimp-Szene und -Importer sowie einen vector in welchem ihr alle geladenen Meshes speichert.
In der OpenGL.h schaut das dann folgendermaßen aus (die markierten Zeilen sind neu oder bearbeitet):
//OpenGL.h - Definiert die OpenGL-Klasse. #ifndef OPENGL_H_INCLUDED #define OPENGL_H_INCLUDED #include "main.h" #include "mesh.h" #include <assimp.hpp> #include <aiScene.h> #include <aiPostProcess.h> class OpenGL { public: /* Methoden */ //Konstruktor dem die Höhe und Breite des Fensters übergeben werden. OpenGL(int w, int h); //Destruktor - Schließt Anwendungsfenster ~OpenGL(); //In der MainLoop werden in einer Endlosschleife Update und Draw aufgerufen. //Sie endet erst, wenn running = false ist. void MainLoop(); //Importiert die gewünschte 3D-Szene void ImportScene(string filename); private: /* Methoden */ //Hier wird das Fenster geöffnet und der Rest für die OpenGL-Szene initialisiert. void Init(); //Hier findet sämtliche Logik statt, die in jedem Frame aufgerufen wird. void Update(); //In der Draw-Methode werden die Objekte in den Backbuffer gezeichnet. //Erklärung: Alles wird zunächst in den Backbuffer gezeichnet und //erst am Schluss der Back- mit dem Frontbuffer getauscht. //Das Bild welches wir sehen ist immer der Frontbuffer. //Durch dieses Prinzip erscheint das Bild stets ruhig und flüssig. void Draw(Mesh* mesh); /* Variablen */ //True wenn das Programm laufen soll. //Kann z.B. durch Esc auf false gesetzt werden um das Programm am Ende des Durchlaufs zu beenden. bool running; //Auflösung int width; int height; //Assimp Szene und Importer const aiScene* scene; Assimp::Importer* importer; //Die Meshes/Modelle in unserer Szene vector<Mesh*> meshes; }; #endif
OpenGL.cpp: ImportScene()-Methode implementieren
In der ImportScene()-Methode erstellt ihr zunächst einen neuen Assimp::Importer. Mit diesem lest ihr nun die als Argument übergene Datei aus und greift dabei auch gleich auf die praktischen Post-Processing-Flags von ASSIMP zurück. Mehr zu den einzelnen Flags findet ihr in der ASSIMP-Dokumentation. Wichtig ist aber vor allem „aiProcess_Triangulate“, welcher alle Polygone in Dreiecke umwandelt, um diese einheitlich in eurer Draw()-Methode zeichnen zu können.
Als letztes müsst ihr nur noch die neuen Mesh-Objekte mithilfe der Werte aus der importierten Szene erstellen.
//Import die angegebene 3D-Szene mit dem Assimp importer und speichert die Vertex-Daten in den void OpenGL::ImportScene(string filename) { importer = new Assimp::Importer(); scene = importer->ReadFile(filename, aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_JoinIdenticalVertices | aiProcess_ImproveCacheLocality | aiProcess_SortByPType); for(unsigned int i = 0; i < scene->mNumMeshes; i++) { Mesh* tempMesh = new Mesh(scene->mMeshes[i]->mNumVertices, scene->mMeshes[i]->mVertices, scene->mMeshes[i]->mNormals, scene->mMeshes[i]->mNumFaces, scene->mMeshes[i]->mFaces); meshes.push_back(tempMesh); } }
OpenGL.cpp: MainLoop()-Methode anpassen
Ein kleiner Zwischenschritt ist es die MainLoop des Renderers für eure neue Draw()-Methode anzupassen.
//In der MainLoop werden in einer Endlosschleife Update und Draw aufgerufen. //Außerdem werden die Fenstergröße aktualisiert und die Buffer geleert. //Sie endet erst, wenn running = false ist. void OpenGL::MainLoop() { do { //Aktualisiert Fenstergröße beim Verschieben/Vergrößern/Verkleinern des Fensters. glfwGetWindowSize( &width, &height ); //Leere Buffer glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //Nun ruft ihr jeden Frame Update() und Draw() auf. Update(); for(unsigned int i = 0; i < meshes.size(); i++) { Draw(meshes[i]); } //Säubert die Buffer und fordert die Befehle darin auf, so schnell wie möglich fertig zu werden //Besonders wichtig für Netzwerk und Eingabe. glFlush(); //Tauscht den Back- mit dem Frontbuffer. //Erklärung dazu siehe Kommentar bei Draw() in der Header-Datei glfwSwapBuffers(); } while(running); }
OpenGL.cpp: Draw()-Methode mit Vertex-Array implementieren
Zu guter Letzt fehlt nur noch die Anpassung der Draw()-Methode für das Rendern der verschiedenen Mesh-Objekte. Dabei empfehle ich euch gleich auf einen Vertex Array zurückzugreifen. Damit entfernt ihr euch vom Immediate-Modus, der bisher verwendet wurde, was auch gleich einen netten Performance-Schub zur Folge hat. Im Prinzip wird hier einfach Pointer auf die Arrays mit den Vertices, Normals, usw. gesetzt. Mithilfe von „glDrawElements()“ werden diese anschließend anhand der Indices gerendert. Klingt simpel und ist es für den Programmierer schlussendlich auch.
//In der Draw-Methode werden die Objekte in den Backbuffer gezeichnet. void OpenGL::Draw(Mesh* mesh) { //Zeichnet das Mesh in mittels Vertex array. //Aktiviert die Arrays für Vertices und Normals. glEnableClientState(GL_NORMAL_ARRAY); glEnableClientState(GL_VERTEX_ARRAY); //Setzt den Pointer auf den Normal- und Vertex-Array glNormalPointer(GL_FLOAT, 0, mesh->normals); glVertexPointer(3, GL_FLOAT, 0, mesh->vertices); //Zeichnet die Elemente als Dreiecke anhand der Indices glDrawElements(GL_TRIANGLES, mesh->numIndices, GL_UNSIGNED_INT, mesh->indices); //Deaktiviert danach wieder die Arrays. glDisableClientState(GL_VERTEX_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); }
OpenGL.cpp: Aufräumarbeiten
Natürlich darf auch das Freigeben der Meshes und des Importers nicht fehlen. Die markierten Code-Zeilen sind neu.
//Destruktor - Schließt Anwendungsfenster OpenGL::~OpenGL() { importer->FreeScene(); delete importer; for(unsigned int i = 0; i < meshes.size(); i++) { delete meshes[i]; } glfwTerminate(); }
Main.cpp: Auf zum fröhlichen Importieren!
Um das Ergebnis eurer Arbeit veranschaulichen zu können, habe ich hier bereits ein einfaches 3D-Modell für den Import vorbereitet:
Entpackt das Archiv in eueren Projekt-Ordner zu den .cpp- und .h-Dateien und importiert das Modell über den ImportScene()-Aufruf in eurer Main.cpp:
//Main.cpp - Haupt- und Startteil des Programms. Hier wird ein OpenGL-Objekt erstellt und gestartet #include "main.h" #include "opengl.h" //Die Main-Funktion wird beim Programmstart aufgerufen int main(int argc, char **argv) { //Erstellt das OpenGL-Objekt mit der Auflösung 800x600. OpenGL* ogl = new OpenGL(800,600); //Import die Sphere-Szene ogl->ImportScene("sphere.obj"); //Startet die MainLoop, die in der Endlosschleife läuft bis das Programm beendet wird. ogl->MainLoop(); //Löscht das OpenGL-Objekt und ruft dessen Destruktor auf delete ogl; return 0; }
Nun solltet ihr eine importierte und sanft beleuchtete Kugel sehen können.
Falls es irgendwo Unklarheiten geben sollte, habe ich hier noch einmal ein kleines Beispielprojekt für euch vorbereitet (mit A und D könnt ihr die Kugel im Raum drehen):
Kommentare