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.

Inhalt


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

#include <fstream>
#include <sstream>
#include <string>
#include "cgmath.h"

struct FileReader
{
    FileReader(std::string filename);
    bool hasLine();
    std::string getString();
    float getFloat();
    Vector2 getVector2();
    Vector3 getVector3();

    std::ifstream fileStream;
    std::istringstream lineStream;
};

filereader.cpp

#include "filereader.h"

FileReader::FileReader(std::string filename)
{
    fileStream.open(filename);
}

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)};
}

Damit die neue Klasse auch kompiliert werden kann, passen wir das Makefile an wie folgt an.

makefile

bin/engine: obj/main.o obj/graphics.o obj/window.o obj/game.o obj/shader.o obj/mesh.o obj/texture.o obj/filereader.o bin
	g++ -g obj/*.o -o bin/engine $(LIB) $(LNK) $(OPT)

obj/filereader.o: filereader.cpp obj
	g++ -g -c filereader.cpp -o obj/filereader.o $(INC) $(OPT)

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 <string>
#include "vertex.h"

class Mesh
{
    public:
    Mesh(Vertex*, int);
    Mesh(std::string);
    ~Mesh();
    void draw();

    private:
    unsigned int VAO, VBO;
    int vertexCount;
    void init(Vertex*, int);
};

mesh.cpp

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include "mesh.h"
#include "cgmath.h"
#include "filereader.h"
#include <fstream>
#include <sstream>
#include <string>
#include <vector>

Mesh::Mesh(Vertex* vertices, int vc)
{
    init(vertices, vc);
}

Mesh::Mesh(std::string filename)
{
    std::vector<Vector3> v;
    std::vector<Vector2> vt;
    std::vector<Vector3> vn;
    std::vector<std::string> f;

    FileReader* reader = new 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());
            }
        }
    }
    //
    std::vector<Vertex> vertices;
    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.push_back((Vertex){ v[values[0]], vt[values[1]] });
    }
    init(reinterpret_cast<Vertex*>(vertices.data()), vertices.size());
}

...

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 Ihre Grafikeinheit so an, dass beim Starten diese beiden neuen Dateien geladen werden.

graphics.cpp

bool graphicsStart(Settings props)
{
    settings = props;
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return false;
    }
    
    glfwSwapInterval(props.vsync ? 1 : 0);
    if (settings.msaa) glEnable(GL_MULTISAMPLE);
    if (settings.culling) glEnable(GL_CULL_FACE);
    if (settings.depth) glEnable(GL_DEPTH_TEST);

    glClearColor(0.2f,0.2f,0.2f,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");

    return true;
}

Wie sie sehen, wird unser Code auf den höheren Abstraktionsebenen immer übersichtlicher. Spezialisierter Code wandert in die dafür vorgesehenen Klassen und die Grafikeinheit wird mehr und mehr zu einer Schaltzentrale die 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:

  1. 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.
  2. Bevor ein Pixel gezeichnet wird, wird seine Tiefe berechnet. Tiefe bedeutet die Entfernung des Pixels zur Kamera.
  3. Die Tiefe des Pixels wird mit dem Wert im Tiefenpuffer verglichen.
  4. 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.
  5. 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.

settings.h

#pragma once

struct Settings
{
    bool fullscreen = false;
    int width = 1280;
    int height = 720;
    bool msaa = true;
    bool vsync = true;
    bool culling = true;
    bool depth = true;
    double walkSpeed = 1.0;
    double mouseSpeed = 1.0;
};

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.

graphics.cpp

void graphicsLoop()
{
    setViewport();
    
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    shader->activate();
    shader->setMatrix("ProjectionMatrix", projectionMatrix);
    shader->setMatrix("ViewMatrix", viewMatrix);
    shader->setMatrix("WorldMatrix", matrixTranslate(0,0,0));
    shader->setTexture("Diffuse", texture);
    mesh->draw();
}

Prüfungsvorbereitung

  • Können Sie das Prinzip des Tiefenpuffers erklären?
  • Was macht reinterpret_cast<Vertex*>(vertices.data())?
  • Was macht lineStream >> p1 >> p2 >> p3;?
  • Können Sie erklären wozu der FileReader die Methode hasLine() besitzt?