First Person Kamera und Steuerung

In diesem Kapitel erstellen wir alle nötigen Komponenten um eine First Person Kamera zu implementieren die man mit A S D W und der Maus bewegen kann.

Aufgaben


Mathe Bibliothek erweitern

Unsere Mathe Bibliothek benötigt ein paar Updates, damit wir alles umsetzen können was wir für dieses Kapitel benötigen.

Vector3 Datentyp

Zusätzlich zu unserer Matrix müssen wir auch in der Lage sein mit Vektoren zu rechnen. Genauer gesagt mit Vektoren die aus 3 Komponenten (X,Y und Z) bestehen. Daher nennen wir diesen Datentyp Vector3 und legen ihn als neue Union/Struktur in unserer Matheeinheit cgmath.h an:

union Vector3 {
    struct {
        float x, y, z;
    };
    float values[3];
};

Matrix Tranformationen

Im letzten Kapitel haben wir uns darauf beschränkt eine Translationsmatrix zu implementieren. Wir müssen aber natürlich auch Rotationen und Skalierungen durchführen können. Hinweise wie diese Formeln entstanden sind, finden Sie hier.

inline Matrix matrixRotateX(float a)
{
    Matrix m;
    m.m11 = 1;  m.m21 =      0;  m.m31 =       0;  m.m41 = 0;
    m.m12 = 0;  m.m22 = cos(a);  m.m32 = -sin(a);  m.m42 = 0;
    m.m13 = 0;  m.m23 = sin(a);  m.m33 =  cos(a);  m.m43 = 0;
    m.m14 = 0;  m.m24 =      0;  m.m34 =       0;  m.m44 = 1;
    return m;
}

inline Matrix matrixRotateY(float a)
{
    Matrix m;
    m.m11 =  cos(a);  m.m21 = 0;  m.m31 = sin(a);  m.m41 = 0;
    m.m12 =       0;  m.m22 = 1;  m.m32 =      0;  m.m42 = 0;
    m.m13 = -sin(a);  m.m23 = 0;  m.m33 = cos(a);  m.m43 = 0;
    m.m14 =       0;  m.m24 = 0;  m.m34 =      0;  m.m44 = 1;
    return m;
}

inline Matrix matrixRotateZ(float a)
{
    Matrix m;
    m.m11 = cos(a);  m.m21 = -sin(a);  m.m31 = 0;  m.m41 = 0;
    m.m12 = sin(a);  m.m22 =  cos(a);  m.m32 = 0;  m.m42 = 0;
    m.m13 =      0;  m.m23 =       0;  m.m33 = 1;  m.m43 = 0;
    m.m14 =      0;  m.m24 =       0;  m.m34 = 0;  m.m44 = 1;
    return m;
}

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

Matrix Multiplikationen

Multiplikationen von Matrizen können auf der GPU automatisch durchgeführt werden. Um das gleiche auf der CPU zu machen, benötigen wir die dazugehörigen Funktionen.

Um mehrere Matrix-Transformationen zu kombinieren, werden die beiden Matrizen miteinander multipliziert. Dies benötigt man zum Beispiel wenn man ein Objekt oder die Kamera erst um die X-Achse und anschließend um die Y-Achse rotieren möchte. Das gleiche gilt natürlich auch für Skalierungen und Bewegungen.

Die Implementierung der Multiplikation zweier Matrizen finden Sie hier:

inline Matrix matrixMultiply(Matrix a, Matrix b)
{
    Matrix m;

    //row 1
    m.m11 = a.m11 * b.m11 + a.m21 * b.m12 + a.m31 * b.m13 + a.m41 * b.m14;
    m.m21 = a.m11 * b.m21 + a.m21 * b.m22 + a.m31 * b.m23 + a.m41 * b.m24;
    m.m31 = a.m11 * b.m31 + a.m21 * b.m32 + a.m31 * b.m33 + a.m41 * b.m34;
    m.m41 = a.m11 * b.m41 + a.m21 * b.m42 + a.m31 * b.m43 + a.m41 * b.m44;
    
    //row 2
    m.m12 = a.m12 * b.m11 + a.m22 * b.m12 + a.m32 * b.m13 + a.m42 * b.m14;
    m.m22 = a.m12 * b.m21 + a.m22 * b.m22 + a.m32 * b.m23 + a.m42 * b.m24;
    m.m32 = a.m12 * b.m31 + a.m22 * b.m32 + a.m32 * b.m33 + a.m42 * b.m34;
    m.m42 = a.m12 * b.m41 + a.m22 * b.m42 + a.m32 * b.m43 + a.m42 * b.m44;

    //row 3
    m.m13 = a.m13 * b.m11 + a.m23 * b.m12 + a.m33 * b.m13 + a.m43 * b.m14;
    m.m23 = a.m13 * b.m21 + a.m23 * b.m22 + a.m33 * b.m23 + a.m43 * b.m24;
    m.m33 = a.m13 * b.m31 + a.m23 * b.m32 + a.m33 * b.m33 + a.m43 * b.m34;
    m.m43 = a.m13 * b.m41 + a.m23 * b.m42 + a.m33 * b.m43 + a.m43 * b.m44;

    //row 4
    m.m14 = a.m14 * b.m11 + a.m24 * b.m12 + a.m34 * b.m13 + a.m44 * b.m14;
    m.m24 = a.m14 * b.m21 + a.m24 * b.m22 + a.m34 * b.m23 + a.m44 * b.m24;
    m.m34 = a.m14 * b.m31 + a.m24 * b.m32 + a.m34 * b.m33 + a.m44 * b.m34;
    m.m44 = a.m14 * b.m41 + a.m24 * b.m42 + a.m34 * b.m43 + a.m44 * b.m44;

    return m;
}

