纹理贴图是在栅格化的模型表面上覆盖图像的技术。 它是为渲染场景添加真实感的最基本和最重要的方法之一。
纹理贴图非常重要,因此硬件也为它提供了支持,使得它具备实现实时的照片级真实感的超高性能。纹理单元是专为纹理设计的硬件组件,现代显卡通常带有数个纹理单元。
为了在 OpenGL/GLSL 中有效地完成纹理贴图,需要协调好以下几个不同的数据集和机制:
图像通常存储在图像文件中,例如.jpg、.png、.gif 或.tiff 文件。为了使纹理图像用于 OpenGL 管线中的着色器, 我们需要从图像中提取颜色并将它们放入 OpenGL 纹理对象(用于保存纹理图像的内置 OpenGL 结构)中。
许多 C++ 库可用于读取和处理图像文件,常见的选择包括 Cimg 、 BoostGIL 和 Magick++。我们选择使用专为 OpenGL 设计的 SOIL2 库。
通常,将纹理加载到 OpenGL 应用程序的步骤是:
(a)使用 SOIL2 实例化 OpenGL 纹理对象并从图像文件中读入数据;
(b)调用 glBindTexture() 以使新创建的纹理对象处于激活状态;
(c)使用 glTexParameter() 函数调整纹理设置。
最终得到的结果就是现在可用的 OpenGL 纹理对象的整型 ID。
GLuint loadTexture(const char* texImagePath) {
GLuint textureID;
textureID = SOIL_load_OGL_texture(texImagePath, SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, SOIL_FLAG_INVERT_Y);
if (textureID == 0)
cout << "Could not find texture file:" << texImagePath << endl;
return textureID;
}
现在我们已经有了将纹理图像加载到 OpenGL 中的方法, 需要指定希望如何将纹理应用于对象的渲染表面。我们通过为模型中的每个顶点指定纹理坐标
来完成此操作。
纹理坐标是对纹理图像(通常是 2D 图像)中的像素的引用。纹理图像中的像素被称为纹元(texel)
,以便将它们与在屏幕上呈现的像素区分开。纹理坐标用于将 3D 模型上的点映射到纹理中的位置。除了将它定位在 3D 空间中的坐标(x,y,z)之外,模型表面上的每个点还具有纹理坐标(s,t), 用来指定纹理图像中的哪个纹元为它提供颜色。 因此,我们将设置两个缓冲区,一个用于顶点坐标(每个条目中有 3 个分量,即 x、 y 和 z),另一个用于相应的纹理坐标(每个条目中有两个分量,即 s 和 t)。这样,每次顶点着色器的调用会接收到一个顶点的数据,包括其空间坐标和相应的纹理坐标。
2D 纹理图像被设定为矩形,左下角的位置坐标为(0,0),右上角的位置坐标为(1,1)。理想情况下,纹理坐标应该在[0, 1]区间内取值。
纹理坐标(由 s 和 t 描述)将图像的部分(纹元)映射到模型正面的栅格化像素上。顶点之间的所有中间像素都已使用图像中间插值的纹元进行绘制。这正是因为纹理坐标在顶点属性中被发送到片段着色器,从而也像顶点本身一样被插值。
下面我们渲染四棱锥,只是这次用砖的图像添加纹理。我们需要指定:
(a)引用纹理图像的整型 ID;
(b)模型顶点的纹理坐标;
(c)用于保存纹理坐标的缓冲区;
(d)顶点属性,以便顶点着色器接收并通过管线转发纹理坐标;
(e)显卡上用于保存纹理对象的纹理单元;
(f)用于访问 GLSL 中纹理单元的统一采样器变量。
为了最大限度地提高性能,我们希望在硬件中执行纹理处理。这意味着片段着色器需要一种访问我们在 C++/OpenGL 应用程序中创建的纹理对象的方法。它的实现机制是通过一个叫作统一采样器变量
的特殊 GLSL 工具。这是一个变量,用于指示显卡上的纹理单元
,从加载的纹理对象中提取或“采样”纹元。
layout(binding = 0) uniform sampler2D samp;
我们声明的变量叫作 samp。声明的 layout(binding=
0)
部分指定此采样器与第 0 个纹理单元关联。
纹理单元(和相关的采样器)可以对我们希望的任何纹理对象进行采样,也可以在运行时更改。 display()函数需要指定纹理单元要为当前帧采样的纹理对象。因此,每次绘制对象时,都需要激活纹理单元并将其绑定到特定的纹理对象,例如:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, brickTexture);
可用纹理单元的数量取决于显卡提供的数量。根据 OpenGL API 文档, OpenGL 4.5 要求每个着色器阶段至少有 16 个单元,所有阶段总共至少有 80 个单元。在这个例子中,我们通过在 glActiveTexture() 调用中指定 GL_TEXTURE0,使得第 0 个纹理单元处于激活状态。
要实际执行纹理处理,我们需要修改片段着色器输出颜色的方式。以前,我们的片段着色器要么输出一个固定的颜色常量,要么从顶点属性获取颜色,而这次我们需要使用从顶点着色器(通过光栅着色器)接收的插值纹理坐标来对纹理对象进行采样。调用 texture()函数如下:
in vec2 tc;// 输入插值过的纹理坐标
...
color = texture(samp, tc);
...
GLuint brickTexture;
void setupVertices(void) {
float pyramidPositions[54] =
{ -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f, //front
1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 0.0f, 1.0f, 0.0f, //right
1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 1.0f, 0.0f, //back
-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 0.0f, //left
-1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, //LF
1.0f, -1.0f, 1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f //RR
};
float textureCoordinates[36] =
{ 0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f,
0.0f, 0.0f, 1.0f, 0.0f, 0.5f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f
};
glGenVertexArrays(1, vao);
glBindVertexArray(vao[0]);
glGenBuffers(numVBOs, vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(pyramidPositions), pyramidPositions, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(textureCoordinates), textureCoordinates, GL_STATIC_DRAW);
}
void init(GLFWwindow* window) {
...
brickTexture = Utils::loadTexture("brick1.jpg");
}
void display(GLFWwindow* window, double currentTime) {
glClear(GL_DEPTH_BUFFER_BIT);
glClearColor(0.0, 0.0, 0.0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(renderingProgram);
mvLoc = glGetUniformLocation(renderingProgram, "mv_matrix");
projLoc = glGetUniformLocation(renderingProgram, "proj_matrix");
vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));
mMat = glm::translate(glm::mat4(1.0f), glm::vec3(pyrLocX, pyrLocY, pyrLocZ));
mMat = glm::rotate(mMat, -0.45f, glm::vec3(1.0f, 0.0f, 0.0f));
mMat = glm::rotate(mMat, 0.61f, glm::vec3(0.0f, 1.0f, 0.0f));
mMat = glm::rotate(mMat, 0.00f, glm::vec3(0.0f, 0.0f, 1.0f));
mvMat = vMat * mMat;
glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(1);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, brickTexture);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glDrawArrays(GL_TRIANGLES, 0, 18);
}
顶点着色器:
#version 430
layout (location=0) in vec3 pos;
layout (location=1) in vec2 texCoord;
out vec2 tc;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
layout (binding=0) uniform sampler2D samp;
void main(void) {
gl_Position = proj_matrix * mv_matrix * vec4(pos,1.0);
tc = texCoord;
}
片段着色器:
#version 430
in vec2 tc;
out vec4 color;
layout(binding = 0) uniform sampler2D samp;
void main(void) {
color = texture(samp, tc);
}
多级渐远纹理贴图通过一种巧妙的机制来工作, 它在纹理图像中存储相同图像的连续的一系列较低分辨率的副本,所用的纹理图像比原始图像大 1/3,其中图像的 RGB 值分别存储在纹理图像空间的 3 个 1/4 区域中来实现的。剩余的 1/4 区域中迭代地将图像分辨率设置为原来的 1/4,直到剩余区域太小而不包含任何有用的图像数据。示例图像和生成的多级渐远纹理的可视化如下图所示。
这种将几个图像填充到一个小空间中的方法(只比存储原始图像所需的空间大一点)是 mipmapping 得名的原因。 mip 代表拉丁语 multumin parvo,意思是“在很小的空间里有很多东西”。
实际给对象添加纹理时,可以通过多种方法对多级渐远纹理进行采样。在 OpenGL 中,可以通过将 GL_TEXTURE_MIN_FILTER 参数设置为所需的缩小方法来选择多级渐远纹理的采样方法,可以选取以下方法之一:
三线性过滤通常是比较好的选择,因为较低的混合级别通常会产生伪影。
OpenGL 提供了丰富的多级渐远纹理支持,其中一些机制可用于构建你自己的多级渐远纹理级别,另一些机制可以让 OpenGL 为你构建它们。在大多数情况下, OpenGL 自动构建的多级渐远纹理已足够。这是通过将以下代码行添加进紧跟 getTextureObject()函数的 Utils::loadTexture()函数来实现的:
glBindTexture(GL_TEXTURE_2D, textureID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D);
上述代码用于通知 OpenGL 生成多级渐远纹理。 glBindTexture()调用激活砖纹理,然后glTexParameteri()函数调用启用前面列出的缩小方法之一,此处为 GL_LINEAR_ MIPMAP_LINEAR,即三线性过滤。
构建多级渐远纹理后,可以在 display()函数中或其他位置再次调用 glTexParameteri()来更改过滤选项(尽管很少有需要这样做的情况),甚至通过选择 GL_NEAREST 或 GL_LINEAR 来禁用多级渐远纹理。
多级渐远纹理贴图有时看起来比非多级渐远纹理贴图更模糊, 尤其是当被贴图对象以严重倾斜的视角渲染时。使用多级渐远纹理贴图在减少伪影的同时也损失了图像细节。
这种细节的丢失是因为当物体倾斜时,其图元看起来在一个轴(即沿宽或高)上的尺寸比在另一个轴上更小。当 OpenGL 为图元贴图时,它选择适合两个轴中尺寸较小的轴的多级渐远纹理(以避免“闪烁”伪影)。表面远离观察者倾斜,因此每个渲染图元将使用适合其更小尺寸(即高度)的多级渐远纹理,对其宽度来说,这个分辨率似乎太小了。
一种恢复一些丢失细节的方法是使用各向异性过滤(Anisotropic Filtering,AF)
。标准的多级渐远纹理贴图以各种正方形分辨率(如 256 像素×256 像素、 128 像素×128 像素等)对纹理图像进行采样,而各向异性过滤却以多种矩形分辨率对纹理进行采样(如 256 像素×128 像素、 64 像素×128 像素等)。这使得从各种角度观看的纹理都保留尽可能多的细节成为可能。
各向异性过滤比标准多级渐远纹理贴图的计算代价更高,并且不是 OpenGL 的必需部分。但是,大多数显卡都支持各向异性过滤(称为 OpenGL 扩展),而 OpenGL 也确实提供了一种查询显卡是否支持各向异性过滤的方法,以及一种访问各向异性过滤的方法,只需在生成多级渐远纹理贴图后立即添加代码:
...
// 如果使用多级渐远纹理贴图
glBindTexture(GL_TEXTURE_2D, textureID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D);
// 如果还使用各向异性过滤
if (glewIsSupported("GL_EXT_texture_filter_anisotropic")) {
GLfloat anisoSetting = 0.0f;
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &anisoSetting);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, anisoSetting);
}
对 glewIsSupported()进行调用可以测试显卡是否支持各向异性过滤。如果支持,我们将其设置为支持的最
大采样程度,这个最大值通过 glGetFloatv()获取。使用glTexParameterf()可以将其应用于激活纹理对象,结果如下图所示。请注意,丢失的大部分细节已经恢复,同时我们仍然消除了闪烁的伪影。
到目前为止,我们假设纹理坐标都落在[0, 1]区间。但是, OpenGL 实际上支持任何取值范围的纹理坐标。有几个选项可以用来指定当纹理坐标超出[0, 1]区间时会发生什么,可以使用glTexParameteri()设置。这些选项如下:
例如,考虑一个使用图 5.2 中纹理图像的四棱锥,其纹理坐标区间已达[0, 5],而不是通常
的[0, 1]。默认行为(即 GL_REPEAT)会导致纹理在表面上重复(有时称为“平铺”),如图 5.17
所示。
为了使平铺块的外观在原图案和其镜像之间交替,我们可以指定以下内容:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
可以按如下方式来将小于 0 或大于 1 的值设定为指定颜色:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
float redColor[4] = { 1.0f, 0.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, redColor);
下图中分别(从左到右)显示了重复、镜像重复、夹紧到边缘和夹紧到边框的效果,其中四棱锥的纹理坐标取值范围为−2~+3:
考虑一个由两个三角形组成的矩形,纹理贴图是棋盘图样,面向相机。当矩形围绕 x 轴旋转时,矩形的顶部会倾斜并远离相机,而矩形的下半部分则更靠近相机。因此,我们希望顶部的方块变小,底部的方块变大。但是,纹理坐标的线性插值将导致所有正方形的高度相等。沿着构成矩形的两个三角形接缝处的对角线加剧失真。产生的失真如下图所示。
幸运的是,存在用于校正透视失真的算法,并且默认情况下, OpenGL 在栅格化期间会应用透视校正算法。
可以通过在包含纹理坐标的顶点属性的声明中添加关键字“noperspective
”来禁用 OpenGL 的透视校正,虽然这样做并不常见。顶点着色器和片段着色器中都需要这样添加关键字。例如,顶点着色器中的顶点属性将声明如下:
noperspective out vec2 texCoord;
片段着色器中的相应属性声明为:
noperspective in vec2 texCoord;
实际上,上图中的扭曲的棋盘图样就使用了这种语法来生成图的。
我们使用的 SOIL2 纹理图像加载库具有相对简单和直观的优点。但是,在学习 OpenGL 时,使用 SOIL2 会产生一项我们不想要的后果,即用户会接触不到一些有用的重要OpenGL 细节。在本节中,我们将描述程序员在没有纹理加载库(如 SOIL2)的情况下加载和使用纹理时需要了解的一些细节。
可以使用 C++和 OpenGL 函数直接将纹理图像文件数据加载到 OpenGL 中。这虽然有点儿复杂,但并不少见。一般步骤如下:
(1)使用 C++ 工具读取图像文件数据;
(2)生成 OpenGL 纹理对象;
(3)将图像文件数据复制到纹理对象中。
我们不会详细描述第一步——有太多方法了。 opengl-tutorials 网站中很好地描述了一种方法,可以使用 C++函数 fopen()和 fread()将数据从.bmp 图像文件读入 unsigned char 类型的数组。
步骤(2)和步骤(3)更通用,主要涉及 OpenGL 调用。在步骤(2)中,我们使用 OpenGL 的glGenTextures()命令创建一个或多个纹理对象。例如,生成单个 OpenGL 纹理对象(使用整型引用 ID)可以按如下方式完成:
GLuint textureID;// 如果需要创建多于一个纹理对象,则使用 GLuint 类型的数组
glGenTextures(1, &textureID);
在步骤(3)中,我们将步骤(1)中的图像文件数据关联到步骤(2)中创建的纹理对象。这是使用 OpenGL 的 glTexImage2D()命令完成的。下面的示例将图像文件数据从步骤(1)中描述的 unsigned char 类型的数组(此处表示为 data)加载到步骤(2)创建的纹理对象中:
glBindTexture(GL_TEXTURE_2D, textureID)
glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, width, height, 0, GL_BGR, GL_UNSIGNED_BYTE, data);
此时,本章前面介绍的用于设置多级渐远纹理贴图等的各种 glTexParameteri()调用也可以应用于纹理对象。
研究人员开发了纹理单元的许多用途,不仅仅用于场景中的纹理模型。在后面的章节中,我们将看到如何使用纹理单元来改变物体反射光线,使其看起来凹凸不平。我们还可以使用纹理单元来存储“高度图”以生成地形,以及存储“阴影贴图”以有效地为场景添加阴影。
着色器还可以向纹理写入数据,允许着色器修改纹理图像,甚至将一个纹理的一部分复制到另一个纹理的某个部分。
多级渐远纹理贴图和各向异性过滤不是减少纹理中的叠影、伪影的唯一工具。例如,全屏抗锯齿( Full-Scene Anti-Aliasing, FSAA)和其他超采样方法也可以改善 3D 场景中纹理的外观。它们虽然不是 OpenGL 核心的一部分,但通过 OpenGL 的扩展机制在许多显卡上得到了支持。
还有一种用于配置和管理纹理和采样器的替代机制。 OpenGL 3.3 引入了采样器对象(有时称为“采样器状态”,不要与采样器变量混淆),可用于保存一组独立于实际纹理对象的纹理设置。将采样器对象附加到纹理单元,可以方便、 有效地更改纹理设置。