LearnOpenGL note - Getting started:Textures

OpenGL入门系列3-纹理

Posted by Tao on Monday, February 21, 2022

0 Preface

本节笔记对应的内容为纹理
在本章中,我们将会了解到:

  • 纹理映射的概念和原理
  • OpenGL中处理二维纹理映射的方式

要使渲染的物体更加逼真,一方面我们可以使用更多的三角形来建模,通过复杂的模型来逼近物体,但是这种方法会增加绘制流水线的负荷,而且很多情况下不是很方便的。使用纹理,将物体表面的细节映射到建模好的物体表面,这样不仅能使渲染的模型表面细节更丰富,而且比较方便高效。纹理映射就是这样一种方法,在程序中通过为物体指定纹理坐标,通过纹理坐标获取纹理对象中的纹理,最终显示在屏幕区域上,已达到更加逼真的效果。

1 纹素(texel)和纹理坐标

纹理对象通常是通过纹理图片读取到的,这个数据保存到一个二维数组中,这个数组中的元素称为纹素(texel, texture pixel),纹素包含颜色值和alpha值。纹理对象的大小的宽度和高度应该为2的整数幂,例如16, 32, 64, 128, 256。要想获取纹理对象中的纹素,需要使用**纹理坐标(texture coordinate)**指定。

Texture Pixel也叫Texel,你可以想象你打开一张.jpg格式图片,不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。

纹理坐标应该与纹理对象大小无关,这样指定的纹理坐标当纹理对象大小变更时,依然能够工作,比如从256x256大小的纹理,换到512x256时,纹理坐标依然能够工作。因此纹理坐标使用规范化的值,大小范围为[0,1],纹理坐标使用uv表示,如下图所示(from: Basic Texture Mapping): tex_coords

u轴从左至右,v轴从底向上指向。右上角为(1,1),左下角为(0,0)。

如何将这么一份材质映射(Map)到一个三角形上呢?
通过指定纹理坐标,可以映射到纹素。例如一个256x256大小的二维纹理,坐标(0.5,1.0)对应的纹素即是(128,256)。(256x0.5 = 128, 256x1.0 = 256)。 textures
纹理映射时只需要为物体的顶点指定纹理坐标即可,其余部分由片元着色器插值完成,上图来自opengl-tutorial

2 纹理环绕方式(wrap)

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

环绕方式 描述
GL_REPEAT 坐标的整数部分被忽略,重复纹理,这是OpenGL纹理默认的处理方式
GL_MIRRORED_REPEAT 每次重复图片是镜像放置的,纹理坐标的整数部分是奇数时会使用镜像重复。
GL_CLAMP_TO_EDGE 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果(stretched edge pattern)。
GL_CLAMP_TO_BORDER 不在[0,1]范围内的纹理坐标会使用用户指定的边缘颜色。

每个选项对应的视觉效果输出:
texture_wrapping

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

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

第一个参数指定了纹理目标;我们使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是WRAP选项,并且指定ST轴。最后一个参数需要我们传递一个环绕方式(Wrapping),在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为GL_MIRRORED_REPEAT

如果我们选择GL_CLAMP_TO_BORDER选项,我们还需要指定一个边缘的颜色。这需要使用glTexParameter函数的fv后缀形式,用GL_TEXTURE_BORDER_COLOR作为它的选项,并且传递一个float数组作为边缘的颜色值:

float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

3 纹理过滤(Filter)

当使用纹理坐标映射到纹素数组时,正好得到对应纹素的中心位置的情况,很少出现。例如上面的(0.5,1.0)对应纹素(128,256)的情况是比较少的。如果纹理坐标映射到纹素位置(152.34,745.14)该怎么办呢?当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。OpenGL提供了纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEARESTGL_LINEAR

GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色。这种方式容易导致走样误差,明显有像素块的感觉。最近邻滤波方法的示意图如下所示: filter_nearest
GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算两个方向上的线性插值,得出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:
filter_linear
还存在其他的滤波方式,如三线性滤波(Trilinear filtering)等,感兴趣的可以参考texture filtering wiki。 那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):
texture_filtering

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

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

4 多级渐远纹理(Mipmaps)

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

相关原理可具体参考Games101-Texture Mapping一章。

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

mipmaps

Mipmaps纹理大小每级是前一等级的一半,按大小递减顺序排列为:

  • 原始纹理 256×256
  • Mip 1 = 128×128
  • Mip 2 = 64×64
  • Mip 3 = 32×32
  • Mip 4 = 16×16
  • Mip 5 = 8×8
  • Mip 6 = 4×4
  • Mip 7 = 2×2
  • Mip 8 = 1×1

OpenGL中通过函数glGenerateMipmap(GL_TEXTURE_2D);来生成Mipmap,前提是已经指定了原始纹理。原始纹理必须自己通过读取纹理图片来加载,这个后面会介绍。