Zusätzlich dazu mehrere Transformationsmatrizen zu einer zusammenzufassen, wollen wir ebenfalls in der Lage sein, einen Vektor im Raum Anhang einer solchen Matrix zu tranformieren. Dies funktioniert durch die Multiplikation des Vektors mit der Matrix.

Die Implementierung zur Multiplikation einer Matrix mit einem Vektor finden Sie hier:

inline Vector3 matrixVector3Multiply(Matrix m, Vector3 v)
{
    Vector3 result = {
        m.m11 * v.x + m.m21 * v.y + m.m31 * v.z,
        m.m12 * v.x + m.m22 * v.y + m.m32 * v.z,
        m.m13 * v.x + m.m23 * v.y + m.m33 * v.z
    };
    return result;
}

Vektoraddition

inline Vector3 vector3Sum(Vector3 a, Vector3 b)
{
    return (Vector3){a.x + b.x, a.y + b.y, a.z + b.z};
}

Perspektivische Projektions Matrix

Hiermit werden wir die Verwendung von glFrustum durch unsere eigene Implementierung ersetzen. Sobald wir später auf die Verwendung von Shadern umsteigen, ist glFrustum nicht mehr verfügbar und wir müssen alle Matrizen selber berechnen.

inline Matrix matrixPerspective(double fov, double aspect, double zNear, double zFar)
{
    double h = zNear * tan(fov * 0.5);
    double w = h * aspect;

    double left = -w;
    double right = w;
    double bottom = -h;
    double top = h; 

    float m11 = 2 * zNear / (right - left);
    float m22 = 2 * zNear / (top - bottom);
    float m31 = (right + left) / (right - left);
    float m32 = (top + bottom) / (top - bottom);
    float m33 = -(zFar + zNear) / (zFar - zNear);
    float m43 = -2 * zFar * zNear / (zFar - zNear);

    Matrix m;
    m.m11 = m11;  m.m21 =   0;  m.m31 = m31;  m.m41 =   0;
    m.m12 =   0;  m.m22 = m22;  m.m32 = m32;  m.m42 =   0;
    m.m13 =   0;  m.m23 =   0;  m.m33 = m33;  m.m43 = m43;
    m.m14 =   0;  m.m24 =   0;  m.m34 =  -1;  m.m44 =   0;
    return m;
}

Sobald Sie diese Ergänzungen in die cgmath.h übernommen haben, sind wir bereit für die nächsten Schritte.


Projektions- und ViewMatrix festlegen

Bisher nutzen wir die Funktion glFrustum um unsere Projektionsmatrix zu erzeugen. Diese Funktion ist in neueren Versionen von OpenGL deprecated und wir ersetzen sie daher durch unsere eigene Implementierung. Die Signatur ist angelehnt an die Funktion D3DXMatrixPerspectiveFovRH aus der DirectX Welt.

Neue Projektionsmatrix

Finden Sie die Stelle in der graphics.cpp wo aktuell glFrustum aufgerufen wird und ersetzen Sie den Aufruf durch den folgenden:

...
double zNear = 0.1;
double zFar = 100.0;
double fov = 0.785; //45deg
double aspect = (double)viewportWidth / (double)viewportHeight;
Matrix projectionMatrix = matrixPerspective(fov, aspect, zNear, zFar);

glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.values);

