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语言。以下是一段顶点着色器的例子:

#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 标签。