能够掌握了上一节中的知识(缓存对象和顶点缓存对象),之后的学习完全可以说是一马平川!因为基本图形绘制中出现的大多数问题,一般都是因为缓冲区绑定混乱导致的,因此,这里再强调一下,如果上一节中有什么地方漏看了,请务必再去回顾一下!
说简单一点,纹理就相当于一种“贴图”,我们可以把它“贴”到图形上(这里纹理充当颜色缓存的作用)。 本质上,纹理对象是一段特殊的存储区域(也是存储在显存中,客户端保留ID),它特殊在我们可以在着色器程序中使用一个名为采样器的东西,快速的从纹理中读取我们想要的数据(待会介绍采样器)
上面特定强调了数据两个字,而不是单单指颜色,是因为纹理有很多其他的用法,并不是只能用来填色,下面例举一些其他的用法,后面我们可能会讲到:
存储图形的高度数据
存储图形的法线
存储图形的阴影
存储光照贴图
通过纹理能实现非常复杂的填色效果
对于一些只需计算一次,之后固定不变的数据,使用纹理是一个非常好的优化方案(比如一个模型各个顶点的法线,在局部空间是不会发生改变的,因此我们没必要每次都重复计算法线,只需提前计算好法线,存储到一张图片中,之后导入进程序当做纹理即可使用)
在OpenGL中,常见纹理类型有1D,2D,3D等,这些纹理可以使用对应的采样器(sampler)获取数据,以我们经常使用的2D纹理为例,2D纹理可以提供两个参数(可以理解为图片的x,y坐标)从采样器【sampler2D】中获取到数据。
这一步骤一般是在片段着色器中进行的,操作类似于下面这样:
in vec2 texCoord; //从顶点着色器传递过来的纹理坐标
uniform sampler2D texture; //2D纹理采样器
void main(){
gl_FragColor=texture2D(texture,texCoord);//通过纹理坐标从采样器中读取到颜色vec4
} 与标准化坐标系不同的是,纹理坐标的范围为[0,1],下面是2D纹理坐标的示意图:

同理,3D纹理使用三维坐标中的[0,1]部分。
QOpenGLTexture的使用方式与QOpenGLBuffer类似,在创建纹理对象QOpenGLTexture的构造函数要求我们必须指定纹理的目标类型,以下是Qt支持的目标类型:

这个类型在底层决定当调用QOpenGLTexture::bind()时具体绑定到哪个缓存区。
创建纹理起始步骤跟使用VBO一样:
QOpenGLTexture texture(QOpenGLTexture:Target2D);
texture.create(); 接下来我们一般需要调用纹理的一些设置方法设置纹理的格式,设置结束之后调用
void QOpenGLTexture::allocateStorage() 为这个纹理对象分配服务器端存储,包括格式、尺寸、mipmap级别、数组层和cubemap面。一旦分配了存储,就不可能再更改这些属性。分配存储之后你可以调用setData()重载之一上传像素数据。 注意:如果不可变纹理存储不可用,那么默认的像素格式和像素类型将被用于创建可变存储。您可以使用另一个allocateStorage()重载来指定在分配可变存储时使用的像素格式和像素类型;这在某些OpenGL ES实现中特别有用(特别是OpenGL ES 2),其中分配时使用的像素格式和像素类型必须与传递给后续setData()调用的格式和类型完全匹配。
Qt允许我们使用一个便捷方法,只需传入一个QImage就能快速创建一个纹理对象,无需再调用allocateStorage()分配存储(因为函数内部会根据QImage的格式自动分配)。

完整的创建方法就像是下面这样:
QOpenGLTexture texture(QOpenGLTexture:Target2D);
texture.create();
QImage image(":/test.png");
texture.setData(image); 接下来我们认识一下纹理的一些采用设置
2D纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL默认的行为是重复这个纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但QOpenGLTexture提供了更多的选择:

当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:

前面提到的每个选项都可以使用setWrapMode函数对单独的一个坐标轴设置(S、T(如果是使用3D纹理那么还有一个R)它们和x、y、z是等价的):
texture.setWrapMode(QOpenGLTexture::DirectionS,QOpenGLTexture::Repeat);
texture.setWrapMode(QOpenGLTexture::DirectionT,QOpenGLTexture::Repeat); 第一个参数指定了应用的纹理轴。
第二个参数我们传递一个环绕方式(Wrapping)。
如果我们选择QOpenGLTexture::ClampToBorder选项,我们还需要指定一个边缘的颜色。这需要使用setBorderColor函数,并且传递一个QColor作为边缘的颜色值:
texture.setBorderColor(QColor(1.0f,1.0f,1.0f,1.0f)); 纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel)映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。你可能已经猜到了,QOpenGLTexutre也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:QOpenGLTexture::Nearest和QOpenGLTexture::Linear
Texture Pixel也叫Texel,你可以想象你打开一张.jpg格式图片,不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标的各个分量都已经标准化到【0,1】,像素坐标则是具体像素的的横纵坐标。
QOpenGLTexture::Nearest(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为QOpenGLTexture::Nearest的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

QOpenGLTexture::Linear(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:

那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):

QOpenGLTexture::Nearest产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而QOpenGLTexture::Linear能够产生更平滑的图案,很难看出单个的纹理像素。QOpenGLTexture::Linear可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。
当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,比如你可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。我们需要使用setMinMagFilters函数为放大和缩小指定过滤方式:
texture.setMinMagFilters(QOpenGLTexture::Nearest,QOpenGLTexture::Linear); 当然我们也可以直接调用setMagnificationFilter和 setMinificationFilter单独进行设置
想象一下,假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。
OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的:

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好QOpenGLTexture有一个generateMipMaps()函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。
在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用QOpenGLTexture::Nearest和QOpenGLTexture::Linear过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:
texture.setMinMagFilters(QOpenGLTexture::Nearest,QOpenGLTexture::Linear);
texture.setMinMagFilters(QOpenGLTexture::LinearMipMapLinear,QOpenGLTexture::Linear); 一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。
在使用纹理之前,我们需要知道:着色器程序并不是直接从纹理对象中读取像素数据,而是通过一个名叫纹理单元的东西,它的目的是让我们在着色器中可以使用多个的纹理。
使用纹理首先需要将纹理对象绑定到一个纹理单元(默认是0),在通过着色器程序对象的setUniform设置着色器代码中的采样器从哪一个纹理单元读取数据。一般步骤如下:
片段着色器代码:
varying vec2 texCoord; //从顶点着色器传递过来的纹理坐标
uniform sampler2D texture; //请注意,这是采样器,不是纹理对象
void main(){
gl_FragColor=texture2D(texture,texCoord);
} 客户端代码:
texture.bind(0); //将纹理对象绑定到0号纹理单元(如果不给参数则默认值为0)
shaderProgram.setUniform("texture",0);//设置采样器[texture]从0号纹理单元中读取数据
OpenGL至少保证有16个纹理单元供你使用,也就是说你可以绑定0-15,它们都是按顺序定义的。
下面我们直接开始操作吧!
在OpenGL中,绘制图片的思路很简单:我们只需要绘制一个矩形,除了之前传递的顶点位置数据,现在我们还需要传递纹理坐标到顶点着色器中。
这次也是使用上一节中的代码,你也可以重新实现一遍当做回顾,然后使用下面这张图片(浏览器右键另存为,笔者已经将它放到了项目目录下面了),废话不多说,我们直接开始操作吧


首先我们修改上一节中的代码来绘制矩形。
然后创建QOpenGLTexture对象,并初始化为2D纹理,在initGL中调用create申请向GPU创建纹理对象,并调用setData设置像素数据(以QImage为参数时会自动分配存储)。
接着我们添加顶点的纹理坐标数据,因为数据存储发生改变,因此需要重新设置VAO存储的解析方式。
然后我们在片段着色器中创建采样器,并在客户端中绑定纹理到0号纹理单元,并设置采样器从0号单元读取数据。
最后我们在顶点着色器中将纹理坐标传递输出到片段着色器中,在片段着色器中使用传递过来的纹理坐标调用texture2D从采样器中获取颜色信息
本次使用的着色器代码中使用了两个新关键字,它们的作用如下:
下一章中我们会详细讲解关于着色器的知识,如果现在不理解,可以暂时不用管。
还没完,你有没有发现我们绘制的图形为什么是倒的,为什么呢?
是因为OpenGL的采样顺序和QImage图片的存储顺序有细微差别:大多数图片都会以图片的左上角开始存储图形数据,而OpenGL的采样器是以左下角为原点进行绘制。要解决这个问题有两个方法:
1.修改纹理坐标为
float vertices[]={
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, 1.0f, 1.0f, // 右下角
-0.5f, 0.5f, 0.0f, 0.0f, 0.0f, // 左上角
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 左下角
}; 2.调用QImage QImage::mirrored(bool horizontal = ..., bool vertical = ...) const &翻转图像
texture.setData(QImage(":/OpenGL.jpg").mirrored()); 两种方法都能得到正确的结果:

鉴于之前的章节太过枯燥无畏,从这一章节开始,每个小节结束,我们会使用本节学到的东西,做一些有意思的扩展,这些扩展除了一些必要的说明之外,将全部以视频的方式演示,对此笔者真的很抱歉,因为时间有限,只能把核心放在基础重点上,不过我会尽量在项目代码中增加注释。
那么这一节要做什么呢?
我们要做一个显示时间的电子钟,就像是下面这样:

当然这一节我们制作的效果还没那么炫,只是简单的文字,泛光效果会在之后我们将帧缓存的时候进行实现。
通过它能能学到什么呢?
使用Qt+OpenGL绘制2D文字。
使用OpenGL绘制动画。
那么下面的话,就直接开始吧!

制作完成之后效果如下:

但是你会发现一个很严重的问题,那就是文字的大小是随着窗口改变的,这是因为我们使用的纹理坐标是整个窗口,而实际上文字的大小应该是我们用QFontMetrics量出来的,,窗口的大小明显大于纹理的大小,因此拉伸导致了变模糊,那有什么方法能解决这个问题呢?其核心就是调整矩形的大小跟纹理的大小一致,使用以下方法都能做到:
使用 glViewport 重新设置绘图区域为文字的矩形区域,我个人比较喜欢这种方式(使用完注意复原),但这也有一个弊端,那就是只适用于2D界面。
在客户端调整顶点数据,将数据调整为文字矩形的四个顶点即可(注意要标准化)。
传入一个变换矩阵,在顶点着色器中对图形顶点进行变换。
代码地址:
https://github.com/Italink/QtOpenGL-Essential-Training/tree/main/Day03_Text2D
上面的代码虽然能完成2D文字的绘制,但是在以后的开发之中,我们总是要绘制纹理,文字等特定的图形,你会发现上面我们的代码很乱,根本无法复用,因此笔者简单封装一个静态工具库,将这些常用图形的绘制算法放在里面,代码地址:
https://github.com/Italink/QtOpenGL-Essential-Training/tree/main/GLTool
该库为静态链接库,笔者使用MSVC2017 32位编译了Debug和Release,如果使用其他编译器的朋友可以手动编译一下,注意在QtCreator左侧的项目配置中取消勾选shadow build
利用这个工具我们重新完成2D文字的绘制,代码地址如下:
https://github.com/Italink/QtOpenGL-Essential-Training/tree/main/Section4_2DTextByTool
完成之后你会发现文字的效果已经非常完善了:
