自学OpenGL(八)-纹理贴图

前言

纹理贴图是在光栅话的模型表面覆盖图像的技术。他是为渲染场景添加真是感的最基本最重要的方法之一。

纹理贴图非常重要,因此硬件也提供了实现实时的照片真是感的超高性能。纹理单元是专为纹理设计的硬件组件,现代显卡通常带有数个纹理单元。

关于图片的加载,本篇博客采用的是 std_image, 在很多关于OpenGL 相关的书籍都使用 SOIL 库作为工具,而经过我一段时间的摸索,SOIL 在M1 芯片的Mac电脑上安装实在费劲,反正我是没有安装成功,如果有朋友安装成过的可以评论区留言或者私信,你将获得拼多多终身砍一刀服务。

纹理坐标

下面你会看到在之前几篇文章中的三角形,贴上来一张砖墙图。


FileSharing.png

为了能够把纹理映射到三角形上,我们需要制定三角形的每个顶点个字对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标,用来表明该纹理图像的哪个部分采样。之后通过光栅化过程在其他片段进行线性插值。

纹理坐标在x 和 y轴上,范围为0到1之间。使用纹理坐标获取纹理颜色叫做采样。纹理坐标的起始于(0,0),也就是纹理图片的左下角,终点为(1,1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的


FileSharing-1.png

我们为三角形制定了3个顶点坐标点。如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角形左下角的纹理坐标设置为(0,0),同理把右下方的顶点设置为(1, 0)。我们只要给顶点着色器传递这三个纹理坐标就行了,接下来他们会被光栅化过程进行线性插值后传入片段着色器中。

纹理坐标看起来就像这样:

float texCoords[] = {
    0.0f, 0.0f, // 左下角
    1.0f, 0.0f, // 右下角
    0.5f, 1.0f // 上中
};

对纹理采样的解释非常宽松,他可以采用几种不同的插值方式。所以我们需要告诉OpenGL该怎样对纹理采样。

纹理环绕方式

纹理坐标的范围通常是(0,0)到(1,1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL默认行为是重复这个纹理图像,但是OpenGL提供了更多的选择:

  • GL_REPEAT:对纹理的默认行为。重复纹理图像。
  • GL_MIRRORED_REPEAT:和GL_REPEAT一样,但每次重复图片是镜像放置的。
  • GL_CLAMP_TO_EDGE:纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
  • GL_CLAMP_TO_BORDER:超出的坐标为用户指定的边缘颜色。

当纹理坐标超出默认范围是,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:


FileSharing-2.png

前面提到的每个选项都可以使用glTexParameter*函数对单独的一个坐标轴设置(s、t(如果是使用3D纹理那么还有一个r)它们和x、y、z是等价的)

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

纹理过滤

纹理坐标不依赖于分辨率,他可以是任意浮点数,所以OpenGL需要知道怎样将纹理像素映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低时,这就变得很重要了。你可能已经对于纹理过滤的选项,但是现在我们只讨论最重要的两种:GL_NEAREST 和 GL_LINEAR.

GL_NEAREST(也叫邻近过滤)是OpenGL默认过滤方式。当设置为 GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素,加号代表纹理坐标


FileSharing-3.png

GL_LINEAR(也叫线性过滤)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色


FileSharing-4.png

那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):


FileSharing-5.png

GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。

当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,比如你可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。我们需要使用glTexParameter*函数为放大和缩小指定过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:


glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

多级渐远纹理

想象一下,假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。

OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的:


FileSharing-6.png

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个glGenerateMipmaps函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。

在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:

  • GL_NEAREST_MIPMAP_NEAREST:使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
  • GL_LINEAR_MIPMAP_NEAREST:使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
  • GL_NEAREST_MIPMAP_LINEAR:在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
  • GL_LINEAR_MIPMAP_LINEAR:在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

加载纹理图像文件

为了在OpenGL/glsl 中有效地完成纹理贴图,需要协调好一下几个不同的数据集和机制:

  • 用于保存纹理图像的纹理对象
  • 一个特殊的统一采样器变量,一遍顶点着色器可以访问纹理;
  • 用于保存纹理坐标的缓冲区;
  • 用于将纹理坐标传递给管线的顶点属性;
  • 显卡上的纹理单元。

纹理图像可以是任何图像,他可以是人造的或者自然产生的食物的图片,例如布、草、行星表面;它也可以是几何图样,如下图中的棋盘图样。子啊电子游戏和动画电影中,纹理图像通常用于给角色绘制表面和衣服,如下图中的海豚生物身上绘制皮肤。


FileSharing-7.png

图像通常存储在图像文件中,例如 .jpg、.png、.gif或者tiff格式。为了使纹理图像可以被用于OpenGL管线中的着色器,我们需要从图像中提取颜色并将它们放入OpenGL纹理对象中。

许多C++库可以用于读取和处理图像文件,我这里选择使用 std_image库。通常我们将纹理加载到OpenGL 应用程序的步骤是:

  1. 使用 stb_image 实例化OpenGL 纹理对象并从图像文件中读入数据;
  2. 调用glBindTexture() 以使新创建的纹理对象处于激活状态;
  3. 使用gltexParameter()函数调整纹理设置。
  4. 最终使用glTexImage2D()函数 获得的结果就是现在可用的OpenGL纹理对象整形ID。

对读取图片和创建纹理,我们经常使用这个函数,于是做一个简单的封装,便于代码的复用

#include <stdio.h>
#include <string>
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "stb_image_resize.h"
#include <stdio.h>
#define GLEW_STATIC
#include <GL/glew.h>

using namespace std;

enum UFPixelSpace {
    UFPixelSpaceGrayscale = 1, // 灰度图
    UFPixelSpaceGrayscaleAndAlpha, // 灰度加透明度
    UFPixelSpaceRGB, // rgb 颜色空间
    UFPixelSpaceRGBA, // rgba 颜色空间
};

class UFImage {
public:
    unsigned char *data{nullptr}; // 图片数据
    UFPixelSpace format; // 图像色彩空间格式
    int width; // 图像宽度
    int height; // 图像高度
    UFImage(std::string path) {
        this->path = path;
        textureID = 0;
        decode();
    }

    /** 图片转纹理 */
    GLuint glTexture() {
        if (textureID) {
            return textureID;
        }
        // 函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的unsigned int数组中
        glGenTextures(1, &textureID);
        // 绑定纹理,让之后任何的纹理指令都可以配置当前绑定的纹理:
        glBindTexture(GL_TEXTURE_2D, textureID);
        // 纹理过滤器
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, (GLint)GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, (GLint)GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, (GLint)GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, (GLint)GL_CLAMP_TO_EDGE);

        // 图片纹理通过 glTexImage2D 来生成
        if (format == UFPixelSpaceRGBA) {
            glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
        } else if (format == UFPixelSpaceRGB) {
            glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
        } else if (format == UFPixelSpaceGrayscale) {
            glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RG, width, height, 0, GL_RG, GL_UNSIGNED_BYTE, data);
        } else {
            glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, data);
        }
        return textureID;
    }

