Texturen laden und anzeigen

In diesem Kapitel erweitern wir die Engine um die Möglichkeit, Texturen zu laden und anzuzeigen.

Screenshot 2024-11-28 at 162441.png

Texturen laden

In diesem Abschnitt werden wir Texturen in den Speicher laden, damit wir sie anschließend beim Rendern unseres Modells verwenden können.

Image Loader

Das Laden von Bilddateien erfordert individuelle Implementierungen je nach verwendetem Bildformat. Der Einfachheit halber verwenden wir den weit verbreiteten Image Loader von Sean Barrett.

Wenn Sie Ihren Projektordner mithilfe des Starter Sets aus Kapitel 1 erzeugt haben, befindet sich die benötigte Headerdatei bereits in Ihrem Projektordner unter libraries/stb/stb_image.h.

Neue Klasse Texture

Beginnen wir mit einem Blick in die Headerdatei.

texture.h

#pragma once

#define GLFW_INCLUDE_GLEXT

#include <GLFW/glfw3.h>
#include <string>

class Texture
{
  public:
    Texture(const std::string &filename);
    ~Texture();

    Texture(const Texture &) = delete;
    Texture &operator=(const Texture &) = delete;
    Texture(Texture &&) = delete;
    Texture &operator=(Texture &&) = delete;

    void bind() const;

  private:
    GLuint id;
};

texture.cpp

#define STB_IMAGE_IMPLEMENTATION

#include "texture.h"

#include "stb_image.h"

#include <stdexcept>

Texture::Texture(const std::string &filename)
{
    int width, height, channels;
    stbi_set_flip_vertically_on_load(1);
    unsigned char *data = stbi_load(filename.c_str(), &width, &height, &channels, 0);
    if (!data)
    {
        throw std::runtime_error("Failed to load texture " + filename);
    }

    GLenum format;
    if (channels == 3)
    {
        format = GL_RGB;
    }
    else if (channels == 4)
    {
        format = GL_RGBA;
    }
    else
    {
        stbi_image_free(data);
        throw std::runtime_error("Unsupported channel count in texture " + filename);
    }

    glGenTextures(1, &id);
    glBindTexture(GL_TEXTURE_2D, id);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
    glGenerateMipmap(GL_TEXTURE_2D);

    stbi_image_free(data);
}

Texture::~Texture()
{
    glDeleteTextures(1, &id);
}

void Texture::bind() const
{
    glBindTexture(GL_TEXTURE_2D, id);
}

Texturdateien einbinden

Erstellen Sie einen neuen Ordner textures in Ihrem Projektordner. Hier speichern wir zukünftig Bilder und ähnliche Ressourcen ab. Für dieses Kapitel benötigen wir 2 Texturen.

Textur der Erde

Als Textur für die Kugel nutzen wir diese Erdtextur von der NASA. Speichern Sie die Textur unter Computergrafik/textures/earth_diffuse.jpg.

Textur für einen THM-Würfel

Für die beiden Würfel nutzen wir eine THM Textur. Speichern Sie die Datei unter Computergrafik/textures/thm2k.png.

Vertex um Texturkoordinaten erweitern

Texturkoordinaten dienen dazu, unsere Texturen auf den Objekten richtig auszurichten. Diese Koordinaten geben für jeden Vertex an, welcher Pixel der Textur an dieser Stelle gezeichnet werden soll. Zwischen den einzelnen Vertices werden die Werte von der Grafikkarte interpoliert, sodass an jeder Stelle des Objektes ein Teil der Textur sichtbar ist.

Die Texturkoordinaten werden in Form von zweidimensionalen Vektoren angegeben, die normalerweise Werte zwischen 0 und 1 haben. Ein Wert von 0 bedeutet, dass der Punkt auf der linken oder unteren Seite der Textur liegt, während ein Wert von 1 auf der rechten oder oberen Seite liegt.

Zuerst erweitern wir unsere Mathe-Bibliothek um einen neuen Datentyp Vector2 und fügen anschließend unserem Vertex-Datentyp eine neue Variable texcoord hinzu. Hier werden die Texturkoordinaten eines jeden Vertex gespeichert.

cgmath.h

struct Vector2
{
    double x;
    double y;

    Vector2(double x, double y)
        : x(x), y(y)
    {
    }
};

...

struct Vertex
{
    Vertex(const Vector3 &position, const Vector3 &norm, const Vector2 &texcoord)
        : 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)),
          texcoord(static_cast<float>(texcoord.x), static_cast<float>(texcoord.y))
    {
    }

    float position[3];
    float normal[3];
    float texcoord[2];
};

