Transformationen im Raum und Kamerasteuerung

In diesem Kapitel werden wir Mauseingaben auswerten, um die Kamera steuerbar zu machen. Anschließend steigen wir in die Mathematik der Transformationen im Raum ein, damit wir diese Bewegung auch optisch sichtbar machen.

Screenshot 2024-10-31 at 184512.png

Mathe-Bibliothek

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.

Funktionsumfang

Im Laufe des Semesters werden wir diese Bibliothek schrittweise jeweils um die Funktionen erweitern, die wir benötigen.

In diesem Kapitel beginnen wir mit der Implementierung von Rotations- und Translationsmatrizen. Fügen Sie Folgendes in die Header-Datei ein:

cgmath.h

#pragma once

#include <cmath>
#include <numbers>

inline double deg2rad(double deg)
{
    return deg * std::numbers::pi / 180.0;
}

struct Matrix4
{
    double m11 = 0;  double m21 = 0;  double m31 = 0;  double m41 = 0;
    double m12 = 0;  double m22 = 0;  double m32 = 0;  double m42 = 0;
    double m13 = 0;  double m23 = 0;  double m33 = 0;  double m43 = 0;
    double m14 = 0;  double m24 = 0;  double m34 = 0;  double m44 = 0;

    /**
     * Creates a translation matrix that translates points
     * by the given x, y, and z values.
     *
     * @param x The translation distance along the x-axis.
     * @param y The translation distance along the y-axis.
     * @param z The translation distance along the z-axis.
     * @return The translation matrix.
     */
    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;
    }

    /**
     * Creates a rotation matrix for a rotation around the X-axis.
     * 
     * @param a The angle of rotation in radians.
     * @return The rotation matrix.
     */
    static Matrix4 rotateX(double a)
    {
        Matrix4 m = {
            1,             0,              0, 0,
            0, std::cos(a), -std::sin(a), 0,
            0, std::sin(a),  std::cos(a), 0,
            0,             0,              0, 1
        };
        return m;
    }

    /**
     * Creates a rotation matrix for a rotation around the Y-axis.
     * 
     * @param a The angle of rotation in radians.
     * @return The rotation matrix.
     */
    static Matrix4 rotateY(double a)
    {
        Matrix4 m = {
             std::cos(a), 0, std::sin(a), 0,
                       0, 1,           0, 0,
            -std::sin(a), 0, std::cos(a), 0,
                       0, 0,           0, 1
        };
        return m;
    }

    /**
     * Creates a rotation matrix for a rotation around the Z-axis.
     * 
     * @param a The angle of rotation in radians.
     * @return The rotation matrix.
     */
    static Matrix4 rotateZ(double a)
    {
        Matrix4 m = {
            std::cos(a), -std::sin(a), 0, 0,
            std::sin(a),  std::cos(a), 0, 0,
                      0,            0, 1, 0,
                      0,            0, 0, 1
        };
        return m;
    }

    /**
     * Converts the matrix to column-major order and stores the result in the provided array.
     * 
     * @param values A reference to a 16-element array of floats where the column-major matrix elements will be stored.
     */
    void toColumnMajor(float (&values)[16]) const
    {
        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);
    }
};

Wir definieren hier ein struct Matrix4, das verschiedene Funktionen zum Rotieren und Verschieben enthält. Außerdem wird eine Funktion toColumnMajor() angelegt, die die gespeicherten double-Werte in float-Werte konvertiert und in Column-Major-Reihenfolge anordnet. Dies ist notwendig, da OpenGL eine Matrix in diesem Format erwartet.

Ansonsten sehen wir eine deg2rad-Funktion, die einen Wert vom Gradmaß ins Bogenmaß umwandelt.

Selbststudium

Untersuchen Sie den Code. Auch Ihre Unterlagen aus den passenden Mathematikvorlesungen können sich jetzt wieder als hilfreich herausstellen. Zur Auffrischung hilft das Kapitel Transformations auf LearnOpenGL und der Artikel zur Drehmatrix auf Wikipedia.

Können Sie die fehlende Rotation um die Z-Achse selber implementieren oder müssen Sie die Lösung von GitHub kopieren?

cgmath.h

static Matrix4 rotateZ(double a) { ... }

Kamerasteuerung

Im Rahmen dieser Übung wollen wir eine 3rd-Person-Kamerasteuerung implementieren. Das heißt, die Kamera hat einen unveränderlichen Zielpunkt und ihre Position kann um diesen Zielpunkt rotiert werden. Zusätzlich werden wir mit dem Mausrad oder der Scrollfunktion in der Lage sein, den Abstand zum Ziel zu verändern.

