码迷,mamicode.com
首页 > 其他好文 > 详细

OpenGL教程翻译 第十六课 基本的纹理贴图

时间:2015-04-21 16:13:26      阅读:500      评论:0      收藏:0      [点我收藏+]

标签:ogl   三维   图形   opengl   3d   

OpenGL教程翻译 第十六课 基本的纹理贴图

原文地址:http://ogldev.atspace.co.uk/(源码请从原文主页下载)

Background

纹理贴图就是将任意一种类型的图片应用到3D模型的一个或多个面。图片(也可以称之为纹理)内容可以是任何东西,但是他们一般都是一些比如砖,叶子,地面等的图案,纹理贴图增加了场景的真实性。例如,对比下面的两幅图片。

技术分享

为了进行纹理贴图,你需要进行三个步骤:将图片加载到OpenGl中,定义模型顶点的纹理坐标(以对其进行贴图),用纹理坐标对图片进行采样操作进而得到像素颜色。因为可对三角形进行缩放,旋转,平移,并最终投影到屏幕上,所以它最终显示到屏幕上的方式是多种多样的,并且由于相机参数的不同看起来也会有很大差异。GPU需要做的就是让图片跟随者三角形的顶点而运动而使场景看上去真实可信。为了实现这些功能,开发者为每个顶点提供了一套坐标叫做‘纹理坐标’。当GPU光栅化这些三角形的时候,它会对整个三角形表面的纹理坐标进行插值计算,之后在片元着色器中开发者把这些纹理坐标映射到纹理上。这种行为被称为‘采样’,采样的结果是产生像素(纹理中的)。纹理像素通常都包含一种颜色,用来给屏幕上相应的纹理像素涂色。这接下来的章节中将会看到一个纹理像素可以包含不同类型的数据,用来产生多种效果。

