前言
在Qt中使用OpenGL(一)
在Qt中使用OpenGL(二)
在之前的文章中,我们首先了解了在Qt中使用OpenGL的全流程,然后我们使用了一个最简单的例子将这个流程给过了一遍。
那么,在本篇文章中,我们将对“最简单的例子”进行一定的修改,以便达成我们接下来的目标:如果为我们绘制出来的东西,加上“纹理”
什么是纹理
既然我们要研究怎么加上纹理,那么我们就需要首先知道什么是纹理。
首先,我们上一个版本的程序,只是绘制出来了一个“白色的三角形”,事实上,在真实的应用里,这种“绘制白色三角形”的需求是不可能出现的,我们大部分时间遇到的情况应该是“添加一个模型”对吧。
那么,你有没有意识到,当你添加一头牛的模型时,它看起来像是一头牛的原因,除了它的形状是一头牛之外,是不是它身上的各个部位的颜色也像是一头牛呢?
这些颜色是怎么来的呢?难道是用shader给牛身上的每个顶点指定了颜色?就像之前我们给顶点指定了白色?
对,也不对。
是的,这些看起来正确的颜色的确是通过shader进行指定的。但是不对的地方是,它并不是通过给每个顶点设置颜色实现的,而是通过“纹理映射”实现。
简单理解,就是将“一层皮”,套到了3D世界中的牛的身上,牛身上的每个顶点都对应到了这层皮上的一个位置,这样,只要每个顶点对应的位置是正确的,那么我们看到的牛就和现实中的牛差距就不会太大了。
那么,为了可以用最简单的例子来解释纹理,我们不使用牛,而是选择最简化的场景,假设我有一张很漂亮的风景画:
我想让这个风景画出现在3D世界中,作为画廊中的一幅画。怎么办呢?
绘制矩形与设定纹理映射坐标
当然,在我们想要在3D世界中增加一幅画,首先还有一个问题要解决。很显然,这张图是个矩形,而我们之前做过的唯一的事情只有绘制三角形。
那么怎么绘制一个矩形呢?
聪明的你大概也猜出来了,没错,我们吧矩形沿对角线剪开,不就是两个三角形了?所以我们只要画两个三角形不就行了?
没错,就是这个道理。我们只要画两个三角形就行了。
然后呢,OpenGL很贴心的提供了多种绘制多个三角形的方法,其中有一个方法,使得我们只要“逆时针定义4个顶点”就可以方便的绘制出可以组成矩形的两个三角形了。
怎么理解呢?
这是默认情况下,OpenGL的屏幕坐标。也就是说,如果我们想要绘制一个占满整个屏幕的矩形,也就是两个三角形,我们需要逆时针,定义4个顶点。
很显然,在这个屏幕坐标中,左上就是(-1,1),左下就是(-1,-1),右下就是(1,-1),右上就是(1,1),那么我们按照这个顺序就可以定义四个顶点了。(z值得话,我们只需要都使用0就行了,简单吧。)当然,你可以任意的调整顶点的顺序,但是顶点必须是逆时针排列顺序。
float _vertex[] = {
// 顶点
-1, 1, 0, // 左上
-1, -1, 0, // 左下
1, -1, 0, // 右下
1, 1, 0, // 右上
};
至此,我们画两个三角形的准备工作就完成了。
但是还有一个工作没有完成,那就是纹理映射坐标。
是的,当我们需要考虑纹理的时候,从顶点的时候就需要考虑了。为什么?
还记得之前说法吗?
简单理解,就是将“一层皮”,套到了3D世界中的牛的身上,牛身上的每个顶点都对应到了这层皮上的一个位置
是的,你需要将每一个顶点,都对应到纹理上的一个位置。这个对应方法,就是使用“纹理映射坐标”。
简单来讲,就是用(x,y)这个二维坐标来表示纹理上的一个位置,其中,x与y的值域为[0,1]。
其中,(0,0)表示左下角。(1,1)点表示右上角。当然,你也可以把(0,0)当作左上角,(1,1)当作右下角,就和一般情况下我们编辑图片时那样。具体你要怎么看待这个坐标系统,实际上不重要。重要的是,一定要保证有一个统一的标准。目前,我们的统一标准是(0,0)表示左下角,(1,1)点表示右上角。
了解了这些,我们就可以给顶点附加纹理映射坐标了,方法很简答,只需要在每个顶点坐标后面,附加两个代表纹理映射坐标的float值就行了,就像这样:
// 顶点缓存中前三个是顶点坐标, 后两个是纹理坐标, 一个顶点由5个float值组成
float _vertex[] = {
// 顶点 纹理
-1, 1, 0, 0, 1, // 左上
-1, -1, 0, 0, 0, // 左下
1, -1, 0, 1, 0, // 右下
1, 1, 0, 1, 1, // 右上
};
注意,当你这么做了,就意味着你的一个顶点有5个float值了,而不是之前的3个。
还记得之前的文章里说过吗?OpenGL根本不知道什么9个值3个顶点的标准,现在明白为什么了嘛?因为这种标准根本就不存在啊!现在,当我们需要“纹理映射”的时候,原先9个值3个顶点就变成了20个值,4个顶点了。每个顶点需要5个值来表示!
那么,我们要如何告诉OpenGL这个新的顶点缓存标准呢?
如何使用包含纹理的顶点缓存
很显然,之前我们使用了setAttributeBuffer()
这个函数告诉了OpenGL每个顶点有3个值,那么我们同样需要使用setAttributeBuffer()
这个函数告诉OpenGL,现在规则改变了,每个点有5个值,其中前三个是顶点坐标,后两个是纹理坐标。
可是,我们要怎么操作呢?
首先,我们需要做的是,修改Shader。
没错,还记得我们之前的Shader里面,定义了1个vec3类型的输入嘛?
这也就意味着OpenGL只能处理一个vec3数据,也就是3个float类型。可我们一个顶点有5个float类型,怎么办?那就再加一个vec2类型的输入不就好了嘛!
#version 330 core
in vec3 vPos;
in vec2 vTexture;
out vec2 oTexture;
void main()
{
gl_Position = vec4(vPos, 1.0);
oTexture = vTexture;
}
于是我们的顶点Shader就变成这个样子了,也就是获取坐标的同时也要获取纹理坐标,然后将纹理坐标发送给其它的Shader。
请注意,我们除了添加了一个输入变量vTexture(in开头),我们同时还定义了一个输出变量oTexture(out开头),这是为什么呢?
因为Shader是一种链式调用的代码,顶点Shader会自动的从顶点缓存中获取数据,(也不是自动,因为我们需要告诉OpenGL如何操作),可FragmentShader并不会,所以我们需要从顶点缓存中获取数据,然后将获取到的数据传给FragmentShader。
那么,到了FragmentShader,又要做什么操作呢?
#version 330 core
in vec2 oTexture;
uniform sampler2D uTexture;
void main()
{
gl_FragColor = texture(uTexture, oTexture);
}
如图所示,我们只要定义一个名字和顶点Shader中定义的输出变量名字一致的输入变量,顶点Shader中的输出变量就会自动的传递到FragmentShader中的输入变量中。然后我们就可以运用OpenGL内置的函数texture来进行纹理映射了。没错,texture这个函数的意义就是将纹理指定位置映射到顶点上。
请注意,这里我们有了一个除了in和out之外的另一个指示器,uniform,简单来说它和in一样,也表示了一种输入,但是,和in不一样的是,它的来源可以是任何地方。而in呢?要么是来自其它shader的输出,要么只能来自于顶点缓存的输入。你可以在不修改顶点缓存的情况下,在任何合理的地方输入uniform的值,例如我们可以根据需要,动态的改变纹理。而sampler2D 这个类型就表示二维纹理了。
好了,至此,顶点,纹理坐标,shader我们都修改好了,让我们告诉OpenGL要如何使用顶点缓存吧。
简单来说,就是这样:
// 绑定顶点坐标信息, 从0 * sizeof(float)字节开始读取3个float, 因为一个顶点有5个float数据, 所以下一个数据需要偏移5 * sizeof(float)个字节
m_program->setAttributeBuffer("vPos", GL_FLOAT, 0 * sizeof(float), 3, 5 * sizeof(float));
m_program->enableAttributeArray("vPos");
// 绑定纹理坐标信息, 从3 * sizeof(float)字节开始读取2个float, 因为一个顶点有5个float数据, 所以下一个数据需要偏移5 * sizeof(float)个字节
m_program->setAttributeBuffer("vTexture", GL_FLOAT, 3 * sizeof(float), 2, 5 * sizeof(float));
m_program->enableAttributeArray("vTexture");
我们都知道setAttributeBuffer()
函数的第1个参数表示shader中变量的名字,第2个参数表示数据类型,那怎么理解setAttributeBuffer()
函数的第3个参数呢?
第3个参数表示在一个顶点中从哪个位置开始,是你需要的数据。
很显然,我们一个顶点由5个float值组成,前三个表示位置,后两个表示纹理坐标,于是对于位置,从第0个位置开始,也就是 0 * sizeof(float)个字节,而纹理坐标从第3个位置开始,也就是 3 * sizeof(float)个字节
setAttributeBuffer()
函数的第4个参数是指有几个数据,坐标有3个数据,纹理坐标有2个数据
setAttributeBuffer()
函数的第5个参数表示你的顶点缓存,多少数据表示一个顶点。我们的顶点缓存,5个float值表示一个顶点,于是就是5 * sizeof(float))个字节。
至此,顶点与纹理坐标都处理好了,接下来就是绘制了。
绘制带纹理的矩形
聪明的你一定发现了,到现在为止,除了纹理坐标,我们实际上根本没有处理任何纹理上的东西。
比如,我们根本就没有从文件中加载一张图片当作纹理啊!
没错,那么,我们就再修改一下初始化,加载一张图片当作纹理吧。
首先,在Qt中,OpenGL的纹理使用QOpenGLTexture
类表示的,于是我们加载一张图片当作纹理就可以这么写:
m_texture = new QOpenGLTexture(QImage("D:/20210928.jpg").mirrored());
只要我们在类的头文件中定义了QOpenGLTexture *m_texture = nullptr;
,那么初始化的时候,我们就可以创建纹理了。
有没有发现什么奇怪的事情?没错,我们加载图片的时候,使用mirrored()这个函数,为什么?查一下这个函数就会发现,它默认会将图片沿y轴进行镜像翻转。
还记得介绍纹理的坐标系统的说的话吗?
其中,(0,0)表示左下角。(1,1)点表示右上角。当然,你也可以把(0,0)当作左上角,(1,1)当作右下角,就和一般情况下我们编辑图片时那样。
因为我们用(0,0)表示左下角,所以坐标和一般的图片坐标发生了镜像翻转,所以加载纹理的时候只要也镜像翻转一下,问题就解决了。
好了,纹理加载好了,我们要如何告诉OpenGL有这么一个纹理呢?
很简单,绘制图像之前bind一下,绘制完毕release就行了。
也就是这样:
在这里,我们绘制的矩形的参数不是之前绘制三角形时的GL_TRIANGLES,0,3,而是GL_TRIANGLE_FAN,0,4。其中,GL_TRIANGLE_FAN就是之前提到的另一种绘制三角形的方法,它允许我们逆时针定义四个顶点,然后绘制出来两个三角形组成一个矩形。(当然,实际上能不能组成一个矩形OpenGL并不关心,能不能组成矩形是用户定义的顶点有关,和OpenGL没关系。甚至它实际上并不是用来绘制矩形的,仅仅是一种可以快速的绘制多个三角形的方法罢了,我们只是利用了它的特性而已)
好了,让我们运行一下程序吧:
恭喜!你成功了!(虽然图片的比例错了,但是你成功的将纹理中的图片绘制到窗口中了)
此时,聪明的你一定发现了一件奇怪的事情。
我们在Shader中定义了一个uniform sampler2D uTexture;
用来表示纹理。
但是自始至终,我们都没有给这个输入变量赋值。那OpenGL又是怎么能将我们加载的纹理传到Shader中呢?
因为OpenGL的纹理系统有一个默认设定,那就是,使用纹理的时候(调用bind函数的时候),会被自动的对应到当前激活的纹理,默认激活的纹理就是纹理0,而我们第一个定义的纹理变量,就会被当作纹理0。这样,我们使用的纹理就自动的和Shader中定义的纹理对应上了,完美。
注意Qt文档中的说明,除了默认的bind函数,还有一个可以指定将纹理bind到那个索引的重载函数。
下一篇:在Qt中使用OpenGL(四)