OpenGL学习笔记(四)纹理
参考资料:OpenGL中文翻译
要完成的纹理效果
纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;你可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的3D的房子上,这样你的房子看起来就像有砖墙外表了。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。
为了能够把纹理映射(Map)到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样(译注:采集片段颜色)。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。
纹理坐标在x和y轴上,范围为0到1之间(注意我们使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。
纹理坐标看起来就像这样:
GLfloat texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};
它可以采用几种不同的插值方式,所以我们需要自己告诉OpenGL该怎样对纹理采样。
纹理环绕方式
纹理坐标的范围通常是从(0, 0)到(1, 1),如果我们把纹理坐标设置在范围之外,那么OpenGL会默认重复这个纹理图像,也可以选择其他的行为:
环绕方式(Wrapping) | 描述 |
---|---|
GL_REPEAT |
对纹理的默认行为。重复纹理图像。 |
GL_MIRRORED_REPEAT |
和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE |
纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER |
超出的坐标为用户指定的边缘颜色。 |
具体效果如图所示:
前面提到的每个选项都可以使用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);
glTexParameter*
函数参数:
- 纹理目标,这里使用的是2D纹理,因此纹理目标是
GL_TEXTURE_2D
- 指定设置的选项与应用的纹理轴,这里配置的是
WRAP
选项,并且指定S
或T
轴 - 环绕方式,既是上面表中的一个选项
如果我们选择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);
纹理过滤
纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST
和GL_LINEAR
。
GL_NEAREST
(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL 默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
GL_LINEAR
(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:
两种纹理过滤方式的视觉效果对比:
GL_NEAREST
产生了颗粒状的图案,我们能够清晰看到组成纹理的像素GL_LINEAR
能够产生更平滑的图案,很难看出单个的纹理像素,即可以产生更真实的输出
当进行 放大(Magnify)和缩小(Minify) 操作的时候可以设置纹理过滤的选项,比如你可以在纹理 (1)被缩小的时候使用邻近过滤,(2)被放大时使用线性过滤。我们需要使用glTexParameter*
函数为放大和缩小指定过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
<--第二个参数为 GL_TEXTURE_MIN_FILTER 或 GL_TEXTURE_MAG_FILTER
多级渐远纹理
想象一下,假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。
OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会 使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的:
OpenGL有一个glGenerateMipmaps
函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。
在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:
过滤方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样 |
GL_LINEAR_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样 |
GL_NEAREST_MIPMAP_LINEAR | 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样 |
GL_LINEAR_MIPMAP_LINEAR | 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样 |
就像纹理过滤一样,我们可以使用glTexParameteri
将过滤方式设置为前面四种提到的方法之一.
加载与创建纹理
使用纹理之前要做的第一件事是把它们加载到我们的应用中。纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列,所以我们如何才能把这些图像加载到应用中呢?一个解决方案是选一个需要的文件格式,比如.PNG,然后自己写一个图像加载器,把图像转化为字节序列。写自己的图像加载器虽然不难,但仍然挺麻烦的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。
另一个解决方案也许是一种更好的选择,使用一个支持多种流行格式的图像加载库来为我们解决这个问题。此时可以使用stb_image.h这个图像加载库,这个库只有一个头文件,使用起来非常方便,只需要include进来即可使用(这里没有使用官方教程推荐的SOIL库,因为那个库还需要.lib等文件,比较麻烦)。
stb_image库的使用方法
int width, height, nrChannel;
unsigned char* image = stbi_load(filename, &width, &height, &nrChannel, 0);
stbi_load
函数的参数:
- 文件名
- 接收宽度信息的变量
- 接收高度信息的变量
- 接收通道信息的变量
- 忘记了
这样我们即可获得图像的字节序列。
生成纹理对象
GLuint texture;
glGenTextures(1, &texture);
glGenTextures
函数参数:
- 纹理对象数量
- 存储id的GLuint数组(这里为一个位置)
然后我们需要将这个对象绑定到OpenGL上下文中:
glBindTexture(GL_TEXTURE_2D, texture);
然后我们使用前面载入的图片数据来对刚刚新建的纹理对象进行设置。Mipmap可以通过glTexImage2D来生成:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
glTexImage2D
函数参数:
- 指定的纹理目标(Target),这里指定GL_TEXTURE_2D,即将图片的信息设置到bind到OpenGL上下文的纹理对象中
- 指定多级渐远纹理的级别,这里我们填0,也就是基本级别
- 希望把纹理储存为何种格式,当前图片只有RGB
- 纹理的宽度
- 纹理的高度
- 填0(历史遗留问题)
- 源图的格式和数据类型
- 图像数据字节序列
当调用glTexImage2D
时,当前绑定的纹理对象就会被附加上纹理图像。
然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。
或者,直接在生成纹理之后调用glGenerateMipmap
。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。(一般直接使用这个即可)
接着是释放图片资源和unbind纹理对象:
stbi_image_free(image);
glBindTexture(GL_TEXTURE_2D, 0);
生成和设置一个纹理对象的全过程:
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 为当前绑定的纹理对象设置环绕、过滤方式
...
// 加载并生成纹理
int width, height, nrChannel;
unsigned char* image = stbi_load(filename, &width, &height, &nrChannel, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(image);
glBindTexture(GL_TEXTURE_2D, 0);
应用纹理
准备新顶点数据:
GLfloat 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(0, 3, GL_FLOAT,GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT,GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 2, GL_FLOAT,GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(6 * sizeof(GLfloat)));
glEnableVertexAttribArray(2);
接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:
// 顶点着色器代码
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 color;
layout (location = 2) in vec2 texCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(position, 1.0f);
ourColor = color;
TexCoord = texCoord;
}
片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D、sampler3D,或在我们的例子中的sampler2D。我们可以简单声明一个uniform sampler2D把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个uniform。
#version 330 core
in vec3 ourColor;
in vec2 TexCoord;
out vec4 color;
uniform sampler2D ourTexture; <--纹理对象使用的数据类型
void main()
{
color = texture(ourTexture, TexCoord);
}
我们使用GLSL内建的texture
函数来采样纹理的颜色
它的参数
- 纹理采样器
- 纹理坐标。
texture函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。
现在只剩下在调用glDrawElements
之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器:
// 主循环内渲染部分代码
glBindTexture(GL_TEXTURE_2D, texture); //绑定纹理
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
运行结果:
把得到的纹理颜色与顶点颜色混合,来获得更有趣的效果:
color = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0f);
运行结果:
纹理单元
你可能会奇怪为什么sampler2D
变量是个uniform
,我们却不用glUniform给它赋值。使用glUniform1i
,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以教程前面部分我们没有分配一个位置值。
纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture
一样,我们可以使用glActiveTexture
激活纹理单元,传入我们需要使用的纹理单元:
glActiveTexture(GL_TEXTURE0); //在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
激活纹理单元之后,接下来的glBindTexture
函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元GL_TEXTURE0
默认总是被激活,所以我们在前面的例子里当我们使用glBindTexture的时候,无需激活任何纹理单元。
我们仍然需要编辑片段着色器来接收另一个采样器:
#version 330 core
...
uniform sampler2D ourTexture1;
uniform sampler2D ourTexture2;
void main()
{
color = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord), 0.2);
}
GLSL内建的mix
函数参数:
前面两个值是两个vec4,第三个参数是第二个参数的比例,即:如果第三个值是0.0,它会返回第一个输入;如果是1.0,会返回第二个输入值;0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。
为了使用两个纹理,我们必须改变一点渲染流程,(1)先绑定两个纹理到对应的纹理单元,(2)然后定义哪个uniform采样器对应哪个纹理单元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glUniform1i(glGetUniformLocation(ourShader.Program, "ourTexture1"), 0);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
glUniform1i(glGetUniformLocation(ourShader.Program, "ourTexture2"), 1);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
使用glUniform1i
设置uniform采样器的位置值,或者说纹理单元id。通过glUniform1i
的设置,我们保证每个uniform采样器对应着正确的纹理单元。
注意点:网站提供的awesomeface是.png格式的图片,使用glTexImage2D
函数读取时需要使用GL_RGBA:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image);
运行结果:
在顶点着色器反转后:
得到预期的结果。