Renderer

Adding Textures

Texturing is the process of modifying a surface’s appearance using some image. This means taking a boring quad and at each pixel, using the image to modify the color of that pixel at that location.

Texturing is not only about the image that gets renderer in the quad, it is also about adding material properties to the surface. Making the surface shiny or making the surface bumpy. All of these can be achieved by using multiple texture images in the fragment shader and combining the effect of each of those textures.

Texture

To start, we want to first create a texture type that we can use to later to apply to our entities.

#ifndef TEXTURE_H_
#define TEXTURE_H_

#include "stb_image.h"
#include <glad/glad.h>
#include <iostream>

struct texture {
    GLuint ID;
    GLenum Type;
    std::string Name;
    std::string Path;
};

void Texture_Create(texture *Tex, const char *File, GLenum TexType, GLenum Slot,
                    GLenum Format, GLenum PixelType);
void Texture_Bind(texture *Tex, GLenum Slot);
void Texture_Uniform(GLuint ShaderID, const char *Uniform, GLuint Unit);
void Texture_Delete(texture *Tex);

#endif

Here are the implementation of the functions:

#include "texture.h"

void Texture_Create(texture *Tex, const char *File, GLenum TexType, GLenum Slot,
                    GLenum Format, GLenum PixelType) {
    Tex->Type = TexType;

    // tell stb_image.h to flip loaded texture's on the y-axis.
    stbi_set_flip_vertically_on_load(true);

    glGenTextures(1, &Tex->ID);
    glActiveTexture(Slot);
    glBindTexture(Tex->Type, Tex->ID);

    // load and generate the texture
    int Width, Height, NrChannels;
    unsigned char *Data = stbi_load(File, &Width, &Height, &NrChannels, 0);
    if (Data) {
        GLenum InternalFormat;
        switch (NrChannels) {
        case 1:
            Format = GL_RED;
            InternalFormat = GL_R8;
            break;
        case 3:
            Format = GL_RGB;
            InternalFormat = GL_RGB8;
            break;
        case 4:
            Format = GL_RGBA;
            InternalFormat = GL_RGBA8;
            break;
        default:
            Format = GL_RGB;
            InternalFormat = GL_RGB8;
            break;
        }
        glTexImage2D(Tex->Type, 0, InternalFormat, Width, Height, 0, Format,
                     PixelType, Data);
        glGenerateMipmap(Tex->Type);

        // set the texture wrapping/filtering options (on the currently bound
        // texture object)
        glTexParameteri(Tex->Type, GL_TEXTURE_WRAP_S,
                        Format == GL_RGBA ? GL_CLAMP_TO_EDGE : GL_REPEAT);
        glTexParameteri(Tex->Type, GL_TEXTURE_WRAP_T,
                        Format == GL_RGBA ? GL_CLAMP_TO_EDGE : GL_REPEAT);
        glTexParameteri(Tex->Type, GL_TEXTURE_MIN_FILTER,
                        GL_LINEAR_MIPMAP_LINEAR);
        glTexParameteri(Tex->Type, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    } else {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(Data);

    // Unbinds the OpenGL Texture object so that it can't accidentally be
    // modified
    glBindTexture(Tex->Type, 0);
}

void Texture_Uniform(GLuint ShaderID, const char *Uniform, GLuint Unit) {
    GLuint TexUni = glGetUniformLocation(ShaderID, Uniform);
    glUseProgram(ShaderID);

    glUniform1i(TexUni, Unit);
}

void Texture_Bind(texture *Tex, GLenum Slot) {
    glActiveTexture(Slot);
    glBindTexture(Tex->Type, Tex->ID);
}

void Texture_Unbind(texture *Tex) {
    glBindTexture(Tex->Type, 0);
}

void Texture_Delete(texture *Tex) {
    glDeleteTextures(1, &Tex->ID);
}

The most important part here is the Texture_Create function which uses the stb_image.h header library to load the image and then set the OpenGL state.

The way to use this would be as follows:

texture MyTexture;
Texture_Create(&MyTexture, "./resources/textures/container.png",
               GL_TEXTURE_2D, GL_TEXTURE0, GL_RGBA, GL_UNSIGNED_BYTE);

We create the type and then call the create function that takes care of everything for us.

The next thing to do is to add the texture to the entity mesh that is being drawn. We will go some steps ahead and already add multiple textures to a mesh. That way we can support some texture mapping like normal mapping or bump mapping.

struct mesh {
    std::vector<vertex> Vertices;
    std::vector<unsigned int> Indices;
    // List of Textures
    std::vector<texture> Textures;

    GLuint VAO;
    GLuint VBO;
    GLuint EBO;
};

The way to use it would be as follows:

texture DiffuseMap;
Texture_Create(&DiffuseMap, "./resources/textures/container.png",
               GL_TEXTURE_2D, GL_TEXTURE0, GL_RGBA, GL_UNSIGNED_BYTE);
DiffuseMap.Name = "diffuse";
texture SpecularMap;
Texture_Create(&SpecularMap,
               "./resources/textures/container_specular.png", GL_TEXTURE_2D,
               GL_TEXTURE1, GL_RGBA, GL_UNSIGNED_BYTE);
SpecularMap.Name = "specular";

std::vector<texture> Textures = {DiffuseMap, SpecularMap};

mesh CubeMesh;
Mesh_CreateCube(&CubeMesh, Textures);

entity Cube = {
    .Type = entity_type::CubeMesh,
    .Position = glm::vec3(0.0f, 0.0f, 0.0f),
    .Scale = glm::vec3(0.0f),
    .Rotation = glm::vec4(0.0f, 1.0f, 0.3f, 0.5f),
    .Color = glm::vec4(1.0f, 0.5f, 0.31f, 1.0f),
    .IsSelected = false,
    .Mesh = CubeMesh,
};

First we create all the different textures. In this case, a diffuse map (image color) and a specular map (for highlights). Then we pass these textures to the Mesh_CreateCube function so they are part of the new mesh. Finally, we attach that mesh to the entity.

Drawing Textures

Now that the textures are part of the entity mesh, we can update the Mesh_Draw function to also set all the textures info to be drawn.

void Mesh_Draw(GLuint ShaderID, mesh *Mesh) {
    // bind appropriate textures
    unsigned int DiffuseNr = 1;
    unsigned int SpecularNr = 1;
    unsigned int NormalNr = 1;
    unsigned int HeightNr = 1;

    glUniform1i(glGetUniformLocation(ShaderID, "u_material.has_specular"), 0);
    glUniform1i(glGetUniformLocation(ShaderID, "u_material.has_normal"), 0);

    for (unsigned int i = 0; i < Mesh->Textures.size(); i++) {
        // active proper texture unit before binding
        glActiveTexture(GL_TEXTURE0 + i);
        // retrieve texture number (the N in diffuse_textureN)
        std::string Number;
        std::string Name = Mesh->Textures[i].Name;
        if (Name == "diffuse") {
            Number = std::to_string(DiffuseNr++);
        } else if (Name == "specular") {
            // transfer unsigned int to string
            Number = std::to_string(SpecularNr++);
            glUniform1i(
                glGetUniformLocation(ShaderID, "u_material.has_specular"), 1);
        } else if (Name == "normal") {
            // transfer unsigned int to string
            Number = std::to_string(NormalNr++);
            glUniform1i(glGetUniformLocation(ShaderID, "u_material.has_normal"),
                        1);
        } else if (Name == "height") {
            // transfer unsigned int to string
            Number = std::to_string(HeightNr++);
        }
        // now set the sampler to the correct texture unit
        glUniform1i(
            glGetUniformLocation(ShaderID, ("u_material." + Name).c_str()), i);
        // and finally bind the texture
        glBindTexture(Mesh->Textures[i].Type, Mesh->Textures[i].ID);
    }

    // draw mesh
    glBindVertexArray(Mesh->VAO);
    glDrawElements(GL_TRIANGLES,
                   static_cast<unsigned int>(Mesh->Indices.size()),
                   GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);

    // always good practice to set everything back to defaults once configured.
    glActiveTexture(GL_TEXTURE0);
}

This function now loops over all the textures and sets the u_material uniform of the shader.

In the shader we have added a uniform that holds all the textures info:

struct Material {
    sampler2D diffuse;
    sampler2D specular;
    sampler2D normal;
    float shininess;
    bool has_specular;
    bool has_normal;
};

uniform Material u_material;

When calculating the light, for example the directional light, we can sample the diffuse and specular map using the TexCoords:

vec3 CalcDirLight(DirLight light, vec3 normal, vec3 view_dir) {
    vec3 light_dir = normalize(-light.direction);

    // diffuse shading
    float diff = max(dot(normal, light_dir), 0.0);

    // specular shading
    vec3 reflect_dir = reflect(-light_dir, normal);
    float spec = pow(max(dot(view_dir, reflect_dir), 0.0), u_material.shininess);

    // combine results
    vec3 ambient  = light.ambient  * vec3(texture(u_material.diffuse, TexCoords));
    vec3 diffuse  = light.diffuse  * diff * vec3(texture(u_material.diffuse, TexCoords));
    vec3 specular = vec3(0.0);
    if (u_material.has_specular) {
        specular = light.specular * spec * vec3(texture(u_material.specular, TexCoords));
    }

    return (ambient + diffuse + specular);
}

This gives so much more live to the scene. But more importantly, we can now add textures to objects that also interact with light to make them look more realistic.

It is time now to build on top of these concepts to render more complex 3D objects. In the next section, we will implement a 3D model loader to create all the geometry (mesh) and all the textures of the model.

Squared Wave SVG