In diesem Kapitel werden wir die Komponenten entwickeln, die notwendig sind, um eine First-Person-Kamera zu implementieren. Diese Kamera kann durch die Tasten A, S, D, W sowie die Mausbewegungen gesteuert werden.
Mathe Bibliothek erweitern
Unsere Mathe Bibliothek benötigt ein paar Erweiterungen, um die für dieses Kapitel notwendigen Berechnungen durchführen zu können.
Zuerst fügen wir zwei Hilfsfunktionen ein um zwischen Degrees und Radians umzurechnen.
Achten Sie auf das zusätzlich benötigte #define _USE_MATH_DEFINES. Diese Zeile muss vor dem #include <cmath> stehen damit die Konstante M_PI auch unter Windows verfügbar ist. Damit Ihr Projekt plattformübergreifend lauffähig ist sollten Sie diese Zeile natürlich in jedem Falle hinzufügen.
#pragma once
#define _USE_MATH_DEFINES
#include <cmath>
inline double deg2rad(double deg)
{
return deg * M_PI / 180.0;
}
inline double rad2deg(double rad)
{
return rad * 180.0 / M_PI;
}
Vector Datentypen erstellen
Zusätzlich zu unserer Matrix müssen wir auch in der Lage sein mit Vektoren zu rechnen. Wir erstellen Datentypen für Vektoren mit drei (x, y, z) und vier (x, y, z, w) Komponenten.
cgmath.h
struct Vector3
{
double x;
double y;
double z;
Vector3(double x, double y, double z)
: x(x), y(y), z(z)
{
}
Vector3 operator+(const Vector3 &b)
{
Vector3 result = {
x + b.x,
y + b.y,
z + b.z
};
return result;
}
};
struct Vector4
{
double x;
double y;
double z;
double w;
Vector4(double x, double y, double z, double w)
: x(x), y(y), z(z), w(w)
{
}
Vector4(const Vector3 &v, double w)
: x(v.x), y(v.y), z(v.z), w(w)
{
}
Vector3 xyz()
{
return {x, y, z};
}
Vector4 operator+(const Vector4 &b)
{
Vector4 result = {
x + b.x,
y + b.y,
z + b.z,
w + b.w
};
return result;
}
};
Die operator+()-Methoden erlauben es uns, den +-Operator für Vektoren zu überladen. Das bedeutet, dass wir einfach v = v1 + v2; schreiben können, um zwei Vektoren zu addieren.
Matrix erweitern
In vorhergehenden Kapiteln haben wir eine Translationsmatrix implementiert. Nun werden wir Rotationen und Skalierungen hinzufügen. Diese Formeln sind hier näher erläutert.
Rotationsmatrizen
Die Rotationsmatrizen ermöglichen es, um die X-, Y- und Z-Achse zu rotieren.
cgmath.h
struct Matrix4
{
...
static Matrix4 rotateX(double a)
{
Matrix4 m = {
1, 0, 0, 0,
0, cos(a), -sin(a), 0,
0, sin(a), cos(a), 0,
0, 0, 0, 1
};
return m;
}
static Matrix4 rotateY(double a)
{
Matrix4 m = {
cos(a), 0, sin(a), 0,
0, 1, 0, 0,
-sin(a), 0, cos(a), 0,
0, 0, 0, 1
};
return m;
}
static Matrix4 rotateZ(double a)
{
Matrix4 m = {
cos(a), -sin(a), 0, 0,
sin(a), cos(a), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
};
return m;
}
...
};
Skalierungsmatrix
Die Skalierungsmatrix ermöglicht es, eine uniforme Skalierung durchzuführen.
cgmath.h
struct Matrix4
{
...
static Matrix4 matrixScale(double a)
{
Matrix4 m = {
a, 0, 0, 0,
0, a, 0, 0,
0, 0, a, 0,
0, 0, 0, 1
};
return m;
}
...
};
Perspektivische Projektionsmatrix
Diese Matrix ersetzt die Funktion glFrustum, die in neueren OpenGL-Versionen nicht mehr unterstützt wird. Die Signatur orientiert sich an D3DXMatrixPerspectiveFovRH.
cgmath.h
struct Matrix4
{
...
static Matrix4 perspective(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;
double m11 = 2 * zNear / (right - left);
double m22 = 2 * zNear / (top - bottom);
double m31 = (right + left) / (right - left);
double m32 = (top + bottom) / (top - bottom);
double m33 = -(zFar + zNear) / (zFar - zNear);
double m43 = -2 * zFar * zNear / (zFar - zNear);
Matrix4 m = {
m11, 0, m31, 0,
0, m22, m32, 0,
0, 0, m33, m43,
0, 0, -1, 0
};
return m;
}
...
};
Identitätsmatrix
Die Identitätsmatrix ist die neutrale Matrix und lässt die zu transformierenden Objekte unverändert.
cgmath.h
struct Matrix4
{
...
static Matrix4 identity()
{
static Matrix4 m = {
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
};
return m;
}
...
};
Matrixmultiplikation
Multiplikationen von Matrizen können auf der GPU automatisch durchgeführt werden. Um dasselbe 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:
struct Matrix4
{
...
Matrix4 operator*(const Matrix4 &b)
{
Matrix4 result = {
m11 * b.m11 + m21 * b.m12 + m31 * b.m13 + m41 * b.m14,
m11 * b.m21 + m21 * b.m22 + m31 * b.m23 + m41 * b.m24,
m11 * b.m31 + m21 * b.m32 + m31 * b.m33 + m41 * b.m34,
m11 * b.m41 + m21 * b.m42 + m31 * b.m43 + m41 * b.m44,
m12 * b.m11 + m22 * b.m12 + m32 * b.m13 + m42 * b.m14,
m12 * b.m21 + m22 * b.m22 + m32 * b.m23 + m42 * b.m24,
m12 * b.m31 + m22 * b.m32 + m32 * b.m33 + m42 * b.m34,
m12 * b.m41 + m22 * b.m42 + m32 * b.m43 + m42 * b.m44,
m13 * b.m11 + m23 * b.m12 + m33 * b.m13 + m43 * b.m14,
m13 * b.m21 + m23 * b.m22 + m33 * b.m23 + m43 * b.m24,
m13 * b.m31 + m23 * b.m32 + m33 * b.m33 + m43 * b.m34,
m13 * b.m41 + m23 * b.m42 + m33 * b.m43 + m43 * b.m44,
m14 * b.m11 + m24 * b.m12 + m34 * b.m13 + m44 * b.m14,
m14 * b.m21 + m24 * b.m22 + m34 * b.m23 + m44 * b.m24,
m14 * b.m31 + m24 * b.m32 + m34 * b.m33 + m44 * b.m34,
m14 * b.m41 + m24 * b.m42 + m34 * b.m43 + m44 * b.m44
};
return result;
}
...
};
Vektortransformation
Um einen Vektor im dreidimensionalen Raum zu transformieren, wird dieser mit der entsprechenden Transformationsmatrix multipliziert.
Die Implementierung zur Multiplikation einer Matrix mit einem Vektor finden Sie hier:
struct Matrix4
{
...
Vector4 operator*(const Vector4 &v) const
{
Vector4 result = {
m11 * v.x + m21 * v.y + m31 * v.z + m41 * v.w,
m12 * v.x + m22 * v.y + m32 * v.z + m42 * v.w,
m13 * v.x + m23 * v.y + m33 * v.z + m43 * v.w,
m14 * v.x + m24 * v.y + m34 + v.z + m44 * v.w
};
return result;
}
...
};
Auch hier verwenden hier die sog. Operator-Überladung um uns das umständliche Aufrufen von Funktionen zu ersparen. So können wir später zwei Matrizen genauso multiplizieren wie Zahlen: m = m1 * m2;
Übernehmen Sie diese Änderungen in die cgmath.h.
Projektions- und ViewMatrix festlegen
Bisher haben wir die Funktion glFrustum verwendet, um unsere Projektionsmatrix zu erzeugen. Hier ersetzen wir diese nun durch den Aufruf unserer eigenen Implementierung: Matrix4::perspective()
Neue Projektionsmatrix
Aktualisieren Sie die Funktion setViewport() im Renderer. Dort werden wir nun die eben definierte perspektivische Projektionsmatrix nutzen.
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);
Matrix4 projectionMatrix = Matrix4::perspective(fov, aspect, zNear, zFar);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.toFloat());
}
}
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
Die Viewmatrix wird immer verändert, wenn sich die Kamera bewegt. Anders als die Projektionsmatrix, die nur bei Änderungen des Seitenverhältnisses angepasst wird. Eine neue Methode für den Renderer erlaubt externe Änderungen der Viewmatrix.
renderer.h
class Renderer
{
public:
...
void setViewMatrix(const Matrix4 &viewMatrix);
...
};
renderer.cpp
void Renderer::setViewMatrix(const Matrix4 &viewMatrix)
{
this->viewMatrix = viewMatrix;
}
Settings erweitern
Erweitern Sie Ihre Settings um die folgenden beiden Eintrage. Diese Werte steuern die Bewegung der Kamera.
settings.h
struct Settings
{
...
double walkSpeed;
double mouseSpeed;
};
Auch die Initialisierung in der main()-Funktion sollte diese neuen Felder beinhalten.
main.cpp
int main()
{
...
Settings settings = {
...
.walkSpeed = 1.0,
.mouseSpeed = 1.0
};
...
}
Window um Callbacks erweitern
Das Window-Objekt wird für zusätzliche Eingabe-Callbacks erweitert. Das passiert genau wie im letzten Kapitel bei der Änderung der Fenstergröße.
window.h
class Window
{
public:
...
void onKeyboardInput(std::function<void(int key, bool state)> callback);
void onMouseMoved(std::function<void(double deltaX, double deltaY)> callback);
private:
...
std::vector<std::function<void(int key, bool state)>> keyboardInputCallbacks = {};
std::vector<std::function<void(double deltaX, double deltaY)>> mouseMovedCallbacks = {};
...
};
window.cpp
void Window::onKeyboardInput(std::function<void(int key, bool state)> callback)
{
keyboardInputCallbacks.push_back(callback);
}
void Window::onMouseMoved(std::function<void(double deltaX, double deltaY)> callback)
{
mouseMovedCallbacks.push_back(callback);
}
Diese Callbacks ermöglichen es, Mauseingaben und Tasteneingaben an andere Programmteile weiterzugeben ohne dass diese Programmteile Zugriff auf GLFW benötigen.
Mauseingaben auswerten und Callbacks aufrufen
Damit die neuen Callbacks auch Verwendung finden müssen wir sie Aufrufen. Wir erweitern dazu das bereits vorhandenen Callback für die Auswertung von Tastatureingaben und fügen ein neues Callback für die Auswertung von Mauseingaben hinzu.
In zwei privaten Feldern merken wir uns die letzte bekannte Mausposition da wir nicht die absoluten Koordinaten benötigen sondern jeweils das Delta zur letzten Position.
window.h
class Window
{
private:
...
double mouseLastX = 0.0;
double mouseLastY = 0.0;
}
In GLFW Callbacks fangen wir Änderungen an Maus und Tastatur ab und rufen die registrierten Callbacks auf. Außerdem aktivieren wir Raw mouse motion Unterstützung falls vorhanden.
window.cpp
Window::Window(const std::string &title, const Settings &settings)
: width(settings.width), height(settings.height), fullscreen(settings.fullscreen)
{
...
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
if (glfwRawMouseMotionSupported())
{
glfwSetInputMode(window, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE);
}
glfwSetKeyCallback(window, [](GLFWwindow *window, int key, int scancode, int action, int mods)
{
...
else if (action == GLFW_PRESS || action == GLFW_RELEASE)
{
for (auto &callback : self->keyboardInputCallbacks)
{
callback(key, action == GLFW_PRESS);
}
}
});
glfwGetCursorPos(window, &mouseLastX, &mouseLastY);
glfwSetCursorPosCallback(window, [](GLFWwindow *window, double xPosition, double yPosition)
{
Window *self = static_cast<Window *>(glfwGetWindowUserPointer(window));
double deltaX = xPosition - self->mouseLastX;
double deltaY = yPosition - self->mouseLastY;
self->mouseLastX = xPosition;
self->mouseLastY = yPosition;
for (auto &callback : self->mouseMovedCallbacks)
{
callback(deltaX, deltaY);
}
});
}
Window Loop um Delta Time erweitern
GLFW gibt uns Zugriff auf einen Zeitstempel. Das ist ein double Wert mit der Zeit seit dem Start der Anwendung. Damit unsere Physikberechnungen oder auch die Kamerabewegung unabhängig von der Framerate des Rechners gleichmäßig läuft, nutzen wir diesen Wert um zu berechnen wie lange der letzte Frame gedauert hat.
window.h
class Window
{
public:
...
bool loop(double &time);
};
window.cpp
bool Window::loop(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;
}
Neue Klasse Simulation
Die Klasse Simulation dient dazu alles zu kapseln was mit dem logischen Inhalt unseres Spiels zu tun hat. Hier werden wir also beispielsweise die Position des Spielers speichern und verändern wenn der Benutzer Tasten drückt oder die Maus bewegt. Die Simulation beliefert den Renderer mit den nötigen Daten damit dieser den logischen Inhalt optisch darstellen kann.
simulation.h
#pragma once
#include "camera.h"
#include "cgmath.h"
#include "settings.h"
#include "window.h"
#include <iostream>
#include <vector>
class Simulation
{
public:
Simulation(const Settings &settings, Window &window);
~Simulation();
void loop(const double time);
const Matrix4 &getCameraViewMatrix();
private:
Camera camera = {};
double cameraYaw = 0.0;
double cameraPitch = 0.0;
std::array<bool, 384> keyStates = {};
};
Konstruktor
Auch die Simulation hat Zugriff auf das Window. Im Konstruktor registriert sich die Simulation für Tastatur und Maus Events. Das Prinzip der Callbacks sollte bekannt sein.
simulation.cpp
#include "simulation.h"
static double walkSpeed;
static double mouseSpeed;
Simulation::Simulation(const Settings &settings, Window &window)
{
walkSpeed = settings.walkSpeed;
mouseSpeed = settings.mouseSpeed;
camera.setPosition(Vector3(0.0, 0.0, 2.0));
window.onKeyboardInput([this](int key, bool state)
{
if (key >= 0 && key < keyStates.size())
{
keyStates[key] = state > 0;
}
});
window.onMouseMoved([this](double deltaX, double deltaY)
{
double yaw = cameraYaw + deltaX * 0.001 * mouseSpeed;
double pitch = cameraPitch + deltaY * 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;
});
}
Im Gegensatz zu den GLFW Callbacks müssen wir hier nicht mit irgendwelchen Pointern interagieren. Unser Lambda Ausdruck bekommt [this] übergeben und hat damit Zugriff auf alle Werte der aktuellen Simulation Instanz.
Was hier Inhaltlich passiert sollte aus dem Code zu erschließen sein.
Simulations Loop
Der eigentliche gameLoop sorgt dafür, dass bevor jedes Bild gerendert wird, die Kamera aktualisiert wird. Der Übergebene Parameter time enthält die verstrichene Zeit seit dem letzen gerenderten Frame und ist notwendig damit die Bewegung der Kamera gleichförmig ist.
simulation.cpp
void Simulation::loop(const double time)
{
// A=65; S=83; D=68; W=87
Vector4 movement(0.0, 0.0, 0.0, 1.0);
if (keyStates[87]) movement.z -= walkSpeed * time;
if (keyStates[83]) movement.z += walkSpeed * time;
if (keyStates[65]) movement.x -= walkSpeed * time;
if (keyStates[68]) movement.x += walkSpeed * time;
movement = Matrix4::rotateY(-cameraYaw) * movement;
camera.setPosition(camera.getPosition() + movement.xyz());
camera.setRotation(Vector3(-cameraPitch, -cameraYaw, 0.0));
}
Zugriff auf Kamera View Matrix
simulation.cpp
const Matrix4 &Simulation::getCameraViewMatrix()
{
return camera.getViewMatrix();
}
Ressourcen freigeben
Und bevor unsere Engine sich beendet können wir im Destruktor aufräumen. Aktuell haben wir noch nichts zum aufräumen, diese Funktion bleibt daher vorerst leer.
simulation.cpp
Simulation::~Simulation()
{
}
Neue Klasse Camera
camera.h
#pragma once
#include "cgmath.h"
class Camera
{
public:
Camera();
~Camera();
void setPosition(Vector3 position);
Vector3 getPosition();
void setRotation(Vector3 rotation);
Vector3 getRotation();
const Matrix4 &getViewMatrix();
private:
Vector3 position = Vector3(0.0, 0.0, 0.0);
Vector3 rotation = Vector3(0.0, 0.0, 0.0); // in rads
Matrix4 viewMatrix = Matrix4::identity();
bool changed = false;
};
camera.cpp
#include "camera.h"
Camera::Camera()
{
}
Camera::~Camera()
{
}
void Camera::setPosition(Vector3 position)
{
this->position = position;
changed = true;
}
Vector3 Camera::getPosition()
{
return this->position;
}
void Camera::setRotation(Vector3 rotation)
{
this->rotation = rotation;
changed = true;
}
Vector3 Camera::getRotation()
{
return this->rotation;
}
const Matrix4 &Camera::getViewMatrix()
{
if (changed)
{
Matrix4 rotation = Matrix4::rotateX(-this->rotation.x) * Matrix4::rotateY(-this->rotation.y) * Matrix4::rotateZ(-this->rotation.z);
Matrix4 translation = Matrix4::translate(-this->position.x, -this->position.y, -this->position.z);
viewMatrix = rotation * translation;
changed = false;
}
return viewMatrix;
}
Main Update
Zuletzt aktualisieren wir die main() mit diesen neuen Ergänzungen. Es wird eine Instanz der Simulation erzeugt
main.cpp
#include "camera.h"
#include "simulation.h"
...
int main()
{
try
{
...
Renderer renderer(settings, window);
Simulation simulation(settings, window);
double deltaTime = 0.0;
while (window->loop(deltaTime))
{
simulation.loop(deltaTime);
renderer.setViewMatrix(simulation.getCameraViewMatrix());
renderer.loop();
}
}
catch ...
}
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 3 Fragen zu diesem Kapitel auf und reichen Sie diese zu Beginn der nächsten Veranstaltung ein.