Fenster öffnen und OpenGL initialisieren

In diesem Kapitel werden wir den grundlegenden Aufbau unserer Engine festlegen. Dabei ist es wichtig die einzelnen Komponenten voneinander zu isolieren damit die Anwendung später leicht portiert werden kann. Wir werden ein Fenster öffnen, OpenGL initialisieren und ein Quadrat zeichnen.

Denken Sie daran für jedes Kapitel einen neuen Unterordner anzulegen. Erstellen Sie dazu jetzt eine Kopie des Ordners cgm_01 und nennen Sie ihn cgm_02. In den folgenden Kapiteln können Sie jeweils eine Kopie des Standes der Vorwoche anlegen.

Inhalt

GLFW einbinden

GLFW ist eine quelloffene, plattformübergreifende Bibliothek für die Entwicklung von OpenGL, OpenGL ES und Vulkan auf dem Desktop. Sie bietet eine einfache API zum Erstellen von Fenstern, Kontexten und Oberflächen sowie zum Empfangen von Eingaben und Ereignissen.

Damit wir uns voll und ganz auf die Arbeit mit OpenGL konzentrieren können, delegieren wir einige Funktionen, die auf jedem Betriebssystem etwas anders funktionieren an GLFW. So läuft unser Code später problemlos unter Linux, macOS und Windows.

Bibliothek herunterladen

Laden Sie die für Ihr Betriebssystem passende Version von GLFW von der GLFW Website herunter. Sie benötigen entweder die Windows pre-compiled binaries (64 bit) und/oder die macOS pre-compiled binaries. Falls Sie Linux nutzen, folgenden Sie den Anweisungen zum Download aus den Paketquellen.

Unter Ubuntu können Sie die Bibliothek mit folgendem Befehl installieren:

sudo apt install libglfw3 libglfw3-dev

Anschließend entpacken Sie die Bibliothek in einen passenden Unterordner libraries in Ihrem Projektordner.

Makefile anpassen

Damit die Bibliothek auch beim Kompilieren mit integriert wird, müssen wir unser makefile wie folgt anpassen:

UNAME = $(shell uname -s)
ifeq ($(findstring NT-10,$(UNAME)),NT-10)
    # Windows 10
	INC = -I../libraries/glfw-3.3.4.bin.WIN64/include
	LIB = -L../libraries/glfw-3.3.4.bin.WIN64/lib-mingw-w64
	LNK = -l glfw3 -l gdi32 -l opengl32
	OPT =
else ifeq ($(findstring Darwin,$(UNAME)),Darwin)
	# macOS
	INC = -I../libraries/glfw-3.3.4.bin.MACOS/include
	LIB = -L../libraries/glfw-3.3.4.bin.MACOS/lib-universal
	LNK = -l glfw3 -framework Cocoa -framework OpenGL -framework IOKit
	OPT = -arch arm64 -arch x86_64
else ifeq ($(findstring Linux,$(UNAME)),Linux)
	# Linux
	INC =
	LIB =
	LNK = -lglfw -lrt -lm -ldl -lGL
	OPT =
endif

