OpenGL 学习笔记 (一)- 综述、渲染管线

更新日志

2020-02-17 将渲染管线重写为现代版本,不再保留老旧设计。

前言

最近写的程序需要使用很多 OpenGL 的 API,但是我对 OpenGL 的认识就停留在多年前写 Minecraft 模组时的简单了解。因此借此机会打算系统的学习一遍 OpenGL,浅窥计算机图形学一隅。由于本学习笔记只是记录个人的学习过程,因此内容会有一定偏向性,并且也难免有错漏,还请各路大神不吝赐教。同时不建议以这系列文章作为初学材料,若是初学建议看更专业、全面的书籍。另外,本文虽不要求有计算机图形学基础,但是需要有一定的数学基础(主要是线性代数),过于基础的数学不会展开描述。

目录

OpenGL 学习笔记 (一)- 综述、渲染管线

OpenGL 学习笔记 (二)- 顶点与绘制指令

OpenGL 学习笔记 (三)- 坐标系与顶点变换

OpenGL

OpenGL 是图形硬件的一种软件接口,从本质上说,是一种用于高性能图形和交互性场景处理的行业标准。简单的来说,OpenGL 管理图形硬件,但本身不依赖于某种特别的图形硬件(比如不同厂家的显卡)。因此 OpenGL 真正的实现,大多是由各显卡的厂家开发的,也就是图形驱动的一部分。

状态机

OpenGL 的内部是一个状态机,绝大多数绘制中的配置都是一种状态。比如若你把当前颜色设置为红色,那么在你把它设置成其他颜色之前,任何绘制出的物体都会使用这种颜色。这样设计的优点是显而易见的。因为在图形绘制中,我们通常会涉及到大量的配置,指望我们每次绘制都提供所有的配置显然十分繁琐,并且多次绘制中这些配置复用的概率是很高的。因此虽然编码中可能会不大习惯,OpenGL 采用了状态机的形式组织 API。

OpenGL 渲染管线

OpenGL 的目的是绘制。因此,OpenGL 绘制操作的结果,是向内存 / 显存中的一段连续空间(也就是帧缓存,Frame buffer)写入若干像素信息,作为屏幕的显示内容。而 OpenGL 接受的,通常是若干三维空间内的数据。因此在绘制过程中,OpenGL 会按照一定的流程对输入做若干变换。而这个相对固定的绘制流程就是 “OpenGL 渲染管线”。

OpenGL 4.5 渲染管线(图源 Reference)

不过为了了解整体渲染过程,这幅图中的渲染管线就显得有点复杂了。我们可以对渲染管线进行简化:

渲染管线总览

图中蓝色部分,是我们可以操控的部分,在这些部分中,实线框的是我们必须实现的,而虚线框(不包括大的虚线框)则是可选。其余的黄色部分就是 OpenGL 帮助我们实现的内容了。

另外,实际上在 OpenGL 中,可被渲染的内容大体可以分成几何(线、面等等)和像素数据(纹理等等)。不过处于简化的目的,上图仅仅画出了几何数据(也就是顶点数据)的处理过程,而像素数据的处理进行了省略。

顶点数据

顶点数据描述了要绘制的几何图形,一切渲染管线的操作都将围绕着顶点数据展开。顶点数据由用户程序提供,用户程序是运行在 CPU 上的,而大部分情况下绘制运算都将在图形硬件中进行。因此,OpenGL 需要负责将各类数据(除了顶点数据还有纹理数据等等)发送至图形硬件。

早期的 OpenGL 允许使用立即渲染模式(immediate mode)进行渲染,这种模式允许用户程序在发出绘制命令时,直接提供绘制所需要的数据。但是显然这种 “现用现给” 的渲染方式效率低下,因此 OpenGL 如今已经不再提倡使用这种渲染方式了。之后 OpenGL 还提供了显示列表(display list)对绘制操作、数据进行缓存,不过这些绘制方式如今都已经被废弃了。

OpenGL 3 + 开始,所有绘制所需要的数据都被存储在显存之中。因此在现代的 OpenGL 中,绘图指令将从显存之中的缓存读取数据,相关的内容将在下一篇文章中介绍。

