说明:跟着learnopengl的内容学习,不是纯翻译,只是自己整理记录。
强烈推荐原文,无论是内容还是排版。 原文链接
本文地址: http://blog.csdn.net/aganlengzi/article/details/50421006
为了使我们创建的对象(比如说三角形)更加生动,我们已经学习了为对象的每个像素点设置不同的颜色来使它变得更加有趣。但是,在实际应用中,这种方式需要我们为模型创建太多的颜色,还要完成颜色与点的映射,工作量巨大,这不现实。
一种普遍为人们所接受的方式是使用纹理。纹理是二维图像(虽然也有一维或者三维纹理的存在),用于描述对象的细节。比如用一张印有砖头的图像贴到一个三维的房子对象的表面上,这个三维的房子变得逼真。因为图像上能够呈现的细节是非常多的,所以我们可以让我们的创建的对象呈现出非常逼真的细节,而无需为它添加更多的点或者颜色,比如门、窗等等。
除了图像,纹理也可以用于存储要发送给shader的大量数据,我们将会在一次单独的教程给予讲解。
本教程中,你将学会怎样将一张砖头的图像映射到之前我们已经创建的三角形上。如下图所示:
为了将一个纹理映射到三角形上,我们需要为三角形的每个顶点绑定相关纹理的哪个位置。每个顶点都应该关联一个纹理坐标,这个坐标指定了这个点要绑定的纹理部分。片段(一个要显示在屏幕上的像素点的所有信息)插值完成其他片段的绑定。
纹理坐标在x和y坐标(我们使用的是二维图像)上的取值范围都是[0,1]。利用纹理坐标来获得纹理颜色的过程叫做采样。纹理坐标中的原点(0,0)在一个纹理的左下角,(1,1)在其右上角。下图展示了我们怎样将纹理坐标映射到三角形:
我们为三角形指定了三个坐标点。我们想要将这个矩形二维图像的左下角映射到我们创建三角形的左下角;将这个矩形图形的右下角映射到三角形的右下角;将这个矩形图片的上边缘中点映射到三角形的上顶点,这正好是矩形图片中三角形砖墙的范围。在图中显示的结果就是,图片的(0,0)坐标点映射到左下角,(1,0)坐标点映射到右下角,(0.5,1)坐标点映射到上顶点。我们只需给顶点处理器发送三个纹理坐标,而顶点处理器将它们传递给片段处理器,片段处理器利用插值的方法完成所有点的映射。
所以上述我们需要指定的纹理坐标如下所示:
GLfloat texCoords[] = {
0.0f, 0.0f, // Lower-left corner
1.0f, 0.0f, // Lower-right corner
0.5f, 1.0f // Top-center corner
};
纹理采样是比较笼统的说法,它可以通过多种方式完成,完成的效果当然各有不同。所以我们的一项重要工作就是告诉OpenGL如何进行纹理采样。
纹理坐标的范围通常是从(0,0)到(1,1),但是当我们指定的坐标值超出这个范围怎么办呢?OpenGL默认的处理方式是一值重复这张图片,但是实际上我们有多种方式可供选择。
GL_REPEAT
: 默认的方式,重复纹理GL_MIRRORED_REPEAT
: 和重复纹理相似,但是重复的是镜像处理过的纹理GL_CLAMP_TO_EDGE
: 在纹理和边缘之间拉伸纹理GL_CLAMP_TO_BORDER
: 坐标之外显示为用户定义的边缘颜色当我们以以上三种不同方式制定坐标值在规定的(0,1)之外时,每一种方式都会产生不同的效果。如下图所示:
前面提到的不同的方式都可以通过glTexParameter*函数设置没个坐标轴(s,t或者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);
函数的第一个参数指定了纹理目标;我们使用的是二维图像,所以纹理目标是GL_TEXTURE_2D
。
第二个参数指定了我们想要设置的坐标轴。最后一个参数指定了我们想要设置的包裹模式,以方便OpenGL在设置当前激活的纹理的包裹方式时选择我们指定的方式。
需要注意的是,如果我们指定的是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);
纹理坐标不依赖分辨率,可以使任何的浮点值,因此OpenGL必须要解析哪一个纹理坐标应该映射到哪一个纹理像素(texture pixel,简写texel)。如果你想要将一个低分辨率的纹理映射到一个很大的对象上的时候,这就显得尤为重要。你可能会猜想OpenGL肯定提供了纹理像素向屏幕像素之间的映射选项。确实,有很多可选的方式,但是目前我们讨论最重要的两个选项GL_NEAREST
和GL_LINEAR
。
GL_NEAREST
(也叫作最邻近过滤)是OpenGL采取的默认纹理过滤方式。当过滤方式设置成GL_NEAREST
,OpenGL选择最靠近纹理坐标的像素点。在下图中,你可以看到4个像素点,中间那个十字号表示纹理坐标的精确位置。左上角的纹理像素的中心是和精确的纹理坐标值最接近的,所以被选为采样的颜色,如下图所示:
GL_LINEAR(也叫作(双)线性过滤),它选择纹理坐标临近纹理像素的颜色值的插值作为采样的颜色。离纹理坐标越近的点的颜色值就越多地被采样。下图可见,返回的是临近像素的混合颜色值:
但是这两种过滤方式下的真实视觉效果是怎么样的呢?让我们试着将一个低分辨率的纹理绑定到一个比这个纹理尺寸要大的对象上的效果(这种情况下,纹理会被拉伸,我们应该能够看到独立的像素点)。
使用GL_NEAREST方式的结果是我们能够清楚地看到一个个的像素点,但是使用GL_LINEAR方式产生的效果完全不同,它产生了一种更为平滑的效果,单独的像素点在这种方式下不容易被看到。GL_LINEAR方式产生了更实际的效果,但是一些开发者更喜欢那种像素点的效果所以选择GL_NEAREST方式。
纹理过滤可以用于设置放大U或者缩小操作,所以你可以在缩小的时候使用最邻近方式,但是在放大的时候使用线性过滤方式。因此我们需要通过glTexParameter*函数指定这两种选项。代码如下所示:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
假设我们有一个分厂大的空间,里面有成千上万的对象,每一个对象都要贴图。在人眼看到的场景中,必然有很多对象在很远处,但是它们有着和近处对象一样高分辨率的贴图。因为这些对象对于观察者来说在很远处,那么它们可能只会产生很少的片段。OpenGL很难在高分辨率的纹理中采样正确的颜色值,因为它不得不从一大块纹理中为一个片段取出一个颜色值(这是不值得的)。这将会在小的对象上面产生视觉上的错误,更不用提在小对象上使用高分辨率纹理的内存浪费。
OpenGL使用一种叫做变频译码的方式来解决这个问题,简单来说,就是一组纹理图像,它们中每一个后来的纹理都是原来的1/2*1/2大小,如下图所示。其中的原理是显而易见的:在举例观察者一定距离的时候,OpenGL将会使用一个与这个距离最佳匹配的不同的变频译码纹理。因为这个对象在很远处,较小的分辨率观察者也不会觉察出来。并且mipmaps对性能也有好处。
手工创建一系列类似于上面的变频映射图像是繁杂的,但是幸运的是,OpenGL为我们提供了这项功能。只需要在我们创建一个纹理之后调用glGenerateMipmaps,后面你将会看到使用的实例。
在不同的变频映射纹理之间选择的时候,OpenGL可能会造成一些显而易见的瑕疵,比如说两种规格的纹理之间边缘错位,无法对接。像正常的纹理过滤,在mipmap的不同等级之间进行过滤也是可以的,实际上和正常的纹理过滤的原理是类似的。在mipmap等级改变的时候,我们也可以使用NEAREST或者LINEAR过滤的方式来消除上面所述的问题。一下是可以用来原始过滤方式的四种选项:
正如纹理过滤设置方法一样,我们需要使用glTexParameteri函数来设置选择以上的四种方式之一,如下所示:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是设置这四种方式中的一种为放大映射,这种方式不会产生任何效果,因为mipmap最主要的应用是在缩小的时候使用(对象在远处,观察者看到的对象是变小的)。所以纹理放大的时候是不用mipmap的,如果为GL_TEXTURE_MAG_FILTER设置了上述mipmap选项中的一种,那么OpenGL就会报GL_INVALID_ENUM错误。
在使用纹理之前,我们首先要做的是将它们加载到我们的应用中。纹理图像可以通过多种格式存储,每一中都有不同的数据结构和组织方式,那么我们应该怎样在应用中得到这些纹理的数据呢?一种解决方法是我们选定一种喜欢的图像格式,例如.PNG并且自己写一个图像加载和转换模块,将这种格式的纹理转换成二维数组格式的数据进行存储。虽然写这个模块并不是太繁琐,但是如果你想要使用更多的图像格式应该怎么办?再写一个?最终是不是将要为你使用的每一种纹理格式都写一个对应的纹理图像加载模块?
另一种可能更好的解决方法是,使用一个支持多种常用图像个数的图像加载函数库,由它来帮助我们完成纹理的加载,即由不同格式的图像加载到我们的程序中变成一个存储着纹理数据的二维数组。就像我们使用的SOIL一样。
SOIL(Simple OpenGL Image Library)支持最常用的多种图像格式,并且非常易用,你可以从这儿下载到。像其它你已经使用的函数库(GLFW,GLEW)一样,你可能需要自己生成.lib文件。
下面就是在讲怎么配置和使用SOIL了。
我配置的方法是:
1)下载得到压缩包soil.zip
2)解压后用Visual Studio 2012打开soil.zip\Simple OpenGL Image Library\projects\VC9的工程,并同意做单向版本提升
3)编译得到Debug/SOIL.lib
4)将yourpathto\soil.zip\Simple OpenGL Image Library\src添加到工程的包含路径下
5)将your\path\to\SOIL.lib添加到工程的库目录下
6)在编写的工程源文件中添加头文件#include
int width, height;
unsigned char* image = SOIL_load_image("container.jpg", &width, &height, 0, SOIL_LOAD_RGB);
这个函数的第一个参数是图像文件container.jpg的位置,第二个和第三个参数是图像的宽度和高度。我们需要在后面生成纹理的时候使用到这两个参数。第四个参数指定了这个图像含有的通道数,但是目前我们将这个值设置成0.最后一个参数指定了SOIL应该以何种方式加载这个图像:我们只对图像的RGB分量感兴趣,加载的结果是将图像存储成一个大的字符/字节数组。
就像之前生成OpenGL中的对象一样,每个纹理生成的时候也绑定一个唯一的ID,如下所示:
GLuint texture;
glGenTextures(1, &texture);
glGenTextures函数的第一个参数指定我们要生成纹理个数,它们的ID将会保存到第二参数中,第二个参数是一个地址,可以使一个变量(在第一个参数为1的情况下)或者是数组的起始地址。我们的例子中因为只生成一个纹理,所以我们给的参数是1和变量texture的地址。像之前的VBO,VAO 和EBO等对象,我们将纹理绑定到其目标上以方便我们对当前绑定的纹理进行操作:
glBindTexture(GL_TEXTURE_2D, texture);
现在这个纹理对象已经绑定到了二维纹理目标上(实际是将其ID进行了绑定),接下来我们就可以利用之前加载的图像数据来生成纹理了。纹理的生成需要使用glTexImage2D函数完成:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
这个函数是一个有很多参数的“大”函数,以下对每个参数进行逐一讲解:
第一个参数指定纹理的目标,我们将GL_TEXTURE_2D作为实参传进去表示接下来的操作将对绑定到GL_TEXTURE_2D的纹理对象进行操作,而不会操作绑定到GL_TEXTURE_1D或者绑定到G_TEXTURE_3D目标的纹理对象。
第二个参数指定了mipmap的级别,实际上我们可以手工设置每一个创建的mipmap的级别,但是我们目前只是将这个值设置为基础级别,也就是0.
第三个参数指定了OpenGL应该以什么样的数据类型来存储创建的纹理。我们的图像应为只加载了RGB信息,所以这里设置的是GL_RGB。
第四个和第五个参数指定了生成纹理的宽度和高度。我们之前已经保存了我们设置的值,这里传进去的就是这两个变量。
下一个参数总是0,先不用管。
第七个和第八个参数指定了源图像的格式和数据类型。我们在加载的时候因为加载的是RGB信息,所以这里指定的是GL_RGB,同时我们之前说过,加载的图像会被保存成字符或者字节类型,所以这里传进去的实参是GL_UNSIGNED_BYTE。
最后一个参数是实际的图像数据地址,也就是我们之前利用那个库加载和转换的数据。
在调用glTexImage2D之后,当前绑定的纹理对象就已经附加上了纹理图像。但是,当前它只有基础级别的mipmap(因为我们目前指定的就只是基础级别的),如果我们想要使用其它级别的mipmap,一种方法是我们需要手工改变第二个参数来生成不同级别的纹理;另一种方法是在生成纹理之后调用glGenerateMipmap函数,它将会自动为我们生成当前绑定纹理对象所有级别的mipmap。
在我们生成了纹理和对应的mipmap之后,释放图像内存和(为特定目标)解绑纹理对象是一种好的习惯。
SOIL_free_image_data(image);
glBindTexture(GL_TEXTURE_2D, 0);
以上纹理生成的整个过程大致是下面代码描述的样子:
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// Set the texture wrapping/filtering options (on the currently bound texture object)
...
// Load and generate the texture
int width, height;
unsigned char* image = SOIL_load_image("container.jpg", &width, &height, 0, SOIL_LOAD_RGB);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
SOIL_free_image_data(image);
glBindTexture(GL_TEXTURE_2D, 0);
下面我们将会使用在前面的几个教程中创建的由两个三角形拼成的矩形来演示怎样使用纹理。我们首先要设置OpenGL采样纹理的方式,所以我们先来修改之前的顶点数据,向其中添加纹理坐标:
GLfloat 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
};
因为设置了纹理坐标,那么之前的关于数据放入内存的方式和VBO的设置以及OpenGL解释数据的方式都要进行更新。目前在内存中数据的组织方式如下图所示:
所以要首先修改vertex shader,让OpenGL能够正确读入数据,并且将纹理坐标信息输出到片段处理程序:
#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;
}
然后指定VBO的设置和OpenGL解释数据的方式:
glVertexAttribPointer(2, 2, GL_FLOAT,GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(6 * sizeof(GLfloat)));
glEnableVertexAttribArray(2);
需要注意的是,我们在设置location=2即刚加载的纹理数据的时候的步长指定为8 * sizeof(GLfloat),在设置position和color的步长的时候也需要设置这个值。
片段处理程序中,我们首先设置其接收顶点处理程序中传递进来的纹理坐标,其次,只知道坐标是不能完成颜色值的采样的,还必须能够访问到纹理数据。但是怎样才能够让片段处理程序访问到纹理对象的数据呢?GLSL中专为纹理对象内嵌了一个数据类型sampler*,后缀是我们要访问的纹理类型,比如说sampler1D,sampler2D等。所以我们可以通过uniform类型的变量很方便地为片段处理程序添加一个纹理,如下所示的sampler2D类型的变量ourTexture,注意这个ourTexture是在OpenGL程序中当前绑定的纹理对象,这也正是使用uniform的原因。
#version 330 core
in vec3 ourColor;
in vec2 TexCoord;
out vec4 color;
uniform sampler2D ourTexture;
void main()
{
color = texture(ourTexture, TexCoord);
}
我们利用GLSL内嵌的纹理颜色采样函数texture来完成采样,这个函数的第一个参数是一个sampler数据类型的变量,第二个参数是相应的纹理坐标。这个texture函数对ourTexture指定的纹理数据通过texCoord指定的纹理坐标进行采样,采样的方式是我们之前已经讲过的默认或者已经设置过的方式(最邻近或者插值)。
目前整个纹理对象的使用剩下的就是在调用渲染函数glDrawElements之前对将这个纹理对象进行绑定了。没错利用我们之前讲过的能够存储状态设置信息的VAO来实现:
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);
得到的效果应该是纹理颜色值和指定的顶点颜色值插值结果的混合颜色;
还不错~
前面说过,我们在fragment shader中设置的uniform sampler2D类型的变量并没有像之前我们使用uniform类型的变量一样需要在OpenGL函数中对其值进行设定。其值默认已经设置成了我们之前绑定的纹理对象。实际上我们也可以在代码中指定我们要操作的纹理对象的位置。
纹理单位的主要目的是让我们在shader中使用超过一个纹理对象。通过为sampler指定纹理单位,我们可以一次绑定多个纹理,只要我们能够在此之前激活相关的纹理单位。就像调用glBindTexture函数一样,我们可以通过glActiveTexture函数激活我们想要使用的纹理,如下所示:
glActiveTexture(GL_TEXTURE0); // Activate the texture unit first before binding texture
glBindTexture(GL_TEXTURE_2D, texture);
激活了纹理单位之后,随后的glBindTexture调用就会绑定纹理到当前激活的纹理单位上。上面代码中指定的纹理单位GL_TEXTURE0实际上是一致默认激活的,所以在之前的代码中我们调用glBindTexture中不需要对纹理单位进行任何的激活操作。
以下的讲解就是如何使用多个纹理。
OpenGL中至少应该有16个纹理单位供我们使用,使用上面的函数可以激活GL_TEXTURE0到GL_TEXTURE15的纹理单位。它们是线性组织的,所以我们也可以通过GL_TEXTURE0 + 8的方式指定GL_TEXTURE8,这在我们使用循环对多个纹理单位进行操作的时候就会显得十分好用。但是我们还应该通过编辑fragment shader的方式来接受另一个sampler对象,这个是相对简单的:
#version 330 core
...
uniform sampler2D ourTexture1;
uniform sampler2D ourTexture2;
void main()
{
color = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord), 0.2);
}
现在通过这个片段处理器产生的最终颜色就是两个纹理颜色的混合值了。GLSL的内置mix函数要求两个参数值,需要对这两个颜色值进行线性插值:上述的texture函数产生颜色值,而mix函数产生这两个颜色值的混合值,混合的方式是由第三个参数指定的:如果第三个参数是0.0,那么整个mix函数返回的就是第一个参数值,如果第三个参数值是1.0,那么返回的就是第二个参数值。所以0.2代表值返回的颜色值是第一个参数的80%和第二个参数的20%的颜色混合值。
我们现在加载和创建另一个纹理。我们应该已经对这个过程比较熟悉了。首先确保创建另一个纹理对象,加载一个图像(利用上面讲到的SOIL)并且通过glTexImage2D函数生成纹理,第二个纹理图像我们将使用你学习OpenGL时候的表情来生成,就是这个。
为了使用两个纹理对象,我们需要对绘制过程进行一点修改,为的是像上面所说的,将两个纹理都绑定到激活的(不同)纹理单位上,并且需要指定哪一个纹理对象对应着哪一个纹理单位:
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函数来为我们设置的sampler对象设置我们的纹理单位的位置。这样我们将对应的uniform变量(也就是sampler变量)设置成正确的纹理单位(GL_TEXTURE0和GL_TEXTURE1),我们应该得到的效果是这样的:
我们注意到我们的笑脸图案是倒置的,产生这种现象的原因是OpenGL默认原点坐标在图像的左下角但是图像通常认为原点坐标在左上角,所以就会造成了倒置的现象。有一些类似于SOIL的加载图像的库是提供加载的时候对y轴进行设置的选项的,但是SOIL没有。
目前我们解决这个问题的方法有两个:
第一种方法是在指定纹理坐标的时候将y坐标轴的坐标值反向(原来的坐标应该绑定在下面的设置成绑定到上面,倒个)。
我们可以通过设置vertex shader来帮助我们完成上述y轴坐标的交换,实际上很简单,只需要将原来的坐标值做如下的操作:TexCoord = vec2(texCoord.x, 1.0f - texCoord.y)。
上述的两种方法实际上只是针对我们这个例子的具体方法,在真正的实现的时候两种方式只能算是奇技淫巧。。。。。。可能在大工程中使用会出乱子的。最好的方式还是在图像数据加载的时候进行,或者是将图像本身进行反转操作。这样的话,得到的数据是适合于OpenGL使用的。
不管使用了哪种方法,我们做过处理之后得到最终的显示效果应该是如下图所示的这样的(我使用的方法是将原图像进行倒置):
最终的源码在这儿包括vertex shader和fragment shader。