Nach dieser Änderung sollten Sie immer noch ein grünes Viereck sehen, genau wie vorher. Falls Ihr Viereck verzerrt oder nicht sichtbar ist, dann stimmt etwas nicht.

ViewMatrix änderbar machen

Während die Projektionsmatrix nur geändert werden muss, wenn sich das Seitenverhältnis des Fensters ändert, muss die ViewMatrix zukünftig immer geändert werden wenn die Kamera bewegt wird. Damit diese Änderung auch von außen durchgeführt werden kann, legen wir eine weitere öffentliche Funktion in der graphics.cpp an:

void graphicsUpdateCamera(Matrix m)
{
    viewMatrix = m;
}

Settings erweitern

Erweitern Sie Ihre settings.h um die folgenden beiden Eintrage. Die Werte sollten selbsterklärend sein.

struct Settings
{
    ...
    double walkSpeed = 1.0;
    double mouseSpeed = 1.0;
};

Neue Komponente: game.cpp

Wir benötigen eine neue Translation Unit game.cpp. Legen Sie die entsprechende Datei an. Fügen Sie einen weiteren Eintrag in in Makefile ein. Sie kennen die einzelnen Schritte bereits aus dem letzten Kapitel, zur Erinnerung hier nochmal der Link.

Die Spieleinheit oder Game Unit dient dazu alles zu kapseln was mit den logischen Inhalt unseres Spiels zu tun hat. Das Game steuert dann die Grafikeinheit um etwas anzuzeigen. Hier werden wir also beispielsweise die Position des Spielers speichern und verändern wenn der Benutzer Tasten drückt oder die Maus bewegt.

Hier ist die neue Einheit im Detail:

Includes

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

Deklarationen aus anderen Dateien

void graphicsSetViewMatrix(Matrix);

Statische Variablen

static bool gameKeyState[256] = {};
static Vector3 cameraPosition = {0.0, 0.0, 2.0};
static double cameraYaw = 0;
static double cameraPitch = 0;
double walkSpeed = 1.0;
double mouseSpeed = 1.0;

Funktionen

Die beiden Funktionen gameSetKey und gameSetMouse werden wir in der window.cpp aufrufen. Sie diesen dazu die Benutzereingaben an die game.cpp zu übergeben.

void gameSetKey(int key, int state)
{
    gameKeyState[key] = state > 0;
}

void gameSetMouse(double dx, double dy)
{
    double yaw = cameraYaw + dx * 0.001 * mouseSpeed;
    double pitch = cameraPitch + dy * 0.001 * mouseSpeed;
    
    if (pitch < -M_PI_2) pitch = -M_PI_2;
    if (pitch > M_PI_2) pitch = M_PI_2;

    cameraYaw = yaw;
    cameraPitch = pitch;
}

Die Funktion gameLoad wird einmalig beim Starten der Engine aufgerufen. Hier werden im Moment lediglich die Settings übernommen.

void gameLoad(Settings settings)
{
    mouseSpeed = settings.mouseSpeed;
    walkSpeed = settings.walkSpeed;
}

Der eigentliche gameLoop sorgt dafür, dass bevor jedes Bild gerendert wird, die Kamera aktualisiert wird. Der Übergebene Parameter “time” enthäkt die verstrichene Zeit seit dem letzen gerenderten Frame und ist notwendig damit die Bewegung der Kamera gleichförmig ist.

void gameLoop(double time)
{
    //A=65; S=83; D=68; W=87
    Vector3 movement = {};
    if (gameKeyState[87]) movement.z -= walkSpeed * time;
    if (gameKeyState[83]) movement.z += walkSpeed * time;
    if (gameKeyState[65]) movement.x -= walkSpeed * time;
    if (gameKeyState[68]) movement.x += walkSpeed * time;

    Matrix tMatrix = matrixTranslate(-cameraPosition.x, -cameraPosition.y, -cameraPosition.z);
    Matrix yMatrix = matrixRotateY(cameraYaw);
    Matrix xMatrix = matrixRotateX(cameraPitch);

    movement = matrixVector3Multiply(matrixRotateY(-cameraYaw), movement);
    cameraPosition = vector3Sum(cameraPosition, movement);

    graphicsSetViewMatrix(matrixMultiply(xMatrix, matrixMultiply(yMatrix, tMatrix)));
}

Und bevor unsere Engine sich beendet können wir in gameUnload aufräumen. Aktuell haben wir noch nichts zum aufräumen, diese Funktion bleibt daher leer.

void gameUnload() {}

Neuerungen zusammenfügen in der Main

Forward declarations

