英文原文:https://learnopengl.com/Getting-started/Textures
我们了解到,为了给我们的物体增加更多的细节,我们可以为每个顶点使用颜色来创造一些有趣的图像。然而,为了获得相当程度的真实感,我们必须要有很多顶点,这样我们就可以指定很多颜色。这需要相当多的额外开销,因为每个模型需要更多的顶点,每个顶点也需要一个颜色属性。
艺术家和程序员一般喜欢的是使用纹理。纹理是一种二维图像(甚至存在一维和三维纹理),用来给物体增加细节;把纹理想象成一张纸,上面有一个漂亮的砖头图像(例如),整齐地叠在你的三维房子上,这样看起来你的房子就有一个石头的外表。因为我们可以在一张图片中插入大量的细节,所以我们可以给人一种物体非常详细的错觉,而不需要指定额外的顶点。
除了图像之外,纹理也可以用来存储大量的任意数据集合,以便发送给着色器,但我们将把这个问题留给另一个话题。
下面你会看到一个砖墙的纹理图像,它被映射到上一章的三角形上。
为了将纹理映射到三角形上,我们需要告诉三角形的每个顶点它对应于纹理的哪一部分。因此,每个顶点都应该有一个与之相关的纹理坐标,指定从纹理图像的哪一部分取样。然后由片段插值来完成其他片段的插值。
纹理坐标在x轴和y轴上的范围是0到1(记住,我们使用的是二维纹理图像)。使用纹理坐标检索纹理颜色被称为采样。纹理坐标从纹理图像的左下角的(0,0)开始到纹理图像的右上角的(1,1)。下图显示了我们如何将纹理坐标映射到三角形上。
我们为三角形指定3个纹理坐标点。我们希望三角形的左下侧与纹理的左下侧相对应,所以我们为三角形的左下侧顶点使用(0,0)纹理坐标。同样的情况也适用于右下侧,使用(1,0)纹理坐标。三角形的顶部应该与纹理图像的顶部中心相对应,所以我们采用(0.5,1.0)作为其纹理坐标。我们只需要将3个纹理坐标传递给顶点着色器,然后再将这些坐标传递给片段着色器,后者将每个片段的所有纹理坐标整齐地插值。
然后产生的纹理坐标将看起来像这样:
float texCoords[] = {
0.0f, 0.0f, // lower-left corner
1.0f, 0.0f, // lower-right corner
0.5f, 1.0f // top-center corner
};
纹理采样有一个松散的解释,可以用许多不同的方式来完成。因此,我们的工作是告诉OpenGL它应该如何对其纹理进行采样。
纹理包裹(Texture Wrapping)
纹理坐标的范围通常在(0,0)到(1,1)之间,但是如果我们指定的坐标超出这个范围会怎样呢?OpenGL的默认行为是重复纹理图像(我们基本上忽略了浮点纹理坐标的整数部分),但OpenGL提供了更多的选项。
- GL_REPEAT:纹理的默认行为。重复纹理图像。
- GL_MIRRORED_REPEAT:与GL_REPEAT相同,但每次重复都会对图像进行镜像。
- GL_CLAMP_TO_EDGE: 夹住0和1之间的坐标。结果是更高的坐标被夹在边缘,导致边缘图案被拉伸。
- gl_clamp_to_border: 范围外的坐标现在被赋予用户指定的边界颜色。
当使用默认范围以外的纹理坐标时,每个选项都有不同的视觉输出。让我们看看这些在纹理图像样本上是什么样子的(原始图像由Hólger Rezende制作)。
上述每个选项都可以通过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)。
第一个参数指定了纹理目标;我们使用的是2D纹理,所以纹理目标是GL_TEXTURE_2D。第二个参数要求我们说出我们要设置的选项以及哪个纹理轴;我们要为S轴和T轴配置它。最后一个参数要求我们传入我们想要的纹理包装模式,在这种情况下,OpenGL将在当前活动的纹理上设置其纹理包装选项GL_MIRRORED_REPEAT。
如果我们选择GL_CLAMP_TO_BORDER选项,我们还应该指定一个边框颜色。这是用相当于glTexParameter函数的fv来完成的,GL_TEXTURE_BORDER_COLOR是它的选项,我们传入一个边界颜色值的浮点数组。
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
纹理过滤(Texture Filtering)
纹理坐标不取决于分辨率,而是可以是任何浮点值,因此OpenGL必须找出纹理像素(也被称为texel)来映射纹理坐标。如果你有一个非常大的物体和一个低分辨率的纹理,这就变得特别重要。你现在可能已经猜到了,OpenGL也有关于这种纹理过滤的选项。有几个选项可用,但现在我们将讨论最重要的选项。GL_NEAREST和GL_LINEAR。
GL_NEAREST(也称为近邻或点过滤)是OpenGL的默认纹理过滤方法。当设置为GL_NEAREST时,OpenGL会选择中心最接近纹理坐标的文本。下面你可以看到4个像素,其中的十字代表了准确的纹理坐标。左上角的texel的中心最接近纹理坐标,因此被选为采样的颜色。
GL_LINEAR(也称为(bi)linear filtering)从纹理坐标的相邻texels中获取一个插值,近似于texels之间的颜色。从纹理坐标到一个texel中心的距离越小,该texel的颜色对采样颜色的贡献就越大。下面我们可以看到,一个相邻像素的混合颜色被返回。
但是这样的纹理过滤方法的视觉效果如何呢?让我们看看在大型物体上使用低分辨率的纹理时,这些方法是如何工作的(纹理因此被向上缩放,单个纹理是明显的)。
GL_NEAREST的结果是,我们可以清楚地看到形成纹理的像素,而GL_LINEAR产生一个更平滑的图案,单个像素不那么明显。GL_LINEAR产生更真实的输出,但有些开发者更喜欢8位的外观,因此选择GL_NEAREST选项。
纹理过滤可以被设置为放大和缩小操作(当向上或向下缩放时),因此你可以例如在纹理向下缩放时使用近邻过滤,而对向上缩放的纹理使用线性过滤。因此,我们必须通过glTexParameter*为两个选项指定过滤方法。代码看起来应该与设置包裹方法类似。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
Mipmaps
想象一下,我们有一个大房间,里面有成千上万的物体,每个物体都有一个附加的纹理。 远处的物体与靠近观察者的物体具有相同的高分辨率纹理。 由于对象距离很远并且可能只产生几个片段,OpenGL 很难从高分辨率纹理中为其片段检索正确的颜色值,因为它必须为跨越大部分纹理的片段选择纹理颜色 . 这会在小物体上产生可见的伪像,更不用说在小物体上使用高分辨率纹理会浪费内存带宽了。
为了解决这个问题,OpenGL 使用了一个称为 mipmaps 的概念,它基本上是纹理图像的集合,其中每个后续纹理都是前一个纹理的两倍。 mipmaps 背后的想法应该很容易理解:在距观察者一定距离阈值之后,OpenGL 将使用最适合对象距离的不同 mipmap 纹理。 由于物体距离较远,因此用户不会注意到较小的分辨率。 然后 OpenGL 能够对正确的纹素进行采样,并且在对那部分 mipmap 进行采样时涉及的缓存内存更少。 让我们仔细看看经过 mipmap 处理的纹理是什么样的:
手动为每个纹理图像创建一个 mipmapped 纹理集合很麻烦,但幸运的是,OpenGL 能够在我们创建纹理后通过一次调用 glGenerateMipmap 为我们完成所有工作。
在渲染期间在 mipmap 级别之间切换时,OpenGL 可能会显示一些伪像,例如两个 mipmap 层之间可见的锐边。 就像普通的纹理过滤一样,也可以使用 NEAREST 和 LINEAR 过滤在 mipmap 级别之间进行过滤,以便在 mipmap 级别之间切换。 要指定 mipmap 级别之间的过滤方法,我们可以用以下四个选项之一替换原始过滤方法:
- GL_NEAREST_MIPMAP_NEAREST:采用最近的mipmap来匹配像素大小,并使用最近邻插值进行纹理采样。
- GL_LINEAR_MIPMAP_NEAREST:采用最近的 mipmap 级别并使用线性插值对该级别进行采样。
- GL_NEAREST_MIPMAP_LINEAR:在最接近像素大小的两个 mipmap 之间进行线性插值,并通过最近邻插值对插值级别进行采样。
- GL_LINEAR_MIPMAP_LINEAR:在两个最接近的mipmap之间进行线性插值,并通过线性插值对插值级别进行采样。
就像纹理过滤一样,我们可以使用 glTexParameteri 将过滤方法设置为上述 4 种方法之一:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是将 mipmap 过滤选项之一设置为放大过滤器。 这没有任何影响,因为 mipmaps 主要用于纹理缩小时:纹理放大不使用 mipmaps 并且给它一个 mipmap 过滤选项将生成 OpenGL GL_INVALID_ENUM 错误代码。
加载和创建纹理
要实际使用纹理,我们需要做的第一件事是将它们加载到我们的应用程序中。 纹理图像可以以多种文件格式存储,每种格式都有自己的结构和数据顺序,那么我们如何在应用程序中获取这些图像呢? 一种解决方案是选择我们想要使用的文件格式,比如 .PNG 并编写我们自己的图像加载器以将图像格式转换为大量字节。 虽然编写自己的图像加载器不是很难,但它仍然很麻烦,如果你想支持更多文件格式怎么办? 然后,您必须为要支持的每种格式编写一个图像加载器。
另一种解决方案,可能是一个不错的解决方案,是使用支持多种流行格式并为我们完成所有艰苦工作的图像加载库。 像 stb_image.h 这样的库。
stb_image.h
stb_image.h 是 Sean Barrett 开发的一个非常流行的单头图像加载库,它能够加载最流行的文件格式,并且很容易集成到您的项目中。 stb_image.h 可以从这里下载。 只需下载单个头文件,将其作为 stb_image.h 添加到您的项目中,并使用以下代码创建一个额外的 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);
该函数首先将图像文件的位置作为输入。 然后它希望您提供三个整数作为它的第二个、第三个和第四个参数,stb_image.h 将用生成的图像的宽度、高度和颜色通道数填充。 我们需要图像的宽度和高度以便稍后生成纹理。
生成纹理
与之前 OpenGL 中的任何对象一样,纹理是通过 ID 引用的; 让我们创建一个:
unsigned int texture;
glGenTextures(1, &texture);
glGenTextures 函数首先将我们想要生成的纹理数量作为输入,并将它们存储在一个无符号整数数组中作为它的第二个参数(在我们的例子中只是一个无符号整数)。 就像我们需要绑定它的其他对象一样,任何后续纹理命令都将配置当前绑定的纹理:
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);
这是一个具有相当多参数的大型函数,因此我们将逐步介绍它们:
- 第一个参数指定纹理目标;将此设置为 GL_TEXTURE_2D 意味着此操作将在同一目标的当前绑定纹理对象上生成纹理(因此任何绑定到目标 GL_TEXTURE_1D 或 GL_TEXTURE_3D 的纹理都不会受到影响)。
- 如果您想手动设置每个 mipmap 级别,第二个参数指定我们要为其创建纹理的 mipmap 级别,但我们会将其保留在基础级别,即 0。
- 第三个参数告诉 OpenGL 我们希望以何种格式存储纹理。我们的图像只有 RGB 值,因此我们也将存储具有 RGB 值的纹理。
- 第 4 个和第 5 个参数设置生成的纹理的宽度和高度。我们在加载图像时存储了它们,因此我们将使用相应的变量。
下一个参数应该总是 0(一些遗留的东西)。 - 第 7 个和第 8 个参数指定源图像的格式和数据类型。我们用 RGB 值加载图像并将它们存储为字符(字节),因此我们将传递相应的值。
- 最后一个参数是实际的图像数据。
一旦 glTexImage2D 被调用,当前绑定的纹理对象现在已经附加了纹理图像。 然而,目前它只加载了基本级别的纹理图像,如果我们想使用 mipmap,我们必须手动指定所有不同的图像(通过不断递增第二个参数),或者我们可以在生成纹理后调用 glGenerateMipmap。 这将自动为当前绑定的纹理生成所有必需的 mipmap。
在我们完成生成纹理及其相应的 mipmap 后,释放图像内存是一个很好的做法:
stbi_image_free(data);
因此,生成纹理的整个过程看起来像这样:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 设置纹理warp/过滤选项(在当前绑定的纹理对象上)
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);
// 加载并生成纹理
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);
应用纹理
对于接下来的部分,我们将使用来自 Hello Triangle 章节最后一部分的 glDrawElements 绘制的矩形。 我们需要通知 OpenGL 如何对纹理进行采样,因此我们必须使用纹理坐标更新顶点数据:
float vertices[] = {
// positions // colors // texture coords
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // top right
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // top left
};
由于我们添加了一个额外的顶点属性,我们必须再次通知 OpenGL 新的顶点格式:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(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;
}
然后片段着色器应该接受 TexCoord 输出变量作为输入变量。
片段着色器也应该可以访问纹理对象,但是我们如何将纹理对象传递给片段着色器呢? GLSL 有一个内置的纹理对象数据类型称为采样器,它以我们想要的纹理类型作为后缀,例如 sampler1D、sampler3D 或者在我们的例子中是 sampler2D。 然后我们可以通过简单地声明一个 uniform 的 sampler2D 来向片段着色器添加一个纹理,我们稍后将我们的纹理分配给它。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
为了对纹理的颜色进行采样,我们使用 GLSL 的内置纹理函数,该函数的第一个参数是纹理采样器,第二个参数是相应的纹理坐标。 纹理函数然后使用我们之前设置的纹理参数对相应的颜色值进行采样。 此片段着色器的输出是纹理在(插值)纹理坐标处的(过滤)颜色。
现在剩下要做的就是在调用 glDrawElements 之前绑定纹理,然后它会自动将纹理分配给片段着色器的采样器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
如果你做的一切正确,你应该看到下图:
如果您的矩形完全是白色或黑色,则您可能在此过程中犯了错误。 检查您的着色器日志并尝试将您的代码与应用程序的源代码进行比较。
如果您的纹理代码不起作用或显示为全黑,请继续阅读并按照您的方式找到最后一个应该起作用的示例。 在某些驱动程序上,需要为每个采样器统一分配一个纹理单元,这是我们将在本章中进一步讨论的内容。
为了有点时髦,我们还可以将生成的纹理颜色与顶点颜色混合。 我们简单地将生成的纹理颜色与片段着色器中的顶点颜色相乘以混合两种颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
结果应该是顶点颜色和纹理颜色的混合:
我想你可以说我们的容器喜欢迪斯科。
纹理单位
你可能想知道为什么 sampler2D 变量是 uniform 的,如果我们甚至没有用 glUniform 给它赋值的话。 使用 glUniform1i 我们实际上可以为纹理采样器分配一个位置值,这样我们就可以在片段着色器中一次设置多个纹理。 纹理的这个位置通常被称为纹理单元。 纹理的默认纹理单元是 0,这是默认的活动纹理单元,因此我们不需要在上一节中分配位置; 请注意,并非所有图形驱动程序都分配默认纹理单元,因此上一节可能没有为您呈现。
纹理单元的主要目的是允许我们在着色器中使用多于 1 个纹理。 通过将纹理单元分配给采样器,我们可以一次绑定到多个纹理,只要我们首先激活相应的纹理单元即可。 就像 glBindTexture 一样,我们可以使用 glActiveTexture 传递我们想要使用的纹理单元来激活纹理单元:
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
激活纹理单元后,随后的 glBindTexture 调用会将该纹理绑定到当前活动的纹理单元。 纹理单元 GL_TEXTURE0 始终默认激活,因此我们在使用 glBindTexture 时不必在前面的示例中激活任何纹理单元。
OpenGL 应该至少有 16 个纹理单元供您使用,您可以使用 GL_TEXTURE0 到 GL_TEXTURE15 激活它们。 它们是按顺序定义的,因此我们也可以通过 GL_TEXTURE0 + 8 获得 GL_TEXTURE8,例如,这在我们必须循环多个纹理单元时很有用。
然而,我们仍然需要编辑片段着色器以接受另一个采样器。 现在这应该是相对简单的:
#version 330 core
...
uniform sampler2D texture1;
uniform sampler2D texture2;
void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
最终输出颜色现在是两个纹理查找的组合。 GLSL 的内置混合函数将两个值作为输入,并根据其第三个参数在它们之间进行线性插值。 如果第三个值为 0.0,则返回第一个输入; 如果它是 1.0,它返回第二个输入值。 值为 0.2 将返回第一个输入颜色的 80% 和第二个输入颜色的 20%,从而导致两种纹理的混合。
我们现在要加载并创建另一个纹理; 你现在应该熟悉这些步骤了。 确保创建另一个纹理对象,加载图像并使用 glTexImage2D 生成最终纹理。 对于第二个纹理,我们将在学习 OpenGL 时使用您的面部表情图像:
unsigned char *data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
请注意,我们现在加载包含 alpha(透明)通道的 .png 图像。 这意味着我们现在需要使用 GL_RGBA 指定图像数据也包含一个 alpha 通道; 否则 OpenGL 将错误地解释图像数据。
要使用第二个纹理(和第一个纹理),我们必须通过将两个纹理绑定到相应的纹理单元来稍微更改渲染过程:
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); // set it manually
ourShader.setInt("texture2", 1); // or with shader class
while(...)
{
[...]
}
通过 glUniform1i 设置采样器,我们确保每个 uniform 采样器对应于正确的纹理单元。 您应该得到以下结果:
您可能注意到纹理颠倒了! 发生这种情况是因为 OpenGL 期望 y 轴上的 0.0 坐标位于图像的底部,但图像通常在 y 轴的顶部具有 0.0。 对我们来说幸运的是,通过在加载任何图像之前添加以下语句,stb_image.h 可以在图像加载期间翻转 y 轴:
stbi_set_flip_vertically_on_load(true);
在加载图像时告诉 stb_image.h 翻转 y 轴后,您应该得到以下结果:
如果你看到一个快乐的容器,你就做对了。 您可以将其与源代码进行比较。