Geometrische Figuren errechnen

In diesem Kapitel beschäftigen wir uns damit, die Vertices von verschiedenen 3D-Modellen rechnerisch zu ermitteln.

Screenshot 2024-11-07 at 180640.png

Mathebibliothek erweitern

Zuerst fügen wir unserem Header ein paar zusätzliche Datentypen hinzu.

cgmath.h

struct Vector3
{
    double x;
    double y;
    double z;
};

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(Vector3 v, double w)
        : x(v.x), y(v.y), z(v.z), w(w)
    {
    }

    Vector3 xyz()
    {
        return {x, y, z};
    }
};

struct Color
{
    double r;
    double g;
    double b;
    double a;
};

struct Vertex
{
    Vertex(const Vector3 &position, const Color &color)
        : position(static_cast<float>(position.x), static_cast<float>(position.y), static_cast<float>(position.z)),
          color(static_cast<float>(color.r), static_cast<float>(color.g), static_cast<float>(color.b))
    {
    }

    float position[3];
    float color[3];
};

Vector3 ist eine Struktur mit 3 double-Komponenten (x, y, z) zur Darstellung von Vektoren im Raum.

Vector4 ist eine Struktur mit 4 double-Komponenten (x, y, z, w) zur Darstellung von Vektoren in homogenen Koordinaten im Raum.

Color ist eine Struktur mit 4 double-Komponenten (red, green, blue, alpha) zur Darstellung von Farben.

Vertex ist eine Struktur, bestehend aus einer Position und einer Farbe als float-Komponenten. Hiermit werden wir zukünftig die Eckpunkte unserer 3D-Modelle definieren.

Multiplikation Matrix ∙ Vector

cgmath.h

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;
    }
    ...
}

Mit dieser Funktion sind wir in der Lage eine Matrix mit einem Vektor zu multiplizieren. Das Ergebnis ist wiederum ein Vektor. Dies ist super hilfreich, weil wir damit genau das tun können, was die GPU intern tut. Wir können auf diese Weise Vektoren rotieren oder verschieben.

Lesen Sie hierzu auch den Artikel auf Wikipedia. Insbesondere werden Sie dort auch Hinweise zu verschiedenen Implementierungen und deren Laufzeiten finden. Wie gesagt: Uns geht es hier in erster Linie um die Lesbarkeit des Codes. Wir werden aber auf dieses Thema in einem zukünftigen Kapitel nochmal zu sprechen kommen.

Multiplikation Matrix ∙ Matrix

cgmath.h

struct Matrix4
{    
    ...
    Matrix4 operator*(const Matrix4 &b) const
    {
        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;
    }
    ...
}

Und diese Funktion ermöglicht uns, eine klassische Matrixmultiplikation durchzuführen. Wollen wir zum Beispiel einen Vektor rotieren und transformieren, dann lassen sich diese beiden Operationen zu einer zusammenfassen, indem man zuerst die beiden Matrizen miteinander multipliziert.


Backface Culling

An dieser Stelle möchte ich Sie bitten, sich mit dem Thema Face Culling zu beschäftigen. Es handelt sich dabei um eine einfache Technik, um in komplexeren Szenen die Anzahl der Renderaufrufe zu reduzieren. Konkret nutzen wir diese Funktion, um die Rückseiten aller Oberflächen beim Rendern zu überspringen. Wenn wir beispielsweise einen Würfel zeichnen, dann ist es nicht notwendig, die Innenseite des Würfels zu zeichnen, da wir diese nicht sehen können (es sei denn, die Kamera stände mittendrin).

Um Backface Culling zu aktivieren, gehen Sie in Ihren Renderer und fügen dort etwa an der gleichen Stelle, wo Sie Multisampling aktivieren, folgende Anweisung hinzu:

renderer.cpp

Renderer::Renderer(const std::string &title, uint32_t width, uint32_t height)
{
    ...

    glEnable(GL_MULTISAMPLE);
    glfwSwapInterval(1);
    glEnable(GL_CULL_FACE);

    ...
}