Wir fügen die drei neuen Funktionen aus der game.cpp als Deklarationen in die main hinzu. Außerdem erweitern wir die Signatur von windowLoop um den Pointer auf ein Double. Warum, sehen wir gleich.

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

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

void gameLoad(Settings);
void gameLoop(double);
void gameUnload();

The Loop

Damit die Funktion gameLoop weiß wieviel Zeit seit dem letzten Frame vergangen ist, übergeben wir ihr ein double dtime (Delta-Time). Die Funktion windowLoop erweitern wir entsprechend damit dort diese Variable mit Inhalt befüllt wird. Auf diese Weise kapseln wir diese plattformspezifische Funktionalität.

Der Rest der Implementierung sollte selbsterklärend sein.

if (windowCreate(settings) && graphicsStart(settings))
{
    gameLoad(settings);
    double dtime = 0;
    while (windowLoop(&dtime))
    {
        gameLoop(dtime);
        graphicsLoop();
    }
    gameUnload();
    graphicsTerminate();
    windowDestroy();
}

Window um Benutzereingaben und Timer erweitern

Deklarationen

Die eben definierten Funktionen aus der game.cpp müssen wir nun in der window.cpp deklarieren.

void gameSetKey(int, int);
void gameSetMouse(double, double);

Anschließend können wir den Aufruf für gameSetKey in den onKeyboardInput Handler einsetzen. Dies machen wir ganz am Ende in einem eigene else-Block. So stellen wir sicher, dass die Tasten für die Fenstersteuerung nicht mit den Steuerungen für das Spiel interferieren können.

static void onKeyboardInput(GLFWwindow* window, int key, int scancode, int action, int mods)
{
    ...
    else
    {
        gameSetKey(key, action);
    }
}

Mouse Callback

Für das Auslesen von Mauseingaben haben wir bisher noch keinen Handler definiert. Wir fügen daher die folgende Funktion in die window.cpp ein.

static void onMouseMoved(GLFWwindow* window, double xpos, double ypos)
{
    static double mouseLastX = xpos;
    static double mouseLastY = ypos;

    double deltaX = xpos - mouseLastX;
    double deltaY = ypos - mouseLastY;

    gameSetMouse(deltaX, deltaY);

    mouseLastX = xpos;
    mouseLastY = ypos;
}

Und um diesen Handler zu nutzen müssen wir bei der Erstellung des Fenster die Maus initialisieren und das Callback registrieren.

bool windowCreate(Settings props)
{

    ...

    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
    if (glfwRawMouseMotionSupported())
    {
        glfwSetInputMode(window, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE);
    }
    glfwSetCursorPosCallback(window, onMouseMoved);

    return true;
}

Timer

Als letzten Schritt implementieren wir nun den Timer. Wir erinnern uns: Der GameLoop soll wissen, wieviel Zeit seit dem letzten gerenderten Bild vergangen ist. Um dies zu erreichen implementieren wir den folgenden Code im windowLoop.

bool windowLoop(double* time)
{
    if (glfwWindowShouldClose(window)) return false;

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

    double now = glfwGetTime();
    static double lastTime = now;
    *time = now - lastTime;
    lastTime = now;

    return true;
}

Mit dieser Änderung sollten Sie nun in er Lage sein die Kamera mit den Tasten ASDW und der Maus zu bedienen. Viel Spaß beim Ausprobieren.


Prüfungsvorbereitung

  • Welche Vorteile und welche Nachteile hätte es, diese Berechnungen der Kamera aus dem gameLoop in die Methoden gameSetMouse und gameSetKey auszulagern? Was muss man dabei beachten?
  • Können Sie erklären welche Funktion die Variable aspect in der Projektionsmatrix hat?
  • Können Sie erklären wozu die Werte zNear und zFar in der Projektionsmatrix dienen? Könnte man dort auch 0 und INT_MAX einsetzen?
  • Wir berechnen für die Kamersteuerung einen movement-Vektor. Können Sie erklären was dieser Vektor genau ist, in welche Richtung er zeigt und warum der Rotationsrichtung ein Minus vorangestellt ist?
  • Wissen Sie was wir mit GLFW_RAW_MOUSE_MOTION einschalten?
  • Wenn Sie eine Funktion aufrufen, die einen Parameter vom Typ (double*) erwartet und sie eine lokale Variable vom Typ double übergeben wollen, können Sie erklären was genau das &-Zeichen vor dem Variablen Namen bedeutet?
  • Bei der Implementierung des Timers wird die Variable lastTime verwendet. Können Sie erklären wozu diese dient und welchen Wert Sie beim ersten, zweiten und dritten Aufruf der Funktion windowLoop hat?