顶点着色器

顶点着色器(vertex shader)通常进行一系列顶点操作。顶点操作的主要行为是对顶点进行齐次坐标变换。简而言之,这一步骤就是为了计算顶点坐标在屏幕中的位置。需要注意的是,OpenGL3.1 删除了所有固定功能的顶点操作,也就是说,这一部分需要我们自行实现。

另外,如果使用了纹理,那纹理坐标的生成与变换(最终贴的位置)都将在这个步骤完成。如果启用了逐顶点光照(per-vertex lighting),则光照相关的计算也会在这一步进行,这些内容将在之后的文章中更新。

早期图元装配

早期图元装配(early primitive assembly)是图元装配的第一个步骤,它负责图元的装配(听起来像废话)。这一步会根据绘制指令制定的顶点的连接关系,把顶点装配成图元(点、线、多边形等等)。可以理解成,在这一步中,三角形的三个顶点会被连接成三角形这个形状。

早期图元装配之所以叫 “早期”,是因为这一步骤其实是被提前执行了。由于之后的 Tessellation 和几何着色器需要对基本图元进行操作,因此这一步将会先组装基本图元。

Tessellation

Tessellation(细分)是一个切分多边形的方式。OpenGL 事实上只能处理三角形,因此使用 Tessellation 可以把复杂的图形转化成三角形图元,由此减少储存图形需要的顶点数。这些复杂的图形称为面片(patch),由至少三个顶点组成。另外,细分操作还能按照特定的步骤进行(比如按照函数或者材质),以增加图形的细节。

几何着色器

几何着色器(geometry shader)是操作几何图元的着色器,可以增加或删去几何图元。几何着色器的作用与细分类似,不过几何着色器操作的是图元,因此功能限制更大。

顶点后处理

顶点后处理(vertex post-processing)是顶点处理的最终步骤,它主要负责把顶点处理(vertex processing,包括顶点着色器、Tessellation 和几何着色器)的结果转换为屏幕空间坐标(screen-space coordinates)。

变换反馈

变换反馈(transform feedback)是一个回馈的过程。这一步骤中,我们可以保存下经过之前处理的图元数据。这样,在下一次渲染时我们就可以使用这些数据了。

裁剪

裁剪的主要目的就是把屏幕不会显示的内容剔除。在顶点操作结束后,所有的顶点都已经被变换到屏幕显示的坐标系(Clip Space)。因此可以简单的找出屏幕范围之外的顶点。不过裁剪的过程中也可能会产生新的顶点。比如,裁剪一个部分在屏幕内的图形就需要在 “屏幕边缘” 补点防止裁剪后无法构成图形。

透视除法

透视除法将投影后的齐次坐标进行处理。通过这个步骤,物体就会产生远大近小的效果。详细内容将会在后续文章中解释。

视口变换

视口变换中,坐标将会被转化为真实屏幕上显示的坐标 —— 也就是屏幕空间坐标(screen-space coordinates)。由于屏幕是二维空间,因此这个步骤也会把坐标的 z 分量转化为深度信息。

图元装配

图元装配(primitive assembly)包含若干个步骤。通过图元装配,顶点数据将会被转化为完整的几何图元,也就是根据颜色、深度等等进行了变化和裁剪的顶点。由于早期图元装配已经做了装配图元的工作(没错,装配图元不是这个时候进行的!),因此此时将主要进行面剔除。

面剔除

经过顶点后处理后,我们已经可以得知图元在屏幕上显示的真实坐标了。因此,我们就可以判断某个面是否朝向屏幕了。这一步可以剔除那些背对屏幕的面,以减轻后续的渲染负担。

光栅化

光栅化接受几何数据、像素数据,并把它们转化为片段(fragment),也就是对应屏幕像素的一个方块。在这一步骤中,会考虑图元的绘制方式,决定片段的多少,然后将图元转化为多个片段的位置信息。之后会对每个片段的颜色信息和深度信息进行计算(根据顶点数据进行插值)。简而言之,就是把各种形状进行 “像素化”。同时针对 “像素化” 的操作也在这个阶段进行,比如抗锯齿运算等等。