Wenn Sie nun Ihre Anwendung starten, sollte Ihr Rechteck nur noch eine Vorderseite haben. Drehen Sie die Kamera hinter das Rechteck, dann sehen Sie nichts.

Diese Einstellung benötigen wir für alle nachfolgenden Kapitel.

Zusätzliche Infos

Wir definieren alle unsere Dreiecke und Rechtecke im Gegenuhrzeigersinn bzw. Counter Clockwise (CCW). Falls bei Ihnen die Vorderseite des Rechtecks unsichtbar sein sollte, dann prüfen Sie, ob bei Ihrem Rechteck tatsächlich CCW als Reihenfolge verwendet wurde. Der obige Artikel sollte dies ausreichend erklären.


Mesh rendern

Als Mesh definieren wir eine neue Klasse. Von dieser Klasse erben dann später zwei weitere Klassen Cube und Sphere.

mesh.h

#pragma once

#include "cgmath.h"

#include <vector>

class Mesh
{
  public:
    virtual void render() const;
    void setPosition(const Vector3 &position);
    void setRotation(const Vector3 &rotation);

  protected:
    Matrix4 position = Matrix4::translate(0, 0, 0);
    Matrix4 rotation = Matrix4::rotateX(0.0);
    std::vector<Vertex> vertices = {};
};

mesh.cpp

#define GLFW_INCLUDE_GLEXT

#include "mesh.h"

#include <GLFW/glfw3.h>

void Mesh::render() const
{
    Matrix4 worldMatrix = position * rotation;

    glPushMatrix();
    float worldMatrixF[16];
    worldMatrix.toColumnMajor(worldMatrixF);
    glMultMatrixf(worldMatrixF);
    glBegin(GL_QUADS);
    for (auto vertex : vertices)
    {
        glColor3fv((float *)&vertex.color);
        glVertex3fv((float *)&vertex.position);
    }
    glEnd();
    glPopMatrix();
}

void Mesh::setPosition(const Vector3 &position)
{
    this->position = Matrix4::translate(position.x, position.y, position.z);
}

void Mesh::setRotation(const Vector3 &rotation)
{
    this->rotation = Matrix4::rotateX(rotation.x) * Matrix4::rotateY(rotation.y) * Matrix4::rotateZ(rotation.z);
}

Die Klasse Mesh ist die Basisklasse für verschiedene 3D-Modelle, die wir weiter unten definieren. Jedes Mesh hat zwei Matrizen zur Beschreibung seiner Position und Rotation im Raum. Außerdem werden hier alle Vertices (Eckpunkte) des Meshes aufbewahrt.

Die render()-Funktion ermöglicht es, das Mesh an die Grafikkarte zu senden und zu zeichnen.

Meshes der Szene hinzufügen

Wir legen einen std::vector in der Szene an, welchen wir über die Funktion addMesh() befüllen können. Wir verwenden hier einen std::shared_ptr<>. Weitere Infos zu Shared Pointern finden Sie hier.

Für den Aufbau eines Grafikprogramms ist der Shared pointer eine sehr praktische Lösung, weil unsere Meshes damit später an mehreren Stellen in unserer Anwendung aufbewahrt werden können und dabei eigenständig freigegeben werden, sobald sie nicht mehr benötigt werden.

scene.h

#pragma once

#include "mesh.h"

#include <memory>
#include <vector>

class Scene
{
  public:
    ...
    void addMesh(const std::shared_ptr<Mesh> &mesh);
    void render();

  private:
    std::vector<std::shared_ptr<Mesh>> meshes;
};

Durch die veränderte Signatur von render() können wir auch die Includes für GLFW und die Kamera entfernen.

scene.cpp

void Scene::addMesh(const std::shared_ptr<Mesh> &mesh)
{
    meshes.push_back(mesh);
}

void Scene::render()
{
    for (std::shared_ptr<Mesh> &mesh : meshes)
    {
        mesh->render();
    }
}

