In diesem Kapitel definieren wir den grundlegenden Aufbau unserer Engine. Es ist wichtig, die einzelnen Komponenten zu isolieren, damit die Anwendung später leicht portiert werden kann. Wir werden ein Fenster öffnen, OpenGL initialisieren und ein Quadrat zeichnen.
Aktueller Hinweis: Den Fehler den wir vorhin gesucht haben, den finden Sie in renderer.h Zeile 44. Die ViewMatrix wurde nicht initialisiert und daher war kein Rechteck zu sehen.
C++ Mathe Bibliothek erstellen
Wir werden uns im Laufe des Kurses eine eigene Mathe-Bibliothek bauen. Sie ermöglicht, dass wir die mathematischen Formeln, die wir benötigen, zumindest einmal selbst implementiert haben.
Anmerkung: Einige Funktionen, die wir implementieren, unterstützt die OpenGL-API von Haus aus. Andere könnte man durch speziell entwickelte, auf Geschwindigkeit optimierte Mathe-Bibliotheken ersetzen. Wir implementieren alle notwendigen Funktionen jedoch selbst, um Klarheit über die Abläufe zu gewinnen. Unser Fokus liegt auf Lesbarkeit, nicht auf Effizienz, um am Ende des Semesters den Programmcode umfassend zu verstehen und die Einzelprozesse nachvollziehen zu können.
#pragma once
#include <cmath>
struct Matrix4
{
double m11; double m21; double m31; double m41;
double m12; double m22; double m32; double m42;
double m13; double m23; double m33; double m43;
double m14; double m24; double m34; double m44;
float values[16];
static Matrix4 translate(double x, double y, double z)
{
Matrix4 m = {
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
};
return m;
}
float *toFloat()
{
// Reorder for OpenGL
values[0] = static_cast<float>(m11);
values[1] = static_cast<float>(m12);
values[2] = static_cast<float>(m13);
values[3] = static_cast<float>(m14);
values[4] = static_cast<float>(m21);
values[5] = static_cast<float>(m22);
values[6] = static_cast<float>(m23);
values[7] = static_cast<float>(m24);
values[8] = static_cast<float>(m31);
values[9] = static_cast<float>(m32);
values[10] = static_cast<float>(m33);
values[11] = static_cast<float>(m34);
values[12] = static_cast<float>(m41);
values[13] = static_cast<float>(m42);
values[14] = static_cast<float>(m43);
values[15] = static_cast<float>(m44);
return values;
}
};
Für dieses Kapitel benötigen wir eine Translationsmatrix. Wir erstellen also die passende Datenstruktur Matrix4 und eine Methode zum Erzeugen einer Translationsmatrix. Informieren Sie sich über das Konzept der Translationsmatrix und das #pragma once Statement, das wir regelmäßig verwenden werden.
Falls Sie den Bachelor-Kurs Grundlagen der Computergrafik nicht besucht haben, empfehle ich das Kapitel Transformations auf LearnOpenGL zu wiederholen. Es ist wichtig, die Grundlagen der linearen Algebra griffbereit zu haben. Ziel ist nicht, alles aus dem Kopf zu wissen, sondern schnell zu finden und abrufen zu können.
Einstellungen (Settings)
Um unsere Engine mit verschiedenen Parametern zu starten, erstellen wir eine Struktur, die alle Einstellungen verwaltet. Auch hier nutzen wir #pragma once. Im Verlauf des Kurses erweitern wir diese Struktur um weitere Felder.
#pragma once
struct Settings
{
bool fullscreen;
int width;
int height;
bool msaa;
bool vsync;
bool culling;
bool depth;
};
Fenster erstellen
Message Loop
Um Echtzeitgrafik darzustellen, muss der Computer viele Einzelbilder generieren und in schneller Folge anzeigen. Ein einfaches while(true) würde die Anwendung völlig auslasten, wodurch sie nicht mehr auf Maus oder Tastatur reagieren könnte.
Daher implementieren wir eine sogenannte "Message Loop". Die Anwendung wechselt in einer Endlosschleife zwischen Bildrendering und Abfragen von Nachrichten beim Betriebssystem. Diese Nachrichten könnten z. B. "Benutzer möchte Fenster maximieren" signalisieren. Solange Nachrichten vorliegen, ermöglicht unsere Anwendung dem Betriebssystem darauf zu reagieren, bevor sie ein weiteres Bild zeichnet.
Für eine saubere Implementierung kapseln wir alle Funktionen zur Erstellung und Verwaltung des Betriebssystem-Fensters in window.cpp und verhindern, dass an anderen Stellen der Engine direkt auf das GLFW-Fenster zugegriffen werden kann.
Window Klasse - Header
Die Header-Datei definiert die Window-Klasse und ihre öffentliche und private Schnittstelle. Der Konstruktor und Destruktor werden verwendet, um das Fenster zu initialisieren und wieder zu schließen.
Die Methode Window::loop() enthält die oben beschriebene Message Loop. Über Window::onSizeChanged() können sich andere Programmteile bei unserem Window registrieren, um über Änderungen der Fenstergröße informiert zu werden.
window.h
#pragma once
#define GLFW_INCLUDE_GLEXT
#include "GLFW/glfw3.h"
#include "settings.h"
#include <iostream>
class Window
{
public:
Window(const std::string &title, const Settings &settings);
~Window();
bool loop();
void printFps();
void onSizeChanged(std::function<void(int width, int height)> callback);
private:
GLFWwindow *window = nullptr;
int width = 0;
int height = 0;
bool fullscreen = false;
double previousTime = 0.0;
int frameCount = 0;
std::vector<std::function<void(int width, int height)>> sizeChangedCallbacks = {};
};
Window Klasse - Implementierung
Die Implementierung der Window Klasse ist recht umfangreich. Schauen wir uns zunächst den Konstruktor an, mit dessen Hilfe ein Fenster geöffnet wird. An drei Stellen im folgenden Code finden sie den Platzhalter `/ ... /`. Diese stellen werden wir uns weiter unten im Detail ansehen und mit Inhalt füllen.
Den betreffenden Funktionen glfwSetErrorCallback, glfwSetFramebufferSizeCallback und glfwSetKeyCallback übergeben wir hier jeweils einen Lambda-Audruck, also eine anonyme Funktion. Es handelt sich dabei um eine moderne C++ Syntax die viele von Ihnen vermutlich aus der Welt von JavaScript kennen. Das Prinzip ist einfach: Wir ermöglichen der GLFW Bibliothek einen von uns definierten Code auszuführen. Am Beispiel von glfwSetErrorCallback können wir definieren was passieren soll wenn GLFW einen Fehler feststellt. Das Konzept nennt man Callback Funktionen.
window.cpp
#include "window.h"
Window::Window(const std::string &title, const Settings &settings)
: width(settings.width), height(settings.height), fullscreen(settings.fullscreen)
{
glfwSetErrorCallback([](int error, const char *description)
{
/* ... */
});
if (!glfwInit())
{
throw std::runtime_error("Failed to initialize GLFW");
}
if (settings.msaa) glfwWindowHint(GLFW_SAMPLES, 4);
if (settings.fullscreen)
{
GLFWmonitor *monitor = glfwGetPrimaryMonitor();
const GLFWvidmode *mode = glfwGetVideoMode(monitor);
window = glfwCreateWindow(mode->width, mode->height, title.c_str(), monitor, nullptr);
}
else
{
window = glfwCreateWindow(settings.width, settings.height, title.c_str(), nullptr, nullptr);
}
if (!window)
{
glfwTerminate();
throw std::runtime_error("Failed to open window");
}
glfwMakeContextCurrent(window);
glfwSetWindowUserPointer(window, this);
glfwGetFramebufferSize(window, &width, &height);
glfwSetFramebufferSizeCallback(window, [](GLFWwindow *window, int width, int height)
{
/* ... */
});
glfwSetKeyCallback(window, [](GLFWwindow *window, int key, int scancode, int action, int mods)
{
/* ... */
});
}
Versuchen Sie den Code nachzuvollziehen. Mit dem Wissen, dass hier ein Fenster erzeugt und geöffnet wird, sollte das meiste selbsterklärend sein.
Weil GLFW eine C-Bibliothek ist, fehlt ihr jegliches Verständnis von Klassen und Objekten. In einem reinen C++ Projekt könnten wir direkt in den 3 Callback Funktionen auf unsere Window Instanz zugreifen. Unter GLFW benötigen wir einen zusätzlichen Schritt. mit glfwSetWindowUserPointer(window, this); speichern wir eine Referenz auf this also auf die aktuelle Instanz unseres Window im GLFWWindow. Diesen Pointer können wir dann innerhalb der Callback nutzen um auf die aktuelle Window Instanz zuzugreifen.
Schauen wir uns nun die einzelnen Callbacks an:
Error Callback
Im Fehlerfall wollen wir eine Meldung ausgeben. Dazu übergeben wir die folgende Lambda Funktion an GLFW. Im Fehlerfall wird nun also die GLFW Fehlermeldung auf der Konsole ausgegeben.
glfwSetErrorCallback([](int error, const char *description)
{
std::cerr << "GLFW Error: " << description << std::endl;
});
Tastatureingaben
Ein weiteres Callback benötigen wir für die Behandlung von Tastatureingaben. Hier reagieren wir auf die Tasten ESC und M.
- Escape schließt das Fenster.
- M wechselt zwischen Vollbildmodus und Fenstermodus hin und her.
glfwSetKeyCallback(window, [](GLFWwindow *window, int key, int scancode, int action, int mods)
{
Window *self = static_cast<Window *>(glfwGetWindowUserPointer(window));
if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
{
glfwSetWindowShouldClose(window, GL_TRUE);
}
else if (key == GLFW_KEY_M && action == GLFW_PRESS)
{
if (self->fullscreen)
{
GLFWmonitor *monitor = glfwGetPrimaryMonitor();
const GLFWvidmode *mode = glfwGetVideoMode(monitor);
glfwSetWindowMonitor(window, nullptr, (mode->width - self->width) / 2, (mode->height - self->height) / 2, self->width, self->height, GLFW_DONT_CARE);
}
else
{
glfwGetWindowSize(window, &self->width, &self->height);
GLFWmonitor *monitor = glfwGetPrimaryMonitor();
const GLFWvidmode *mode = glfwGetVideoMode(monitor);
glfwSetWindowMonitor(window, monitor, 0, 0, mode->width, mode->height, mode->refreshRate);
}
self->fullscreen = !self->fullscreen;
}
});
Weitere Infos was hier geschieht finden Sie im GLFW Window Guide.
Callback bei Änderung der Framebuffer Größe
Wie oben schon erwähnt, müssen wir unserem Renderer mitteilen wenn sich die Fenstergröße ändert. Dazu dient diese Callback Funktion.
glfwSetFramebufferSizeCallback(window, [](GLFWwindow *window, int width, int height)
{
Window *self = static_cast<Window *>(glfwGetWindowUserPointer(window));
self->width = width;
self->height = height;
for (auto &callback : self->sizeChangedCallbacks)
{
callback(width, height);
}
});
Damit sich nun andere Programmteile für diese Benachrichtigung per Callback registrieren können, erstellen wir zuletzt noch die Methode onSizeChanged.
void Window::onSizeChanged(std::function<void(int width, int height)> callback)
{
callback(width, height);
sizeChangedCallbacks.push_back(callback);
}
Ressourcen Freigeben
Extrem wichtig in C++ ist es sich immer bewusst zu sein, welche Ressourcen unsere Anwendung aktuell belegt und diese beim Beenden des Programms wieder freizugeben. In der Window Klasse tun wir dies im Destruktor:
Window::~Window()
{
glfwDestroyWindow(window);
glfwTerminate();
}
Window-Loop
Diese Funktion wird pro Frame aufgerufen.
Wie ganz am Anfang erklärt, müssen wir dem Betriebssystem die Möglichkeit geben, Events zu verarbeiten. Das tun wir in der Funktion Window::loop(). Ebenfalls wird hier geprüft ob das Fenster geschlossen werden soll. Lesen sie sich ebenfalls ein in das Thema von Swap Chains damit Sie verstehen was glfwSwapBuffers() tut.
bool Window::loop()
{
if (glfwWindowShouldClose(window)) return false;
glfwSwapBuffers(window);
glfwPollEvents();
printFps();
return true;
}
Anzeigen der Framerate
Hier geben wir einmal pro Sekunde die Framerate auf der Konsole aus. Dies hilft uns später die Performance unserer Anwendung einschätzen zu können.
void Window::printFps()
{
double currentTime = glfwGetTime();
if (currentTime - previousTime >= 1.0)
{
uint32_t fps = frameCount;
std::cout << "FPS: " << fps << std::endl;
frameCount = 0;
previousTime = currentTime;
}
frameCount++;
}
Renderer erstellen
Der Renderer übernimmt das eigentliche Zeichnen der Frames auf unser übergebenes Window. Das Zeichnen findet innerhalb der Funktion Renderer::loop() statt.
renderer.h
#pragma once
#define GLFW_INCLUDE_GLEXT
#include "GLFW/glfw3.h"
#include "cgmath.h"
#include "settings.h"
#include "window.h"
#include <iostream>
#include <memory>
class Renderer
{
public:
Renderer(const Settings &settings, Window &window);
~Renderer();
void loop();
private:
void setViewport();
int viewportWidth = 0;
int viewportHeight = 0;
bool resizeViewport = false;
Matrix4 viewMatrix = Matrix4::translate(0.0, 0.0, -2.0);
};
renderer.cpp
Konstruktor und Destruktor
Renderer::Renderer(const Settings &settings, Window &window)
{
glfwSwapInterval(settings.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.29f, 0.36f, 0.4f, 1.0f);
window.onSizeChanged([this](int width, int height)
{
std::cout << "Resolution: " << width << "x" << height << std::endl;
viewportWidth = width;
viewportHeight = height;
resizeViewport = true;
});
}
Renderer::~Renderer()
{
}
Falls sich die Fenstergröße geändert hat, wird die Funktion setViewport die nötigen Anpassungen am Viewport und der Projektionsmatrix vornehmen.
void Renderer::setViewport()
{
if (resizeViewport)
{
resizeViewport = false;
glViewport(0, 0, viewportWidth, viewportHeight);
double zNear = 0.1;
double zFar = 100.0;
double fov = 0.785; // 45deg
double h = zNear * tan(fov * 0.5);
double w = h * (double)viewportWidth / (double)viewportHeight;
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glFrustum(-w, w, -h, h, zNear, zFar);
}
}
Mit jedem Frame rufen wir Renderer::loop() auf. Hier finden wir den Code der ein Quadrat auf den Bildschirm zeichnet.
void Renderer::loop()
{
setViewport();
glClear(GL_COLOR_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glMultMatrixf(viewMatrix.toFloat());
glBegin(GL_QUADS);
glColor3f(0.5f, 0.73f, 0.14f);
glVertex3f(-0.6f, 0.0f, 0.0f);
glVertex3f(0.0f, -0.6f, 0.0f);
glVertex3f(0.6f, 0.0f, 0.0f);
glVertex3f(0.0f, 0.6f, 0.0f);
glEnd();
}
main.cpp
Hier werden die beiden neuen Komponenten zusammengefügt. Es wird eine Instanz der Settings erzeugt. Anschließend wird das Window dem Renderer als Renderkontext übergeben und die Renderer::loop() wird ausgeführt.
#include "renderer.h"
#include "window.h"
#include <iostream>
#include <memory>
int main()
{
try
{
std::cout << "Engine starting..." << std::endl;
Settings settings = {
.fullscreen = false,
.width = 1280,
.height = 720,
.msaa = true,
.vsync = true,
.culling = true,
.depth = false
};
Window window("Computergrafik", settings);
Renderer renderer(settings, window);
while (window.loop())
{
renderer.loop();
}
}
catch (const std::exception &e)
{
std::cerr << e.what() << std::endl;
return EXIT_FAILURE;
}
std::cout << "Engine terminated." << std::endl;
return EXIT_SUCCESS;
}
Versuchen Sie Schritt für Schritt nachzuvollziehen was hier passiert.
Hausaufgabe
- Denken Sie daran, das nächste Kapitel für die nächste Veranstaltung vorzubereiten.
- Denken Sie auch an Ihre 3 Fragen für die nächste Veranstaltung.