Mesh Klasse anpassen

Unsere Texturen müssen irgendwo aufbewahrt werden. Dazu nutzen wir die Mesh-Klassen. Eine Besonderheit dabei ist, dass mehrere Meshes die gleiche Textur verwenden sollen. Um das möglichst einfach möglich zu machen, übergeben wir die Textur an das Mesh als Shared Pointer. So ist sichergestellt, dass die Ressourcen der Textur freigegeben werden, sobald das letzte Mesh zerstört wird, welches eine Textur verwendet.

Außerdem wollen wir die Materialeigenschaften eines Meshes nicht mehr für jeden Vertex individuell setzen. Sobald die Details aus der Textur kommen, ist dies nicht mehr notwendig und wird nun einmalig pro Mesh definiert.

mesh.h

#pragma once

#include "cgmath.h"
#include "texture.h"

#include <memory>
#include <vector>

class Mesh
{
  public:
    Mesh(const std::shared_ptr<Texture> &texture);
    virtual ~Mesh() = default;
    virtual void render() const;
    void setPosition(const Vector3 &position);
    void setRotation(const Vector3 &rotation);
    void setMaterial(const Color &ambient, const Color &diffuse, const Color &specular, const Color &emission, const float shininess);

  protected:
    Matrix4 position = Matrix4::identity();
    Matrix4 rotation = Matrix4::identity();
    std::vector<Vertex> vertices = {};
    std::shared_ptr<Texture> texture = nullptr;
    float ambient[4] = {1.0f, 1.0f, 1.0f, 1.0f};
    float diffuse[4] = {1.0f, 1.0f, 1.0f, 1.0f};
    float specular[4] = {1.0f, 1.0f, 1.0f, 1.0f};
    float emission[4] = {0.0f, 0.0f, 0.0f, 1.0f};
    float shininess = 30.0f;
};

mesh.cpp

#include "mesh.h"

#include "texture.h"

Mesh::Mesh(const std::shared_ptr<Texture> &texture)
    : texture(texture)
{
}

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

    if (texture)
    {
        texture->bind();
    }

    glPushMatrix();
    float worldMatrixF[16];
    worldMatrix.toColumnMajor(worldMatrixF);
    glMultMatrixf(worldMatrixF);

    glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, ambient);
    glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, diffuse);
    glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, specular);
    glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, emission);
    glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, shininess);

    glBegin(GL_QUADS);
    for (const auto &vertex : vertices)
    {
        glNormal3fv(vertex.normal);
        glTexCoord2fv(vertex.texcoord);
        glVertex3fv(vertex.position);
    }
    glEnd();
    glPopMatrix();
    if (texture) glBindTexture(GL_TEXTURE_2D, 0);
}

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

void Mesh::setMaterial(const Color &ambient, const Color &diffuse, const Color &specular, const Color &emission, const float shininess)
{
    this->ambient[0] = static_cast<float>(ambient.r);
    this->ambient[1] = static_cast<float>(ambient.g);
    this->ambient[2] = static_cast<float>(ambient.b);
    this->ambient[3] = static_cast<float>(ambient.a);

    this->diffuse[0] = static_cast<float>(diffuse.r);
    this->diffuse[1] = static_cast<float>(diffuse.g);
    this->diffuse[2] = static_cast<float>(diffuse.b);
    this->diffuse[3] = static_cast<float>(diffuse.a);

    this->specular[0] = static_cast<float>(specular.r);
    this->specular[1] = static_cast<float>(specular.g);
    this->specular[2] = static_cast<float>(specular.b);
    this->specular[3] = static_cast<float>(specular.a);

    this->emission[0] = static_cast<float>(emission.r);
    this->emission[1] = static_cast<float>(emission.g);
    this->emission[2] = static_cast<float>(emission.b);
    this->emission[3] = static_cast<float>(emission.a);

    this->shininess = shininess;
}

Cube und Sphere anpassen

Anschließend müssen die beiden Stellen, an denen wir die Würfel und Kugel erzeugen, so angepasst werden, dass dort zusätzlich zu Vertexposition und Normalenvektor auch die Texturkoordinate gesetzt wird.

cube.h

class Cube : public Mesh
{
  public:
    Cube(const std::shared_ptr<Texture> &texture);
};

cube.cpp