private:
    GLuint textureID;
    string path;
    void decode() {
        int iw, ih, n;
        data = stbi_load(path.c_str(), &iw, &ih, &n, 0);
        format = static_cast<UFPixelSpace>(n);
        width = iw;
        height = ih;
    }

    void destroy() {
        if (textureID) {
            glDeleteTextures(1, &textureID);
            textureID = 0;
        }
        if (data) {
            stbi_image_free(data);
            data = NULL;
        }
    }
};

这样,我们的C++ 应用程序就只管调用上述的UFImage 类来创建OpenGL纹理对象,代码如下:

UFImage *image = new UFImage("image.png");
GLuint texture = image->glTexture();

纹理的应用

我们需要告知OpenGL如何采样纹理,所以我们必须使用纹理坐标更新顶点数据:

    float vertices[] = {
    //     ---- 位置 ----       ---- 颜色 ----     - 纹理坐标 -
         0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   1.0f, 1.0f,   // 右上
         0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,   1.0f, 0.0f,   // 右下
        -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f,   // 左下

        -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,   0.0f, 1.0f,    // 左上
        0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   1.0f, 1.0f,   // 右上
        -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f,   // 左下
    };

由于我们添加了一个额外的顶点属性,我们必须告诉OpenGL我们新的顶点格式:


FileSharing-8.png