另外,如果使用了纹理,这部分也会执行纹理坐标的计算。这一步将对每一个片段计算其索引的纹理像素。

片段着色器

片段着色器(fragment shader)会对光栅化处理完的片段进行处理,并更改片段的属性。总而言之,这是一个执行用户定义的片段操作的阶段。一般说来,在这一步我们会计算出一个片段的颜色。

逐片段操作

经过光栅化,我们已经得到了若干片段。但是这些片段还不能被直接送至帧缓冲器。比如对于物体重叠的情况,此时我们将得到若干同个位置的片段,因此我们需要对这些片段进行选择。逐片段操作包含若干这样的操作。

在这些操作中,测试(test)通常舍弃片段。可能的测试有剪裁测试、alpha 测试、模板测试和深度缓冲区测试等等。如果失败(比如发现片段被另一个片段遮挡)将会抛弃这个片段。

之后将会进行混合、抖动、逻辑操作、写掩码等等复杂的处理。这些处理同样也会在之后的文章中进行进一步的阐述。

总而言之,片段操作的结果就是一个个屏幕上显示的像素了。它们将会被送到帧缓冲器中。

帧缓冲

帧缓冲是渲染结果显示到屏幕的内容缓存。不过通常情况下,程序采用双缓冲(double buffer)的形式。因为如果仅采用一个缓冲,那渲染新一帧的过程中写入和新数据与旧数据混杂,会导致画面撕裂。因此通常程序会设置两个缓冲区。前缓冲区用来保存供屏幕显示的内容,后缓冲区用于渲染程序的绘制操作。在新一帧的渲染结束之后,交换两个缓冲区的内容。这样画面撕裂问题就能得到很好的缓解。

帧缓冲实际上除了颜色缓冲区还包含了其他缓冲区,详细的内容将会在介绍逐片段操作的文章中进行介绍。

着色器

经过对 OpenGL 渲染管线的阐述,估计你对具体的渲染流程还是很难建立一个明确的印象(毕竟之前都是很抽象的内容)。这一节将会结合着色器对渲染流程进一步作出解释。图中展示了一个三角形的具体渲染流程,接下来我们结合这个流程来简述着色器的作用。

包含着色器的渲染流程,图中的图元装配实际上是早期图元装配(图源 Reference)

着色器(shader)是运行于 GPU 上的若干程序。每个着色器通常负责完成一项特定的功能(如图元组装),若干不同的着色器相互连接就构成了 OpenGL 渲染管线。OpenGL 实现了大量的着色器以构建渲染管线。不过有时候,我们还是希望自己负责一些着色器的实现(比如渲染复杂的特效),因此 OpenGL 也允许我们手工实现部分着色器。图中标蓝的部分就是我们可以编程替换的着色器 —— 顶点着色器、几何着色器和片段着色器(当然还有更高级的着色器,比如细分着色器等等,目前暂不讨论)。其中,现代 OpenGL 不包含顶点着色器和片段着色器,因此我们需要实现至少一个顶点着色器。

顶点数据(也就是求值器求值后)首先被传递给顶点着色器,此时所有的数据还保持为顶点形式。之后进行早期图元装配,顶点被装配为图元。之后图元数据进入几何着色器,此时可以编辑现有图元,或产生新的图元。之后图元进入光栅化,被转化为若干片段。这些片段之后进入片段着色器,此时我们可以对片段进行操作。之后就是片段的测试与混合,并将结果送入帧缓存。

GLSL

注意:此处关于 GLSL 的介绍仅仅是启发性的,为了保证篇幅的完整故编写这一部分。如果你阅读时感到疑惑,建议你跳过这一段。

由于可编程着色器是在 GPU 上运行的,因此我们不能使用通常的方法编写并编译。编写这些着色器的语言是 OpenGL 着色器语言(OpenGL Shading Language,后略 GLSL),并由 OpenGL 进行编译。受制于篇幅,此处仅仅简单的对 GLSL 进行说明,进一步的使用可以参考 Reference 中的资源。