OpenGL支持多种类型的纹理比如1D,2D,3D,立方体等等,不同类型的纹理可被用于不同的技巧。现在我们用2D的。一个2D纹理可以有任意的高度和宽度,但必须是在规定的范围之内。高度*宽度是纹理的像素数。那么你如何指定一个顶点的纹理坐标?不,这不是纹理中像素的坐标。那样做局限性太大,因为如果用不同width/height值的纹理替换原来的纹理时,我们就必须更新所有顶点的纹理坐标。理想的做法是只更新纹理但不更新纹理坐标。因而所有纹理坐标是被指定在‘纹理空间’(规范化的范围是[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认为U轴上的值从左走到右,V轴上的值从下走到上。如下图:技术分享

这幅图片表示纹理空间,你可以看见纹理空间的原点在左下角。U

轴向右逐渐变大,V轴向上逐渐变大。现在考虑这个纹理坐标定义在下图中的三角形:
技术分享

我们在模型上应用了一个纹理,当使用这些纹理坐标的时候,我们在上面的位置得到一个小房子的图片。现在这个三角形通过许多变换,当要光栅化的时候,它看起来如下:

技术分享

如你所看见的,纹理坐标跟随着顶点,就如同纹理本来就是模型的一部分一样,在各种变换过程中并不改变。当对纹理进行纹理坐标插值时,大部分像素会得到与原来的图片中相同的纹理坐标(它们相对于顶点来说相对位置没有改变),并且因为三角形是不断运动的,所以贴在它上面的纹理也跟着做同样的运动。这意味着原来的三角形旋转,放大,缩小,纹理也紧跟着这样变换。注意也有改变纹理坐标的方法,这是为了将纹理以受控的方式从三角形表面移过。但是现在我不不讨论这一情况,而仅仅是坐标保持不变。

另一个与纹理相关的重要概念是 ‘过滤’。我们已经讨论过了如何把纹理坐标映射到一个像素上。在纹理上像素的位置总是被指定为整数,但是如果纹理坐标(记住纹理坐标总是在0到1之间)映射到纹理上的位置为(152.34,745.14)将会发生什么?最容易的想到的就是四舍五入到(152,745). 嗯,这种方法确实有效还提供精确的结果,不过在某些情况下效果不怎么好。一个更好的方法是取四个相邻的像素位置((152,745), (153,745), (152,744) 和(153,744)),在它们的颜色之间做线性插值。这个线性插值一定要反应出每个像素点和(152.34,745.14)之间的相对距离。最靠近(152.34,745.14)的坐标对最终得到的结果影响最大,而远一点的坐标对最终得到的结果影响就小一些。这看上去比之前的那种方法更好。 

确定最终像素值的方法是过滤。简单的舍入纹理坐标的方法是‘nearest filtering‘ ,而更复杂的是‘linear filtering‘。‘nearest filtering‘另外一个名字是‘point filtering‘。OpenGL支持多种类型的过滤,你可以选择这其中的一种。通常更好的过滤结果需要GPU更好的计算能力,并且对帧速率有一定的影响。选择过滤方法应考虑过滤效果与目标平台的性能之间的平衡。

既然我们理解了纹理坐标的概念,让我们现在看看纹理贴图是如何在OpenGL中被完成的。在OpenGL中的纹理贴图意味着要控制好着色器中纹理对象,纹理单元,采样器对象,采样器一致变量之间的错综复杂的关系。

纹理对象包含它本身纹理图片的数据,比如像素。纹理可以是不同的类型的(1D,2D等等),分别有不同维度。其底层数据类型有不同的格式(RGB,RGBA等等)。OpenGL提供一个方法,用来指定数据和上述所有属性在内存的开始地址并将数据加载进入GPU。也有很多参数你可以控制,比如过滤类型等等。与顶点缓冲区对象类似,纹理对象也有一个句柄。在创建句柄和加载纹理数据和参数后,你可以给OpenGL状态绑定不同的句柄来更换纹理。你不需要再一次将数据加载到GPU中,从现在起,确保数据在渲染开始之前已被及时的加载进入GPU是OpenGL驱动程序的工作。

纹理对象不是直接被绑定到着色器(实际采样的地方),而是被绑定到一个纹理单元,纹理单元的索引被传递到着色器中。所以着色器通过纹理单元来访问纹理对象。OpenGL中通常可以有多个纹理单元,具体的个数取决于你显卡的性能。为了绑定一个纹理对象A到一个纹理单元0,首先你需要确定纹理单元0被激活,之后再绑定纹理对象A。你现在可以激活纹理单元1并给它绑定一个不同的(甚至是相同的)纹理对象。此时纹理单元0仍然保持与纹理对象A的绑定。

事实上这存在一定的复杂性,每一个纹理单元实际上可以同时绑定多个不同的纹理对象,只要这几个纹理的类型是不同的。纹理的类型被称为纹理对象的‘target’。当你绑定一个纹理对象到一个纹理单元时,你可以指定target(1D,2D等等)。所以你可以让纹理对象A的target设定为1D ,而纹理对象B的target可以设定为2D,但是这两个纹理对象都可以绑定到同一个纹理单元。

采样操作通常发生在片元着色器的内部,并有一个特殊的函数来实现采样操作。采样函数需要知道纹理单元的句柄,因为你可以从着色器中的多个纹理单元中进行采样。为此,我们特别设定一组一致变量,根据纹理target分为:‘sampler1D‘, ‘sampler2D‘, ‘sampler3D‘, ‘samplerCube‘等等。你可以按需要定义多个采样器一致变量,然后通过程序将每个纹理单元的值分配给每个一致变量。任何时候你对采样一致变量调用采样函数,相应的纹理单元(和纹理对象)都将被使用。

最后一个概念就是采样器对象。不要混淆它和采样一致变量!它们两个是不相关的实体,区别在于纹理对象既包含纹理数据,又包含设置采样操作的参数。这些参数是采样状态的一部分。然而你也可以自己建立一个采样器对象,用一个采样状态来设置它,并把它绑定到纹理单元。当你创建采样对象时,在纹理对象中定义的采样状态将会被覆盖。先不用担心,因为现在我们一点也用不到采样器对象,但是我们需要知道它是存在的。

 

下面的一幅图中总结了我们上面学的纹理概念之间的关系:
技术分享

Code Walkthru

OpenGL知道如何从内存中加载不同类型的纹理数据,但是不提供例如PNG和JPG类型的纹理图片文件进入内存的途径。我们将使用一个扩展库来做这些工作。我们可以有很多选择来实现它,这里我们使用ImageMagick,它是一个免费的软件库,可以支持许多图片类型并且支持跨平台。你可以在安装的时候看介绍。

 

大部分处理纹理的操作都被封装在下面这个类:

(ogldev_texture.h:27)

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

   bool Load();

   void Bind(GLenum TextureUnit);
};

当创建一个纹理对象的时候,你需要指定一个target(这里我们使用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中。我们先用文件名初始化一个Magic::Image类的成员变量。这个函数调用将纹理加载为内存中的表示(二进制),这些数据是ImageMagick 私有的,不能被OpenGL直接使用。下一步我们使用RGBA(红,绿,蓝和alpha通道)格式把图片写入Magic:: Blob对象。BLOB(二进制大型对象)是一种非常有用的机制,它以外部项目可以使用的方式存储内存中已编码的图片。如果存在错误,将会抛出异常,所以我们需要做好准备。

 

(ogldev_texture.cpp:40)

glGenTextures(1,&m_textureObj);

这个OpenGL函数非常相似于glGenBuffers()。它生成指定数目的的纹理对象并将其句柄储存在GLuint数组指针中(第二个参数就是)。在这里我们只需要一个对象。

 

(ogldev_texture.cpp:41)

glBindTexture(m_textureTarget,m_textureObj);

我们将进行几个纹理相关的调用,这类似于顶点缓冲区,OpenGL需要知道要操作哪个纹理对象,这就是glBindTexture()函数的目的。它告诉OpenGL在接下来纹理的相关调用中涉及到的是哪个纹理,直到一个新的纹理对象被绑定时才发生变化。除了纹理对象的句柄(第二个参数),我们也指定纹理target(可能是GL_TEXTURE_1D,GL_TEXTURE_2D等等)。每个target可以同时绑定多个不同的纹理对象。在代码中,target是作为构造函数的参数被传递进去的(此处我们用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());

这个相当复杂的函数用来加载纹理对象的主要部分(即纹理数据)。OpenGL中有几个glTexImage*函数并且每一个函数都包含几个纹理target。纹理target总是第一个参数。第二个参数是LOD(Level-Of-Detail)。一个纹理对象可以包含有着不同的分辨率的相同纹理,这就是mip-mapping。每一个mip-map有着一个不同的LOD值,从0(代表最高的分辨率)开始,随分辨率降低LOD值增大。现在我们只有一个mip-map,所以传入0 。

下一个参数是OpenGL储存纹理数据的内部格式。例如,你可以传入有着四个颜色通道(RGBA)的纹理,但是如果你指定参数为GL_RED,那么你得到的纹理只有红色通道。我们使用GL_RGBA来得到完整的纹理颜色。接下来两个参数是纹理的宽度和高度(按像素来说)。当加载纹理时,ImageMagick顺便为我们把这两个值存了下来,而我们通过Image::columns()/rows()来获取高度宽度。第5个参数是边框的宽度,现在我们设为0.

最后三个参数指定纹理数据来源,分别是格式,类型和内存地址。format告诉我们使用颜色通道的个数,需要与内存中的BLOB进行匹配。type描述每一个通道的基本数据类型。OpenGL支持很多数据类型,但是在ImageMagickBLOB中,每个通道都是一个字节,所以我们使用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控制纹理采样的许多方面。这些方面是纹理采样状态机的一部分。这里我们为放大和缩小使用过滤。每个纹理有给定的宽度和高度,但是纹理的尺寸很少适用于同样比例的三角形。在大部分情况下三角形比纹理偏大或者偏小。在这种情况下,过滤的类型决定放大或者缩小纹理以匹配三角形的尺寸。当光栅化的三角形比纹理大时(比如三角形非常靠近相机)我们必须用一个纹理像素覆盖三角形的几个像素(放大)。当三角形比纹理小的时候(比如三角形离相机比较远),几个纹理像素被三角形上的一个像素覆盖(缩小)。这里我这两种情况我们都选择线性插值过滤类型。就如之前我们见过的,线性插值通过混合2*2纹素四边形,基于实际像素的位置(通过计算按照纹理维度放缩的坐标得到)的临近度,的颜色可以产生不错的渲染效果。


(ogldev_texture.cpp:49)

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

随着我们3D应用程序变得更复杂,我们在渲染循环中的许多绘图命令可能会用到许多不同的纹理。在调用每个绘图命令之前,我们需要将纹理对象绑定到我们想要的纹理单元,以使片元着色器可以对它进行采样。这个函数使用纹理单元的枚举值(GL_TEXTURE0,GL_TEXTURE1, 等等)作为参数。使用glActiveTexture()来激活纹理单元,之后在绑定纹理对象到纹理单元上。这个对象将一直保持绑定在这个纹理单元上直到下一个纹理对象被绑定在这个纹理单元上。


(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。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结构体(包含position)序列。现在我们的‘Vertex‘结构体不仅包括position,还包括纹理坐标(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), (constGLvoid*)12);
...
pTexture->Bind(GL_TEXTURE0);
...
glDisableVertexAttribArray(1);

这里对于渲染循环有一定改变。我们先将纹理数组的顶点属性设置为1,之前已将位置属性设置为0。这刚好对应于在顶点着色器中的布局声明。接下来我们调用glVertexAttribPointer来指明纹理坐标在顶点缓冲区的位置。纹理坐标由两个浮点型数据组成,刚好和第二个,第三个参数对应。注意一下第五个参数,这是vertex结构体(包括位置和纹理坐标)的大小。这个参数就是所谓的“顶点布幅”,它告诉OpenGL相邻顶点之间同一属性之间间隔的字节数。我们的例子中,缓冲区内储存顺序为: pos0, texturecoords0, pos1, texture coords1 等等。在之前的章节中,我们只有位置信息,所以参数设置为0或者sizeof(Vector3f)是可以的。但是现在我们有超过一个属性,所以布幅(stride)只能是Vertex结构体的字节数。最后一个参数是从Vertex结构体的开始位置到纹理属性位置间的偏移量。为使函数得到预期的偏移量,我们必须进行强制类型转换GLvoid*。

在绘图命令调用之前,我们得先绑定纹理到我们想使用的纹理单元上。这里我们只用一个纹理,所有绑定到任何一个纹理单元都是可以的。我们只需要确定相同的纹理单元被设置进入着色器(看下面)。在绘图命令调用之后,我们禁用属性。

 

(tutorial16.cpp:253)

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

这个OpenGL函数的调用不是真正的和纹理贴图有关,而仅仅是使效果看起来更好。这些函数确保了剔除背面和一个常用的优化:在进入繁重的光栅化进程之前去掉不需要的三角形。原因是一个物体50%的面通常是看不到的(人、车和房子等的背面)。glFrontFace()函数告诉OpenGL三角形的顶点按顺时针方向指定,也就是说当你看着三角形的前面时,你会发现在顶点缓冲区中该三角形的顶点是按顺时针排列的。glCullFace()告诉GPU要剔除背面。这意味着一个对象内部的点不需要被渲染。最终背面剔除被启用(默认是关闭的)。注意在这一节中,我逆转了底部三角形顶点索引的顺序。因为之前的顺序使我们的三角形看起来好像是面对着金字塔的内部。

 

(tutorial16.cpp:262)

glUniform1i(gSampler,0);

这里将我们将要用到的纹理单元的索引值设定到着色器内的采样器一致变量中。‘gSampler‘是一个一致变量,它的值在之前通过使用glGetUniformLocation()函数获得到。注意这里用的是纹理单元真正的索引,而不是OpenGL的枚举GL_TEXTURE0值。


(tutorial16.cpp:264)

pTexture = newTexture(GL_TEXTURE_2D, "test.png");

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

这里我们定义一个纹理对象并加载它。被这一节的源代码使用的是‘test.png‘,但是ImageMagick应该能够处理几乎所有的文件。

练习:如果你运行这一节的代码,你将会注意到金字塔的面不是完全相同的。试着理解为什么会发生这种情况,并考虑如何才能使它们相同。


OpenGL教程翻译 第十六课 基本的纹理贴图

标签:ogl   三维   图形   opengl   3d   

原文地址:http://blog.csdn.net/vcube/article/details/45170435

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!