Die Klasse Camera

Beginnen wir mit einem Blick in die einfachste Version der Header-Datei.

camera.h

#pragma once

class Camera
{
  public:
    Camera();
    Camera(double pitch, double yaw, double cameraDistance);
    ~Camera();
    void changePosition(double x, double y);
    void changeDistance(double deltaZ);
    void loadProjectionMatrix(double aspectRatio) const;
    void loadViewMatrix() const;

  private:
    double pitch = 0.0;
    double yaw = 0.0;

    double cameraDistance = 0.0;

    double mouseLastX = 0.0;
    double mouseLastY = 0.0;
    double scrollSpeed = 0.1;
    double mouseSpeed = 0.05;
};

Hier definieren wir zwei Methoden, mit deren Hilfe wir zukünftig die Projektionsmatrix und die View-Matrix laden. Eine gute Erklärung zu diesen Transformationen finden Sie in diesem Artikel auf LearnOpenGL.

camera.cpp

#define GLFW_INCLUDE_GLEXT

#include "camera.h"

#include "cgmath.h"

#include <GLFW/glfw3.h>

Camera::Camera(double pitch, double yaw, double cameraDistance)
    : pitch(pitch), yaw(yaw), cameraDistance(cameraDistance)
{
}

void Camera::changePosition(double x, double y)
{
    double deltaX = x - mouseLastX;
    mouseLastX = x;

    double yaw = this->yaw - deltaX * mouseSpeed;

    while (yaw < 0)
    {
        yaw += 360;
    }
    while (yaw >= 360)
    {
        yaw -= 360;
    }

    this->yaw = yaw;

    double deltaY = y - mouseLastY;
    mouseLastY = y;

    double pitch = this->pitch - deltaY * mouseSpeed;

    if (pitch < -90) pitch = -90;
    if (pitch > 90) pitch = 90;

    this->pitch = pitch;
}

void Camera::changeDistance(double deltaZ)
{
    double distance = cameraDistance - deltaZ * scrollSpeed;

    if (distance < 1.4) distance = 1.4;
    if (distance > 20.0) distance = 20.0;

    cameraDistance = distance;
}

void Camera::loadProjectionMatrix(double aspectRatio) const
{
    double zNear = 0.1;
    double zFar = 100.0;
    double fov = deg2rad(45.0);

    double h = zNear * std::tan(fov * 0.5);
    double w = h * aspectRatio;

    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    glFrustum(-w, w, -h, h, zNear, zFar);
}

void Camera::loadViewMatrix() const
{
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();

    Matrix4 translation = Matrix4::translate(0.0, 0.0, -cameraDistance);
    float translationF[16];
    translation.toColumnMajor(translationF);
    glMultMatrixf(translationF);

    Matrix4 pitchRotation = Matrix4::rotateX(deg2rad(-pitch));
    float pitchRotationF[16];
    pitchRotation.toColumnMajor(pitchRotationF);
    glMultMatrixf(pitchRotationF);

    Matrix4 yawRotation = Matrix4::rotateY(deg2rad(-yaw));
    float yawRotationF[16];
    yawRotation.toColumnMajor(yawRotationF);
    glMultMatrixf(yawRotationF);
}

In der Projektionsmatrix nutzen wir glFrustum, um eine perspektivische Transformation zu realisieren. Zusätzlich kommt ein bisschen Pythagoras ins Spiel, um aus einem FOV (= Field of View) die Höhe der Near Plane zu berechnen. Diese Matrix ändert sich nur, falls sich das Seitenverhältnis des Fensters ändert. Lesen Sie sich in diese Materie ein und stellen Sie sicher, dass Sie alles verstehen.

Die View-Matrix dient nun dazu, die eigentliche Bewegung der Camera umzusetzen. Sie sehen, dass wir hier mehrere Matrizen laden und nacheinander zusammen multiplizieren.

Integration in den bestehenden Code

Um von diesen beiden Matrizen Gebrauch zu machen, entfernen Sie den bisherigen Code, wie die orthogonale Projektion aus Ihrer Scene, und fügen Sie die gerade erstellten Methoden hinzu:

scene.h

class Scene
{
  public:
    ...
    void render(GLFWwindow *window, const Camera &camera) const;
};

scene.cpp

