Bisher haben wir OpenGLs Fixed Function Pipeline genutzt. Also die gleiche Technologie die wir auch im Bachelor Kurs “Grundlagen der Computergrafik” verwendet haben. Das hat uns entscheidend dabei unterstützt, schnell zu Ergebnissen zu kommen.

In diesem Kapitel werden wir nun unseren Code auf die moderne OpenGL API portieren. Dazu benötigen wir Shader.

Core Profil laden

Das OpenGL Core Profile – im Gegensatz zum sog. Compatibility Profile – bezeichnet eine spezifische Konfiguration der OpenGL API, bei der veraltete (deprecated) Funktionen entfernt werden. Beispielsweise ist eine Verwendung der Fixed Function Pipeline nicht mehr möglich und alle Zeichenoperationen müssen mit Shadern realisiert werden.

Das Core Profile bietet Entwicklern eine sauberere und effizientere Schnittstelle. Gleichzeitig kann die Performance der fertigen Anwendung gesteigert werden.

Damit die Anwendung auch auf dem Mac lauffähig ist, muss zusätzlich Forward Compatibility aktiviert werden. Programme die im Forward-Compatible-Modus geschrieben sind, bleiben auch auf zukünftigen Versionen von OpenGL lauffähig, auch wenn in der zukünftigen Version einzelnen Funktionen entfernt oder geändert wurden.

Suchen Sie sich eine geeignete Stelle im Window-Konstruktor und rufen Sie die folgenden 4 Befehle auf, bevor das Fenster erzeugt wird:

window.cpp

glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

Mit diesen Parametern legen wir fest, dass wir OpenGL Version 3.3 Core Profile laden wollen. Für ein besseres Verständnis empfehle ich diesen Artikel auf LearnOpenGL.

GLAD einbinden

Die OpenGL Bibliothek ist modular aufgebaut. Das Betriebssystem bringt dabei nur einen Teil der Funktionen mit. Der Rest wird vom Grafikkartentreiber bereitgestellt. Je nach Grafikkarte kann es dabei vorkommen, dass auf manchen Systemen andere Funktionen bereitstehen als woanders. Insbesondere AMD, NVIDIA und Intel unterscheiden sich hier.

Damit wir Zugriff auf die neuen Funktionen des Core Profiles bekommen, benötigen wir einen Loader der die Funktionen des aktuellen Systems verfügbar macht. Wir nutzen dazu GLAD. Sie finden eine sehr gute Beschreibung dieses Themas auf LearnOpenGL.

Include Statements

GLAD Implementierung laden. Hierfür passen wir unsere Renderer-Klasse wie folgt an.

Fügen Sie #include <glad.c> in renderer.cpp ein.

An den Anfang des Renderer-Konstruktors kommt folgender Code. Auch dies sollte selbsterklärend sein. Falls die Initialisierung fehlschlägt wird eine Exception geworfen.

renderer.cpp

Renderer::Renderer(const Settings &settings, Window &window) { if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { throw std::runtime_error("Failed to initialize GLAD"); } ... }

Hinweis

Es ist normal, dass nach diesem Schritt einige Codezeilen als Fehler angezeigt werden und das Programm nicht mehr kompilierbar ist. Das liegt daran, dass wir nun alles für die neue OpenGL Version vorbereitet haben, aber noch Code aus der alten Version aufrufen. Das stellen wir in den folgenden Abschnitten um. Zuerst wollen wir aber die Shader erstellen.

Vertex- und Fragmentshader

Ab sofort müssen wir die Grafikkarte selber programmieren. Dafür benötigen wir mindestens einen Vertex Shader und einen Fragment Shader. Lesen sie dazu auch den passenden Artikel über Shader auf LearnOpenGL.

Vertexshader

Der Vertexshader dient dazu, jeden Vertex den wir an die Grafikkarte senden entsprechend unserer Vorgaben zu transformieren.

Legen Sie für den Vertex Shader eine neue Datei Computergrafik/cgm_04/shaders/vertex_shader.glsl mit folgendem Inhalt an:

vertex_shader.glsl

#version 330 core layout (location = 0) in vec3 VertPos; uniform mat4 WorldMatrix; uniform mat4 ViewMatrix; uniform mat4 ProjectionMatrix; void main() { mat4 WvpMatrix = ProjectionMatrix * ViewMatrix * WorldMatrix; gl_Position = WvpMatrix * vec4(VertPos, 1.0); }

Die Funktion main im Vertexshader ist für die Transformation zuständig. Sie wird für jeden Vertex einmal aufgerufen. Diese Aufrufe finden auf der GPU parallelisiert statt.

Als Eingabewerte greift die main() auf die Vertex Position (VertPos) zu und verwendet die 3 Matrizen World-, View- und ProjectionMatrix.

Fragmentshader

Der Fragmentshader dient dazu das Ergebnis des Vertexshaders weiterzuverarbeiten und für jeden sichtbaren Pixel die Farbe zu bestimmen.

Legen Sie für den Fragment Shader eine neue Datei Computergrafik/cgm_04/shaders/fragment_shader.glsl mit folgendem Inhalt an:

fragment_shader.glsl

#version 330 core out vec4 FragColor; void main() { FragColor = vec4(0.5f, 0.73f, 0.14f, 1.0f); }

Die Funktion main() des Fragmentshaders wird einmal für jedes sichtbare Pixel aufgerufen. In dieser einfachen Form des Fragmentshaders beachten wir keinerlei Eingabewerte sondern geben einfach immer eine festgelegte Farbe zurück.

Die Struktur Vertex

Um uns die Arbeit mit Vertexkoordinaten zu vereinfachen, legen wir uns ein neues struct an. Aktuell enthält ein Vertex lediglich eine Komponente für seine Position in Form eines float-Arrays. Zukünftig werden wir hier weitere Felder für Texturkoordinaten oder Farben hinzufügen.

vertex.h

#pragma once #include "cgmath.h" struct Vertex { Vertex(const Vector3 &position); ~Vertex(); float position[3] = {}; };

vertex.cpp

#include "vertex.h" Vertex::Vertex(const Vector3 &position) : position{static_cast<float>(position.x), static_cast<float>(position.y), static_cast<float>(position.z)} { } Vertex::~Vertex() { }

Hierbei wird dem Konstruktor ein Vector3 übergeben. Die double-Werte, die der Vector3 enthält, werden bei der Zuweisung zur Membervariablen von Vertex auf einen float-Wert gecastet.

Die Klasse Shader

Alle Funktionen um mit dem Shader in unserem Code zu interagieren, wollen wir in einer neuen Klasse Shader kapseln.

shader.h

#pragma once #include "cgmath.h" #include <string> class Shader { public: Shader(); Shader(const std::string &vertexShaderFile, const std::string &fragmentShaderFile); ~Shader(); void activate(); void setMatrix4(const std::string &uniformName, const Matrix4 &matrix4); void setVector3(const std::string &uniformName, const Vector3 &vector3); private: uint32_t shaderProgramID = 0; void compile(const std::string &filename, uint32_t *shader, uint32_t type); std::string readFile(const std::string &filename); };

shader.cpp

