opengl 教程(16) 纹理映射

简介: 原帖地址:http://ogldev.atspace.co.uk/www/tutorial16/tutorial16.html       纹理映射意思就是把图片(或者说纹理)映射到3D模型的一个或多个面上。

原帖地址:http://ogldev.atspace.co.uk/www/tutorial16/tutorial16.html

      纹理映射意思就是把图片(或者说纹理)映射到3D模型的一个或多个面上。纹理可以是任何图片,使用纹理映射可以增加3D物体的真实感,我们常见的纹理有砖,植物叶子等等。

下图中是使用纹理映射和没有使用纹理映射四面体的比较。

txt_example

      要使用纹理映射,我们必须做以下三件事情:在OpenGL中装入纹理,为顶点提供纹理坐标(为了把纹理映射到顶点),用纹理坐标在纹理上执行一个采样操作,得到一个像素颜色。

      三维空间中的物体经过缩放,旋转,平移,最终投影到屏幕上,依赖于摄像机位置和方位的不同,最终呈现的形式可能千差万别,但根据纹理坐标,GPU会保证最终的纹理映射结果是正确的。在光栅化阶段,GPU也会插值纹理坐标,这样,每个片元都有一个对应纹理坐标。在片元shader中,片元(或像素)会根据纹理坐标,采样得到最终的纹理单元颜色,并把这些颜色和当点片元的颜色或者根据光照计算的颜色混合,从而输出像素的最终颜色。下面的教程中,我们将看到,纹理单元能够包含不同的数据,实现很多特效。

      OpenGL支持 1D, 2D, 3D, cube等等多种纹理,这些纹理在不同的技术中使用。我们首先来学习2D纹理,2D纹理通常来说就是一块有高度和宽度的surface(表面),宽度乘以高度的结果就是纹理单元的数目。那么如何指定顶点的纹理坐标呢?其实顶点的纹理坐标并不是顶点在纹理surface上的坐标,否则的话,那受限制就太大了,因为我们的三维物体表面是变化的,有的大,有的小,这样的话,意味着我们要不断更新纹理坐标,这显然很难做到。因此,存在纹理坐标空间,每维的纹理坐标范围都是[0,1],所以纹理坐标通常都是[0,1]之间的一个浮点数,我们用纹理坐标乘以纹理的高度或宽度,就可以得到顶点在纹理上对应的纹理单元位置,例如:如果纹理位置是 [0.5,0.1],纹理宽度是320,纹理高度是200,那个对应纹理单元位置就是 (160,20) (0.5 * 320 = 160 和 0.1 * 200 = 20)。

      通常纹理空间又叫UV空间,U对应2维笛卡尔坐标的x轴,V对应y轴,OpenGL中,U轴方向从左到右,V轴方向从下到上,如下图所示,可以看到(0,0)位置在左下角,向上V增加,向右U增加:

txt_coords

下图中的三角形被指定纹理坐标:

tri1

当三角形做了各种变化后,它的纹理坐标保持不变,假设三角形光栅化前,它的位置如下。