Der Aufruf der Funktion muss nun auch im Renderer angepasst werden.

renderer.h

...
class Renderer
{
  public:
    ...
  private:
    ...
    void renderScene(Scene &scene) const;
};

renderer.cpp

void Renderer::renderScene(Scene &scene) const
{
    glClear(GL_COLOR_BUFFER_BIT);

    activeCamera.loadViewMatrix();

    scene.render();
}

Würfel erstellen

Die Klasse Cube erbt von der Klasse Mesh. Da die Basisklasse Mesh bereits eine Implementierung von render() bereitstellt, müssen wir diese Funktion in Cube nicht überschreiben. Der Konstruktor berechnet lediglich die Vertices.

cube.h

#pragma once

#include "mesh.h"

class Cube : public Mesh
{
  public:
    Cube(const Color &color);
};

cube.cpp

#include "cube.h"

Cube::Cube(const Color &color)
{
    Vector3 p1(-1, -1, 1);
    Vector3 p2( 1, -1, 1);
    Vector3 p3( 1,  1, 1);
    Vector3 p4(-1,  1, 1);

    vertices.emplace_back(p1, color);
    vertices.emplace_back(p2, color);
    vertices.emplace_back(p3, color);
    vertices.emplace_back(p4, color);
    for (int i = 1; i < 6; i++)
    {
        Matrix4 rotationMatrix;
        if (i <= 3) rotationMatrix = Matrix4::rotateY(deg2rad(90.0 * i));
        if (i == 4) rotationMatrix = Matrix4::rotateX(deg2rad(90.0));
        if (i == 5) rotationMatrix = Matrix4::rotateX(deg2rad(-90.0));

        Vector4 result = rotationMatrix * Vector4(p1, 1.0);
        vertices.emplace_back(result.xyz(), color);

        result = rotationMatrix * Vector4(p2, 1.0);
        vertices.emplace_back(result.xyz(), color);

        result = rotationMatrix * Vector4(p3, 1.0);
        vertices.emplace_back(result.xyz(), color);

        result = rotationMatrix * Vector4(p4, 1.0);
        vertices.emplace_back(result.xyz(), color);
    }
}

Innerhalb des Konstruktors werden die Vertices des Cubes berechnet. Die Funktionen zum Rendern werden bereits von der Basisklasse Mesh bereitgestellt.

Kugel erstellen

Analog zu unserem Würfel erstellen wir nun eine Kugel.

sphere.h

#pragma once

#include "mesh.h"

class Sphere : public Mesh
{
  public:
    Sphere(const Color &color);
};

sphere.cpp

#include "sphere.h"

Sphere::Sphere(const Color &color)
{
    const int segments = 64;
    const int rings = segments / 2;
    std::vector<std::vector<Vector3>> vectors;
    vectors.resize(segments + 1);
    std::vector<Vector3> fillVector;
    fillVector.resize(rings + 1);
    std::fill(fillVector.begin(), fillVector.end(), Vector3(0.0, 0.0, 0.0));
    std::fill(vectors.begin(), vectors.end(), fillVector);

    for (int y = 0; y <= rings; y++)
    {
        float deg = 180.0f / rings * (y - rings * 0.5f);
        Matrix4 rotationMatrixX = Matrix4::rotateX(deg2rad(deg));
        Vector4 startVector = rotationMatrixX * Vector4(0, 0, 1, 1);
        vectors[0][y] = startVector.xyz();
        vectors[segments][y] = startVector.xyz();
        for (int x = 1; x < segments; x++)
        {
            float deg2 = 360.0f / (float)segments * (float)x;
            Matrix4 rotationMatrixY = Matrix4::rotateY(deg2rad(deg2));
            Vector4 result = rotationMatrixY * Vector4(vectors[0][y], 1.0);
            vectors[x][y] = result.xyz();
        }
    }
    for (int y = 0; y < rings; y++)
    {
        for (int x = 0; x < segments; x++)
        {
            vertices.emplace_back(vectors[x][y + 1], color);
            vertices.emplace_back(vectors[x + 1][y + 1], color);
            vertices.emplace_back(vectors[x + 1][y], color);
            vertices.emplace_back(vectors[x][y], color);
        }
    }
}

