深入理解现代 OpenGL
在博客中曾经写了一篇文章介绍了 OpenGL 编程环境的搭建,并对 OpenGL 进行了简要介绍。今天想在这篇博客里,更为深入地学习和理解 OpenGL。文章的内容主要整理自《OpenGL 编程指南》(第 8 版) 的第一章「 OpenGL 概述」。这本称之为 「 OpenGL 红宝书」的第 8 版和第 7 版之间最大的区别,是 OpenGL 的版本从 OpenGL 2.x 变成了 OpenGL 4.x,渲染管线也从固定管线变为可编程管线。接下来的内容将覆盖 OpenGL 的作用以及 OpenGL 渲染管线各个阶段的介绍。
OpenGL 的含义
OpenGL 是一种应用程序编程接口(Application Programming Interface,API),它是一种可以对图形硬件设备特性进行访问的软件库。OpenGL 库最新的 4.3 版本包含了超过 500 个不同的命令,可以用于设置所需的对象、图像和操作,以便开发出交互式的三维计算机图形应用程序。
OpenGL 被设计为一个现代化、硬件无关的接口。因此可在不考虑计算机操作系统或窗口系统的前提下,在多种不同的图形硬件系统上,或者完全通过软件的方式应用 OpenGL 的接口。OpenGL 自身并不包括任何执行窗口任务或者处理用户输入的函数,OpenGL 也没有提供任何用于表达三维物体模型,或者读取图像文件(例如JPEG文件)的操作。这时,需要通过一系列的几何图元(geometric primitive)(包括点、线、三角形)来创建三维空间的物体。
一个用来渲染图像的 OpenGL 需要执行的主要操作如下所示:
- 从 OpenGL 的几何图元中设置数据,用于构建形状。
- 使用不同的着色器(shader)对输入的图元数据执行计算操作,判断它们的位置、颜色,以及其他渲染属性。
- 将输入图元的数学描述转换为与屏幕位置对应的像素片元(fragment)。这一步也称为光栅化(rasterization)。
- 最后针对光栅化过程产生的每个片元,执行片元着色器(fragment shader),从而决定这个片元的最终颜色和位置。
- 如果有必要,还需要对每个片元执行一些额外的操作,例如判断片元对应的对象是否可见,或者将片元的颜色与当前屏幕位置的颜色进行融合。
OpenGL 是使用客户端-服务端的形式实现的,我们编写的应用程序可以看作客户端,而计算机图形硬件厂商所提供的 OpenGL 实现可以看作服务端。OpenGL 的某些实现允许服务端和客户端在一个网络内的不同计算机上运行。这种情况下,客户端负责提交 OpenGL 命令,这些 OpenGL 命令随后被转换为窗口系统相关的协议,通过共享网络传输到服务端,最终执行并产生图像内容。
OpenGL 中常用的相关概念
在进一步介绍 OpenGL 的渲染管线之前,可先对 OpenGL 与图形学有关的概念作一定的了解。
渲染(render)表示计算机从模型创建最终图像的过程。OpenGL 只是其中一种渲染系统,除此之外,还有很多其他的渲染系统。OpenGL 是基于光栅化的系统,但还有别的方法用于生成图像。
模型(model),是通过几何图元,例如点、线、和三角形来构建的,而它与模型的顶点(vertex)也存在着各种对应关系。
OpenGL 另一个最本质的概念叫做着色器(shader),它是图形硬件设备所执行的一类特殊函数。理解着色器最好的方法是把它看作专为图形处理单元(GPU)编译的一种小型程序。OpenGL 在其内部包含了所有的编译器工具,可以直接从着色器源代码创建 GPU 所需的编译代码并执行。在 OpenGL 中,会用到四种不同的着色阶段(shader stage)。其中最常用的包括顶点着色器(vertex shader)以及片元着色器(fragment shader),前者用于处理顶点数据,后者用于处理光栅化后的片元数据。所有的 OpenGL 程序都需要用到这两类着色器。
最终生成的图像包含了屏幕上绘制的所有像素点。像素(pixel)是显示器上最小显示单位。计算机系统将所有的像素都保存在了帧缓存(frame buffer)中,然后传输至显示设备,帧缓存是由图形硬件管理的一个存储区。
OpenGL 渲染管线
OpenGL 实现了我们通常所说的渲染管线(rendering pipeline),它是一系列数据处理过程,并且将应用程序的数据转换到最终渲染的图像。下图为 OpenGL 4.3 版本的渲染管线。自从 OpenGL 诞生以来,它的渲染管线已经发生了非常大的变化。
OpenGL 首先接收用户提供的几何数据(顶点和几何图元),并且将它输入到一系列着色器阶段中进行处理,包括:顶点着色、细分着色,以及最后的几何着色,然后它将被送入光栅化单元(rasterizer)。光栅化单元负责对所有剪切区域(clipping region)内的图元生成片元数据,然后对每个生成的片元都执行一个片元着色器。
对于 OpenGL 应用程序而言,着色器扮演了一个最重要的角色。你可完全控制自己需要用到的着色器来实现自己所需的功能。我们不需要用到所有的着色阶段,实际上,只有顶点着色器和片元着色器是必需的。细分和几何着色器是可选的步骤。现在,将稍微深入到每个着色阶段当中,对其中背景概念进一步了解,对 OpenGL 开发过程会有一个更好的帮助。
1. 准备向 OpenGL 传输数据
OpenGL 需要将所有的数据都保存到缓存对象(buffer object)中,它相当于由 OpenGL 服务端维护的一块内存区域。我们可以使用多种方式来创建这样的数据缓存,不过最常用的方法就是 glBufferData() 函数,并需要对缓存做一些额外的设置。
2. 将数据传输到 OpenGL
当将缓存初始化完毕之后,我们可以通过调用 OpenGL 的一个绘制命令来请求渲染几何图元,glDrawArrays() 就是一个常用的绘制命令。
OpenGL 的绘制通常就是将顶点数据传输到 OpenGL 服务端。我们可以将一个顶点视为一个需要统一处理的数据包。这个包中的数据可以是我们需要的任何数据,几乎始终会包含位置数据。其它的数据可能用来决定一个像素的最终颜色。
3. 顶点着色
对于绘制命令传输的每个顶点,OpenGL 都会调用一个顶点着色器来处理顶点相关的数据。根据其它光栅化之前的着色器的活跃与否,顶点着色器可能会非常简单,例如,只是将数据复制并传递到下一个着色阶段,这叫做传递着色器(pass-through shader);它也可能非常复杂,例如,执行大量的计算来得到定点在屏幕上的位置(一般情况下,会用到变换矩阵),或者通过光照的计算来判断顶点的颜色,或者其他的一些技法的实现。
通常来说,一个复杂的应用程序可能包含许多个顶点着色器,但是在同一时刻只能有一个顶点着色器起作用。
4. 细分着色
顶点着色器处理每个顶点的关联数据之后,如果同时激活了细分着色器(tessellation shader),那么它将进一步处理这些数据。细分着色器会使用 Patch 来描述一个物体的形状,并且使用相对简单的 Patch 几何体连接来完成细分的工作,其结果是几何图元的数量增加,并且模型的外观会变得更为平顺。细分着色阶段会用到两个着色器来分别管理 Patch 数据并生成最终的形状。
5. 几何着色
下一个着色阶段是几何着色,允许在光栅化之前对每个几何图元做更进一步的处理,例如创建新的图元。这个着色阶段也是可选的,但是非常有用。
6. 图元装配
前面介绍的着色阶段所处理的都是顶点数据,此外这些顶点之间如何构成几何图元的所有信息也会被传递到 OpenGL 当中。图元装配阶段将这些顶点与相关的几何图元之间组织起来,准备下一步的剪切和光栅化工作。
7. 剪切
顶点可能会落在视口(viewport)之外,也就是我们可以进行绘制的窗口区域之外,此时与顶点相关的图元会进行一些改动,以保证相关的像素不会在视口外绘制。这一过程叫做剪切(clipping),它是由 OpenGL 自动完成的。
8. 光栅化
剪切之后马上要执行的工作,就是将更新后的图元传递到光栅化单元,生成对应的片元。我们可以将一个片元视为一个“候选的像素”,也就是可以设置在帧缓存中的像素,但是它也可能给被最终剔除,不再更新对应的像素位置。之后的两个阶段将会执行片元的处理,即片元着色和逐片元的操作。
9. 片元着色
最后一个可以通过编程控制屏幕上显示颜色的阶段,叫做片元着色阶段。在这个阶段中,我们使用着色器来计算片元的最终颜色(尽管在下一个阶段(逐片元的操作)时可能还会改变颜色一次)和它的深度值。片元着色器非常强大,在这里我们会使用纹理映射的方式,对顶点处理阶段所计算的颜色值进行补充。如果我们觉得不应该继续绘制某个片元,在片元着色器中还可以终止这个片元处理,这一步叫做片元的丢弃(discard)。
为了更好地理解处理顶点的着色器和片元着色器的区别,可以用这种方式思考:顶点着色(包括细分和几何着色)决定了一个图元应该位于屏幕的什么位置,而片元着色使用这些信息来决定某个片元的颜色应该是什么。
10. 逐片元的操作
除了我们在片元着色器里做的工作之外,片元操作的下一步就是最后的独立片元处理过程。在这个阶段里会使用深度测试(depth test,或者通常也称作 z-buffering)和模板测试(stencil test)的方式来决定一个片元是否是可见的。
如果一个片元成功地通过了所有激活的测试,那么它就可以被直接会知道帧缓存中了,它对应的像素的颜色值(也可能是包括深度值)会被更新,如果开启了融合(blending)模式,那么片元的颜色会与改像素当前的颜色相叠加,形成一个新的颜色值并写入帧缓存中。
初识 OpenGL 程序
这里会给出一个简单的代码例子来展示 OpenGL 应用程序的基本结构,但这里暂时不对具体代码作进一步展开,程序目的只是辅助理解上述较为抽象的 OpenGL 概念。下图显示了一个简单的OpenGL程序的输出,在窗口中渲染了两个蓝色的三角形。
核心的主程序 triangles.cpp 代码如下:
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 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | #include <iostream> using namespace std; #include "vgl.h" #include "LoadShaders.h" enum VAO_IDs {Triangles, NumVAOs}; enum Buffer_IDs {ArrayBuffer, NumBuffers}; enum Attrib_IDs {vPosition = 0}; GLuint VAOs[NumVAOs]; GLuint Buffers[NumBuffers]; const GLuint NumVertices = 6; void init(void) { glGenVertexArrays(NumVAOs, VAOs); glBindVertexArray(VAOs[Triangles]); GLfloat vertices[NumVertices][2] = { {-0.90, -0.90}, // Triangle 1 {0.85, -0.90}, {-0.90, 0.85}, {0.90, -0.85}, // Triangle 2 {0.90, 0.90}, {-0.85, 0.90} }; glGenBuffers(NumBuffers, Buffers); glBindBuffer(GL_ARRAY_BUFFER, Buffers[ArrayBuffer]); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); ShaderInfo shaders[] = { {GL_VERTEX_SHADER, "triangles.vert"}, {GL_FRAGMENT_SHADER, "triangles.frag"}, {GL_NONE, NULL} }; GLuint program = LoadShaders(shaders); glUseProgram(program); glVertexAttribPointer(vPosition, 2, GL_FLOAT, GL_FALSE, 0, BUFFER_OFFSET(0)); glEnableVertexAttribArray(vPosition); } void display(void) { glClear(GL_COLOR_BUFFER_BIT); glBindVertexArray(VAOs[Triangles]); glDrawArrays(GL_TRIANGLES, 0, NumVertices); glFlush(); } int main(int argc, char** argv) { glutInit(&argc, argv); glutInitDisplayMode(GLUT_RGBA); glutInitWindowSize(512, 512); glutInitContextVersion(4, 3); glutInitContextProfile(GLUT_CORE_PROFILE); glutCreateWindow(argv[0]); glewExperimental = GL_TRUE; if (glewInit()) { cerr << "Unable to initialize GLEW ... exiting" << endl; exit(EXIT_FAILURE); } init(); glutDisplayFunc(display); glutMainLoop(); } |
顶点着色器 triangles.vert:
1 2 3 4 5 6 7 8 | #version 430 core layout(location = 0) in vec4 vPosition; void main() { gl_Position = vPosition; } |
片元着色器 triangles.frag:
1 2 3 4 5 6 7 8 | #version 430 core out vec4 fColor; void main() { fColor = vec4(0.0, 0.0, 1.0, 1.0); } |
核心代码文件 triangles.cpp 主要由三个函数构成,分别是init(),display() 和 main()。init() 函数是用于初始化后面程序要用到的一些数据,display() 函数执行了程序中的渲染工作,而 main() 函数则是用于窗口的建立和进入时间循环。另外两个文件是顶点着色器和片元着色器,它们会在初始化的过程中被加载。
需要注意的是核心代码文件中引用到的两个头文件“vgl.h”和“LoadShaders.h”都只是基础工具类头文件。LoadShaders() 函数就是用于加载给 GPU 执行的shader,即加载上面的两个着色器文件 triangles.vert 和 triangles.frag。
参考文献
Shreiner D, Sellers G, Kessenich J M, et al. OpenGL programming guide: The Official guide to learning OpenGL, version 4.3[M]. Addison-Wesley, 2013.
Latest posts by LEEMANCHIU (see all)
- SIGGRAPH Asia 2017 之旅 - 2017-12-10
- SIGGRAPH 2017 之旅 - 2017-12-05
- 深入理解现代 OpenGL - 2016-08-29