【一步步学OpenGL 16】 -《纹理贴图》

时间:2021-08-30 03:23:18

教程16

纹理贴图基础

【一步步学OpenGL 16】 -《纹理贴图》
原文: http://ogldev.atspace.co.uk/www/tutorial16/tutorial16.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

纹理贴图意思是将任意类型的图片贴在3d模型的一个或者多个面上。图片可以是任意的但通常是一种通用的样式,比如:砖块、植物、荒芜的土地等等,可以提高场景的真实性。比较下面两幅图片:
【一步步学OpenGL 16】 -《纹理贴图》

为了实现纹理贴图我们需要做三件事:将一张贴图加载到OpenGL中,提供纹理坐标和顶点(将纹理对应匹配到顶点上),并使用纹理坐标从纹理中进行取样操作取得像素颜色。由于三角形会被缩放、旋转、平移变换导致最后会以不同的结果投影显示到屏幕上,而且由于camera的不同操作看上去也会很不一样。GPU要做的就是让纹理紧跟三角形图元顶点的移动使其看上去真实(如果纹理看上去明显游离在三角形上产生错位就不真实了)。为实现这个效果开发者需要为每个顶点提供一系列纹理坐标。在GPU光栅化三角形阶段,会对纹理坐标进行插值计算并覆盖到整个三角形面上,并且在片段着色器中开发者要将这些坐标跟纹理进行匹配。这个操作叫做‘取样’,取样的结果叫做‘纹素’(纹理中的一个像素)。纹素通常包含一个颜色值用于画屏幕上对应的一个像素。后面的教程中我们会看到纹素可以包含不同的数据类型来产生不同的效果。

OpenGL支持几种不同类型的纹理:1D,2D,3D,立方体等等,可以应用于不同的技术中。现在这里就先只使用2D纹理。一张2D纹理可以在某些特殊限制下有任意宽度和高度的,宽度和高度相乘可以计算得到纹素的数量。那么如何确定纹理坐标和顶点呢?事实上不是的,坐标并不是在纹理中纹素的坐标,那样局限性太大了,因为这样如果要用一张宽度高度不一样的纹理替换一张纹理的话我们得更新所有顶点的坐标来匹配新的纹理图片。理想的方案是要能够在不改变纹理坐标的情况下随意更换纹理贴图。因此,纹理坐标是定义在‘纹理空间’的,也就是定义在单位化的[0,1]范围内。也就是说纹理坐标事实上是个分数,纹理的宽度高度乘以相应的比例分数就可以算出纹素在纹理中的坐标。例如:如果纹理坐标是[0.5,0.1]并且纹理的宽度为320,高度为200,那么纹素在纹理中的坐标为(160,20)(0.5 * 320 = 160 和 0.1 * 200 = 20)。

通常的约定是使用U和V作为纹理空间中的轴线,U对应于2D坐标系的X轴,V对应于Y轴。在OpenGL中对UV轴上的值的处理方式为:在U轴上从左往右递增,V轴上从下往上递增(原点在左下角)。看下面的示意图:
【一步步学OpenGL 16】 -《纹理贴图》

这张图展示了纹理空间坐标系,可以看到原点在左下角,U往右递增,V往上递增。现在思考一个三角形的纹理坐标定义如下图:
【一步步学OpenGL 16】 -《纹理贴图》

在使用纹理坐标系的时候我们获得小房子贴图上面标示的位置,现在三角形经过一系列变化后到了要光栅化的阶段它看上去如下:

【一步步学OpenGL 16】 -《纹理贴图》

可以看到,纹理坐标作为核心属性一样固定到了顶点上,不会因为变换而发生任何错位变化。在对纹理坐标进行插值时多数的像素可以获得原图片中对应相同的纹理坐标(因为它们相对于顶点的相对位置相同),并且当三角形翻转时贴在三角形上面的纹理贴图也会跟着翻转。也就是说,当三角形图元旋转、拉伸或者挤压时,纹理贴图会不断地跟着作相应的变换。但注意也有一种技术为了控制移除三角形面上的纹理贴图而改变纹理坐标。