#include "shader.h" #include <fstream> #include <glad/glad.h> Shader::Shader() { } Shader::Shader(const std::string &vertexShaderFile, const std::string &fragmentShaderFile) { int success = 0; char infoLog[512]; uint32_t vertexShaderID, fragmentShaderID; compile(vertexShaderFile.c_str(), &vertexShaderID, GL_VERTEX_SHADER); compile(fragmentShaderFile.c_str(), &fragmentShaderID, GL_FRAGMENT_SHADER); shaderProgramID = glCreateProgram(); glAttachShader(shaderProgramID, vertexShaderID); glAttachShader(shaderProgramID, fragmentShaderID); glLinkProgram(shaderProgramID); glGetProgramiv(shaderProgramID, GL_LINK_STATUS, &success); if (!success) { glGetProgramInfoLog(shaderProgramID, 512, nullptr, infoLog); throw std::runtime_error("Failed to link shader program " + vertexShaderFile + " + " + fragmentShaderFile + ":\n" + infoLog); } glDeleteShader(vertexShaderID); glDeleteShader(fragmentShaderID); } Shader::~Shader() { glDeleteProgram(shaderProgramID); } void Shader::activate() { glUseProgram(shaderProgramID); } void Shader::setMatrix4(const std::string &uniformName, const Matrix4 &matrix4) { GLint uniformLocation = glGetUniformLocation(shaderProgramID, uniformName.c_str()); float values[16] = { static_cast<float>(matrix4.m11), static_cast<float>(matrix4.m12), static_cast<float>(matrix4.m13), static_cast<float>(matrix4.m14), static_cast<float>(matrix4.m21), static_cast<float>(matrix4.m22), static_cast<float>(matrix4.m23), static_cast<float>(matrix4.m24), static_cast<float>(matrix4.m31), static_cast<float>(matrix4.m32), static_cast<float>(matrix4.m33), static_cast<float>(matrix4.m34), static_cast<float>(matrix4.m41), static_cast<float>(matrix4.m42), static_cast<float>(matrix4.m43), static_cast<float>(matrix4.m44) }; glUniformMatrix4fv(uniformLocation, 1, GL_FALSE, values); } void Shader::setVector3(const std::string &uniformName, const Vector3 &vector3) { GLint uniformLocation = glGetUniformLocation(shaderProgramID, uniformName.c_str()); float values[3] = {static_cast<float>(vector3.x), static_cast<float>(vector3.y), static_cast<float>(vector3.z)}; glUniform3fv(uniformLocation, 1, values); } void Shader::compile(const std::string &filename, uint32_t *shader, uint32_t type) { std::string shaderCode = readFile(filename); char *shaderCodeChar = shaderCode.data(); int success = 0; char infoLog[512]; *shader = glCreateShader(type); glShaderSource(*shader, 1, &shaderCodeChar, nullptr); glCompileShader(*shader); glGetShaderiv(*shader, GL_COMPILE_STATUS, &success); if (!success) { glGetShaderInfoLog(*shader, 512, nullptr, infoLog); throw std::runtime_error("Failed to compile shader " + filename + ":\n" + infoLog); } } std::string Shader::readFile(const std::string &filename) { std::ifstream file(filename); if (!file.is_open()) { throw std::runtime_error("Failed to open file " + filename); } std::string content; std::string line; while (getline(file, line)) { content += (line + "\n"); } file.close(); return content; }

Lesen Sie sich in den Code ein und versuchen Sie Zeile für Zeile nachzuvollziehen, was hier geschieht. Betrachten Sie insbesondere die Funktion Shader::compile() zum Kompilieren des Shaderquellcodes und die Funktion Shader::setMatrix4() mit deren Hilfe beispielweise die WorldMatrix mit Inhalt befüllt werden kann.

Die Klasse Mesh

Eine weitere Klasse benötigen wir, um die kleinste logische Einheit unserer 3D-Modelle zu kapseln: Das Mesh. Ein Mesh ist eine Liste von Vertices die eine zusammenhängende dreidimensionale Struktur bilden. In Zukunft wollen wir die Mesh-Klasse auch dafür erweitern Meshes aus Dateien von der Platte zu laden. Aktuell beschränken wir uns darauf eine Liste von manuell definierten Vertices an den Konstruktor zu übergeben.

mesh.h

#pragma once #include "vertex.h" #include <vector> class Mesh { public: Mesh(); Mesh(const std::vector<Vertex> &vertices); ~Mesh(); void draw(); private: std::vector<Vertex> vertices = {}; uint32_t vaoID = 0; uint32_t vboID = 0; };

mesh.cpp

