OpenGL入门,本文以这篇文章的入门部分为基础完成。有扩展。

OpenGL

        OpenGL(英语:Open Graphics Library,译名:开放图形库或者“开放式图形库”)是用于渲染2D、3D矢量图形的跨语言、跨平台的应用程序编程接口(API)。
        众所周知,绘图是软件开发中不可或缺的一环。在传统方式下,CPU会同时负责逻辑运算以及绘制操作,使得程序性能较为低下。在现代的程序设计中,我们会使用GPU来帮助CPU完成绘图工作,从而将CPU解放出来,以完成程序业务逻辑等其它计算任务,能很好地提升程序性能。
        那么,我们的程序都是跑在CPU上的,如何指挥GPU工作,并让它绘制出我们想要的图形呢?这就需要调用相关的API。
        显然,对于不同的显卡厂商以及不同的驱动程序,在使用时,常常无法统一,这会带来很多麻烦。因此我们需要一个统一的标准,使得不同的显卡可以提供同一种API,OpenGL就是其中之一。
        类似的图形库还有DirectX(Windows),metal(Apple)以及Vulkan。
        OpenGL是很多手机端游戏的底层图形API,不过vulkan有取代之势(2016年的Android N(安卓7.0)开始支持Vulkan API)。

        (上图为Linux下调用OpenGL API的框架示意)

CPU与GPU的区别

CPU需要很强的通用性来处理各种不同的数据类型,同时又要逻辑判断又会引入大量的分支跳转和中断的处理。这些都使得CPU的内部结构异常复杂。而GPU面对的则是类型高度统一的、相互无依赖的大规模数据和不需要被打断的纯净的计算环境。
GPU采用了数量众多的计算单元和超长的流水线,但只有非常简单的控制逻辑并省去了Cache。而CPU不仅被Cache占据了大量空间,而且还有有复杂的控制逻辑和诸多优化电路,相比之下计算能力只是CPU很小的一部分。
链接:https://www.zhihu.com/question/19903344/answer/13779421
来源:知乎

        通俗的理解:如果CPU是几个教授,那么GPU就是几百个中学生。GPU擅长大吞吐量的简单运算。

渲染管线

        在显卡中,图形的渲染是通过一定的步骤完成的,称之为管线(pipeline)。它像流水线一样对图像进行层层处理,最终绘制到屏幕上。每一个管线很容易并行执行。当今大多数显卡都有成千上万的小处理核心,它们能快速地完成大量渲染工作,就是通过渲染管线的并行执行完成。

        (上图为OpenGL渲染管线的示意图)
        我们现在对其中一些过程做简单的讨论。
        在渲染时,图形渲染管线会接受一组3D坐标,并在顶点着色器(Vertex Shader)中做一些几何变换。
        紧接着,这些顶点数据会在图元装配的过程中组合成一定的几何形状。至于如何组合,组合成什么形状(如三角形、点)是由程序员指定的。
        图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。
        接下来是光栅化阶段(Rasterization Stage),在这一步,图元会映射到屏幕上的2D像素,生成片段,供片段着色器使用。在片段着色器执行之前,会进行一步裁剪,剪掉视图之外的部分,能提高效率。
        片段将会被传送到片段着色器(Fragment Shader),在这里将计算出一个像素的最终颜色,这是OpenGL所有高级效果产生的地方。
        最后,会经过一步测试和混合阶段。在这个阶段,会进行一些深度测试(Depth Test)以及alpha混合过程。所谓深度测试,简单的说就是判定显示物体的遮挡关系,而深度测试就是决定有重叠物体重叠部分的处理模式。
        对于早期的OpenGL,这些过程都是固定的,称为立即渲染模式(Immediate mode),或固定管线。这样的方式固然方便,但是可控性差。从OpenGL3.2开始,规范文档开始废弃立即渲染模式,并鼓励开发者在OpenGL的核心模式(Core-profile)下进行开发,这个分支的规范完全移除了旧的特性。这样的模式提供了对管线某些步骤的可编程能力,从而使控制渲染过程成为可能。
        上图中蓝色部分就是可以注入自定义程序的部分,其中几何着色器可选。