和纹理映射相关的另一个重要概念是‘过滤’。我们已经讨论了怎样将纹理坐标(这是个0到1之间的分数!)映射到纹素上,纹理贴图中纹素的坐标总是以整数定义的,但是如果纹理坐标映射到纹素上的坐标为(152.34,745.14)怎么办?不明智的方案是将这个坐标舍去小数变为(152,745)。这种方法虽然可以有效果但是在某些情形下效果会很差。一个更好的办法是选取该坐标周围纹素2x2的4个坐标 ( (152,745), (153,745), (152,744) 和 (153,744) ) 并根据他们的颜色做线性插值。线性插值必须体现出坐标(152.34,745.14)和四个坐标的相对距离来。距离近的点的颜色对最终纹素的颜色值影响大,越远影响越小,这样就比开始的方法简单多了。

选择最终纹素的方法叫做’过滤‘,最简单的实现小数纹理坐标取整的方法叫做‘最邻近过滤’,更复杂一点的方法叫做‘线性过滤’,另外一种可能遇到的最邻近过滤方法叫做‘点过滤’。OpenGL支持几种不同类型的过滤方式可以选择。通常效果好的过滤方式需要GPU更强大计算能力并且可能对帧率产生影响,选择不同的过滤方式就需要权衡对过滤效果的需求和对目标设备能力的要求了。

知道了纹理坐标的概念之后现在可以看一下纹理映射在OpenGL中实现的方式了。OpenGL中的纹理贴图意味着要操作四个概念之间错综复杂的联系:着色器中的纹理对象,纹理单元,取样器对象和取样器一致变量
纹理对象包含着纹理贴图自身的数据,比如:纹素。贴图可以是不同类型不同维度的图片(1D,2D等),图片内在格式可能有多种像RGB或者RGBA等。OpenGL提供了一种方式来定义元数据在内存中的起始地址以及上面所有的属性同时将这些数据加载到GPU。很多像过滤类型这样的参数都是可以控制的,和顶点缓冲对象类似纹理对象也绑定在一个引用句柄上。创建句柄并加载纹理数据和参数之后你可以通过将不同的句柄绑定到OpenGL状体上来简单切换纹理贴图,不需要反复加载同一个纹理数据。从现在开始就需要OpenGL驱动器来确保数据在开始渲染之前已经加载到GPU了。

纹理对象并不是直接绑定到shader上的(事实上是采样阶段发生的地方),而是绑定到‘纹理单元’上,‘纹理单元’的索引会被传到shader中,因此shader是通过纹理单元得到纹理对象的。一般可以同时有多个可用的纹理单元,数量上限取决于显卡的容量。为了将一个纹理对象A绑定到纹理单元0上,首先你需要激活纹理单元0然后绑定纹理对象A。你现在可以激活纹理单元1然后绑定一个不同的(甚至是和纹理单元0绑定的相同的)纹理对象到上面,而纹理单元0还是绑定的纹理对象A。

事实上每个纹理单元可以同时绑定几个纹理对象,纹理又有不同的类型,类型叫做纹理对象的‘目标’。当你绑定一个纹理对象到一个纹理单元的时候,需要定义这个目标(1D,2D等等)。所以你可以将纹理对象A绑定到1D的目标上,同时纹理对象B绑定到同一个纹理单元的2D目标上。

采样操作通常发生在片段着色器中并通过一个特殊的函数来完成。采样函数需要知道从哪个纹理单元取样,因为在片断着色器中是可以从多个纹理单元中取样的,对此有一组特殊的一致变量(取样器一致变量),针对纹理目标:’sampler1D’, ‘sampler2D’, ‘sampler3D’, ‘samplerCube’等等。你可以创建任意数量的这种取样器一致变量,并在应用中将纹理单元的值赋给他们。对某个取样器一致变量调用取样函数时相应的纹理单元(和纹理对象)都会被用到。

最后一个概念是取样器对象。不要和取样器一致变量混淆,他们是不同的东西。纹理对象是同时包含有纹理数据和配置取样操作的参数的,这些参数是取样状态的一部分。然而,你也可以创建一个取样对象,用取样状态来配置它并绑定到纹理单元上,这样取样器对象将覆盖纹理对象中定义的所有取样状态。不过不用担心,目前我们还根本用不到取样器对象,先了解一下它也好。

