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):