void Scene::render(GLFWwindow *window, const Camera &camera) const
{
    int width, height;
    glfwGetFramebufferSize(window, &width, &height);
    glViewport(0, 0, width, height);

    glClearColor(0.29f, 0.36f, 0.4f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    camera.loadProjectionMatrix(width / static_cast<double>(height));
    camera.loadViewMatrix();

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

renderer.h

class Renderer
{
    ...
  private:
    ...
    Camera activeCamera = Camera(0.0, 0.0, 3.0);
};

renderer.cpp

void Renderer::start()
{
    ...
        foreground.render(window, activeCamera);
    ...
}

Mauseingaben verarbeiten

Wenn bis hierhin alles geklappt hat, können Sie nun mit der Verarbeitung der Mauseingaben weitermachen.

Hierzu erweitern wir Camera um neue Funktionen:

camera.h

class Camera
{
  public:
    ...
    void changePosition(double x, double y);
    void changeDistance(double deltaZ);

  private:
    ...
    double mouseLastX = 0.0;
    double mouseLastY = 0.0;
    double scrollSpeed = 0.1;
    double mouseSpeed = 0.05;
};

camera.cpp

void Camera::changePosition(double x, double y)
{
    double deltaX = x - mouseLastX;
    mouseLastX = x;

    double yaw = this->yaw - deltaX * mouseSpeed;

    while (yaw < 0)
    {
        yaw += 360;
    }
    while (yaw >= 360)
    {
        yaw -= 360;
    }

    this->yaw = yaw;

    double deltaY = y - mouseLastY;
    mouseLastY = y;

    double pitch = this->pitch - deltaY * mouseSpeed;

    if (pitch < -90) pitch = -90;
    if (pitch > 90) pitch = 90;

    this->pitch = pitch;
}

void Camera::changeDistance(double deltaZ)
{
    double distance = cameraDistance - deltaZ * scrollSpeed;

    if (distance < 1.4) distance = 1.4;
    if (distance > 20.0) distance = 20.0;

    cameraDistance = distance;
}

Maussteuerung aktivieren

Im Renderer werden diese Funktionen der Camera nun innerhalb der entsprechenden GLFW-Callbacks aufgerufen:

renderer.cpp

Renderer::Renderer(const std::string &title, uint32_t width, uint32_t height)
{
    ...
    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
    if (glfwRawMouseMotionSupported())
    {
        glfwSetInputMode(window, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE);
    }

    double mousePositionX, mousePositionY;
    glfwGetCursorPos(window, &mousePositionX, &mousePositionY);
    activeCamera.changePosition(mousePositionX, mousePositionY);
    glfwSetCursorPosCallback(window, [](GLFWwindow *window, double x, double y)
    {
        Renderer *self = static_cast<Renderer *>(glfwGetWindowUserPointer(window));
        self->activeCamera.changePosition(x, y);
    });
    
    glfwSetScrollCallback(window, [](GLFWwindow *window, double xOffset, double yOffset)
    {
        Renderer *self = static_cast<Renderer *>(glfwGetWindowUserPointer(window));
        self->activeCamera.changeDistance(yOffset);
    });
}

Testen Sie, ob Sie die Kamera mit der Maus steuern können. Passen Sie bei Bedarf die Werte von scrollSpeed und mouseSpeed an Ihre Umgebung/Hardware an.


Lernziele

Nach diesem Kapitel sollten Sie die folgenden Fragen beantworten können:

  • Was ist eine Transformationsmatrix und warum nutzt man 4×4-Matrizen statt 3×3-Matrizen für Transformationen im 3D-Raum?
  • Was ist der Unterschied zwischen Gradmaß und Bogenmaß und warum erwarten trigonometrische Funktionen in C++ das Bogenmaß?
  • Wie ist eine Rotationsmatrix aufgebaut und warum gibt es für jede Achse (X, Y, Z) eine eigene?
  • Was ist der Unterschied zwischen einer perspektivischen und einer orthografischen Projektion und welche Rolle spielen Near Plane, Far Plane und Field of View?
  • Was beschreiben Pitch und Yaw und warum reichen diese beiden Winkel aus, um eine 3rd-Person-Kamera zu steuern?
  • Warum wird die View-Matrix aus mehreren einzelnen Matrizen zusammenmultipliziert und in welcher Reihenfolge müssen Translation und Rotation dabei angewendet werden?
  • Was bedeutet Column-Major-Order und warum muss die Matrix vor der Übergabe an OpenGL in dieses Format konvertiert werden?
  • Warum kann man eine C+±Lambda-Funktion als GLFW-Callback verwenden und welche Rolle spielt dabei glfwGetWindowUserPointer()?

Bearbeitungshinweise

Arbeiten Sie dieses Kapitel eigenständig vor dem zugehörigen Veranstaltungstermin durch. Wie die Vorbereitung abläuft und worauf es dabei ankommt, erfahren Sie im Konzept der Veranstaltung.