绘制一个三角形

​ 现在我们举一个例子:绘制一个三角形来认识应用OpenGL过程。

顶点缓冲对象

        OpenGL是一个标准,它不是一个现成的库实现。在这里,我们会使用Qt提供的OpenGL功能来绘制窗口和一个三角形。如果你不了解Qt也没有关系,可以采用其它的实现方式,因为本质是相同的。
        在Qt5中,提供了新的QOpenGLWidget类来完成OpenGL的绘制,以代替之前的QGLWidget。这里使用Qt5版本,OpenGL采用3.3 core版本。

1
2
3
4
5
6
7
8
9
10
class OpenGLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core
{
Q_OBJECT
public:
explicit OpenGLWidget(QWidget *parent = nullptr);

signals:

public slots:
};

        在这里我们继承QOpenGLWidget并保护继承QOpenGLFunctions_3_3_Core类来使用Qt提供的OpenGL函数。
        下一步,重写这两个函数:

1
2
3
4
protected:
void initializeGL();
void paintGL();


        Qt的部分完毕,下面开始OpenGL API调用过程。
        在initializeGL()函数中,我们要完成一些初始化工作,比如初始化OpenGL函数以及设置视口。

1
2
3
4
5
void OpenGLWidget::initializeGL()
{
initializeOpenGLFunctions();
glViewport(0, 0, 800, 600);
}

        只有设置了视口,OpenGL才能知道如何根据窗口大小来设置坐标与窗口坐标的映射关系。在这里,我们大小为800*600的窗口在OpenGL中会成为x坐标为[-1,1],y轴坐标为[-1,1]的区域。

[标准化设备坐标(Normalized Device Coordinates, NDC)]当一个顶点坐标在经过顶点着色器后,它就会成为标准化设备坐标。在这个系统中,只有x、y和z坐标都在[-1,1]中时才是可见的,超出这个范围的图元不可见。
标准化设备坐标的坐标系是右手系,其中x轴水平向右,y轴垂直向上。

        接下来,我们设置顶点数据。

1
2
3
4
5
float v[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};

        在这里我们定义了一个float数组储存三角形的三个顶点。由于OpenGL是3D的,因此还会有一个z坐标,这里设置成0就好了。
        接下来,需要将这个顶点数据发送到显存中,这个过程需要顶点缓冲对象(VBO)来完成。

1
2
3
4
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(v), v, GL_STATIC_DRAW);

        产生一个VBO对象比较容易,我们可以调用glGenBuffers函数来产生VBO并获取它的ID。紧接着,通过glBindBuffer函数来指定这是一个顶点缓冲对象。
        最后一行代码,将v数组加载到VBO中。在这个函数的最后一个参数中,指定了显卡管理数据的方式,这里选择的是GL_STATIC_DRAW,表示数据几乎不会变化,如果我们选择的是GL_DYNAMIC_DRAW,那么显卡会将顶点放到能更快速写入的内存段中。
        现在已经能将顶点发送到显卡了,但是还没有定义顶点着色器的操作。在上文中,已经提及现代OpenGL需要自定义顶点着色器以及片段着色器的行为,这里先定义第一种:顶点着色器。

顶点着色器

        OpenGL中的着色器程序是用一种特殊的语言完成编写的,称为着色器语言GLSL(OpenGL Shading Language),它和c语言很像,下面来看看最简单的顶点着色器程序代码:

1
2
3
4
5
6
7
#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

        第一行指定了OpenGL版本,接下来的一行我们定义了OpenGL的输入变量,这是一个vec3(三维向量)类型的变量,而layout (location = 0)设置了它的位置(见后文)。下面的main函数中,我们对内建变量gl_Position进行了赋值以指定顶点的坐标。这是一个vec4(四维向量)的变量,它的最后一维并不是用来描述位置的,而是用于后来的几何变换上(下文讲述),这一个值设置成1.0即可。
        这就是一个非常简单的顶点着色器程序源码,接下来将它设置到顶点着色器中:

1
2
3
4
5
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);//vertexShaderSource就是顶点着色器代码,char*
//这里的1是字符串数量
glCompileShader(vertexShader);