下面的图概括了上面纹理的概念之间的关系:

【一步步学OpenGL 16】 -《纹理贴图》

源代码详解

OpenGL知道怎样从内存中加载不同格式的纹理数据,但是没有提供方法将像PNG或JPG格式的图像文件加载到内存当中。这里我们将使用一个外部第三方库来实现,这个有多种选择而我们使用ImageMagick这个库,这是一个免费的库支持多种图像格式并且可以很方便的跨多个操作系统。安装的时候可以看到这个库的更多的介绍。
对纹理多数的操作都封装在下面的类中:
(ogldev_texture.h:27)

class Texture
{
public:
Texture(GLenum TextureTarget, const std::string& FileName);

bool Load();

void Bind(GLenum TextureUnit);
};

创建一个纹理对象的时候需要定义一个纹理目标类型(这里使用GL_TEXTURE_2D)和文件名,然后要调用Load()函数。加载可能会有失败的情况,比如文件不存在或者ImageMagick遇到一些其他错误。当你想要定义一个纹理实例的时候你需要将它绑定到一个纹理单元上。

(ogldev_texture.cpp:31)

try {
m_pImage = new Magick::Image(m_fileName);
m_pImage->write(&m_blob, "RGBA");
}
catch (Magick::Error& Error) {
std::cout << "Error loading texture '" << m_fileName << "': " << Error.what() << std::endl;
return false;
}

这段代码展示了我们如何使用ImageMagick从文件加载纹理并将它保存到内存*OpenGL加载使用。我们开始先使用文件名参数用Magick::Image类定义实例化一个对象,这样纹理会被加载到一段内存中,但对ImageMagick类是私有的成员变量,OpenGL还不可以直接使用。然后我们将图像以RGBA格式写入一个Magick::Blob对象中。BLOB (Binary Large Object)是一种在内存中存储编码后的图像并可以被外部程序使用的很有用的机制。如果出现任何错误它会抛出异常,所以我们要对异常情况做处理。

(ogldev_texture.cpp:40)
glGenTextures(1, &m_textureObj);

这个OpenGL函数和我们已经熟悉的glGenBuffers()函数类似,产生指定数量的纹理对象,并将他们的引用句柄放到GLuint数组指针(第二个参数)中。这里只需要一个纹理对象。

(ogldev_texture.cpp:41)
glBindTexture(m_textureTarget, m_textureObj);

我们将使用几个和顶点缓冲器类似模式的纹理相关的函数调用。OpenGL需要知道要操作什么纹理对象,这就是glBindTexture()这个函数的作用,它告诉OpenGL在后面所有和纹理相关调用中我们所引用的是这个绑定的纹理对象,直到有新的纹理对象被绑定。函数中除了第二个参数那个对象句柄我们还要定义目标纹理,纹理可以是GL_TEXTURE_1D, GL_TEXTURE_2D等等。可以有不同的纹理对象同时绑定到每一个目标纹理上。在我们的代码实现中目标纹理作为构造函数的一个参数传入(并且我们使用GL_TEXTURE_2D纹理)。

(ogldev_texture.cpp:42)
glTexImage2D(m_textureTarget, 0, GL_RGBA, m_pImage->columns(), m_pImage->rows(), 0, GL_RGBA, GL_UNSIGNED_BYTE, m_blob.data());

这个复杂的函数是用来加载纹理对象的主要部分的,也就是纹理数据本身。有很多个glTexImage*这种前缀命名的函数,每一个函数各自应用于几种目标纹理。目标纹理总是参数中的第一个;第二个参数是LOD(Level-Of-Detail),一个纹理对象可以包含在不同分辨率下的相同的纹理。有个概念叫做‘多级渐远纹理(Mip-mapping)’每张mip-map有一个不同的LOD索引,最好分辨率时索引为0,并随着分辨率降低索引值增大。目前我们只有一张mip-map所以索引值暂时传递0;下一个参数是OpenGL存储纹理的内部格式,比如你可以使用完整的四通道(红,绿,蓝,透明度)传递纹理。但是如果参数是GL_RED的话,你将只得到纹理的红色通道,看上去有点…呵呵,红色的!可以试一下~。这里我们使用GL_RGBA来获取颜色完整的纹理图片;后面两个参数是纹理的纹素单位宽度和高度。ImageMagick中存储了纹理的这些信息,我们可以使用Image::columns()/rows()函数很方便的获取这些参数;第五个参数是边界,现在先置0;最后的三个参数定义了导入的纹理数据的来源,参数是格式、类型和内存地址。格式参数告诉我们通道个数,需要和内存中的BLOB匹配。类型参数每个通道的基本存储数据类型,OpenGL支持很多数据类型,但是在ImageMagick BLOB中每个通道我们只有一个byte,所以我们使用GL_UNSIGNED_BYTE。最后就是真实数据的内存地址了,这个地址我们使用Blob::data()函数从BLOB中得到。