bin/engine: obj/main.o bin
	g++ -g obj/*.o -o bin/engine $(LIB) $(LNK) $(OPT)

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

bin:
	mkdir -p bin

obj:
	mkdir -p obj

run: bin/engine
	@bin/engine

clean:
	rm -rf obj
	rm -rf bin

Wie Sie sehen, haben wir nun oberhalb unserer Regeln einen if/else Block indem abhängig vom Betriebssystem entsprechende Variablen gesetzt werden. Diese Variablen enthalten die Pfade zur GLFW Bibliothek die Sie gerade heruntergeladen haben und zusätzlich die nötigen Kommandos um die passenden Bibliotheken des Betriebssystems einzubinden. Die Variablen werden dann in den Regeln weiter unten eingebunden.

Hinweis für Mac Nutzer: Den Parameter -arch arm64 müssen Sie weglassen falls Sie einen Intel-Mac nutzen. Er sorgt dafür dass das kompilierte Programm nativ auf den neuen M1-Chips läuft.


Projektstruktur festlegen

Zuerst legen Sie ein paar neue Dateien an:

  • cgmath.h
  • settings.h
  • window.cpp
  • graphics.cpp

Für den Moment können diese Dateien leer bleiben. Wir werden Sie in den nachfolgenden Teilaufgaben dieses Kapitels mit Inhalt füllen.

Translation Units in C++

Genau wie unter C spricht man auch unter C++ von sog. Translation Units. Diese “Übersetzungseinheiten” sind die Summer aller Dateien die man einem Compiler gibt um eine Objektdatei zu erstellen. Das beinhaltet jeweils eine .cpp Datei sowie alle eingebundenen (#include) .h und .cpp Dateien. Aktuell hat unser Programm genau eine Translation Unit: main.o

Mit den zwei neuen Dateien window.cpp und graphics.cpp führen wir zwei neue Translation Units ein, die uns dabei helfen unseren Code zu strukturieren und voneinander zu isolieren.

Neue Makefile Targets einfügen

Diese beiden Translation Units müssen auch als neue Regeln in das Makefile hinzugefügt werden. Außerdem müssen wir die beiden daraus resultierenden .o Files als Abhängigkeit für die Hauptregel hinzufügen:

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

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

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

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

Wenn wir zukünftig weitere Translation Units zu unserem Projekt hinzufügen, dann wissen Sie nun, wie Sie diese im Makefile einbinden.

Führen Sie make clean und make aus um zu schauen ob alles ohne Probleme funktioniert.


C++ Mathe Bibliothek erstellen

Wie auch im Bachelor Kurs werden wir uns eine eigene Mathe Bibliothek bauen. Sie dient dazu, dass wir die mathematischen Formeln die wir brauche zumindest einmal selbst implementiert haben.

Anmerkung: Einige der Funktionen die wir hier implementieren, unterstützt die OpenGL API auch selbst. Für andere Funktionen würde es sich anbieten auf eine speziell dafür entworfene und auf Geschwindigkeit optimierte Mathe Bibliothek zurückzugreifen. Trotzdem werden wir alle Funktionen die wir für diese Veranstaltung brauchen, selber implementieren. Unser Ziel ist dabei nicht, besonders effizient zu programmieren sondern besonders lesbar. So sind wir am Ende des Semesters in der Lage, genau zu verstehen was unser Programm tut und können die einzelnen Schritte auch von Hand nachrechnen.

Matritzen

Für dieses Kapitel benötigen wir lediglich eine Translationsmatrix. Wir erstellen daher die nötige Datenstruktur Matrix und eine Funktion zum Erzeugen einer Tranlationsmatrix. Lesen Sie sich in die Thematik zur Translationsmatrix ein. Außerdem sollten Sie das Konzept von Unions in C++ sowie das #pragma once Statement verstehen.

cgmath.h

#pragma once
#include <math.h>

union Matrix {
    struct {
        float m11;
        float m12;
        float m13;
        float m14;
        float m21;
        float m22;
        float m23;
        float m24;
        float m31;
        float m32;
        float m33;
        float m34;
        float m41;
        float m42;
        float m43;
        float m44;
    };
    float v[16];
};

inline Matrix matrixTranslate(float x, float y, float z)
{
    Matrix m;
    m.m11 = 1;  m.m21 = 0;  m.m31 = 0;  m.m41 = x;
    m.m12 = 0;  m.m22 = 1;  m.m32 = 0;  m.m42 = y;
    m.m13 = 0;  m.m23 = 0;  m.m33 = 1;  m.m43 = z;
    m.m14 = 0;  m.m24 = 0;  m.m34 = 0;  m.m44 = 1;
    return m;
}

Unterschiede zum Bachelor Kurs

In diesem Kurs werden wir versuchen alle Funktionen direkt in der Header (.h) Datei zu implementieren. Damit das klappt markieren wir allen Funktionen als inline. Auf diese Weise wird der Compiler keine eigene Unit erzeugen sondern die jeweils benötigten Funktionen werden direkt an den Stellen integriert, wo sie benötigt werden.


Settings

Damit wir unsere Engine mit verschiedenen Parametern aufrufen können, erstellen wir uns eine Struktur um alle Einstellungen zu verwalten. Auch hier verwenden wir wieder #pragma once

Im Laufe des Kurses werden wir diese Struktur um weitere Felder erweitern.

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 = false;
};

Die settings.h werden wir in allen drei .cpp Dateien die wir bisher haben einbinden. Wenn Sie mögen, können Sie den Eintrag jetzt direkt einfügen, dann wird es hinterher nicht vergessen.

#include "settings.h"

Da es sich hierbei lediglich um eine Header Datei handelt, ist es nicht notwendig das Makefile zu verändern.


Fenster erstellen

Der Message Loop

Um Echtzeitgrafik anzuzeigen muss der Computer viele Einzelbilder generieren und in schneller Folge anzeigen. Würde man dafür ein einfaches while(true)verwenden, wäre die Anwendung vollkommen ausgelastet und nicht mehr in der Lage beispielsweise auf Maus oder Tastatur zu reagieren.

Aus diesem Grunde implementiert man einen sogeannnen Message Loop. Unsere Anwendung wird in einer Endlosschleife immer wechselseitig ein Bild rendern und dann beim Betriebssystem nachfragen ob eine Nachricht vorliegt. Eine solche Nachricht könnte z.B. sein “Benutzer möchte Fenster maximieren”. Solange Nachrichten vorliegen, wird unsere Anwendung dem Betriebssystem die nötige Zeit geben um auf diese Anfragen zu reagieren und erst anschließend wird ein weiteres Bild gezeichnet.

Um das möglichst sauber zu implementieren, werden wir alle Funktionen die mit der Erstellung und Verwaltung des Betriebssystem-Fensters zu tun haben im unsere window.cpp kapseln und sicherstellen, dass an keiner anderen Selle der Engine direkt auf das Fenster zugegriffen werden kann. Wie nennen diese Komponente Fenstereinheit oder Window Unit da es sich um eine translation unit im Sinne von C/C++ handelt.

window.cpp

Die folgenden Code-Blöcke kommen in unsere window.cpp.

Zuerst benötigen wir natürlich Zugriff auf die wichtigsten Funktionen von GLFW. Außerdem nutzen wir iostream um Fehlermeldungen auszugeben und wir binden die settings.h ein, damit wir beim Erstellen des Fensters wissen wie groß das Fenster sien soll und ob es im Vollbildmodus gestartet werden soll oder nicht.

Dies bringt uns zu den folgenden Include-Statements:

#define GLFW_INCLUDE_GLEXT
#include <GLFW/glfw3.h>
#include <iostream>
#include "settings.h"

Vorwärtsdeklarationen

Damit der Kompiler in der Lage ist zu prüfen ob wir eine Funktion mit den richtigen Argumenten aufrufen, muss er die Signaturen aller aufgerufenen Funktionen kennen. Das gilt auch für Funktionen die in anderen Übersetzungseinheiten, also anderen .cpp Dateien implementiert wurden. Dafür gibt es zwei Möglichkeiten. Im Bachelorkurs nutzen wir Header Dateien. Hier nutzen wir die klassische Forward Deklaration ohne Header Dateien. Das heißt wir vermerken im Kopf der Datei die Signaturen aller Funktionen die wir aus anderen Dateien aufrufen wollen. Dies bietet sich hier an, das es nur eine einzige Funktion betrifft und das Verwalten einer .h Datei in keinem Verhältnis zum Nutzen stehen würde.

void graphicsSetWindowSize(int, int);

graphicsSetWindowSize, die wir hier deklarieren, nutzen wir dazu unserer Grafikeinheit mitzuteilen wann immer sich die Größe des Viewports geändert hat. Das passiert üblicherweise einmal beim Start der Anwendung und danach bei jeder Änderung der Fenstergröße.

Statische Variablen

Die folgenden 4 Variablen nutzen wir um uns den Fensterzustand zu merken. Sie sind als static markiert damit andere Programmteile keinen Zugriff darauf haben. Wir erinnern uns: static global variables in c/c++ verhalten sich so ähnlich wie private variables in Java.

static GLFWwindow* window;
static int windowWidth, windowHeight;
static bool isFullscreen = false;

Error Callback

Im Fehlerfall wollen wir eine Meldung ausgeben. Dazu implementieren wir die onError Funktion. Sie werden später sehen, wo diese Funktion an die API übergeben wird.

static void onError(int error, const char* description)
{
    std::cout << "Error: " << description << "\n";
}

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.

static void onKeyboardInput(GLFWwindow* window, int key, int scancode, int action, int mods)
{
    if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
    {
        glfwSetWindowShouldClose(window, GL_TRUE);
    }
    else if (key == GLFW_KEY_M && action == GLFW_PRESS)
    {
        if (isFullscreen)
        {
            GLFWmonitor * monitor = glfwGetPrimaryMonitor();
            const GLFWvidmode* mode = glfwGetVideoMode(monitor);
            glfwSetWindowMonitor(window, NULL, (mode->width - windowWidth) / 2, (mode->height - windowHeight) / 2, windowWidth, windowHeight, GLFW_DONT_CARE);
        }
        else
        {
            glfwGetWindowSize(window, &windowWidth, &windowHeight);
            GLFWmonitor * monitor = glfwGetPrimaryMonitor();
            const GLFWvidmode* mode = glfwGetVideoMode(monitor);
            glfwSetWindowMonitor(window, monitor, 0, 0, mode->width, mode->height, mode->refreshRate);
        }
        isFullscreen = !isFullscreen;
    }
}

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 unserer Grafikeinheit mitteilen wenn sich die Fenstergröße ändert. Dazu dient diese Callback Funktion.

static void onFramebufferSizeChanged(GLFWwindow* window, int width, int height)
{
    graphicsSetWindowSize(width, height);
}

Framerate (FPS) anzeigen

Die Ausgabe der Framerate klauen wir uns direkt aus dem Bachelor Kurs. Das einzige was hier als wichtig anzumerken ist, ist die Verwendung des static Keywords innerhalb einer Funktion. Falls Ihnen nicht klar ist, was das bedeutet, lesen Sie unbedingt nach.

static void printFps()
{
    static double previousTime = 0;
    static int frameCount = 0;
    
    double currentTime = glfwGetTime();
    if (currentTime - previousTime >= 1.0)
    {
        std::cout << "FPS: " << frameCount << "\n";

        frameCount = 0;
        previousTime = currentTime;
    }
    frameCount++;
}

Die öffentliche Schnittstelle

Genau wie beim Design von Klassen, wollen wir auch nur bestimmte Funktionen unserer Fenstereinheit nach außen sichtbar machen. Bei allen oben defnierten Funktionen haben wir daher das Schlüsselwort static voran gestellt. Diese Funktionen sind nur innerhalb der Fenstereinheit nutzbar.

Nun definieren wir 3 öffentliche Funktionen die später von unserer Main aufgerufen werden sollen.

Window Creation

windowCreate wird einmalig aufgerufen wenn die Anwendung startet. Hier bauen wir die Einzelteile zusammen. Wir erzeugen ein Fenster, übergeben die nötigen Callbacks und geben entsprechende Fehlermeldungen aus, wenn etwas nicht klappt.

bool windowCreate(Settings props)
{
    windowWidth = props.width;
    windowHeight = props.height;
    isFullscreen = props.fullscreen;

    glfwSetErrorCallback(onError);
    
    if (!glfwInit())
    {
        std::cout << "Error initilizing graphics.";
        return false;
    }

    if (props.msaa) glfwWindowHint(GLFW_SAMPLES, 4);

    if (props.fullscreen)
    {
        GLFWmonitor * monitor = glfwGetPrimaryMonitor();
        const GLFWvidmode* mode = glfwGetVideoMode(monitor);
        window = glfwCreateWindow(mode->width, mode->height, "CGM", monitor, NULL);
    }
    else
    {
        window = glfwCreateWindow(props.width, props.height, "CGM", NULL, NULL);
    }
    if (!window)
    {
        glfwTerminate();
        std::cout << "Error opening window.";
        return false;
    }

    glfwMakeContextCurrent(window);
    glfwSetKeyCallback(window, onKeyboardInput);
    glfwSetFramebufferSizeCallback(window, onFramebufferSizeChanged);
    int w, h;
    glfwGetFramebufferSize(window, &w, &h);
    graphicsSetWindowSize(w, h);
    return true;
}

Window Loop

windowLoop wird mit jedem gezeichneten Bild aufgerufen.

Wie ganz am Anfang erklärt, müssen wir die Betriebssystem die Möglichkeit geben, Events zu verarbeiten. Das tun wir in der Funktion windowLoop. 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 windowLoop()
{
    if (glfwWindowShouldClose(window)) return false;

    glfwSwapBuffers(window);
    glfwPollEvents();
    printFps();

    return true;
}

Windows Destruction

windowDestroy wird einmalig aufgerufen bevor sich unsere Anwendung beendet. Sie gibt uns die Möglichkeit aufzuräumen.

void windowDestroy()
{
    glfwDestroyWindow(window);
    glfwTerminate();
}

Grafik initialisieren

Genau wie die Fenstereinheit definieren wir hier nun unsere Grafikeinheit. Auch hier verzichten wir bewusst auf die Verwendung einer Header Datei oder der Implementierung als Klasse, da diese Vorgehensweise für den Anwendungsfall ungeeignet erscheint.

graphics.cpp

Die folgenden Code-Blöcke kommen in unsere graphics.cpp. Wir beginnen mit den Includes.

#define GLFW_INCLUDE_GLEXT
#include <GLFW/glfw3.h>
#include <iostream>
#include "cgmath.h"
#include "settings.h"

Die Größe des Viewports, also der Fläche auf die wir zeichnen, ist abhängig von der Größe des Fensters. Wir nutzen die folgenden statische Variablen um uns die aktuell Größe zu merken. Die Variable resizeViewport dient dazu Änderungen festzustellen und bei Bedarf die nötigen Anpassungen vorzunehmen.

static int viewportWidth = 0;
static int viewportHeight = 0;
static int resizeViewport = false;
static Matrix viewMatrix = matrixTranslate(0.0f, 0.0f, -2.0f);

Falls sich die Fenstergröße geändert hat, wird die Funktion setViewport die nötigen Anpassungen am Viewport und der Projektionsmatrix vornehmen.

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

Einmalig beim Start rufen wir graphicsStart auf.

bool graphicsStart(Settings props)
{
    settings = props;
    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.29f,0.36f,0.4f,1.0f);

    return true;
}

Mit jedem Frame rufen wir graphicsLoop auf. Hier finden wir den Code der ein Quadrat auf den Bildschirm zeichnet.

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

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glMultMatrixf(viewMatrix.v);

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

Einmalig wenn das Programm sich beendet, rufen wir graphicsTerminate auf.

void graphicsTerminate()
{

}

Die einzige Schnittstelle zur Fenstereinheit bietet die Funktion graphicsSetWindowSize welche wir bereits weiter oben kennengelernt haben. Hier nun die implementierung.

void graphicsSetWindowSize(int width, int height)
{
    std::cout << "Resolution: " << width << "x" << height << "\n";
    viewportWidth = width;
    viewportHeight = height;
    resizeViewport = true;
}

Komponenten zusammenfügen

Damit nun alle diese neuen Komponenten zusammen funktionieren, ändern wir unsere main.cpp wie folgt:

Includes

#include <iostream>
#include "settings.h"

Vorwärtsdeklarationen

bool windowCreate(Settings);
bool windowLoop();
void windowDestroy();

bool graphicsStart(Settings);
void graphicsLoop();
void graphicsTerminate();

Main

int main()
{
    std::cout << "Engine starting...\n";

    Settings settings = Settings();

    if (windowCreate(settings) && graphicsStart(settings))
    {
        while (windowLoop())
        {
            graphicsLoop();
        }
        graphicsTerminate();
        windowDestroy();
    }

    std::cout << "Engine terminated.\n";

    return 0;
}

Prüfungsvorbereitung

  • Haben Sie verstanden was Übersetzungseinheiten sind?
  • Können Sie erklären was static in C++ bedeutet und in welchen 3 Varianten es vorkommt?
  • Können Sie erklären was ein Union und was ein Struct ist und wieso wir hier beide kombinieren?
  • Sie haben gesehen, dass wir glVertex3f() benutzen um ein Quadrat zu zeichnen. Können Sie aus dem Kopf erklären ob wir es hier mit einem rechtshändischen oder linkshändischen Koordinatensystem zu tun haben und welche Achse in welche Richtung zeigt?
  • Können Sie erklären welche Konzepte hinter GL_MULTISAMPLE, GL_CULL_FACE und GL_DEPTH_TEST stecken?
  • Erinnern Sie sich wie man eine Matrix mit einem Vektor multipliziert? Könnten Sie anhand eines Beispiels vorrechnen wie man einen Vektor im Raum mit Hilfe einer Matrix verschiebt?
  • Wenn das Fenster aus dem Vollbildmodus zurück in den Fenstermodus wechselt wird eine seltsame Rechenoperation ausgeführt. Können Sie erklären was da passiert und es bei Bedarf vorrechnen.
  • Können Sie erklären was eine Projektionsmatrix und was eine View-Matrix ist?