如果直接在不同等级(Level)的MipMap之间切换,会形成明显的边缘,因此对于Mipmap也可以同纹素一样使用滤波方法在不同等级的Mipmap之间滤波。要在不同等级的MipMap之间滤波,需要将之前设置的GL_TEXTURE_MIN_FILTER选项更改为以下选项之一:

过滤方式 描述
GL_NEAREST_MIPMAP_NEAREST 使用最接近像素大小的Mipmap,纹理内部使用最近邻滤波
GL_LINEAR_MIPMAP_NEAREST 使用最接近像素大小的Mipmap,纹理内部使用线性滤波
GL_NEAREST_MIPMAP_LINEAR 在两个最接近像素大小的Mipmap中做线性插值,纹理内部使用最近邻滤波
GL_LINEAR_MIPMAP_LINEAR 在两个最接近像素大小的Mipmap中做线性插值,纹理内部使用线性滤波

命名规律:GL_A_MIPMAP_B 其中:A为各层级采样方式;B为层级间采样方式。

Mipmap使用注意:使用使用glGenerateMipmap(GL_TEXTURE_2D)产生Mipmap的前提是你已经加载了原始的纹理对象。使用MipMap时设置GL_TEXTURE_MIN_FILTER选项才能起作用,设置GL_TEXTURE_MAG_FILTER的Mipmap选项将会导致无效操作,OpenGL错误码为GL_INVALID_ENUM

设置Mipmap选项如下代码所示:

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

5 加载原始纹理(stb_image

使用纹理之前要做的第一件事是把它们加载到我们的应用中,从图片加载纹理这部分工作不是OpenGL函数完成的。这里使用一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说要用的stb_image.h库。
点击这里下载,进去以后点选Raw,在新出现的页面中,右键另存为,存到源码目录中。在要加载纹理的C++文件中,输入以下代码:

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

通过定义STB_IMAGE_IMPLEMENTATION,预处理器会修改头文件,让其只包含相关的函数定义源码,等于是将这个头文件变为一个 .cpp 文件了。现在只需要在你的程序中包含stb_image.h并编译就可以了。

下面的教程中,我们会使用一张木箱的图片。要使用stb_image.h加载图片,我们需要使用它的stbi_load函数:

int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);

这个函数首先接受一个图像文件的位置作为输入。接下来它需要三个int作为它的第二、第三和第四个参数,stb_image.h将会用图像的宽度高度颜色通道的个数填充这三个变量。我们之后生成纹理的时候会用到的图像的宽度和高度的。

6 生成纹理

和之前生成的OpenGL对象一样,纹理也是使用ID引用的。让我们来创建一个:

unsigned int texture;
glGenTextures(1, &texture);

glGenTextures函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的unsigned int数组中(我们的例子中只是单独的一个unsigned int),就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理

glBindTexture(GL_TEXTURE_2D, texture);

现在纹理已经绑定了,我们可以使用前面载入的图片数据生成一个纹理了。纹理可以通过glTexImage2D来生成:

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

API void glTexImage2D(GLenum target, GLint level, GLint internalformat,
GLsizei width, GLsizei height, GLint border,
GLenum format, GLenum type, const void * data);

函数参数很多,我们一个一个地讲解:

  • 第1个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
  • 第2个参数level为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。0代表原始纹理。
  • 第3个参数internalFormat指定OpenGL存储纹理的格式。我们读取的图片格式包含RGB颜色,因此我们也把纹理储存为RGB值。
  • 第4和第5个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
  • border参数应该总是被设为0(历史遗留的问题)。
  • 最后三个参数指定原始图片数据的格式(format)和数据类型(type,为GL_UNSIGNED_BYTE, GL_BYTE等值),以及数据的内存地址(data指针)。

当调用glTexImage2D时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。

7 使用纹理的完整过程

Step1 我们需要告知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    // 左上
};

目前对应的Vertex Attribute结构如下:
vertex_attribute_pointer_interleaved_textures

通过查看上图,我们按照如下方式设置glVertexAttribPointer,让OpenGL知道如何解析上述数据:

// 顶点位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid *)0);
glEnableVertexAttribArray(0);
// 顶点颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid *)(3 * sizeof(GL_FLOAT)));
glEnableVertexAttribArray(1);
// 顶点纹理坐标
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid *)(6 * sizeof(GL_FLOAT)));
glEnableVertexAttribArray(2);

注意,我们同样需要调整前面两个顶点属性的步长参数为8 * sizeof(float)
接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:

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

Step2 :然后需要设置OpenGL纹理参数;最后通过读取纹理图片,定义纹理图像格式等信息。纹理数据最终传递到了显卡中存储。

// Section3 准备纹理对象
// Step1 创建并绑定纹理对象
GLuint textureId;
glGenTextures(1, &textureId);
glBindTexture(GL_TEXTURE_2D, textureId);
// Step2 设定wrap参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// Step3 设定filter参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
// Step4 加载纹理
GLubyte *imageData = NULL;
int width, height, nrChannel;
stbi_set_flip_vertically_on_load(true);
imageData = stbi_load("container.jpg", &width, &height, &nrChannel, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, picWidth, picHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, imageData);
glGenerateMipmap(GL_TEXTURE_2D);
// Step5 释放纹理图片资源
stbi_image_free(data);
glBindTexture(GL_TEXTURE_2D, 0);

