在前面的学习过程中,我们已经了解到可以在顶点数据中置入各顶点的颜色数据,让其每个顶点都呈现不同的颜色,但让我们自己去指定顶点的颜色来还原现实场景终究是不现实的,因为这样做我们需要足够多的的顶点,那么就要指定足够多的的颜色,这显然是一件繁杂且浪费效能的一件事情。
为了代替手动地指定颜色,纹理(Texture)应运而生。纹理是一张2D图片,我们要做的就是把它无缝地贴合到3D的模型上去,这样我们的3D模型就有了外表。为了还原真实,我们可以给纹理图添加很多细节,而不是去给模型增加额外的顶点。
以我们之前创建的三角形为例,要是我们想给三角形贴上一张砖墙的图片:
原图如上,而效果图如下:
这个过程叫映射,就是把纹理图映射到三角形上,要想实现这个映射就要指定三角形的坐标在纹理图的哪个位置,这就要用到两种坐标,一个是三角形自己的顶点坐标,一个是纹理图的坐标(纹理坐标(Texture Coordinate)),只要这两个坐标对上了,才知道三角形要的是纹理图的哪个部分。
纹理坐标
纹理坐标是指明在纹理图某个位置的坐标,坐标系(xy轴/uv轴/st轴)在图片的左下角,xy的范围都是0到1之间,例如砖墙的纹理坐标图就是:
某个顶点坐标要想获取纹理图某个位置的颜色就要使用位置对应的纹理坐标,这个过程叫采样(Sampling)。下图展示了如何把纹理坐标映射到三角形上的:
我们指定了3个纹理坐标点,我们可以把这3个坐标包含在顶点数据中传递给顶点着色器,接下来它们会被传片段着色器中,它会为每个片段进行纹理坐标的插值。但在考虑实现之前,我们应该先去考虑更加重要的东西,这涉及到一些原理上知识。
纹理环绕方式
纹理坐标的范围设定在了0到1之间,如果把纹理坐标设置在范围之外,那会发生什么情况,于OpenGL而言,默认的行为是把这个纹理图重复往外扩展,直至纹理坐标处,那么无论把坐标设置在哪,实际上这个点都落在这幅纹理图内。除了这个处理方式外,OpenGL还提供了更多的选择:
环绕方式 | 描述 |
---|---|
GL_REPEAT |
对纹理的默认行为。重复纹理图像。 |
GL_MIRRORED_REPEAT |
和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE |
纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER |
超出的坐标为用户指定的边缘颜色。 |
上述4种环绕方式的视角效果如下:
在此我想在拓展一下,在Unity里关于纹理文件的纹理环绕方式也是有相似的选择:
其中Repeat就是
GL_REPEAT
,Clamp就是GL_CLAMP_TO_EDGE
,Mirror就是GL_MIRRORED_REPEAT
,而对于GL_CLAMP_TO_BORDER
我的Unity版本并没有这个环绕方式。而在Unity中还能设置纹理坐标的缩放倍数:
只要大于(1, 1)纹理图就会根据选择的环绕方式进行范围外的平铺。
现在我知道如何在Unity里选择纹理环绕方式,那OpenGL呢?OpenGL提供了
glTexParameter*
函数来设置纹理环绕方式,且还能单独对一个坐标轴设置,即两个坐标轴可以有不同的环绕方式。例如:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
glTexParameteri(GLenum target, GLenum pname, GLint param)
:第一个参数指定纹理目标,我们使用的是2D纹理,所以是GL_TEXTURE_2D
,除此之外还有3D和1D纹理;第二个参数指定设置纹理的哪个属性项和应用于哪个轴,我们要设置的是纹理的环绕方式(WRAR)以及S轴和T轴,所以是GL_TEXTURE_WRAP_S
和GL_TEXTURE_WRAP_T
;第三个参数指定环绕方式,我们选择了GL_MIRRORED_REPEAT
镜像重复方式。
另外要注意的是,在使用这个函数之前,要先绑定好纹理对象。在后面我们会讨论到如何创建和绑定一个纹理对象,而纹理对象其实是一个管理存储了纹理图所有信息的缓冲区(与VBO管理顶点数据缓冲区类似)的对象。在绑定纹理对象时,要先说明绑定什么纹理目标给当前纹理对象,然后接下来的设置纹理图各种属性项时依旧要指定纹理目标,在绑定对象时已经指定好的纹理目标,而后为什么还要重复一遍呢?至此我的理解是,一个纹理对象其实是可以容纳多种纹理目标的,即GL_TEXTURE_1D
、GL_TEXTURE_2D
、GL_TEXTURE_3D
等等都可以有,且可以同时绑定,我使用了绑定函数,使用了GL_TEXTURE_2D
参数,然后我再次调用绑定函数,使用了GL_TEXTURE_3D
参数,不会发生冲突。而在后续的设置纹理对象属性的时候指定纹理坐标,也是为了区分这种情况。
纹理过滤
如果一张低分辨率(像素少)的纹理图要贴在一个很大的平面上,纹理坐标(任意浮点值)的数量还多过像素的数量,那么OpenGL该如何将纹理像素(Texture Pixel)映射到纹理坐标上呢?OpenGL有纹理过滤(Texture Filtering)的功能来解决这个问题。纹理过滤有很多种,这了只讨论最重要的两种:GL_NEAREST
和GL_LINEAR
。
Texture Pixel也叫Texel,你可以想象你打开一张.jpg格式图片,不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。
-
GL_NEAREST
邻近过滤,Nearest Neighbor Filtering。是OpenGL默认的纹理过滤方式。当采用这个过滤方式时,OpenGL会选择中心点最接近纹理坐标的像素作为该纹理坐标的像素值。下图中有4个像素点,加号是纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
-
GL_LINEAR
线性过滤, Bilinear Filtering。 采用该过滤方式时,会基于纹理坐标附近的纹理像素,计算出一个插值(取平均值),一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。这个值作为样本颜色。
我们可以看看两种过滤方式作用于同一张低分辨率的图会有什么不同的效果:
可以看到邻近过滤后的图能看到颗粒状的像素,有点锯齿的感觉;而线性过滤后能看到比较平滑的图。
对于怎么设置纹理图的过滤方式,其操作与设置环绕方式类似,使用相同的函数,设置不同的参数而已:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
除了缩小(Minify)的时候要进行纹理过滤操作,放大(Magnify)也需要进行同样操作,那么就要通过TEXTURE_MIN_FILTER
和GL_TEXTURE_MAG_FILTER
参数为其设置各自的过滤方式。
多级渐远纹理
观察现象,提出问题。现在我有一个平面,平铺了一张纹理图上去,如图所示:
能看出它是通过Repeat的方式铺满整个平面的。现在我把镜头拉到无限远处:
原本铺满整个屏幕的平面现在只是一个小方格。那么OpenGL该如何为纹理坐标采样呢?
一个游戏场景有着很多的物体,每个物体上都有纹理,且物体有远有近。远处物体的纹理与近处物体的纹理一样有着高分辨率(高像素),但远处的物体只会产生很少的片段(如上图),OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。
为此,OpenGL使用多级渐远纹理(Mipmap)来解决这个问题。即作出相同图案但不同大小的一系列图像,后一个纹理图是前一个的二分之一。
在观察着超过物体一定距离时,就会使用切换物体当前的纹理图像,换成适合当前距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。
另外OpenGL非常贴心地帮我们准备好了代替手动创建多级纹理图的方法,
glGenerateMipmaps
函数能够在我们导入了纹理图后自动为其生成一系列的多级纹理图。就是说我们只需导入上面砖墙的第一张,后面的几张它会帮我们生成。
在切换多级纹理图时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。即切换的过程很突兀,由其是观察者在距离阈值边界来回走动时,这种突兀就更明显。不过我们可以像纹理过滤一样,在切换时做邻近过滤或线性过滤的处理,这样切换就会显得平滑流畅。OpenGL提供了同时设置纹理过滤和不同多级渐远纹理级别之间过滤的参数:
过滤方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST |
使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样 |
GL_LINEAR_MIPMAP_NEAREST |
使用最邻近的多级渐远纹理级别,并使用线性插值进行采样 |
GL_NEAREST_MIPMAP_LINEAR |
在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样 |
GL_LINEAR_MIPMAP_LINEAR |
在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样 |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
需要注意的是放大(Magnify)过滤是不需要考虑多级远纹理过滤的,因为多级渐远纹理主要是使用在纹理被缩小的情况下的。
实践
讨论了这么多关于纹理的相关知识,是时候来实现把纹理图加载到我们创建的图案中了。
1.加载纹理图
纹理图像有可能是各种存储格式,例如.png或.jpg等,每种都有自己的数据结构和排列,例如png图片有Alpha通道,而jpg没有。读取不同格式的纹理图就需要不同的图片加载器,把图片的数据转为字节序列,如果我们自己编写图片加载器那势必是一个非常复杂的工作,且已经偏离了学习OpenGL的重点。那么最好的解决方法是使用前辈造好的轮子——一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说我们要用的stb_image.h库。
stb_image.h
stb_image.h是Sean Barrett的一个非常流行的单头文件图像加载库,它能够加载大部分流行的文件格式,并且能够很简单得整合到你的工程之中。我们要做的就是下载它,并导入到自己的工程中:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
通过定义STB_IMAGE_IMPLEMENTATION
预处理器会修改头文件,让其只包含相关的函数定义源码,等于是将这个头文件变为一个 .cpp 文件了。现在只需要在你的程序中包含stb_image.h并编译就可以了。
我们先把要用到的图放入自己的项目中,一张container是木箱图,一张awesomeface是笑脸图:
我们使用该库的stbi_load
函数来导入我们的木箱图,原图如下:
//读取纹理图片
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
该函数能获取图片的宽、高、颜色通道数和各种数据(存放在字符数组中),这些数据我们之后都会用到。
2.创建纹理对象
在上面已经对纹理对象稍作讨论,得知纹理对象其实与VBO类似,都是管理缓冲区的对象,所以其创建的操作也与创建VBO大同小异:
//木箱
unsigned int textureBuffer1;
glGenTextures(1, &textureBuffer1);
当然也少不了绑定操作:
glBindTexture(GL_TEXTURE_2D, textureBuffer1);
在绑定之后设置该纹理对象的纹理环绕方式和过滤方式:
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);
最后是把刚加载的纹理图像数据灌进纹理对象管理的缓冲区中,并生成多级渐远纹理图:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexImage2D(GLenum targe, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void *pixels)
:
- target: 第一个参数同样是指定纹理目标。设置为
GL_TEXTURE_2D
,意味着目前加载的纹理图像数据是针对目前绑定对象的GL_TEXTURE_2D
目标,即使现在同时绑定了同一个纹理对象GL_TEXTURE_3D
目标,这个目标的缓冲区不会受影响。 - level: 第二个参数是指定多级渐远纹理的层级,0是基本级别。
- internalformat: 第三个参数是指定该纹理图的存储格式。我们的图像只有RGB值,所以就存储为RGB格式。
- width:设置最终纹理的宽度,在加载时我们已经保存了它,使用它就是了。
- height:设置最终纹理的高度,同上。
- border:应该总是被设为0(历史遗留的问题)
- format:指定源图的格式,由于我们的图像只有RGB值所以指定为RGB格式。与 internalformat不同,这个是用什么格式读取,而不是存储为什么格式。
- type:指定源图的数据类型,我们在加载时用的是字符(BYTE)数组,所以是
GL_UNSIGNED_BYTE
。 - pixels:最后一个参数是真正的图像数据。
生成了纹理和相应的多级渐远纹理后,释放图像的内存是一个很好的习惯。
stbi_image_free(data);
应用纹理
现在我们的纹理数据已经存储在了纹理对象管理的缓冲区内,是时候修改顶点数据和着色器源码来读取它们了。
- 顶点数据
因为导入的纹理图是木箱,所以我们打算绘制一个矩形来对整个木箱图进行采样。
float vertices[] = {
//坐标 //颜色 //纹理
0.5f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,
0.5f, -0.5f,0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f
};
因为我们绑定了EBO,所以把索引了一并修改了:
unsigned int indices[] = { // 注意索引从0开始!
0, 1, 2,// 第一个三角形
2, 3, 0
};
顶点数据已经发生了变化,此时要相应作出调整肯定还有链接顶点属性函数的参数值:
可以看到现在一个顶点数据组包含了3个坐标值、3个颜色值、两个纹理坐标值,相应每个数据的步长都应纹理坐标值的加入而发生了变化:
//链接顶点属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 8, (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 8, (void*)(sizeof(float) * 3));
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(float) * 8, (void*)(sizeof(float) * 6));
glEnableVertexAttribArray(2);
接下来是两个着色器的源码,首先是顶点着色器。要添加一个顶点属性来接收顶点数据里的纹理坐标值,然后直接输出给片段着色器:
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1
layout (location = 2) in vec2 aTextCoord;
out vec3 ourColor; // 向片段着色器输出一个颜色
out vec2 textCoord;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0f);
ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
textCoord = aTextCoord;
}
然后在片段着色器把输出变量aTextCoord作为输入变量。片段着色器作为给片段计算颜色的存在,那么它必须知道纹理对象管理的纹理数据,不然只有纹理坐标是没法给顶点采样的。那么我们怎样把纹理对象传递给片段着色器呢?GLSL提供一种数据类型叫采样器(Sampler),我们可以通过创建该类型的uniform变量,这样在绑定纹理对象的时候,就会自动把数据赋值给该Sampler变量,至于为什么,稍后我们会讨论。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 textCoord;
uniform sampler2D ourTexture1;
void main()
{
//FragColor = vec4(ourColor, 1.0f);
FragColor = texture(ourTexture1, textCoord);
}
在片段着色器获得纹理坐标和纹理数据后,使用GLSL内建的texture
函数来进行采样。第一个参数就是包含了纹理数据的纹理采样器,第二个参数是对应的纹理坐标。该函数会根据之前设置的纹理属性(使用glTexParameteri
函数进行的相关设置),对纹理坐标进行采样。
由于在此之前已经绑定好了纹理对象,如若没有解绑,即可直接在渲染循环中调用绘制函数,进行绘制:
while (!glfwWindowShouldClose(window))
{
//处理输入
processInput(window);
//渲染指令
glClearColor(1.0f, 0, 0, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glBindVertexArray(VAO[0]);
shader.use();
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
//接收输入,交换缓冲
glfwSwapBuffers(window);
glfwPollEvents();
}
如果运行没有错误,那么就是见到如下图像:
在片段着色器的源码内,我接受了来自顶点着色器的颜色数据ourColor但是没有使用,现在我们可以来使用一下,让这个木箱变得稍微炫酷起来:
纹理单元
你现在或许会产生当初我学习纹理时的疑问,就是明明没有对这个uniform变量使用glUniform
函数进行赋值啊,它是怎么获取到纹理数据的。因为在OpenGL中存在纹理单元这么一个概念,一个片段着色器是可有多个纹理单元的(最多16个),一个纹理单元就是存储同一组纹理数据的位置,16个纹理单元对应0-15的位置,第一个纹理(Sampler变量)默认的纹理单元是位置0,位置0是默认激活的纹理单元。所以在前面我们并没有给纹理分配位置。在位置0激活的情况下,函数glBindTexture
就会把纹理数据绑定到激活的纹理单元下,然后就不用刻意调用glUniform
函数对Sampler变量赋值。
但是如果有多于1组的纹理数据要传递给片段着色器,那么就要在绑定前告知你要放在哪个纹理单元(位置)上,当然你可以不用按顺序从0到1,你可以第一个绑定5的位置,第二个绑定3的位置,只要你在调用激活函数时指定好位置就行了。
现在我打算再导入一张纹理图,是一张笑脸图(awesomeface.png),看看有多组纹理数据时是怎么进行传递给片段着色器的。
//笑脸
data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
unsigned int textureBuffer2;
glGenTextures(1, &textureBuffer2);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textureBuffer2);
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);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
可以看到在绑定纹理对象前要激活纹理单元,说明该纹理对象对应某指定纹理单元。上面是激活1的位置。接下来需要修改片段着色器来接收另一组纹理数据:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 textCoord;
uniform sampler2D ourTexture1;
uniform sampler2D ourTexture2;
void main()
{
//FragColor = vec4(ourColor, 1.0f);
FragColor = mix(texture(ourTexture1, textCoord),texture(ourTexture2, textCoord), 0.2);
//FragColor = texture(ourTexture1, textCoord) * vec4(ourColor, 1.0);
}
在输出颜色处使用了mix
函数对两个纹理的颜色进行混合。mix
函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。如果第三个值是0.0,它会返回第一个输入;如果是1.0,会返回第二个输入值。0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。
现在虽然不需要使用glUniform
函数来传递纹理数据,但是需要使用该函数来告知指定采样器(Sampler变量)是对标哪个纹理单元的,如果只有一个纹理单元和1个采样器就可以不用告知。就是说你可以把第一个宣告的采样器指定为位置1的纹理单元而第二个宣告的采样器指定为位置0的采样器。
shader.use();
shader.setInt("ourTexture1",0);
shader.setInt("ourTexture2", 1);
通过使用glUniform1i
设置采样器,我们保证了每个uniform采样器对应着正确的纹理单元。你应该能得到下面的结果:
诶,怎么这个笑脸是上下颠倒的?这是因为OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部。很幸运,stb_image.h能够在图像加载时帮助我们翻转y轴,只需要在加载任何图像前加入以下语句即可:
stbi_set_flip_vertically_on_load(true);
现在笑脸的方向就正常了。