In diesem Kapitel erweitern wir die Engine um die Möglichkeit, komplexe Modelle zu laden und anzuzeigen. Wir nutzen dafür das offene OBJ-Dateiformat.
Neue Klasse FileReader
In diesem Kapitel werden wir erstmals 3D-Daten zur Laufzeit aus einer Datei lesen. Wir beginnen damit einzelne Meshes zu laden und werden das später auf Modelle und Szenen erweitern.
Damit wir möglichst wenig doppelt machen, lagern wir alle Funktionen zum Lesen aus Dateien in eine neue Klasse FileReader aus. Dieser FileReader ist stark optimiert auf die Anforderungen der verwendeten Dateitypen die wir im Folgenden noch näher kennenlernen werden.
Der FileReader liest Dateien zeilenweise und ist in der Lage Strings und Vektoren zu extrahieren.
filereader.h
#pragma once
#include "cgmath.h"
#include <fstream>
#include <sstream>
#include <string>
class FileReader
{
public:
FileReader(const std::string &filename);
~FileReader();
bool hasLine();
std::string getString();
float getFloat();
Vector2 getVector2();
Vector3 getVector3();
private:
std::ifstream fileStream;
std::istringstream lineStream;
};
filereader.cpp
#include "filereader.h"
FileReader::FileReader(const std::string &filename)
{
fileStream.open(filename);
}
FileReader::~FileReader()
{
fileStream.close();
}
bool FileReader::hasLine()
{
std::string lineBuffer;
if (getline(fileStream, lineBuffer))
{
lineStream.clear();
lineStream.str(lineBuffer);
return true;
}
lineStream.clear();
fileStream.close();
return false;
}
std::string FileReader::getString()
{
std::string str;
lineStream >> str;
return str;
}
float FileReader::getFloat()
{
std::string p1;
lineStream >> p1;
return stof(p1);
}
Vector2 FileReader::getVector2()
{
std::string p1, p2;
lineStream >> p1 >> p2;
return (Vector2){stof(p1), stof(p2)};
}
Vector3 FileReader::getVector3()
{
std::string p1, p2, p3;
lineStream >> p1 >> p2 >> p3;
return (Vector3){stof(p1), stof(p2), stof(p3)};
}
Neuer Konstruktor für die Mesh Klasse
Um Meshes aus Dateien zu laden, erzeugen wir einen neuen Konstruktor für die Klasse Mesh. Dieser Konstruktor erhält als Parameter den Pfad zur Mesh Datei und übernimmt die gesamte Logik zum Laden der Datei.
mesh.h
#pragma once
#include "vertex.h"
#include <string>
#include <vector>
class Mesh
{
public:
...
Mesh(const std::string &filename);
private:
void init();
...
};
mesh.cpp
...
Mesh::Mesh(const std::vector<Vertex> &vertices)
: vertices(vertices)
{
init();
}
Mesh::Mesh(const std::string &filename)
{
std::vector<Vector3> v;
std::vector<Vector2> vt;
std::vector<Vector3> vn;
std::vector<std::string> f;
FileReader reader = FileReader(filename);
while (reader.hasLine())
{
std::string type = reader.getString();
if (type == "v")
{
v.push_back(reader.getVector3());
}
else if (type == "vt")
{
vt.push_back(reader.getVector2());
}
else if (type == "vn")
{
vn.push_back(reader.getVector3());
}
else if (type == "f")
{
for (int i = 0; i < 3; i++)
{
f.push_back(reader.getString());
}
}
}
vertices.reserve(v.size());
for (int i = 0; i < f.size(); i++)
{
std::stringstream stream(f[i]);
std::string item;
std::vector<int> values;
while (getline(stream, item, '/'))
{
values.push_back(stoi(item) - 1);
}
vertices.emplace_back(v[values[0]], vt[values[1]]);
}
init();
}
void Mesh::init()
{
glGenVertexArrays(1, &vaoID);
glBindVertexArray(vaoID);
glGenBuffers(1, &vboID);
glBindBuffer(GL_ARRAY_BUFFER, vboID);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)offsetof(Vertex, position));
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *)offsetof(Vertex, texCoord));
glEnableVertexAttribArray(1);
}
Können Sie aus dem Code nachvollziehen, wie eine OBJ Datei aufgebaut ist? Im folgenden Abschnitt finden Sie eine OBJ Datei zum Analysieren. Die genaue Spezifikation (die wir hier nur teilweise umsetzen) finden Sie auf Wikipedia.
Mesh aus Datei laden
Damit wir etwas zum Laden haben, benötigen wir eine tatsächliche Mesh Datei.
Laden Sie sich die Mesh Datei thm.obj herunter und entpacken Sie sie nach Computergrafik/cgm_06/meshes/thm.obj – Diese Datei enthält den THM Schriftzug als 3D Modell.
Laden Sie sich außerdem die dazu passende Textur thm_colors.jpg herunter und speichern Sie sie als Computergrafik/cgm_06/textures/thm_colors.jpg
Anschließend passen Sie Ihren Renderer so an, dass beim Starten diese beiden neuen Dateien geladen werden.
renderer.cpp
Renderer::Renderer(const Settings &settings, Window &window)
{
...
glClearColor(0.19f, 0.26f, 0.3f, 1.0f);
...
mesh = new Mesh("meshes/thm.obj");
shader = new Shader("shaders/vertex_shader.glsl", "shaders/fragment_shader.glsl");
texture = new Texture("textures/thm_colors.jpg");
}
Wie Sie sehen, wird unser Code auf den höheren Abstraktionsebenen immer übersichtlicher. Spezialisierter Code wandert in die dafür vorgesehenen Klassen und der Renderer wird mehr und mehr zu einer Schaltzentrale, der die verschiedenen Elemente zusammenfügt.
Im folgenden Kapitel werden wir hier noch weiter aufräumen und Mesh, Shader und Texture komplett auslagern.
Tiefenpuffer aktivieren
Das komplexe THM Logo zeigt nun sehr anschaulich ein Problem in der 3D Grafik: Die einzelnen Vertices eines Objektes werden in genau der Reihenfolge gezeichnet, in der sie an die GPU gesendet werden. Das führt bei frei beweglichen Kameras zu Problemen. Je nach Blickwinkel erscheinen Teile des Objektes die eigentlich verdeckt sein müssten, im Vordergrund. Hierbei hilft der Einsatz des Tiefenpuffers.
Ein Tiefenpuffer dient dazu, die Tiefe jedes Pixel auf dem Bildschirm zu speichern. Dies ist wichtig, um sicherzustellen, dass bestimmte Pixel, die näher an der Kamera sind, auf dem Bildschirm sichtbar bleiben und andere, die weiter weg sind, verdeckt werden.
Und so funktioniert der Tiefenpuffer:
- Beim Laden der Engine oder auch beim Ändern der Fenstergröße wird ein Tiefenpuffer erstellt. Es handelt sich dabei um einen Speicherbereich der genau so groß ist wie der ViewPort.
- Bevor ein Pixel gezeichnet wird, wird seine Tiefe berechnet. Tiefe bedeutet die Entfernung des Pixels zur Kamera.
- Die Tiefe des Pixels wird mit dem Wert im Tiefenpuffer verglichen.
- Wenn die Tiefe des zu zeichnenden Pixels näher an der Kamera ist als der Wert im Tiefenpuffer, wird das Pixel auf dem Bildschirm gezeichnet und der Tiefenpuffer-Wert wird aktualisiert.
- Wenn die Tiefe des zu zeichnenden Pixels weiter von der Kamera entfernt ist als der Wert im Tiefenpuffer, wird das Pixel nicht gezeichnet.
Auf diese Weise stellt der Tiefenpuffer sicher, dass Pixel in der richtigen Reihenfolge gezeichnet werden, um Überlappungen zu vermeiden und eine korrekte Darstellung des Objektes zu gewährleisten.
main.cpp
int main()
{
...
Settings settings = {
...
.depth = true,
...
};
...
}
Den Code zum Aktivieren haben wir bereits im letzten Kapitel eingebaut. Schlagen Sie gerne nach, wo die oben gesetzte Einstellung “depth” ausgelesen wird und probieren Sie den Unterschied aus. Zusätzlich müssen wir nun vor dem Rendern eines jeden Bildes den Tiefenpuffer leeren, damit er mit frischen Daten befüllt werden kann.
renderer.cpp
void Renderer::loop()
{
...
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
...
}
Hausaufgabe
- Bereiten Sie dieses Kapitel eigenständig bis zum nächsten Termin vor.
- Ziehen Sie weitere Quellen zu Rate um wirklich zu verstehen was hier passiert und wozu das nötig ist.
- Bringen Sie Fragen und Ideen mit in das nächste Plenum.