创建 VBO,VAO,并将顶点数据发送到顶点缓冲区

    // vbo,vao
    unsigned int vbo, vao;
    glGenBuffers(1, &vbo);
    glGenVertexArrays(1, &vao);
    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);

    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,  8*sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3* sizeof(float)));
    glEnableVertexAttribArray(1);

    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8*sizeof(float), (void*)(6* sizeof(float)));
    glEnableVertexAttribArray(2);

    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);

注意,我们同样需要调整前面两个顶点属性的步长参数为8 * sizeof(float)。

创建纹理,我们使用上面封装的类来创建纹理

    string path = string("/Users/chenxueming/Desktop/LearnOpenGL/Texture/Texture/container.jpeg");
    UFImage *image = new UFImage(path);
    GLuint texture = image->glTexture();

注:这里的路径,调试过程中需要替换成自己电脑的路径。

接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;

out vec3 ourColor;
out vec2 TexCoord;

void main()
{
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor;
    TexCoord = aTexCoord;
}

片段着色器应该接下来会把输出变量TexCoord作为输入变量。

片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D、sampler3D,或在我们的例子中的sampler2D。我们可以简单声明一个uniform sampler2D把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个uniform。

#version 330 core
out vec4 FragColor;

in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

我们使用GLSL内建的texture函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。

现在只剩下在调用 glDrawArrays 之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
GLint location = glGetUniformLocation(shader->ID, "ourTexture");
glUniform1i(location, 0);

如果你跟着这个教程正确地做完了,你会看到下面的图像:


FileSharing-9.png

我们还可以把得到的纹理颜色与顶点颜色混合,来获得更有趣的效果。我们只需把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:

FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);

最终的效果应该是顶点颜色和纹理颜色的混合色:

FileSharing-10.png

我猜你会说我们的箱子喜欢跳70年代的迪斯科。

纹理单元

使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元。一个纹理的默认纹理单元是0,它是默认的激活纹理单元。

纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture一样,我们可以使用glActiveTexture激活纹理单元,传入我们需要使用的纹理单元:

glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);

激活纹理单元之后,接下来的glBindTexture函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元GL_TEXTURE0默认总是被激活,所以我们在前面的例子里当我们使用glBindTexture的时候,无需激活任何纹理单元。我们仍然需要编辑片段着色器来接收另一个采样器。这应该相对来说非常直接了:

最终输出颜色现在是两个纹理的结合。GLSL内建的mix函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。如果第三个值是0.0,它会返回第一个输入;如果是1.0,会返回第二个输入值。0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。

我们现在需要载入并创建另一个纹理;你应该对这些步骤很熟悉了。记得创建另一个纹理对象,载入图片,使用glTexImage2D生成最终纹理。对于第二个纹理我们使用一张你学习OpenGL时的面部表情图片。

为了使用第二个纹理(以及第一个),我们必须改变一点渲染流程,先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

我们还要通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元。我们只需要设置一次即可,所以这个会放在渲染循环的前面:

ourShader.use(); // 不要忘记在设置uniform变量之前激活着色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置
ourShader.setInt("texture2", 1); // 或者使用着色器类设置

while(...) 
{
    [...]
}

通过使用glUniform1i设置采样器,我们保证了每个uniform采样器对应着正确的纹理单元。你应该能得到下面的结果:

FileSharing-11.png

你可能注意到纹理上下颠倒了!这是因为OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部。很幸运,stb_image.h能够在图像加载时帮助我们翻转y轴,只需要在UFImage 加载图像之前加上这行代码即可

stbi_set_flip_vertically_on_load(true);

在让stb_image.h在加载图片时翻转y轴之后你就应该能够获得下面的结果了:


FileSharing-12.png

如果你看到了一个开心的箱子,你就做对了。

修改片段着色器,仅让笑脸图案朝另一个方向看

把纹理的x坐标做一个水平翻转就能实现这一需求:

        vec2 faceCoord = vec2(1.0 - TexCoord.x, TexCoord.y);
        FragColor = mix(texture(texture1, TexCoord), texture(texture2, faceCoord), 0.2);

看效果:


FileSharing-13.png

如果你运行出来有问题或者出错了,情仔细检查一下代码,或者在本文下方下载笔者写的demo。

代码示例大多来自于 https://learnopengl-cn.github.io/,作者把相关代码结构性的封装了一下,如果想学习更多 OpenGL 以及相关知识,请移步 learnopengl。

源代码在这里:https://github.com/muxueChen/LearnOpenGL

推荐阅读更多精彩内容