#include "mesh.h" #include <glad/glad.h> Mesh::Mesh() { } Mesh::Mesh(const std::vector<Vertex> &vertices) : vertices(vertices) { 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 *)0); glEnableVertexAttribArray(0); } Mesh::~Mesh() { glDeleteVertexArrays(1, &vaoID); glDeleteBuffers(1, &vboID); } void Mesh::draw() { glBindVertexArray(vaoID); glDrawArrays(GL_TRIANGLES, 0, vertices.size()); }

Sie sehen, dass wir hier im Gegensatz zu unserer alten Implementierung verschiedene Buffer und Array Objekte erzeugen. VAO steht für Vertex Array Object und VBO steht für Vertex Buffer Object. Auf LearnOpenGL werden diese Zusammehänge im Kapitel Hello Triangle beschrieben. Wichtig ist hier zu verstehen, dass wir Datenstrukturen erzeugen und befüllen, die die GPU direkt lesen kann. Mit Hilfe verschiedener Flags (z.B.: GL_STATIC_DRAW) teilen wir dem System mit, wie oft oder auf welche Weise wir planen mit diesen Daten später zu interagieren. OpenGL sorgt dann dafür, die Daten möglichst effizient abzulegen und bei Bedarf zwischen System RAM und GPU RAM hin und her zu schieben.

Umstellen auf die neue API

Nun da wir alle Einzelteile zusammen haben, können wir die Fehler in der renderer.cpp beheben und damit die Umstellung auf die neue API vollenden.

Include Statements

renderer.h

... #include "mesh.h" #include "shader.h" #include "window.h" ...

renderer.cpp

class Renderer { ... private: ... Matrix4 projectionMatrix = Matrix4::identity(); Mesh *mesh = nullptr; Shader *shader = nullptr; };

SetViewport()

Hier haben wir bisher die Projektionsmatrix direkt an die Grafikkarte gesendet. Das fällt nun weg. Stattdessen speichern wir sie lediglich in die Membervariable, die wir gerade angelegt haben. Hier müssen wir also einiges löschen damit unser Code anschließend so aussieht:

renderer.cpp

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 aspect = static_cast<double>(viewportWidth) / static_cast<double>(viewportHeight); projectionMatrix = Matrix4::perspective(fov, aspect, zNear, zFar); } }

Renderer-Konstruktor

Beim Erzeugen des Renderers wollen wir nun die Vertices in ein Mesh laden und die Shader kompilieren. Die fertige Funktion sieht wie folgt aus:

renderer.cpp

Renderer::Renderer(const Settings &settings, Window &window) { if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { throw std::runtime_error("Failed to initialize GLAD"); } 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; }); std::vector<Vertex> vertices = { Vertex({-0.6, 0.0, 0.0}), Vertex({0.0, -0.6, 0.0}), Vertex({0.6, 0.0, 0.0}), Vertex({-0.6, 0.0, 0.0}), Vertex({0.6, 0.0, 0.0}), Vertex({0.0, 0.6, 0.0})}; mesh = new Mesh(vertices); shader = new Shader("shaders/vertex_shader.glsl", "shaders/fragment_shader.glsl"); }

Renderer-Loop

Die Renderer::loop() können wir ebenfalls stark vereinfachen. Hier fliegt alles raus was zur alten API gehört und stattdessen rufen wir nun die passenden Methoden unserer neuen Klassen auf um unser Mesh zu rendern:

renderer.cpp

void Renderer::loop() { setViewport(); glClear(GL_COLOR_BUFFER_BIT); shader->activate(); shader->setMatrix4("ProjectionMatrix", projectionMatrix); shader->setMatrix4("ViewMatrix", viewMatrix); shader->setMatrix4("WorldMatrix", Matrix4::identity()); mesh->draw(); }

Renderer-Destruktor

Anschließend müssen wie unsere neu erstellten Objekte auch wieder freigeben.

renderer.cpp

Renderer::~Renderer() { delete shader; delete mesh; }

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.
  • Schreiben Sie 2 Fragen zu diesem Kapitel auf und reichen Sie diese zu Beginn der nächsten Veranstaltung ein.