(ogldev_texture.cpp:43)

glTexParameterf(m_textureTarget, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(m_textureTarget, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

通用函数glTexParameterf可以控制纹理采样操作的很多方面,这些操作的方面也是纹理采样状态的一部分。这里我们定义用于放大和缩小的过滤器。每一张纹理都会有宽度和高度这两个维度信息,但是纹理很少会以原尺寸贴在三角形图元上,多数情况下三角形要么略大要么略小,这时过滤器的类型就决定了如何缩放操作来匹配三角形的比例尺寸。如果栅格化的三角形比纹理大(比如离camera很近的时候),这时候可能会有相同的纹素覆盖在几个像素点上(放大)。相反如果三角形很小(离camera很远)的时候可能就会有多个纹素重叠覆盖在同一个像素点上(缩小)。这里我们都选择线性过滤插值的方法。就像我们之前看到的那样,线性插值根据实际纹素的位置将周围2x2四个纹素的颜色值混合插值可以得到看上去比较不错的效果(通过缩放纹理坐标来计算)。

(ogldev_texture.cpp:49)

void Texture::Bind(GLenum TextureUnit)
{
glActiveTexture(TextureUnit);
glBindTexture(m_textureTarget, m_textureObj);
}

现在3d应用已经越来越复杂,我们可能要在渲染循环中的很多绘制回调中使用很多不同的纹理。在每次绘制回调之前我们需要将我们需要的纹理对象绑定到一个纹理单元上,从而可以在片段着色器中进行采样。这里这个绑定函数使用纹理单元的枚举作为一个参数(GL_TEXTURE0, GL_TEXTURE1等等)。使用glActiveTexture()函数激活纹理单元然后将纹理对象绑定到上面。这个纹理对象将一直绑定到这个纹理单换上直到下一次调用Texture::Bind()这个函数并绑定同一个纹理单元。

(shader.vs)

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;

uniform mat4 gWVP;

out vec2 TexCoord0;

void main()
{
gl_Position = gWVP * vec4(Position, 1.0);
TexCoord0 = TexCoord;
};

这里顶点着色器代码要更新了,这里有一个新的输入参数叫做:纹理坐标TexCoord(一个2d向量)。同时这里输出的不是颜色了而是将纹理坐标从顶点缓冲器传递到片段着色器。栅格器会对纹理坐标在整个三角形面上进行插值,并且每一个片段着色器都会和其特有的纹理坐标一起起作用。

(shader.fs)

in vec2 TexCoord0;

out vec4 FragColor;

uniform sampler2D gSampler;

void main()
{
FragColor = texture2D(gSampler, TexCoord0.st);
};

这是新的片段着色器代码,有一个叫做TexCoord0的输入参数,参数里有从顶点着色器得到的插值后的纹理坐标。还有一个新的叫做gSampler的一致性变量,类型为sampler2D。这是采样器一致变量的一个例子。应用必须要将纹理单元的值设置到这个变量中这样片段着色器才能获取使用到该纹理。主要的作用就一个:它使用内部的texture2D函数来对纹理取样。第一个参数是取样器一致变量,第二个参数是纹理坐标,返回的就是采样并经过过滤后的纹素值(这里是颜色值),这也是本教程最终像素的颜色值了。在后面的教程中我会将看到光线只是根据光线参数简单的改变缩放那个颜色值而已。

(tutorial16.cpp:128)

Vertex Vertices[4] = { 
Vertex(Vector3f(-1.0f, -1.0f, 0.5773f), Vector2f(0.0f, 0.0f)),
Vertex(Vector3f(0.0f, -1.0f, -1.15475), Vector2f(0.5f, 0.0f)),
Vertex(Vector3f(1.0f, -1.0f, 0.5773f), Vector2f(1.0f, 0.0f)),
Vertex(Vector3f(0.0f, 1.0f, 0.0f), Vector2f(0.5f, 1.0f)) };

到此教程为止我们的顶点缓冲器只是简单地一系列的含有位置信息的Vector3f结构,现在这里我们的顶点结构既包含位置数据同时又包含Vector2f类型的纹理坐标。

(tutorial16.cpp:80)

...
glEnableVertexAttribArray(1);
...
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12);
...
pTexture->Bind(GL_TEXTURE0);
...
glDisableVertexAttribArray(1);

主渲染循环中的代码有所添改,在之前启用的用于顶点位置的顶点属性0之后,这里我们启动一个顶点属性1用于纹理坐标。这个和顶点着色器中的layout变量声明是一一对应的。然后我们调用glVertexAttribPointer函数来定义顶点缓冲器中纹理坐标的位置。纹理坐标包含两个浮点数数值,分别和第二个、第三个参数对应。注意第五个参数,这个是包含顶点位置和纹理坐标的顶点结构的size数据大小。这个参数称作‘顶点跨度vertex stride’,它会告诉OpenGL一个顶点属性的开始位置到下一个顶点的相同属性开始位置之间间隔的bytes数目。我们这个例子中缓冲器包含:pos0,纹理coords0,pos1,纹理coords1等等。之前的教程中我们只有位置信息所以将参数设置为0或者sizeof(Vector3f)。现在我们这个顶点结构包含了不止一个属性,因此顶点跨度应该为这个符合的顶点结构的byte数。最后一个参数是从顶点结构的起始位置到里面纹理属性之间的byte数偏移值,而且还必须要转为函数需要的GLvoid*类型。在绘制回调之前我们还要将我们需要用的的纹理绑定到纹理单元上。这里我们只有一个纹理所以任何一个纹理单元都可以。我们唯一要保证相同的纹理单元要设置在shader中(见下面)。绘制回调结束后记得关掉顶点属性数组。

(tutorial16.cpp:253)

glFrontFace(GL_CW);
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);