片段着色器

        设置好了顶点着色器当然也要设置片段着色器,它决定三角形的外观。这里将三角形的颜色设置成橘黄色,片段着色器的源码如下:

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

        这里输出了一个四维向量FragColor,它由四个分量组成:R、G、B、A,前三个就是指red、green和blue的分量,A就是alpha通道,可以认为是透明度。它们的值都必须在[0,1]之间。
        当到达片段着色器时,输入的就是一个个片段,我们给它们指定同一个颜色。然后,将代码编译到片段着色器上:

1
2
3
4
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

        两个着色器都完成编译了,现在需要链接产生一个着色器程序。

1
2
3
4
5
6
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);


        这里产生了一个着色器程序,并将两个着色器附加到程序上。这里记得清一下着色器,它们已经没有用了。
1
2
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

        现在,顶点数据已经完成发送,并且着色器也经过绑定了,但是还没有指示OpenGL如何处理内存中的数据。这里需要进行绑定顶点属性的操作。

属性的绑定

        我们的顶点数据就是三个顶点的坐标,但是OpenGL并不会解析这个数据,需要人为指定。在这里可以用下面的代码来完成这一个操作:

1
2
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

        在这里,唯一一个不怎么好理解的应该就是第一个API调用。首先第一个参数,就是我们在上文中指定的location=0(没错,这就是它的作用)。接下来是这一个属性包含值得数目。接下来指定了这个值的类型,它是一个GL_FLOAT类型,可以认为就是float。

        第四个参数指示了是否需要标准化。重点是最后两个参数,倒数第二个是每两个相邻属性组之间的间距,这里只有一个属性,并且是float类型,且有3个,那么下一个属性的位置显然是3sizeof(float)之后。如果有两个属性,都是float类型,第一个有3部分,第二个有2个部分,那么在指定第一个属性时,这一个值就应该是5sizeof(float)。最后一个是偏移量,由于这一个属性时是数组的第一个位置,因此最后一个是(void*)0表示不偏移。

        具体说的话,直接搬原文了:

第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0。
第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec都是由浮点数值组成的)。
下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3
sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

顶点数组对象

        当渲染一个物体时,可以绑定相应的VBO和着色器程序来完成渲染,然后绑定属性,但是这样做显然太麻烦了,这里我们就需要用到顶点数组对象(VAO)
        VAO可以像VBO那样被绑定,它会储存之后设置的所有顶点属性调用,这样一来,如果我们需要渲染一些物体,只需要把相应的VAO拿过来就可以了。

一个顶点数组对象会储存以下这些内容:

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

        下面的代码会绑定一个VAO,我们把它放到绑定VBO的上一行:

1
2
3
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

        这样就做完了所有的准备工作,现在可以绘制出我们需要的三角形了。

绘制

        绘制过程比较容易,直接拿出对应的着色器程序以及VAO,然后调用函数:

1
2
3
4
glClear(GL_COLOR_BUFFER_BIT);//把窗口清除为当前颜色
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);//画一个三角形,3是顶点数量

        GL_TRIANGLES指定了绘制的形状,也就是一个三角形。这样运行程序就能看到需要的结果了:

绘制一个矩形

        现在我们已经能够绘制出一个三角形了,那么现在来尝试绘制一个矩形。
        所谓的矩形就是两个三角形拼在一起,这样就需要6个顶点。在这里顶点数据只有三个量,即坐标,但以后可以有几十个属性值来限定物品,这里绘制6个顶点,却有2个是重复出现的,这样做显然浪费内存,也不够优雅。
        这里需要引入索引缓冲对象(IBO),它能够指定绘制时取点的顺序。

1
2
3
4
5
6
7
8
9
10
11
12
float v[] = {
0.5f, 0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
-0.5f, 0.5f, 0.0f
};

unsigned int indices[] = {
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};

        定义好四个顶点的位置,然后将索引数组写出来。然后绑定一个IBO:

1
2
3
4
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

        IBO也会被VAO保存,这样就可以用VAO来控制。
        在使用IBO时,不能再用glDrawArrays函数,而是glDrawElements来绘制:

1
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        然后就可以绘制出矩形了:

纹理

        看到这里,可能有人会说,折腾了几十行代码,就画了这样一个简单的几何图形!?确实,其实你用某些库(Qt就可以)的函数几行就能画出一个不错的三角形,但是上文也以及提及,这些库中提供的绘制方法往往是CPU绘制,效率低下。在图元数量增加,绘制过程复杂时就能够明显地看出二者性能的差别。
        但是实际上我们几乎不会给某一个图元纯色,这样做太过单调了。如果希望给图元加上图片贴图,如何做呢?
        在这里我们尝试画一个圆形的弹幕贴图。
        由于用到了纹理,必须对着色器程序做一些修改。当然,在此之前,需要先对顶点数组做一些修改。

1
2
3
4
5
6
float v[] = {
0.045f, 0.06f, 0.0f, 1.0f, 1.0f,
0.045f, -0.06f, 0.0f, 1.0f, 0.0f,
-0.045f, -0.06f, 0.0f, 0.0f, 0.0f,
-0.045f, 0.06f, 0.0f, 0.0f, 1.0f
};

        在修改了顶点坐标的基础上,我们给每一个顶点一个新的属性,即这个顶点对应的纹理坐标位置,它决定了对于图元上的某一个位置与纹理的映射关系。在OpenGL中,2D纹理坐标值位于[0,1]之间,并且原点在左下角。
        由于传入了新的属性,我们的顶点和片段着色器程序也应该经过修改:
1
2
3
4
5
6
7
8
9
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 Texcoord;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
TexCoord = Texcoord;
}

        有没有看出区别?这里定义了一个新的量Texcoord,它的类型是vec2,正好对应着纹理的坐标。然后又定义了一个新的输出变量TexCoord(不要吐槽命名),将Texcoord赋给TexCoord,这个变量会传送到渲染管线的下一个步骤。
        同样地,片元着色器也应该经过修改,就像下面一样:
1
2
3
4
5
6
7
8
9
#version 330 core
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D mainTexture;

void main()
{
FragColor = texture(mainTexture, TexCoord);
}

        这里用了一个变量TexCoord来接受来自顶点着色器的同名数据,然后设置了一个uniform,uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,它是全局的。这里的uniform是一个名为mainTexture的sampler2D类型变量,这意味着它是一个2D类型的纹理,或者说采样器
        接下来,使用glsl的内建函数texture函数根据纹理坐标从采样器中取出响应的RGBA值,发送出去。
        下面就需要加载纹理了。由于我们想画一个圆形的弹幕子弹,准备一张贴图:

        这张贴图需要转化成字节序列才能被OpenGL加载,这个过程是比较麻烦的,好在有一些工具供我们使用,这里可以用stb_image这个库。当然Qt中也提供了类似的工具。
        使用下面的代码将这个库加载进来:
1
2
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h

        接下来,调用库中的stbi_load函数加载纹理:
1
2
3
int width, height, nrChannels;
unsigned char *data = stbi_load("/home/liyh/QT/opengl/bullet.png", &width, &height, &nrChannels, 0);


        width和height就是图片的宽高数据,nrChannels即颜色通道数量。我们的贴图18x18,且是RGBA格式,当然是四个通道。
        获取到字节序列后,就可以生成纹理了,它的做法如下:
1
2
3
4
glGenTextures(1, &texture);//产生一个纹理,1指定数量
glBindTexture(GL_TEXTURE_2D, texture);//表示是2D类型
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

        这里主要是第三个函数让人疑惑。首先第一个为纹理目标,GL_TEXTURE_2D意味着这是一个2D纹理。接下来的0表示将纹理指定为多级渐远纹理级别。第三个参数指定了储存的纹理格式,它是RGBA格式。
        第四和第五个参数指定了宽和高,第六个参数由于历史遗留问题总应该是0。第七个参数表明源图的格式,以及数据类型,最后一个就是我们的图像数据。
        glGenerateMipmap(GL_TEXTURE_2D);这一语句会帮我们为当前绑定的纹理自动生成所有需要的多级渐远纹理。
        做完之后手动清一下内存,它已经没有用了。
