Beleuchtung und Tiefenpuffer

In diesem Kapitel wollen wir die Beleuchtung unserer Szene implementieren.

Screenshot 2024-11-07 at 183316.png

Aktivieren des Tiefenpuffers

Das Ergebnis des letzten Kapitels zeigt sehr anschaulich ein Problem in der 3D-Grafik: Objekte oder Vertices werden in genau der Reihenfolge gezeichnet, in der sie an die GPU gesendet werden. Das führt bei frei beweglichen Kameras zu Problemen, da Objekte nicht unbedingt in einer logischen Reihenfolge gezeichnet werden. Hierbei hilft der Tiefenpuffer. Eine Erklärung dazu finden Sie auch im OpenGL Wiki.

Die GPU bringt die dafür benötigte Funktionalität mit. Wir müssen das Feature lediglich aktivieren und vor jedem Renderaufruf sicherstellen, dass der Tiefenpuffer geleert wird.

renderer.cpp

Renderer::Renderer(const std::string &title, uint32_t width, uint32_t height)
{
    ...
    glEnable(GL_MULTISAMPLE);
    glfwSwapInterval(1);
    glEnable(GL_CULL_FACE);
    glEnable(GL_DEPTH_TEST);
    ...
}

void Renderer::renderScene(Scene &scene) const
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    ...
}

Wenn Sie nun die Kamera drehen, werden Sie feststellen, dass die Objekte in der richtigen Reihenfolge erscheinen.

3D-Modelle für Beleuchtung vorbereiten

Erweiterung der Vertex-Struktur

Unser Vertex besteht aktuell nur aus Position und Farbe. Um die Helligkeit von Oberflächen zu berechnen, benötigt die GPU zusätzlich Informationen darüber, in welche Richtung die Oberfläche ausgerichtet ist. Hierfür erweitern wir die Vertex-Struktur, damit wir pro Vertex einen zusätzlichen Normalenvektor übergeben können.

cgmath.h

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

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

Anschließend müssen wir unsere 3D-Modelle so anpassen, dass diese für jedes Vertex die passende Normale berechnen.

Würfel mit Normalen

Den folgenden Code können Sie so übernehmen. Wichtig ist, dass Sie verstehen und erklären können, in welche Richtung die Normalenvektoren des Würfels zeigen.

cube.cpp

Cube::Cube(const Color &color)
{
    ...
    Vector3 normal(0, 0, 1);

    vertices.emplace_back(p1, normal, color);
    vertices.emplace_back(p2, normal, color);
    vertices.emplace_back(p3, normal, color);
    vertices.emplace_back(p4, normal, 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));

        Vector3 normalRotated = (rotationMatrix * Vector4(normal, 1)).xyz();

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

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

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

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

Kugel mit Normalen

Den folgenden Code können Sie so übernehmen. Wichtig ist, dass Sie verstehen und erklären können, in welche Richtung die Normalenvektoren der Kugel zeigen.

sphere.cpp

Sphere::Sphere(const Color &color)
{
    ...
    for (int y = 0; y < rings; y++)
    {
        for (int x = 0; x < segments; x++)
        {
            vertices.emplace_back(vectors[x][y + 1], vectors[x][y + 1], color);
            vertices.emplace_back(vectors[x + 1][y + 1], vectors[x + 1][y + 1], color);
            vertices.emplace_back(vectors[x + 1][y], vectors[x + 1][y], color);
            vertices.emplace_back(vectors[x][y], vectors[x][y], color);
        }
    }
}

Beleuchtung aktivieren

Unsere Szene sieht aktuell noch nicht wirklich dreidimensional aus. Das liegt daran, dass wir bisher keinerlei Beleuchtung implementiert haben. Alle Seiten der Objekte sind gleich hell. Das menschliche Auge benötigt aber Helligkeitsunterschiede, um die Form eines Objektes erkennen zu können.

Licht einschalten

Damit unsere GPU die nötigen Funktionen zur Beleuchtung nutzen kann, müssen wir zuerst das Feature einschalten.

renderer.cpp

Renderer::Renderer(const std::string &title, uint32_t width, uint32_t height)
{
    ...
    glEnable(GL_MULTISAMPLE);
    glfwSwapInterval(1);
    glEnable(GL_CULL_FACE);
    glEnable(GL_DEPTH_TEST);

    glEnable(GL_LIGHTING);
    glEnable(GL_NORMALIZE);
    float noLight[4] = {0.0, 0.0, 0.0, 1.0};
    glLightModelfv(GL_LIGHT_MODEL_AMBIENT, noLight);
    ...
}

Lichtfarben und -positionen festlegen

Anschließend definieren wir die Lichtfarben. Hierfür nehmen wir an, dass die Lichtquelle weißes Licht emittiert. Zusätzlich setzen wir einen Wert für das Umgebungslicht mit einer Helligkeit von 10% der Lichtquelle.

renderer.cpp

namespace Colors
{
    Color white = Color(1.0, 1.0, 1.0, 1.0);
    Color sunLight = Color(0.9, 0.9, 0.9, 1.0);
    Color ambientLight = Color(0.05, 0.05, 0.05, 1.0);
}

OpenGL unterstützt mindestens 8 Lichtquellen (GL_LIGHT0 bis GL_LIGHT7). Jede dieser Lichtquellen können wir zur Laufzeit mit Parametern versehen sowie ein- und ausschalten. Wir entscheiden uns dafür, GL_LIGHT1 zu verwenden, und befüllen es nun mit den eben festgelegten Werten.

scene.h