Step3 着色器中使用纹理对象

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

片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1Dsampler3D,或在我们的例子中的sampler2D。我们可以简单声明一个uniform sampler2D把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个uniform。
由于纹理对象通过使用uniform变量来像片元着色器传递,实际上这里传递的是对应纹理单元(texture unit)的索引号。

OpenGL中的纹理贴图意味着要操作四个概念之间错综复杂的联系:着色器中的纹理对象,纹理单元,取样器对象和取样器一致变量

纹理对象是一种可被shader读写的存储类型。他们经常被用来存储图片数据,图片数据有各种各样的格式。大部分图片数据都是二维的,但是纹理对象依然可以是一维或者是三维的,以及纹理数组、Cube等等。 纹理对象可以被表现成为object,并被绑定到纹理单元上,然后进行相关操作。在使用纹理对象之前,我们你需要调用glCreateTextures去创建纹理对象。调用完毕后,我们就能拿到代表纹理对象的值, 然后我们就可以给里面塞数据,然后在我们的渲染上下文中使用它了。

纹理对象并不是直接绑定到shader上的,而是绑定到纹理单元上,纹理单元的索引会被传到shader中,因此shader是通过纹理单元得到纹理对象的。一般可以同时有多个可用的纹理单元,数量上限取决于显卡的容量。为了将一个纹理对象A绑定到纹理单元0上,首先你需要激活纹理单元0然后绑定纹理对象A。你现在可以激活纹理单元1然后绑定一个不同的(甚至是和纹理单元0绑定的相同的)纹理对象到上面,而纹理单元0还是绑定的纹理对象A。

采样操作通常发生在片段着色器中并通过一个特殊的函数来完成。采样函数需要知道从哪个纹理单元取样,因为在片断着色器中是可以从多个纹理单元中取样的,对此有一组特殊的一致变量(取样器一致变量),针对纹理目标:sampler1D,sampler2D,sampler3D,samplerCube等等。

最后一个概念是取样器对象。不要和取样器一致变量混淆,他们是不同的东西。纹理对象是同时包含有纹理数据和配置取样操作的参数的,这些参数是取样状态的一部分。然而,你也可以创建一个取样对象,用取样状态来配置它并绑定到纹理单元上,这样取样器对象将覆盖纹理对象中定义的所有取样状态。不过不用担心,目前我们还根本用不到取样器对象,先了解一下它也好。

纹理对象不仅包含纹理数据,还包含采样参数,这些采样参数称之为采样状态(sampling state)。而采样对象(sampler object)就是只包含采样参数的对象,将它绑定到纹理单元时,它会覆盖纹理对象中的采样状态,从而重新配置采样方式。

纹理单元、纹理对象对应关系如下图所示:

sampling_diagram.png

着色器通过纹理单元的索引号索引纹理单元,每个纹理单元可以绑定多个纹理到不同的目标(1D,2D)。OpenGL可以支持的纹理单元数目,一般至少有16个,依次为GL_TEXTURE0GL_TEXTURE15,纹理单元最大支持数目可以通过查询GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS常量获取。这些常量值是按照顺序定义的,因此可以采用 GL_TEXTURE0 + i 的形式书写常量,其中整数i在[0, GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS)范围内。

要使用纹理必须在使用之前激活对应的纹理单元,默认状态下0号纹理单元是激活的,因此即使没有显式地激活也能工作。激活并使用纹理的代码如下:

// 使用0号纹理单元
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureId);
glUniform1i(glGetUniformLocation(testShader->ID, "ourTexture"), 0);

上述glUniform1i将0号纹理单元作为整数传递给片元着色器,片元着色器中使用uniform变量对应这个纹理采样器,使用变量类型为:

uniform sampler2D ourTexture;

完整的片元着色器代码为:

#version 330 core
out vec4 FragColor;

in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

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

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

8 纹理单元再解释

本节来源于傅老师的OpenGL课程中对纹理单元的解释。

你可能会奇怪为什么sampler2D变量是个uniform,我们却不用glUniform给它赋值。使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为纹理单元。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以教程前面部分我们没有分配一个位置值。

pipeline2

9 多个纹理单元

上面介绍了一个纹理单元支持多个纹理绑定到不同的目标,一个程序中也可以使用多个纹理单元加载多个2D纹理。使用多个纹理单元的代码如下(先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元):

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, TexBufferA);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, TexBufferB);

testShader->use();
glUniform1i(glGetUniformLocation(testShader->ID, "ourTexture"), 0);
glUniform1i(glGetUniformLocation(testShader->ID, "ourFace"), 1);

在着色器中,对两个纹理的颜色进行混合:

#version 330 core
...

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
    FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}

对于第二个纹理我们使用一张表情图片

最后,你应该能得到下面的结果:

result

项目完整工程见仓库

10 Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus