本文主要解决一个问题:
在OpenGL中如何使用纹理?
一、什么是纹理?
纹理,英文是texture,中文可以翻译成纹理、纹理图、纹理映射等等一堆东西。不过不管翻译成啥,讲的都是一个东西。我们通常说的纹理,指的是一张二维的图片,把它像贴纸一样贴在什么东西上面,让那个东西看起来像我们贴纸所要表现的东西那样。
举例来说,假如我们想绘制一面砖墙,我们该怎么办?根据我们已经掌握的知识来看,我们需要用成千上万的点来模拟它的颜色,我的天,这要搞到猴年马月才能搞出来?显然不现实!于是聪明的程序员们想出了一个好方法,就是用一张图“贴”到物体的表面上,让它看起来像是一面砖墙的样子,省时省力省心。
用一句话来总结,纹理就是一张贴到物体上的2维图像。
二、映射方式
既然是要把图贴到物体上,自然就要想怎么贴才行。我们可以横贴、竖贴、斜贴怎么贴都行,但是怎样贴才能达到我们想要的效果呢?总要有个规章制度来规定一下怎么贴才行吧。这个贴法,就称为映射。
规则是:以左下角为原点,向右伸展到1.0的位置,向上伸展到1.0的位置,表示一整张的纹理图像。
使用纹理图的时候,我们需要在顶点数据中添加一个纹理坐标的数据,标明我们是如何将纹理上的元素映射到顶点上的,这个我们在后面的实现环节再详细说明。
三、纹理环绕方式(Texture Wrapping)
通常,纹理坐标的范围在(0,0)到(1,1)之间,但是如果我们制定的坐标在这之外呢?OpenGL会如何做出反应?默认情况下,OpenGL会重复绘制纹理图,不过,OpenGL也提供了更多的选择方案:
- GL_REPEAT: 默认方案,重复纹理图片。
- GL_MIRRORED_REPEAT:类似于默认方案,不过每次重复的时候进行镜像重复。
- GL_CLAMP_TP_EDGE:将坐标限制在0到1之间。超出的坐标会重复绘制边缘的像素,变成一种扩展边缘的图案。(通常很难看)
- GL_CLAMP_TO_BORDER:超出的坐标将会被绘制成用户指定的边界颜色。
每种方案的显示效果截然不同,不必担心你会搞混了,看看效果就知道了。
怎么样,只要视力在1000度之内,都能看出明显的区别吧。
设置纹理环绕方式的方法是调用glTexParameteri
函数,具体方式如下:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); //横坐标
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); //纵坐标
至于当你设定了GL_CLAMP_TO_BORDER的环绕方式,想要指定边界颜色,就需要使用glTexParameterfv
函数了,像这样:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f }; //指定成黄色
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
记得在设置了环绕方式之后用!
四、纹理过滤(Texture Filtering)
纹理坐标采用了浮点数的形式,表明了它和分辨率无关。OpenGL需要非常精确的计算出纹理像素(通常被称为纹素)和纹理坐标之间的对应关系。当你有一张低分辨率的纹理图,但是需要用到一个非常大的物体上时,这种操作的重要性就更加明显了。聪明的你可能已经猜到了,没错,OpenGL提供了几种不同的方案来解决这个问题,我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。
- GL_NEAREST
最近点过滤。指的是纹理坐标最靠近哪个纹素,就用哪个纹素。这是OpenGL默认的过滤方式,速度最快,但是效果最差。 - GL_LINEAR
(双)线性过滤。指的是纹理坐标位置附近的几个纹素值进行某种插值计算之后的结果。这是应用最广泛的一种方式,效果一般,速度较快。
不过,你一定有个疑问,这些方法使用后,显示上有啥区别?别急,我们来看两张图就知道了。
什么,你问我那张美女图呢?这个,不是我不想用,是那张图实在是太不明显了,怕你看不出来。我们还是来看这张图,效果杠杠的。
很明显,最近点过滤的像素痕迹非常明显,看着就跟“屎大棒”似的。而线性过滤的方式效果就好上很多了,虽然感觉很模糊,但我们完全能理解一张小图放大之后会模糊这件事。
设置过滤方式还是使用glTexParameteri
,像这样:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); //缩小时的过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //放大时的过滤方式
五、Mipmaps
多级渐进纹理。虽然有这样的翻译,但是这种翻译太小众了,还是决定使用英文来表示。(笔者对自己的英文能力还是有信心的,不然也不会去看英文资料,嘿嘿~)
言归正传!想象这样一个场景,有非常多的同种物体,距离观察者的远近各不相同,我们可以对这种物体使用同一张纹理贴图,但是问题来了,对于那些离观察者比较远的物体,真的有必要用原始的纹理贴图去映射吗?答案当然是否定的。太远的物体我们看不清楚,显示的非常精细没有意义,而且使用原始贴图计算映射起来太麻烦,所以,我们使用一种mipmaps的方式来进行处理。
所谓的mipmaps,就是一系列的纹理图片,每一张纹理图的大小都是前一张的1/4,直到剩最后一个像素为止。看起来就像是这一个样子:
当物体越离越远的时候,就可以选用较小纹理去映射,这样不仅效果好,而且速度也快。
你可能会有疑问,这个图片是程序弄的还是美术弄的呢?对于这个问题,我的回答是:美术弄不弄我不知道,但是程序肯定可以弄,而且弄起来还非常方便。我们只要调用一个函数就行了,那就是glGenerateMipmaps
。
那我们开始用吧!哦偶,还不行,还有一个问题没解决。
疑问:
看上去很好,可是我物体的大小刚好在两张mipmaps之间怎么办呢?
对于刚好在两张图片之间的物体,我们可以参考前面两种过滤方式,最近点(采用最接近的图)或者线性(采用两张图的加权平均)。这样,我们就有了四种不同的过滤方案。
- GL_NEAREST_MIPMAP_NEAREST:采用最近的mipmap图,在纹理采样的时候使用最近点过滤采样。
- GL_LINEAR_MIPMAP_NEAREST:采用最近的mipmap图,纹理采样的时候使用线性过滤采样。
- GL_NEAREST_MIPMAP_LINEAR:采用两张mipmap图的线性插值纹理图,纹理采样的时候采用最近点过滤采样。
- GL_LINEAR_MIPMAP_LINEAR:采用两张mipmap图的线性插值纹理图,纹理采样的时候采用线性过滤采样。
好了,这次是真的可以用了。使用的函数还是一样,glTexParameteri
。像这样:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); //都是线性过滤
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
这里要注意一点,mipmaps是用来处理物体变小时候如何进行贴图的问题的,所以不需要设置GL_TEXTURE_MAG_FILTER成mipmap方式。如果强行使用了,会报错。(不信的话去试试!)
六、使用
准备
我们要做的第一件事就是将纹理图加载到我们的应用之中,但是,图片的格式不计其数,难道我们要对每一种格式都写一个加载的模块吗?这显然不是我们想要的。最好能有一个库来加载所有的图片格式,让我们可以直接用,这样我们就可以专注在映射上了。
幸运的是,确实有这样一个库我们可以直接用(链接)。打包下载之后,将其中的stb_image.h文件包含到我们的项目中去,我们要使用这个文件。
使用方式
我们使用stbi_load
函数来加载图片,并且将图片格式信息保存起来。像这样:
int width, height, nrChannels;
unsigned char *data = stbi_load("beauty.jpg", &width, &height, &nrChannels, 0);
width,height和nrChannels变量中分别会保存图片的宽度、高度和颜色通道数量的信息,这些都是在之后有用的数据。
顶点数据
到这里,我们的顶点已经有许多相关联的数据了,比如颜色,比如纹理坐标,是时候该给我们的顶点数组升升级了。
我们的顶点数组中需要三样东西:位置、颜色、纹理坐标。每个顶点都需要这三组数据,在数组中依次排下去。于是,顶点数组就成了现在这个样子:
float 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 //左上角
};
可以看到,我们的跨度变成了32,也就是8sizeof(float),颜色的起始偏移值为3sizeof(float),纹理的起始偏移值为6*sizeof(float)。这样,我们指定顶点属性的时候自然需要指定相应的位置和偏移。
颜色属性:
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
纹理属性:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
顶点着色器中,我们需要采用输入的颜色和纹理坐标进行操作,所以,在着色器中添加两个输入是非常好的选择。
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
然后,我们的顶点着色器代码就成了这个样子:
//顶点着色器代码
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
代码都是自解释的,非常容易理解,就不再浪费口舌了。相应的,我们的片元着色器也就成了这个样子:
//片元着色器代码
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord); //对纹理指定位置进行采样
}
创建纹理
想OpenGL里的其他东西那样,纹理也需要一个唯一ID,我们来创建一个:
unsigned int texture;
glGenTextures(1, &texture);
glGenTextures
的第一个参数是要创建的纹理数量,后面的参数就是保存这么多数量的整型数数组。当然,创建完了之后我们也需要绑定到OpenGL的环境里才能操作。
glBindTexture(GL_TEXTURE_2D, texture);
绑定完成后,我们就可以把之前加载的图片数据放到纹理中去了。
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
又是一个拥有很多参数的庞大函数。我们一点点啃死它!
- 参数一:指定目标纹理。GL_TEXTURE_2D就表示当前的操作会对绑定的2D纹理产生作用(GL_TEXTURE_1D和GL_TEXTURE_3D里的东西就不会受影响)
- 参数二:mipmap层级。我们之后会调用glGenerateMipmap来创建,这里只需要创建原始图就行了。(或者你也可以手动的一次次调用这个函数来创建,(坏笑~))
- 参数三:我们需要保存的纹理格式。我们的图片只有RGB信息,所以用GL_RGB格式。
- 参数四和参数五:纹理图片的宽高。之前保存的那个。
- 参数六:一定要设置成0(有一些遗留的工作)
- 参数七和参数八:源图片的格式和数据类型。我们加载的图片中有RGB值,并且以字节的方式保存。所以我们传递了这两个参数。
- 参数九:加载的图片数据。
调用glTexImage2D
之后,当前的纹理对象就和我们的纹理图绑定起来了。然后,我们再调用glGenerateMipmap
函数创建mipmaps,非常流畅~
操作完成之后,最好要把图片释放掉,因为数据已经都保存到纹理对象中了,这样干:
stbi_image_free(data);
整个代码块就是这个样子:
//纹理
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
//设置纹理包装和过滤的方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
int width, height, nrChannels;
//stbi_set_flip_vertically_on_load(true);
unsigned char* data = stbi_load("beauty.jpg", &width, &height, &nrChannels, 0);
if (data) {
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
std::cout << "无法加载问题,请检查代码或资源是否有误。" << std::endl;
stbi_image_free(data);
七、运行
好,咱就运行起来。
嗯?不对啊,怎么是倒的?
这是因为OpenGL期待原点(0,0)位于左下角,而通常一张图片的原点位于左上角。不过,这不是问题,stb库早就已经为我们准备了解决方案。在加载图片之前调用stbi_set_flip_vertically_on_load(true);
哈哈,是不是注意到了我之前注释掉的那行代码,没错,就是为了显示出倒图而故意注掉的。
好,再次编译运行,这次的效果才对!
八、结语
呼~ 好长的一篇,不过总算是弄了点东西,辛苦没有白费~
参考资料:
www.learningopengl.com(非常好的网站,建议也去学习)