class Scene
{
  public:
    ...
    void render(const Camera &camera) const;
    void setLight(const Vector4 &position, const Color &diffuse, const Color &ambient, const Color &specular);

  private:
    ...
    float lightPosition[4] = {0.0f, 0.0f, 0.0f, 0.0f};
    float lightAmbient[3] = {0.0f, 0.0f, 0.0f};
    float lightDiffuse[3] = {0.0f, 0.0f, 0.0f};
    float lightSpecular[3] = {0.0f, 0.0f, 0.0f};
};

scene.cpp

void Scene::render(const Camera &camera) const
{
    camera.loadViewMatrix();

    glEnable(GL_LIGHT1);
    glLightfv(GL_LIGHT1, GL_POSITION, lightPosition);
    glLightfv(GL_LIGHT1, GL_AMBIENT, lightAmbient);
    glLightfv(GL_LIGHT1, GL_DIFFUSE, lightDiffuse);
    glLightfv(GL_LIGHT1, GL_SPECULAR, lightSpecular);

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

    glDisable(GL_LIGHT1);
}

void Scene::setLight(const Vector4 &position, const Color &diffuse, const Color &ambient, const Color &specular)
{
    lightPosition[0] = static_cast<float>(position.x);
    lightPosition[1] = static_cast<float>(position.y);
    lightPosition[2] = static_cast<float>(position.z);
    lightPosition[3] = static_cast<float>(position.w);

    lightAmbient[0] = static_cast<float>(ambient.r);
    lightAmbient[1] = static_cast<float>(ambient.g);
    lightAmbient[2] = static_cast<float>(ambient.b);

    lightDiffuse[0] = static_cast<float>(diffuse.r);
    lightDiffuse[1] = static_cast<float>(diffuse.g);
    lightDiffuse[2] = static_cast<float>(diffuse.b);

    lightSpecular[0] = static_cast<float>(specular.r);
    lightSpecular[1] = static_cast<float>(specular.g);
    lightSpecular[2] = static_cast<float>(specular.b);
}

Rendern mit Beleuchtung

Beim Rendern können wir nun für jeden Frame das Licht anpassen, falls wir ein bewegliches Licht haben. Und für jedes Mesh übergeben wir nun zuerst den Normalenvektor und dann die Farbe. Beachten Sie, dass wir statt glColor3fv() nun glMaterialfv() verwenden. Mit dieser Funktion können wir genauer bestimmen, welche Eigenschaften eine Oberfläche hat. Hier legen wir fest, dass unser Material sowohl im direkten Licht als auch im indirekten Umgebungslicht die gleiche Farbe hat.

Die Scene heißt seit diesem Kapitel foreground, weil wir sie in den nachfolgenden Kapiteln um eine weitere background Scene ergänzen werden.

renderer.cpp

void Renderer::start()
{
    ...
    Vector4 lightPosition(50000, 20000, 50000, 0);
    foreground.setLight(lightPosition, Colors::sunLight, Colors::ambientLight, Colors::white);

    while (!glfwWindowShouldClose(window))
    {
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        foreground.render(activeCamera);
        glfwSwapBuffers(window);
        ...
    }
}

mesh.cpp

void Mesh::render() const
{
    ...
    for (auto vertex : vertices)
    {
        glNormal3fv((float *)&vertex.normal);
        glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, (float *)&vertex.color);
        glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, (float *)&vertex.color);
        glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 30.0f);
        glVertex3fv((float *)&vertex.position);
    }
    glEnd();
    glPopMatrix();
}

Praktikum

Schreiben Sie einen neuen Konstruktor für die Sphere-Klasse, mit dem sich ein Sphere-Segment erzeugen lässt. Ein solches Segment ist ein Achtel der kompletten Kugel wie im folgenden Bild:

Screenshot 2025-05-09 at 153222.png

Sobald Sie in der Lage sind, ein solches Segment zu erzeugen, versuchen Sie im Renderer eine komplette Kugel zu erzeugen, indem Sie 8 Einzelteile laden und entsprechend transformieren.

renderer.cpp

void Renderer::start()
{
    auto sphereSegment1 = std::make_shared<Sphere>(...);
    auto sphereSegment2 = std::make_shared<Sphere>(...);
    auto sphereSegment3 = std::make_shared<Sphere>(...);
    ...
}

Das Ergebnis sollte genauso aussehen wie vorher, nur dass man nun die einzelnen Kugelsegmente unterschiedlich einfärben kann.

Screenshot 2025-05-09 at 154132.png

Lernziele

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

  • Was ist ein Tiefenpuffer (Z-Buffer) und wie entscheidet die GPU mit seiner Hilfe, welches Fragment sichtbar ist?
  • Was ist ein Normalenvektor und warum wird er pro Vertex und nicht pro Dreieck gespeichert?
  • Worin unterscheiden sich ambiente, diffuse und spekulare Beleuchtung und wie tragen sie zum Gesamteindruck einer Oberfläche bei?
  • Was beschreibt das Phong-Beleuchtungsmodell und aus welchen Komponenten setzt es die Helligkeit eines Punktes zusammen?
  • Warum muss die Lichtposition nach der View-Matrix gesetzt werden und welchen Effekt hat die vierte Komponente (w) des Positionsvektors auf die Art der Lichtquelle?
  • Was ist der Unterschied zwischen glColor und glMaterial und warum reicht eine einfache Farbe für beleuchtete Szenen nicht aus?
  • Was bewirkt GL_NORMALIZE und warum können Normalenvektoren nach einer Transformation nicht mehr normalisiert sein?

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.