tri2

      纹理坐标是三角形顶点的属性,无论三角形怎么变化,对于顶点来说,纹理坐标相对位置都不变,当然也可以在顶点shader中,动态改变纹理坐标,这个主要用于实现一些特殊的效果,比如水面效果等等。在本教程中,我们将保持纹理坐标不变。

      另一个和纹理映射相关的概念是“滤波”,前面我们讨论了通过一个纹理坐标,得到相应的纹理单元,由于纹理坐标是[0,1]之间的浮点数,它乘以纹理高度宽度,可能得到一个浮点的映射坐标,比如我们把纹理坐标映射到纹理单元 (152.34,745.14),此时怎么得到纹理单元呢?最简单的方法,我们可以四舍五入,得到 (152,745),这种方法,可以工作,但是在一些情况下,效果并不是很好,一个更佳的方案是:得到一个2×2的quad纹理单元, ( (152,745), (153,745), (152,744) 和(153,744) ),然后在这些纹理单元颜色之间进行线性插值操作,线性插值和该纹理单元到(152.34,745.14)的距离有关,越接近这个坐标,影响就越大,越远,影响越小,这个效果要比四射五入直接选取纹理单元要好。

     决定最终哪一个纹理单元被选择的方法就称作“滤波”,最简单的方法就是前面说的四舍五入方法,这种滤波方式又叫nearst滤波 (nearest filtering),这是一种点采样的滤波方式,前面说的基于线性插值的滤波称作线性滤波 。OpenGL提供多种采样方式,你可以选择其中任意一种,通常,更好的滤波效果需要更高的GPU运算能力,这有可能影响帧率。选择更好的效果和更流畅的画面是个balance问题。

      下面我们看看OpenGL中如何实现纹理映射:在OpenGL中使用纹理,我们首先要学习四个概念:纹理对象,纹理单元,采样对象以及shader中的采样uniform变量

      纹理对象本身包括纹理需要的数据,比如图像数据。根据存储的数据格式(RGB,RGBA等等),纹理可分为1维纹理,2维纹理,3维纹理等等,OpenGL提供了一种方便的函数,只要指定数据的起始地址,以及数据格式属性,就可以很方便的把数据装入GPU,纹理通常就是通过这种方法装入video memory,在装入纹理时候,可以指定多个参数,比如滤波方式等等。类似顶点缓冲数据,我们也可以把纹理和句柄关联起来。当创建句柄,装入纹理后,我们能够实时切换纹理,和不同的OpenGL句柄进行绑定,而不需要再次装入数据,此时,OpenGL的driver会保证,渲染前,纹理数据被装入video memory。

      纹理对象并不直接和shader(纹理采样实际上在shader中实施)打交道,而是通过一个纹理单元(texture unit),该纹理单元的索引被传递到shader中。这样,shader就能通过该纹理单元访问纹理对象。通常我们可以使用多个纹理单元(数目和具体gpu有关), 为了把一个纹理对象A绑定到纹理单元0,我们首先要激活纹理单元0,然后才能绑定到纹理对象A,此时,如果要使用第二个纹理对象,可以激活纹理单元1,然后绑定到相应的纹理对象。

      实际的情况可能有点复杂,一个纹理单元其实可以同时绑定多个纹理对象,只要这些纹理对象的类型不同,这类型是纹理对象的target,比如1D,2D等等,绑定纹理对象和纹理单元时,我们必须指定target,例如:我们可以把targe维1D纹理对象A和target维2D的纹理对象B同时绑定到同一个纹理单元。

      采样操作通常在片元shader中实施,具体操作是通过一个采样函数,采样函数需要知道采样的纹理单元,因为shader中可能有多个纹理单元。具体是通过一组纹理uniform变量来区分不同的纹理单元,这些uniform变量和纹理单元是一一对应关系,当你对某个uniform变量进行采样操作时,该变量对应的纹理对象被使用。

      最后再来看一下采样对象,注意不要把它和采样uniform变量混淆。纹理对象包含图像数据,也包括配置采样操作的参数等等,这些参数是采样状态的一部分,我们也可以创建一个采样对象,配置它的参数,并把它绑定到纹理单元,这将会重载纹理对象中定义的采样状态,本文中,我们并没有实施采样对象。

下图总结了我们前面学习的一些概念:

sampling_diagram

主要代码:

       OpenGL能够装入内存中的纹理数据,但并没有提供一个方法,把图像文件,比如PNG,JPG等,装入到内存中,我们使用一个开源的图像处理库  ImageMagick, 该库支持多种格式的图像处理。在程序代码中,直接包含了该库的源代码。

大部分的纹理操作被包装在texture类中:

texture.h

class Texture
{
public:
   Texture(GLenum TextureTarget, const std::string& FileName);
   bool Load();
   void Bind(GLenum TextureUnit);
};

      创建一个纹理对象时候,我们需要指定target(我们用GL_TEXTURE_2D),以及图像文件名字,之后,我们可以调用Load函数,来装入纹理数据。如果需要把纹理对象绑定到特殊的纹理单元,我们可以用Bind函数。

texture.cpp

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;
}

       用上面的代码,我们把图像文件装入内存(此时在system memory中),并准备装入OpenGL。我们使用了Magic::Image实例,并提供图像文件名字,使用该函数后,将把纹理图像数据装入m_pImage对象内部,OpenGL不能直接访问,所以我们接着做一个write操作,把纹理数据写到m_blob变量表示的内存中,我们使用的图像格式是RGBA。BLOB (Binary Large Object)是一个二进制文件块,常用来存储图像块,以便其它程序使用。

glGenTextures(1, &m_textureObj);

      上面这个OpenGL函数和 glGenBuffers()很相似,第一个参数是个数字,指定要创建的纹理对象数量,第二个参数是纹理对象数组。在本教程中,我们使用一个纹理对象。