1
stbi_image_free(data);

        现在纹理也生成好了,着色器程序也做完了,但还差一些,那就是绑定属性。根据之前的内容,我们不难写出下面的代码:
1
2
3
4
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

        还差什么呢?那就是uniform还没有指定!这一个变量是需要我们自己指定的,在这我们给它指定一个值:
1
2
int vertexColorLocation = glGetUniformLocation(shaderProgram, "mainTexture");
glUniform1i(vertexColorLocation, 0);

        首先,用glGetUniformLocation获取这个uniform的id(返回-1就是没有找到),然后调用glUniform1i给它赋值为0。
        这里就会有一个疑问:为什么赋的值是一个整数?这里的值是一个位置值,它使得应用多张纹理成为可能,0的意思就是这个纹理会占用GL_TEXTURE0这个纹理单元。
        现在就可以绘制我们的贴图了!绑定纹理、VAO以及着色器程序,调用API绘图:
1
2
3
4
5
6
7
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glActiveTexture(GL_TEXTURE0);//激活GL_TEXTURE0这个纹理单元
glBindTexture(GL_TEXTURE_2D, texture);//绑定纹理
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);


        不出意外的话,你会得到下面的输出结果:

        真丑
        事情似乎没有我们想像中那样简单,原本我们的贴图是透明背景的,但是在这里它的矩形边框被突出了出来,这显然是很糟糕的。
        问题出在哪里?这就需要用到混合的相关知识了。

混合模式

        混合(Blend)模式通俗的说,就是告诉OpenGL处理重叠图元部分。要启用混合,需要手动开启:

1
2
glEnable(GL_BLEND);//开启混合
//glDisable(GL_BLEND);关闭混合

        其实,对于上面的边缘情况,是由于图片太小,在放大时采用插值生成像素,导致边缘像素出现问题,这个问题可以用混合模式解决,但具体原理我也没有搞清楚。
        首先需要知道混合是怎么做的。如果某一个像素原有的颜色是向量$V_{D}$,源颜色是$V_S$,那么这两个向量就是:

        所谓混合,就是指定两个参数$P_S$(称为源因子)和$P_D$(称为目标因子)来完成下面的运算:

        得到的就是最终像素的颜色。
        源因子和目标因子的选取是很关键的,它有以下的选择:

  • GL_ZERO 表示使用0.0作为因子,实际上相当于不使用这种颜色参与混合运算。
  • GL_ONE 表示使用1.0作为因子,实际上相当于完全的使用了这种颜色参与混合运算。
  • GL_SRC_ALPHA 表示使用源颜色的alpha值来作为因子。
  • GL_DST_ALPHA 表示使用目标颜色的alpha值来作为因子。
  • GL_ONE_MINUS_SRC_ALPHA 表示用1.0减去源颜色的alpha值来作为因子。
  • GL_ONE_MINUS_DST_ALPHA 表示用1.0减去目标颜色的alpha值来作为因子。

        常用的就是这些,当然还有其余的。新版的OpenGL还允许RGBA分别采用不同的因子。我们可以使用glBlendFunc这个函数来设置混合方式,比如:

1
glBlendFunc(GL_ONE, GL_ZERO);

        第一个参数指定了源因子,第二个则是目标因子。上面的形式表示完全使用源图元而完全不使用目标图元,这就会产生一个覆盖的效果,也是OpenGL默认的实现方式。
        可以改成这样:
1
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

        在这种混合模式下,就可以得到一个不错的输出了:

        接下来我们看看两个物体间混合的区别。
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);混合模式:

        glBlendFunc(GL_SRC_ALPHA, GL_ONE);混合模式:

        上面这一种混合模式可以在叠图时产生光亮效果,有时候用于游戏特效。

几何变换

        物体总是静止的,这样肯定没有什么意思,我们应该让物体动起来,这就需要几何变换。主要有缩放、平移和旋转。
        显然可以直接在写顶点数组时就修改好这些值,但那样确实过于麻烦了,这里一个很好的做法是利用矩阵。

缩放

        为方便起见,认为物体的坐标总在$(0,0,0)$位置。

        上面的矩阵运算就可以将物体的坐标按照$x$轴缩放$S_1$倍,$y$轴缩放$S_2$倍,$z$轴缩放$S_3$倍的方式来进行变换。因此左侧的矩阵又称为缩放矩阵。这个矩阵运算是很好理解的。

平移

        现在考虑将物体移动$(a,b,c)$这个偏移量如何做,如果矩阵是三维的,那么在矩阵乘法中是很难某一个值加上一个常数的,因此就需要四维矩阵,这就是引入第四维$w=1$的原因。

        这样就可以完成平移操作。

旋转

        旋转可能是比较麻烦的一个操作,它按照沿$x$轴、$y$轴、$z$轴也有不同的形态:
        x轴:

        y轴:

        z轴:

        当然也可以绕任何一个旋转轴旋转,但是那样的旋转矩阵是极其复杂的。
        根据矩阵乘法的结合律,可以将这几种变换乘在一起,得到一个大矩阵,一次性完成对物体的所有几何变换,这就是用矩阵的好处。
        仅绕x、y、z旋转固然方便,但是这样很快就会导致一个问题:万向节死锁(Gimbal Lock),这里暂不讨论。
        这里的矩阵既可以在glsl中用显卡计算,也可以用cpu计算后用unform传进去,这里不再赘述。

在glsl中,可以使用mat4定义一个四维矩阵。glsl提供了矩阵乘法以及三角函数。
若用cpu计算,有一些现成的库提供了矩阵运算方法,甚至将平常的几何变换也封装了起来,比如glm就提供了这种方法,当然Qt也有。

3D

坐标

        在进入3D之前,有必要先了解一下3D坐标以及坐标系变换。
        局部空间。局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。在上文中,我们绘制的矩形坐标在[-0.5,0.5]之间,这就是它的局部坐标。
        世界空间。我们肯定不希望物体都挤在同一个位置,需要将它们放到更广阔的空间中(比如x轴坐标值达到1000甚至更高),这就是世界空间,它也是很符合实际世界的一个空间。由局部空间到世界空间的转换是通过模型矩阵(Model Matrix)实现的。
        观察空间。对于世界上那么多物体,一个屏幕显然装不下,必须对某一个区域的物体进行显示,于是就有了观察空间。我们通常会用一个摄像机来直观地表示观察空间,这样观察空间的变换实质上就是摄像机在世界空间中的移动。这一步变换需要观察矩阵(View Matrix)完成。
        做一个实例。将之前的矩阵贴上一个纹理:

        当我们用模型矩阵时,如果将这个纹理平移到很远的位置(比如(100,50,30)),该物体将是不可见的,这很显然,因为OpenGL只会显示[-1,1]的物体,其余的都会被裁切。也就是说,世界空间的坐标是不能直接用于绘制的。
        现在尝试将这个矩形向后旋转(沿x轴旋转),看看会发生什么。
        修改顶点着色器代码,引入模型矩阵:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 Texcoord;

uniform mat4 model;//一个四维矩阵
out vec2 TexCoord;
void main()
{
gl_Position = model * vec4(aPos.x, aPos.y, aPos.z, 1.0);//与坐标相乘
TexCoord = Texcoord;
}

        调用Qt的库,完成旋转:
1
2
3
4
5
QMatrix4x4 matrix;
matrix.rotate(-45, 1.0, 0.0, 0.0);//绕(1,0,0)旋转-45度,就是绕x轴
int model = glGetUniformLocation(shaderProgram, "model");
glUniformMatrix4fv(model, 1, GL_FALSE, matrix.constData());
//这里的1是矩阵数量,第三个参数表示是否转置

        按道理,应该有一个视觉上的倾斜效果。来看一下最终结果:

        并不像想像的那样,只是物体变得扁了。
        这个结果是很显然的,根据之前的几何变换一节,我们代入矩阵,得出的坐标就上图显示的坐标。对于z坐标,OpenGL直接投射到屏幕上了。
        当物体放到很远的位置时,这个物体根本就不会显示,这是很糟糕的。因此我们有必要将物体从观察空间向裁剪空间转换。转换之后,顶点就会变成标准化设备坐标,从而得到绘制。
        从观察空间到裁剪空间的转化需要用到投影矩阵(Projection Matrix),可以分为正射投影以及透视投影。