Szene erzeugen und rendern

Innerhalb unserer Renderer Klasse erzeugen wir nun eine Szene mit diesen Meshes.

renderer.cpp

void Renderer::start()
{
    auto cube1 = std::make_shared<Cube>(Color(0.5f, 0.73f, 0.14f, 1.0f));
    cube1->setPosition(Vector3(3.0, 0.0, 0.0));

    auto cube2 = std::make_shared<Cube>(Color(0.96f, 0.67f, 0.0f, 1.0f));
    cube2->setPosition(Vector3(-3.0, 0.0, 0.0));

    auto sphere = std::make_shared<Sphere>(Color(0.61f, 0.07f, 0.18f, 1.0f));

    Scene scene;
    scene.addMesh(cube1);
    scene.addMesh(cube2);
    scene.addMesh(sphere);

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

    while (!glfwWindowShouldClose(window))
    {
        renderScene(scene);
        glfwSwapBuffers(window);
        glfwPollEvents();
        printFps();
    }
}

Viewport an Fenstergröße anpassen

Falls sich die Fenstergröße ändert, muss ein Callback gesetzt werden, damit wir entsprechend darauf reagieren können. Wir fügen dazu zunächst im Renderer ein Flag resized hinzu und setzen es auf true, wenn das Callback ausgelöst wird. Ebenfalls fügen wir eine Funktion setViewportSize ein, die die nötigen Änderungen vornehmen soll.

renderer.h

class Renderer
{
  public:
    ...
  private:
    bool resized = false;
    ...
    void setViewportSize();
};

renderer.cpp

Renderer::Renderer(const std::string &title, uint32_t width, uint32_t height)
{
    ...
    glfwSetWindowUserPointer(window, this);
    ...
    glfwSetFramebufferSizeCallback(window, [](GLFWwindow *w, int width, int height)
    {
        Renderer *self = static_cast<Renderer *>(glfwGetWindowUserPointer(w));
        self->resized = true;
    });
    ...
}

void Renderer::start()
{
    ...
    setViewportSize();
    glClearColor(0.29f, 0.36f, 0.4f, 1.0f);

    while (!glfwWindowShouldClose(window))
    {
        ...
        if (resized)
        {
            resized = false;
            setViewportSize();
        }
    }
}

void Renderer::setViewportSize()
{
    int width, height;
    glfwGetFramebufferSize(window, &width, &height);
    glViewport(0, 0, width, height);
    activeCamera.loadProjectionMatrix(width / static_cast<double>(height));
}

Lernziele

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

  • Was sind homogene Koordinaten und warum benötigt man eine vierte Komponente (w), um Translation als Matrixmultiplikation auszudrücken?
  • Wie funktioniert die Multiplikation einer Matrix mit einem Vektor bzw. einer Matrix mit einer Matrix und warum lassen sich damit mehrere Transformationen zu einer zusammenfassen?
  • Was ist Backface Culling und warum verbessert es die Renderperformance?
  • Was bedeutet die Wicklungsreihenfolge (Winding Order) eines Dreiecks und wie bestimmt OpenGL damit, welche Seite die Vorder- bzw. Rückseite ist?
  • Wie lassen sich die Vertices eines Würfels aus einer einzigen Seite durch Rotationsmatrizen berechnen?
  • Nach welchem Prinzip wird eine Kugel durch Ringe und Segmente in Quads unterteilt und warum eignet sich diese Parametrisierung für die prozedurale Erzeugung?
  • Was ist ein std::shared_ptr und welches Problem der Speicherverwaltung löst er gegenüber einem rohen Zeiger?
  • Warum muss der Viewport bei einer Fenstergrößenänderung aktualisiert werden und welchen Einfluss hat das Seitenverhältnis auf die Projektionsmatrix?

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.