glBindTexture(m_textureTarget, m_textureObj);

      通过glBindTexture()函数,我们绑定一个纹理对象,这样下面所有对纹理的操作都是基于该对象,如果我们要操作别的纹理对象,需要重新使用glBindTexture()函数绑定别的纹理对象。glBindTexture()函数中第二个参数是纹理对象句柄,第一个参数是纹理target,它的值可能是 GL_TEXTURE_1D, GL_TEXTURE_2D等等。不同的纹理对象同时只能绑定一个target,在本教程中,target在纹理类构造函数中实施,我们使用的是GL_TEXTURE_2D 。

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

      glTexImage2D函数用来装入纹理对象的数据,也就是把system memory中的数据(m_blob)和纹理对象关联起来,可能在该函数调用时候就拷贝到video memory,也可能是延时拷贝,这个是由driver控制的。 glTexImage* 函数有几个版本,每个版本都对应一个纹理target。该函数的第一个参数是纹理target,第二个参数是LOD(层次细节),一个纹理对象可能包含多个分辨率的相同图像,这些图像称作mipmap层,每个mipmap层数都有一个LOD索引,范围从0到最高分辨率。本教程程序中,只有一个mipmap层,所以该值为0 。

     第三个参数是纹理对象的格式,你可以指定为4通道的颜色RGBA,或者仅指定红色通道GL_RED,本教程程序中,我们使用GL_RGBA ,接下来的2个参数是纹理的高度和宽度,通过 ImageMagick的内部函数rows和colomns,我们可以很方便的得到这两个值。第5个参数是纹理的边选项,本程序中我们设置为0。

      最后的三个参数指定源纹理数据的格式,类型以及数据内存地址。格式指定颜色channel的格式,这必须和m_blob中的data相匹配,类型描述每个颜色channel的格式,本程序中为无符号8位数字GL_UNSIGNED_BYTE,最后一个参数是纹理数据内存地址。

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

      上面两个函数指定纹理采样的方式,纹理采样方式是纹理状态的一部分。对于magnification 和 minification ( http://www.cnblogs.com/mikewolf2002/archive/2012/04/07/2436063.html , 关于这两个概念请参考这个链接 ),我们都指定线性滤波.

(texture.cpp)

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

      在3D程序中,可能有多个draw,每个draw提交前,可能需要绑定不同的纹理,以便在shader中使用,上面的Bind函数就是使我们方便的切换不同的纹理,它的参数是一个纹理单元。

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;
};

      这是更新后的顶点shader,这儿有一个输入参数纹理坐标,是一个2D向量,在顶点shader中我们并没有对纹理坐标进行任何变化,而是直接输出,但在片元shader前的光栅化阶段,会对纹理坐标进行插值操作。

in vec2 TexCoord0;

out vec4 FragColor;
uniform sampler2D gSampler;
void main()
{
    FragColor = texture2D(gSampler, TexCoord0.st);
};

      上面是更新后的片元shader,其中有一个输入变量TexCoord0,它包含了插值后的纹理坐标,还有一个uniform变量gSampler,它是sampler2D类型,应用程序必须设置纹理单元值以便和这个uniform变量连接起来,这样shader才能访问纹理,返回值就是采样的纹理单元颜色。后面的光照的教程中,都是根据光照因子乘以这个采样颜色,从而得到最终的像素颜色。

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)) };

新的顶点结构包括顶点位置以及顶点纹理坐标。

tutorial16.cpp

...
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);

      在渲染循环中也有一些代码变动,因为增加了纹理坐标属性,所以我们启动了属性1,这和顶点shader中的layout是一致的,接着我们会调用glVertexAttribPointer指定顶点缓冲中纹理坐标的位置,纹理坐标是2个浮点数,所以函数第二个参数是2,注意第五个参数都是顶点结构的大小,这对于位置和纹理属性是一样的, 这个参数称作 'vertex stride',就是2个顶点之间的字节数目。在我们的顶点缓冲中,包含pos0, texture coords0, pos1, texture coords1, 等等,前面的教程中,只有一个位置属性,所以该参数设置为0。最后一个参数顶点结构起始地址到纹理属性的偏移字节数。

      在draw调用前,我们进行一次纹理绑定操作,注意下面的禁止顶点属性函数调用,指定顶点属性后,我们需要在一次禁止它。

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

      上面三个函数设定三角形面背面剔除功能,启用该功能后,会在PA阶段,剔除法向朝后的三角面(背面三角形本来就看不见),从而这些面不会做片元shader,从而提高程序性能。第一个函数指定三角形顶点为顺时针顺序,就是说从前面看向三角形时,它的顶点是顺时针排列,第二个函数指定剔除背面(而不是前面),第三个参数开启剔除功能。

glUniform1i(gSampler, 0);

      设定纹理单元的索引,我们将会在片元shader中通过uniform变量使用纹理。在前面的代码中,gSampler会通过 glGetUniformLocation()函数得到。

pTexture = new Texture(GL_TEXTURE_2D, "test.png");
if (!pTexture->Load()) {
    return 1;
}

上面的代码创建纹理对象,并装入它。

程序执行后界面如下:

image

相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
相关文章
openGL简明教程(一)---开始的开始,绘制一个三角形
openGL简明教程(一)---开始的开始,绘制一个三角形
254 0