语法

GLSL 的语法类似 C 语言。以下是一段顶点着色器的例子:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 texCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
texCoord = aTexCoord;
}
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec2 aTexCoord; out vec2 texCoord; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { gl_Position = projection * view * model * vec4(aPos, 1.0); texCoord = aTexCoord; }
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 texCoord;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    texCoord = aTexCoord;
}

程序开头需要使用 #version 预编译头指定 GLSL 的版本。之后是静态区声明。除了一般的变量声明外,GLSL 还可以使用特殊的限定符(in、out、inout、layout、uniform 等等)来限定部分特殊的变量。这些特殊的变量将在之后的小节进行说明。

之后是程序入口。在 GLSL 中,程序入口限定为 “void main ()”。退出语句除了 return 还增加了 discard,用于在片段着色器中抛弃一个片段。流程控制语句基本类似 C 语言,除了没有 goto 语句。

GLSL 的函数声明和 C 语言中的没有太大区别,除了 main 函数的返回值是 void。比较特别的是,GLSL 还提供了子程序这一类特别的函数,以便使用接口(在当前编程语言,如 C++)控制着色器的行为。

数据类型

除了 void、bool、int、uint、float,GLSL 还提供了向量(vec)和矩阵(mat)。向量之后用 1 位数字注明长度(如:vec3),向量之前可以指定其类型(如 3 维无符号整数向量:uvec3)。矩阵后使用 “行 x 列” 的形式表示大小(如 mat2x4),对于方阵可以直接使用一位数字(如 mat4)。向量和矩阵的维度最多支持 4 维。

GLSL 同样支持数组和结构体,此外 GLSL 还支持一种特殊的结构体 uniform 块,这将在之后的小节中介绍。

此外,GLSL 还提供了采样器,这将在后续文章中进行介绍。

输入输出

GLSL 有很多不同的类型限定器,这里仅仅介绍用于输入输出的 in 与 out。从之前着色器的例子中可以看到,可编程着色器都是有输出与输入的。在 GLSL 中,输出与输入通过 in 与 out 限定器进行标注。如 “in vec3 aPos;” 表示这个着色器接受名为 aPos 的 vec3 作为输入。如果变量名、类型相同,那着色器之间的输入将会相互连接。比如上一个着色器的输出 “aPos”,下一个着色器的输入 “aPos” 将会被连接该输出。

一般来说,着色器还有一些固定的输入输出。比如对于顶点着色器,OpenGL 希望我们响应的顶点数据。对于这种情况,GLSL 提供了若干内建的 in、out 作为 OpenGL 提供的输入、输出。图示为 GLSL 1.50 提供的内建输入输出,图中蓝色的部分不建议使用。

GLSL 1.50 提供的内建输入输出(图源 Reference)

Uniform

uniform 是用户程序通过接口向着色器程序提供额外数据(比如纹理数据)的入口。通过 glUniform 系列函数可以将数据提供给相应着色器程序。此外,uniform 也可以是结构体,在 GLSL 中可以通过声明 uniform 块的方式接受结构体。

编译与使用

编译的过程通过调用若干接口来实现。源程序的形式是字符串,最后编译的结果将会以句柄的形式返回给用户程序。具体的编译流程见下图。

GLSL 着色器程序编译流程(图源 Reference)

Reference

  1. OpenGL 编程指南(原书第 9 版)(红宝书)
  2. OpenGL 4.5 API Reference Card
  3. OpenGL 渲染管线 (https://www.cnblogs.com/hefee/p/3822171.html
  4. opengl 渲染管线(https://blog.csdn.net/cjneo/article/details/50538033
  5. LearnOpenGL CN(https://learnopengl-cn.github.io/
  6. OpenGL(http://www.songho.ca/opengl/
  7. OpenGL Shading Language 1.50 Quick Reference Card
  8. Rendering Pipeline Overview(https://www.khronos.org/opengl/wiki/Rendering_Pipeline_Overview
分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

评论

还没有评论。

在此评论中不能使用 HTML 标签。