Cube::Cube(const std::shared_ptr<Texture> &texture)
    : Mesh(texture)
{
    ...
    Vector2 t1(0, 0);
    Vector2 t2(1, 0);
    Vector2 t3(1, 1);
    Vector2 t4(0, 1);

    vertices.emplace_back(p1, normal, t1);
    vertices.emplace_back(p2, normal, t2);
    vertices.emplace_back(p3, normal, t3);
    vertices.emplace_back(p4, normal, t4);
    for (int i = 1; i < 6; i++)
    {
        ...

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

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

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

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

sphere.h

class Sphere : public Mesh
{
  public:
    Sphere(const std::shared_ptr<Texture> &texture);
};

sphere.cpp

Sphere::Sphere(const std::shared_ptr<Texture> &texture)
    : Mesh(texture)
{
    ...

    int i = 0;
    for (int y = 0; y < rings; y++)
    {
        for (int x = 0; x < segments; x++)
        {
            double tw = 1.0 / segments;
            double th = 1.0 / rings;
            double ty = rings - y;
            vertices.emplace_back(vectors[x][y + 1], vectors[x][y + 1].normalize(), Vector2(x * tw, (ty - 1) * th));
            vertices.emplace_back(vectors[x + 1][y + 1], vectors[x + 1][y + 1].normalize(), Vector2((x + 1) * tw, (ty - 1) * th));
            vertices.emplace_back(vectors[x + 1][y], vectors[x + 1][y].normalize(), Vector2((x + 1) * tw, (ty)*th));
            vertices.emplace_back(vectors[x][y], vectors[x][y].normalize(), Vector2(x * tw, (ty)*th));
        }
    }
}

Objekte mit Texturen laden und rendern

Damit Texturen überhaupt von unserer Grafikpipeline unterstützt werden, müssen wir das Feature einschalten. Das tun wir im Konstruktor der Renderer-Klasse.

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

Nun passen wir unsere Scene so an, dass die Modelle mitsamt ihrer Texturen geladen und gerendert werden. Wir beginnen mit dem Laden der Texturen und übergeben diese dann an den Konstruktor von Würfel und Kugel.

Dies passiert in der Funktion Renderer::start(). Schauen Sie sich unbedingt std::make_shared<Texture>(...) an und informieren Sie sich, was es damit auf sich hat.

renderer.cpp

void Renderer::start()
{
    auto thmTexture = std::make_shared<Texture>("textures/thm2k.png");
    auto earthTexture = std::make_shared<Texture>("textures/earth_diffuse.jpg");

    auto cube1 = std::make_shared<Cube>(thmTexture);
    cube1->setPosition(Vector3(3.0, 0.0, 0.0));

    auto cube2 = std::make_shared<Cube>(thmTexture);
    cube2->setPosition(Vector3(-3.0, 0.0, 0.0));

    auto sphere = std::make_shared<Sphere>(earthTexture);

    Scene foreground;
    foreground.addMesh(cube1);
    foreground.addMesh(cube2);
    foreground.addMesh(sphere);
    ...
}

Mögliche Probleme

Falls Sie die Texturen auf den Würfeln sehen, Ihre Kugel aber weiß bleibt, dann liegt es wahrscheinlich daran, dass Ihre Grafikkarte die 8192×4096 Pixel große Erdtextur nicht unterstützt. In diesem Falle können Sie einfach die Texturgröße in einem Bildbearbeitungsprogramm halbieren. Danach sollten Sie die Erde sehen.


Lernziele

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

  • Was sind Texturkoordinaten (UV-Koordinaten) und warum liegen sie üblicherweise im Bereich von 0 bis 1?
  • Welche Wrapping-Modi gibt es für Texturen (z. B. GL_REPEAT) und wie beeinflussen sie das Ergebnis, wenn Texturkoordinaten außerhalb von [0, 1] liegen?
  • Was ist der Unterschied zwischen Texture Minification und Magnification und warum benötigen beide unterschiedliche Filterverfahren?
  • Was sind Mipmaps und warum verbessern sie sowohl die Renderqualität als auch die Performance bei weit entfernten Objekten?
  • Warum muss ein Bild beim Laden für OpenGL vertikal gespiegelt werden und welche Konvention steckt dahinter?
  • Was ist ein std::shared_ptr und warum eignet er sich besonders gut, wenn mehrere Meshes dieselbe Textur verwenden?
  • Wie unterscheidet sich die UV-Parametrisierung einer Kugel von der eines Würfels und welche Artefakte können bei einer Kugelprojektion auftreten?

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.