本文同时发布在我的个人博客上:https://dragon_boy.gitee.io
请多多参考原文:https://learnopengl.com/Getting-started/Textures
纹理
经过前面的学习,我们可以通过赋予顶点颜色属性来丰富图形的细节,但若是为了获的更为真实的图像,手动的赋予是个很复杂的事。为了解决这一问题,下面来介绍一下纹理贴图技术。纹理是一张2维平面图像(也可以是1维和3维),应用纹理到模型可以想象成将图片按某种方式包裹住一个模型,这样就可以获得颜色丰富的模型(当然贴图还有其他的用法,这里就先不介绍)。
下面是一个纹理的例子,我们将下面的砖瓦图片应用到之前的三角形上:
为了能够正确地将纹理映射到三角形上,我们需要判断需要那一部分图片需要映射,并指定每个纹理坐标与每个顶点坐标相对应,其他的颜色片段由片元插值完成。
纹理的坐标系以左下角为原点,为右手坐标系,范围在1*1的方格内,下面这张图展示了映射规则:
由此我们可以重新定义纹理坐标(顺序与顶点的定义一致):
float texCoords[] = {
0.0f, 0.0f,
1.0f, 0.0f,
0.5f, 1.0f
};
接下来需要进行纹理的采样,实现的方式有很多种,我们需要告知OpenGL要使用哪种采样方式。
纹理映射
纹理坐标通常位于1*1的小方格内,如果超出这个范围的话,OpenGL会重复纹理图片。OpenGL提供了以下四种方式:
- GL_REPEAT : 默认的方式,重复纹理图片。
- GL_MIRRORED_REPEAT : 类似于GL_REPEAT,但每次重复都会镜像图片。
- GL_CLAMP_TO_EDGE : 将坐标的范围限制在(0, 0)到(1, 1)内,边缘会造成拉伸效果。
-
GL_CLAMP_TO_BORDER : 超出范围的区域设置为用户自定义的边界颜色。
每种方式的效果图如下:
我们可以通过glTexParameter方法来设置映射方式。我们需针对两个轴进行设置,s对应x,t对应y(如果是三维纹理的话r对应z):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
glTexParameteri第一个参数为纹理目标,这种概念和前几篇文章的缓冲对象目标类似;第二个参数为纹理轴向;第三个为映射方法。(方法名最后的i代表整型,这种概念在着色器一章提到过)
当然如果我们想使用GL_TEXTURE_BORDER需要传入边界颜色,不需要分轴向映射:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
纹理滤镜
由于图片是由像素点构成的,所以我们需要告知OpenGL如何选择像素点映射到对应的纹理坐标上。关于这一点,OpenGL提供了几个选项,常用的是GL_NEARESE和GL_LINEAR。
GL_NEAREST是默认的纹理滤镜方式,在这种方式下,OpenGL会选择里纹理坐标最近的像素点来进行映射。如下是一个例子,十字线代表纹理坐标的位置:
GL_LINEAR按周围像素点的距离影响获得一个插值结果,离纹理坐标越近,对颜色的影响越大。如下是一个例子:
下面是针对同一张图片应用两种不同方式的结果:
可以看到,GL_NEAREST可以较为清晰的看到像素点,而GL_LINEAR有一种模糊的效果。可以按实际情况选择不同的方式。
贴图滤镜可以为放大和缩小图片设置,缩小时我们应用GL_NEAREST,放大是我们应用GL_LINEAR是一种比较好的办法。同样,我们通过glTexParameteri方法来为放大和缩小进行滤镜设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
Mipmaps
在实际应用中,我们针对一个模型可能会准备两套纹理,在视角靠近模型时,我们绘制用高分辨率纹理,当视角远离时,我们又会换上低分辨率纹理来节省资源。针对高分辨率纹理,通过纹理坐标获取正确的像素点颜色是比较困难的,尤其是将高分辨率图片应用到较小的物体上时。
为解决这一问题,OpenGL使用Mipmaps的技术,简要来说就是大量同一类型纹理的集合,按顺序排列,后一张纹理是前一张纹理的大小的一半,OpenGL会按照与视角之间的距离切换不同的纹理应用到模型上。Mipmaps大概长这样:
虽说这项工作很复杂,但OpenGL已经帮我们做好了大部分工作,我们可以通过glGenerateMipmaps方法来设置mipmaps。
当然,就和纹理映射一样,转换不同的mipmap时可能会有点视觉上的问题,我们同样可以添加相应的滤镜,这里OpenGL提供了四种方式:
- GL_NEAREST_MIPMAP_NEAREST
- GL_LINEAR_MIPMAP_NEAREST
- GL_NEAREST_MIPMAP_LINEAR
- GL_LINEAR_MIPMAP_LINEAR
我们可以将GL_****和MIPMAP_****拆开理解,相当于MIPMAP方式和滤镜的组合。同样,可以使用glTexParameteri方法来设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是为图片放大时设置mipmaps滤镜,这并不会起作用,按照之前的说明,mipmaps主要是为缩小时使用的,若设置的话会报错。
加载和创建纹理
如今,图片的格式非常之多,如果手动为每种图片格式设置加载的代码会是一件很麻烦的事。当然,我们使用现成的库,如stb_image.h,下载地址在这里。我们只需要这个头文件就可以。按照文件内部的说明,我们在其他包含文件调用后,这样使用stb_image.h:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
接下来的案例我们会使用下面这张木箱纹理:
使用stb_load方法加载图片:
int width, height, nrChannels;
unsigned char* data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
stb_load方法的第一个参数为图片的路径;第二个和第三个参数为图片的宽和高地址;第四个参数为图片的通道地址;最后一个参数设为0即可。方法将返回图片的信息。
生成纹理
和之前的对象一样,绑定一个ID:
unsigned int texture;
glGenTexture(1, &texture);
绑定纹理对象到对应位置
glBindTexture(GL_TEXTURE_2D, texture);
接着通过之前的图片数据生成纹理,使用glTexImage2D,并使用glGenerateMipmap生成mipmaps:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexImage2D方法的第一个参数时纹理目标,即纹理对象所绑定的位置;第二个参数代表mipmap等级,如果想手动修改就设为其他的数,这里我们简单的设置为0;第三个参数为结果纹理的通道模式;第四和五个参数代表纹理的宽高;第六个参数一定要设为0(历史原因);第七个参数代表源文件的通道模式;第八个参数代表源文件的数据类型;最后一个参数是要加载的图像数据。
现在纹理图片已经附加在纹理对象上了,但我们仍需手动设置Mipmaps,所以我们需要在生成纹理后调用glGenerateMipmap方法。
上述工作结束后,我们释放一下内存空间:
stbi_image_free(data);
综合上述流程,完整的生成纹理的过程如下:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 设置映射方式和滤镜
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);
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);
应用纹理
接下来我们在之前绘制四边形的代码的基础上应用纹理。首先修改一下顶点的属性:
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
};
同样,顶点的属性构成如下:
我们添加纹理坐标对应的顶点属性指针:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
接着修改顶点着色器,新增纹理坐标作为输入和输出:
#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;
}
片元着色器将纹理坐标作为输入,但如何将纹理对象传递给片元着色器?GLSL有一个内建的数据类型sampler,同时将1D,2D,3D作为后缀,这里我们使用sampler2D。我们可以用uniform声明一个sampler2D的纹理变量,之后我们可以将纹理传递给这个变量:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
为了采样纹理的颜色我们使用内建的texture方法,第一个参数接受一个纹理采样器,第二个参数对应纹理坐标。
在上述工作结束后,接下来要做的就是在渲染循环中的绘制命令前绑定纹理,这个方法会自动的将纹理对象传递给片元着色器的纹理采样器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
编译并运行,就会得到这样的结果:
在这里贴上原文代码供参考:Code
注意到我们并未在片元着色器中使用自定义的颜色属性,我们可以修改一下输出:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
然后可以得到这样的效果:
纹理单元
根据经验我们会发现一个问题,sampler2D类型的变量我们使用uniform方式声明的,但却不需要额外的赋予变量一个值。因为这里就一张贴图,这么做没什么问题,但若是有多张贴图的话,我们就需要手动的赋值了。跟之前一样,我们需要获取纹理变量的位置并通过glUniform方法赋值,这种纹理的位置我们成为纹理单元。
在面对多张纹理的情况时,我们只需要先激活对应位置的纹理单元,然后在进行纹理的绑定即可。这里我们使用glActiveTexture来激活纹理单元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
在激活纹理单元后,glBindTexture将纹理对象绑定至对应的纹理单元。在OpenGL中,有0-15共16个纹理单元可以使用,GL_TEXTURE0至GL_TEXTURE15,当然,由于是按顺序定义的,也可以使用GL_TEXTUREx+y(如GL_TEXTURE8可以表示为GL_TEXTURE0 + 8)这种方式来表示纹理单元,这在循环时非常有用。
下面这个例子我们为正方形贴上两张贴图,片元着色器修改如下:
#version 330 core
...
uniform sampler2D texture1;
uniform sampler2D texture2;
void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
我们使用内建的mix方法来混合两张贴图,第三个参数代表后一个纹理的权重。
我们将下面这张图片作为第二张纹理:
同样配置好各种纹理对象的参数,映射方式和滤镜,注意,图片的加载略有不同:
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);
}
由于图片为.png格式,带有透明通道,所以其中一个代表源文件的通道模式的参数我们改为GL_RGBA,结果纹理的通道目前暂时设为GL_RGB即可。
就像之前说的,我们需要先设置uniform变量:
ourShader.use(); //一定要先激活着色器程序
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置方式(所赋值对应纹理单元的序号)
ourShader.setInt("texture2", 1); //使用我们的自定义类进行设置
接着在渲染循环中如下设置,注意先激活纹理单元再绑定纹理对象:
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);
若设置正确可得到下面的结果:
注意到图片反了,这是由于OpenGL中定义纹理坐标时以左下角为原点,而图片往往以左上角为原点,我们用下面的命令在加载图片的时候翻转一下:
stbi_set_flip_vertically_on_load(true);
最终效果如下:
这里给出原文代码供参考:Code