正射投影

        由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection)。(来自原文)
        一旦所有顶点被变换到裁剪空间,最终的操作——透视除法(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。(来自原文)
        正射投影创造了这样一个平截头体:

        它有一个近平面和一个远平面,在这个区域中的物体才会被显示。正射投影没有将透视考虑进去,看起来是不真实的。

透视投影

        所谓透视就如同日常生活中的那样,离你越远的东西看起来更小。
        透视投影的平截头体如下:

        可以调用perspective函数来产生一个透视投影矩阵。

1
matrix.perspective(45.0, 800.0f / 600.0f, 0.1f, 50.0f);

        这里第一个参数是视野(fov),接下来的参数是宽高比,再后来是近平面和远平面距离。这意味这距离摄像机距离在50以内,0.1之外的部分都是可见的。

进入3D

        现在可以尝试绘制一些3D物体,这需要将模型矩阵、观察矩阵以及透视矩阵组合起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 Texcoord;

uniform mat4 model;//模型矩阵
uniform mat4 view;//观察矩阵
uniform mat4 perspective;//透视矩阵

out vec2 TexCoord;
void main()
{
gl_Position = perspective * view * model * vec4(aPos.x, aPos.y, aPos.z, 1.0);//注意顺序
TexCoord = Texcoord;
}

        然后做一些变换:
1
2
3
4
5
6
7
8
9
10
11
QMatrix4x4 m, v, p;
m.rotate(-70, 1.0, 0.0, 0.0);//绕x轴-70度
v.translate(0, 0, -1.5);//物体后移1.5,就是摄像机前移1.5
p.perspective(45.0, 800.0f / 600.0f, 0.1f, 50.0f);//透视投影
int model = glGetUniformLocation(shaderProgram, "model");
glUniformMatrix4fv(model, 1, GL_FALSE, m.constData());
int view = glGetUniformLocation(shaderProgram, "view");
glUniformMatrix4fv(view, 1, GL_FALSE, v.constData());
int perspective = glGetUniformLocation(shaderProgram, "perspective");
glUniformMatrix4fv(perspective, 1, GL_FALSE, p.constData());


        会得到下面的结果:

        就是这样的效果!看起来似乎木板躺在了地上。

摄像机

        现在已经能够绘制一些3D物体了,但是如果想要在3D世界中自由遨游,还需要一些别的东西。
        上文已经提到观察矩阵。可知观察矩阵决定那些物体应该出现在屏幕中,这正好就是我们实现3D世界自由移动的基础。
        首先需要确定的是摄像机的位置,这个很容易,用一个三维向量就可以表示。
        接下来需要确定摄像机目标,也就是摄像机看向哪一个点。这里提一下,将目标点坐标减去摄像机位置坐标,可以得到一个向量,对这个向量做单位化,就得到方向向量。
        现在有了方向向量,也有了位置坐标,还不能确定摄像机的具体位置,还差一些,那就是上向量。顾名思义,上向量就是指定摄像机上侧方向的向量,大部分情况下,设置成(0,1,0)就好了。
        有了这些量,我们可以精确地定位一个摄像机。这样做已经可以完成摄像机的自由移动,但是向左右移动时需要右向量,它如何确定?
        根据向量叉积的运算,我们可以用上向量和方向向量做叉积然后单位化得到右向量,如果你使用Qt的库,代码看起来是这样的:

1
QVector3D::crossProduct(cameraFront, cameraUp).normalized();

        那有了这些量,如何得到观察矩阵?这里直接给出它的形式:

        $R$是右向量,$U$是上向量,$D$是方向向量,$P$是位置向量。这样就可以构造出观察矩阵,其实它在库中也集成了,可以用下面的方法获得:

1
matrix.lookAt(cameraPos, cameraPos + cameraFront, cameraUp);

[实例]3D烟花

        现在让我们运用上面的知识画出烟花的3D效果。

天空盒

        纯色的背景确实有些难看了,我们最好用一些比较好看的3D场景,天空盒就是实现的一种方法。所谓天空盒就是一个立方体,它的6个面都是一些做好的纹理,这样把摄像机放到立方体中,环视四周就会有一个巨大的3D场景效果。

        上图是天空盒的一部分。
        但这样很快就会导致一个问题:3D场景应该是无限大的,我们不希望在其中移动会走出天空盒,这样就会显得非常假。如何解决?可以用这样一种小技巧:将观察矩阵变成三维,然后填充上单位阵数据再变回四维矩阵,这样能够屏蔽掉矩阵中的平移部分,只留下旋转,问题便解决了。

烟花贴图

        烟花贴图还是使用上面的蓝色圆形子弹图片。
        这显然会带来一个问题:图片是2D的,它并不是一个球,放到3D空间中如何才能产生3D效果?当然可以学习一下OpenGL关于球渲染和球面贴图的知识,但是这里不会涉及,我们会采用另一种方法,直接用2D图片来产生3D效果。方法原理也很简单:旋转图片,保证图片时刻都正对着摄像机。
        但是,对于烟花来说,它的要素相当多,每一个粒子旋转的角度不尽相同,如果每一个都手动计算然后调用glDrawElements函数,这会产生相当数量的drawcall调用,性能会大幅下降。
        既然drawcall次数不能过多,那么就将所有粒子合批成一次,一次性将数据发送到显卡,只进行一次drawcall,性能会有质的飞跃。
        现在来观察一下,如果需要绘制一个粒子,它的顶点都需要什么属性:

  • 顶点坐标
  • 大小
  • 摄像机坐标(用于旋转)
  • 纹理坐标

        至少需要这四种属性,但是根据上文中的描述,当物体平移后,就不是那么容易旋转了(毕竟绕坐标轴旋转的矩阵好算)。于是最好先把物体放到原点旋转,之后再移动到合理的位置。
        每一个粒子的旋转角度不尽相同,我们需要在GPU中计算这些数据,这里需要一定的立体几何和三角学知识。关于这一块的glsl代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#version 330 core
layout (location = 0) in vec3 aPos;//目的坐标
layout (location = 1) in vec2 aTexCoord;//纹理坐标
layout (location = 2) in vec2 yuan;//原坐标(0,0,0)位置
layout (location = 3) in vec3 my;//摄像机位置

const float PI = 3.1415926535;
uniform mat4 matrix;

out vec2 TexCoord;

mat4 getYRotateMatrix(){
vec3 tmp = my - aPos;
mat4 ans = mat4(vec4(1,0,0,0),vec4(0,1,0,0),vec4(0,0,1,0),vec4(0,0,0,1));
if(tmp.z != 0){
float tg = tmp.x / tmp.z;
float aa = atan(tg);
if(tmp.z < 0){
if(aa > 0)aa += PI;
else aa -= PI;
}
ans = mat4(vec4(cos(aa),0,-sin(aa),0),vec4(0,1,0,0),vec4(sin(aa),0,cos(aa),0),vec4(0,0,0,1));
}
return ans;
}

mat4 getXRotateMatrix(){
vec3 tmp = my - aPos;
mat4 ans = mat4(vec4(1,0,0,0),vec4(0,1,0,0),vec4(0,0,1,0),vec4(0,0,0,1));
if(tmp.y != 0){
float c = sqrt(tmp.x*tmp.x+tmp.z*tmp.z) / sqrt(tmp.x*tmp.x+tmp.y*tmp.y+tmp.z*tmp.z);
float aa = acos(c);
if(tmp.y > 0)aa = -aa;
ans = mat4(vec4(1,0,0,0),vec4(0,cos(aa),sin(aa),0),vec4(0,-sin(aa),cos(aa),0),vec4(0,0,0,1));
}
return ans;
}

mat4 getTranslateMatrix(){
mat4 ans = mat4(vec4(1,0,0,0),vec4(0,1,0,0),vec4(0,0,1,0),vec4(aPos.x,aPos.y,aPos.z,1));
return ans;
}
void main(){
gl_Position = matrix * getTranslateMatrix() * getYRotateMatrix() * getXRotateMatrix() * vec4(yuan.x,yuan.y,0.0,1.0);
TexCoord = aTexCoord;
}

        然后就可以写一些生成粒子的逻辑做出烟花效果了。