这些OpenGL回调函数的调用并不是真正和纹理相关的,这里添加只是为了看上去效果更好(可以尝试去掉…)。这些函数可以开启面的剔除,一种常用的优化方法,在栅格化之前将一些不需要的三角形丢弃掉,这样做的主要原因是经常一个物体表面有大约一半的面是相对我们隐藏的(人、房子、车等的背面)。glFrontFace()函数告诉OpenGL三角形的顶点是按照顺时针顺序定义的,也就是当你看向三角形的正面时,会发现缓冲器中的三角形顶点是顺时针顺序排列的。glCullFace()告诉GPU剔除三角形的背面,也就是物体的内表面不需要渲染,只渲染外表面。最后是开启面剔除本身(默认是关闭的)。注意这个教程中我将三角形底部的顶点顺序颠倒了,使三角形看上去好像面对金字塔的里面(见源码tutorial16.cpp第170行)。

(tutorial16.cpp:262)
glUniform1i(gSampler, 0);

这里我们将我们将要使用的纹理单元的索引放到shader中的取样器一致变量里。‘gSampler’是之前使用glGetUniformLocation()函数获取的一个一致变量。这里要注意的是纹理单元的实际索引是在此处使用的,而不是那个OpenGL枚举GL_TEXTURE0,值是不一样的。

(tutorial16.cpp:264)

pTexture = new Texture(GL_TEXTURE_2D, "test.png");

if (!pTexture->Load()) {
return 1;
}

这里我们创建纹理对象并加载它。’test.png’这张图片在教程的源码工程里有,而ImageMagick实际上是可以处理几乎任何类型的文件的。
练习:如果你运行这个教程的示例代码你会发现金字塔的面并不是一致的,想想为什么会这样,需要怎样改变才能使他们一致起来。