1 前言
在正式开始讲解OpenGL渲染管线之前,首先介绍下模型是如何从3D空间渲染到2D屏幕的,下图演示了一个甲壳虫的渲染结果。这个例子在后面的文章中会详细介绍,并会给出完成源码。
左上角的图像是一个完整的3D甲壳虫渲染结果,它是由左下角的模型,和右下角的纹理共同绘制得到的,我们在渲染3D模型时,第一步是将3D模型投影到屏幕上,即左下图的结果,然后根据每个顶点在右下角纹理图像中对应的采样坐标(原点为左下角,水平向右为x轴,垂直向上为y轴,它们取值范围是[0, 1])绘制对应的颜色,就能得到左上角的完整图像。
这里仅仅是希望读者对3D图像的渲染有一个基本的了解,完整的细节后续的章节会介绍。
2 概览
在上一节中,我们对OpenGL已经有了最简单的了解,本小节将对OpenGL的图形渲染管线进行简单介绍,以期望加深读者对整个3D图形渲染的流程有更深一步的了解。仍然需要注意的是,本文的目的并不是详细的论述OpenGL每个特性的详细细节,这些工作是后续文章的任务。
现在只需要了解一个模型的渲染是通过一个OpenGL程序完成的,一个OpenGL由多个阶段组成,它们串连成一个流水线,这被称为OpenGL图形渲染管道,其中有些阶段是可以通过编程实现的,这个子程序被称为着色器(Shader)。本篇文章的知识点如下。
- OpenGL图形渲染管道,3D图形渲染流程。
- 独立于图形渲染管道之外的计算着色器。
3 顶点抓取阶段
在OpenGL的图形渲染管线中,顶点着色器(VertexShader)是第一个可编程的步骤,但是它并不是管线的第一个步骤,管线的第一个步骤是顶点抓取(Vertex Fetching Stage),这是一个固定函数,系统负责调用并给顶点着色器提供输入数据。我们可以使用类似于glVertexAttrib*()
的函数来通知OpenGL在该阶段的时候执行某些特定的操作。当前仅仅需要了解到这里即可,后续文章会详细介绍。
4 顶点着色器阶段
4.1 着色器实现
顶点着色器(Vertex Shader)是OpenGL的图形渲染管线中的第2个阶段,这个阶段是可以通过编程实现的,一个简单的顶点着色器如下。它的作用是确定顶点的位置。
#version 410 core
// layout关键字和location关键字指定了该输入的索引值为0,
// 后面使用glVertexAttrib4fv函数时使用相同的值就能完成数据通信
layout (location = 0) in vec4 offset;
void main() {
const vec4 vertices[3] = vec4[3](vec4( 0.25, -0.25, 0.5, 1.0),
vec4(-0.25, -0.25, 0.5, 1.0),
vec4( 0.25, 0.25, 0.5, 1.0));
gl_Position = vertices[gl_VertexID] + offset;
}
在上面的代码中,首先我们定义了该着色器的版本是兼容至OpenGL4.1,然后我们声明了该着色器的输入顶点属性偏移量offset,需要注意的是单个顶点可能会存在多个属性,这些属性可以组成一个数组,为了能够在外部正确为顶点属性赋值,我们需要定义该属性在数组中的索引,这里定义为0。在基于CPU运行的程序中为该顶点属性赋值方法如下。
GLfloat attrib[] = { 0.5f, 0.6f, 0.0f, 0.0f };
glVertexAttrib4fv(0, attrib);
Main函数是着色器执行的入口,这里我们需要绘制一个三角形模型,因此首先定义了顶点数据,当然通常我们的顶点数据是通过缓存的方式存储和读取的,这个在后面的文章中会介绍。由于本章节还未讲到坐标系,因此先简单的理解为屏幕中心为原点,水平向右为x轴正方向,垂直向上为y轴正方向,垂直向屏幕内为y轴正方向,它们的取值范围为[-1, 1],这也是OpenGL的坐标系之一标准设备坐标系(Normal Device Coordinate System)。另外3D空间中唯一点需要使用3个分量即可表示,这里使用了4个分量是因为投影变换的关系,这个也会在后面文章有详细介绍,这里只需要知道第四个分量为0表示一个向量,第四个分量为1表示一个点。
接下来我们需要在该着色器内部确定顶点位置,在顶点着色器中OpenGL提供了一个输出gl_Position
,我们只需要对其赋值即可。gl_VertexID
也是OpenGL提供的一个内置变量,它表示的是当前着色器执行时的顶点索引,对于绘制一个三角形,顶点着色器会被调用3次,而该变量的值分别为1、2和3。
4.2 不同阶段间数据通信
正如定义了in
关键字用于在着色器中声明输入变量,图形着色器语言(GLSL, Graphic Language Shader Language)还定义了out
关键字用于声明输出变量,任意一个用out
关键字定义在某个模块中的变量,在其下一个模块都能使用同样的标识符通过in
关键字获取。本小节中我们只使用到了顶点着色器和片段着色器,因此我们需要将顶点着色器中计算得到的数据传入到片段着色器中,这里我们传递颜色变量。数据能够以基本数据、和复杂结构类型传递。
4.2.1 传递简单数据
简单数据指的是如float,int和vec4等基础类型的方式传递,其过程如下。
顶点着色器输出变量vsColor
#version 410 core
layout (location = 0) in vec4 offset;
layout (location = 1) in vec4 color;
out vec4 vsColor;
void main() {
gl_Position = ...;
vsColor = color;
}
片段着色器输入变量vsColor
#version 410 core
in vec4 vsColor;
out vec4 FragColor;
void main() {
FragColor = vsColor;
}
4.2.2 传递复杂数据结构
GLSL允许在Shader之间以结构体的方式传递数据。这样有两个好处,第一:允许输出变量和输入变量名称不同,只需要他们结构的定义相同。第二:允许用同一个变量传递多个数据。但是需要注意的是这种方式不允许在顶点着色器(Vertex Shader)的输入变量,和片段着色器(Fragment Shader)的输出变量中使用。其传递过程如下
顶点着色器输出变量vs_out
#version 410 core
layout (location = 0) in vec4 offset;
layout (location = 1) in vec4 color;
out VS_OUT {
vec4 color;
} vs_out;
void main() {
gl_Position = ...;
vs_out.color = color;
}
片段着色器输入变量fs_in
#version 410 core
in VS_OUT {
vec4 color;
} fs_in;
out vec4 color;
void main() {
color = fs_in.color;
}
上面代码的渲染结果如下。源码传送门
5 曲面细分阶段
曲面细分阶段(Tessellation Stage)位于顶点着色阶段之后,是OpenGL图形管道中的第3个阶段,该阶段将单个图元分解为大量的较小的点、线和三角,使这些小图元能直接被光栅化器使用。曲面细分阶由一个可编程曲面细分控制模块(Tessellation Control Shader),一个固定模块曲面细分引擎(Tessellation Engine)和曲面细分计算模块(Tessellation Evaluation Shader)组成。
在曲面细分工作开始之前,我们需要在基于CPU运行的主程序中调用函数glPatchParameteri()
,并将参数之一pname
设置为GL_PATCH_VERTICES
,从而设置被细分的每个曲面,或者说是每个补丁(Patch)的控制点个数。不调用此函数时,默认设置为3。
当曲面细分阶段被激活时,OpenGL会为每个控制点调用一次顶点着色器(Vertex Shader),另外控制点将被分组进行批量处理,其每批处理的控制点大小和每个补丁(Patch)的控制点数量相同(即每个Patch是依序处理的)。曲面细分控制模块每批次生成的控制点数量可以改变,从而使得该模块的输入顶点数不同于输出顶点数。
5.1 曲面细分控制模块
曲面细分控制模块(Tessellation Control Shader)主要负责两件事情:
1. 确定曲面的每条边需要被分为多少份,使得曲面细分引擎能够正常工作。
2. 生成曲面细分引擎,和曲面细分计算着色器需要的数据,即将原始图元的控制点做自定义修改后以数组的方式传入后面的两个模块。
下面是一个简单的曲面细分控制着色器的例子。
#version 410 core
layout (vertices = 3) out;
void main(void) {
if (gl_InvocationID == 0) {
gl_TessLevelInner[0] = 5.0;
gl_TessLevelOuter[0] = 5.0;
gl_TessLevelOuter[1] = 5.0;
gl_TessLevelOuter[2] = 5.0;
}
gl_out[gl_InvocationID].gl_Position =
gl_in[gl_InvocationID].gl_Position+vec4(0.25,0,0,0.0);
}
在上面的代码中,我们首先声明了控制着色器每批次输出的控制点数量layout (vertices = 3) out;
。
接下来使用到了一些OpenGL内建变量为曲面细分的后续操作准备数据和参数。内建变量(Built-in Variable)gl_InvocationID
表示当前曲面细分控制着色器在当前批次的调用索引,前面讲过曲面细分是分批次进行的,每个批次处理一个曲面,而一个曲面执行曲面细分控制着色器的次数和单个曲面的控制点数相同。因此该参数可以理解为控制点的索引值。
内建变量gl_TessLevelInner
和gl_TessLevelOuter
保存的是图元每个内边和外边的细分等级,它们是一个数组,对于三角形图元的曲面细分而言,前者数组的容量为1,后者为3,具体细节在后续文章中会详细介绍。它们仅需在第一次着色器调用时设置一次即可。
内建变量gl_out
和gl_in
分别为保存输出和输入控制点的数组,前面讲过输入控制点的数组容量取决于我们要进行曲面细分的图元类型,而输出数组的容量取决于我们设置的输出控制点数量。这里输出控制点的数量可以小于输入数组。
5.2 曲面细分引擎
曲面细分引擎(Tessellation Engine)是一个固定函数,它根据前一个模组传入的细分因子将图元组(Patches)细分为大量点、线和三角图元。然后组成基础图元的顶点相对坐标被曲面细分计算着色器获取,从而计算得到新的图元,为光栅化阶段准备数据。
5.3 曲面细分计算模块
OpenGL会为每一个曲面细分引擎生成的顶点调用一次曲面细分计算着色器(Tessellation Evaluation Shader)。曲面细分等级越高,生成的顶点越多,曲面细分计算着色器的调用次数越多,该模块的耗时越长。我们需要注意这点,并且避免复杂的曲面细分计算函数。下面是一个简单的曲面细分计算着色器。
#version 410 core
layout (triangles, equal_spacing, cw) in;
void main(void) {
gl_Position = (gl_TessCoord.x * gl_in[0].gl_Position +
gl_TessCoord.y * gl_in[1].gl_Position +
gl_TessCoord.z * gl_in[2].gl_Position);
}
在上面的代码中,关键字layout
后是曲面细分计算着色器必须声明的参数,其中triangles
表示接受的输入图元类型为三角形,equal_spacing
表示曲面细分的方式是等距离细分,cw
表示顶点生成的方向是顺时针,更多的知识在后续文章中会详细说明。
另外曲面细分计算着色器接受来曲面细分控制着色器输出的控制点数组gl_in[]
,并以此为基础建立重心坐标系(Barycentric Coordinate System),同时接收曲面细分引擎输入的顶点重心坐标gl_TessCoord
,最终计算得到当前顶点在当前欧式坐标系(关于坐标系的知识在后续文章讲解)中的绝对坐标。
在本实力中为了观察程序执行的效果,在渲染时调用glPolygonMode()
函数控制渲染模式,此例中使用GL_FRONT_AND_BACK
作为其参数face
表示背向和朝向观察者的面都需要绘制,使用GL_LINE
作为参数mode
表示以线的方式绘制模型的轮廓。另外启用曲面细分功能后,在绘制函数中的涂鸦类型也需要改为GL_PATCHES
。函数调用如下。
void glPolygonMode(GLenum face, GLenum mode);
glDrawArrays(GL_PATCHES, 0, 3);
上述示例的执行结果如下图。源码传送门
6 几何着色器阶段
几何着色器(Geometry Shader)阶段是管道前端的最后一个模组,位于曲面细分模组和光栅化模组之间。当其被激活时,每处理一个图元,几何着色器将会被调用一次,并接收组成该图元的所有顶点数据。在众多模组间,它具有独特的性质。
1. 它可以通过两个函数EmitVertex()
和EndPrimitive()
来直接改变在管道中流动的数据量。
2. 它可以改变图元的类型,如将三角分解为顶点输出,或者将顶点组合为三角输出。
下面是一个简单的几何着色器。
#version 410 core
layout (triangles) in;
layout (points, max_vertices = 3) out;
void main() {
for (int i = 0; i < gl_in.length(); i++) {
gl_Position = gl_in[i].gl_Position;
// Generate a vertex
EmitVertex();
}
// Composite vertext to geometry as designed and clear canvas
EndPrimitive();
}
在上面的代码中,首先通过关键字triangles
声明了几何着色器的输入图元为三角形,关键字points
声明了输出的图元类型为点图元,关键字max_vertices = 3
声明了每个着色器的输出顶点数为3。
在主函数中,计算出每个输出顶点的坐标后,调用函数EmitVertex()
向画布中输出一个顶点。在着色器的末尾,OpenGL会自动调用EndPrimitive()
函数将当前缓存中的顶点组成我们定义的图元并将其输出,随后清空画布。在基于CPU的主程序中调用函数glPointSize()
设置像素大小为5后,上面的程序渲染结果如下。源码传送门
7 光栅化阶段
当管道前端(Vertex, Tessellation, and Geometry Shader)运行完成后,不可编程的光栅化阶段(Rasterization Stage)执行了一系列的任务将标准坐标系中的图元转化为显示窗口内的像素。在这一系列复杂的任务之中,第一步被称为图元装配(Primitive Assembly),它将顶点组装成线、三角和点图元。随后OpenGL将会对其不可见部分进行剪切。最后可见图元被发送到光栅化器(Rasterizer)。光栅化器将图元分解为像素,并将这些像素点发送给片段着色器进行进一步处理。
7.1 剪切
在讲些剪切(Clipping)操作之前,先介绍下OpenGL渲染3D模型需要使用到的欧式几何和投影几何,如下图。
在上图中,我们从3D建模师得到的原始模型文件中顶点坐标都是在模型空间中(Model Space)定义的,而多个模型确定相对位置需要一个共有的世界空间(World Space)。3D图形渲染的原理简单的说可以是透视投影,也就是设置一个观察点(Eye),建立相机空间(View Space),确定能够看到的近平面(Near Plane)和视野极限的远平面(Far Plane),从观察点发出的射线刚好穿越这两个平面的边界,在这个截锥体内的模型才是可见的,其余部分都会被裁减,从而避免性能浪费。
在OpenGL中点的顶点坐标,正如在各个着色器中使用的glPosition
变量都是由4个坐标组成的,它们被称为齐次坐标(Homogeneous Coordinate)。在投影几何中使用齐次坐标空间表示点比使用常规的笛卡尔空间(Cartesian Space)更简单。在将顶点从齐次坐标系转化为笛卡尔坐标系的过程中,OPenGL执行了透视除法(Perspective Division),即将前三个元素(x.y.z)都除以第4个元素(w)。具体细节会在后面数学章节中详细介绍。
经过投影除法后,所有点都位于标准设备空间(Normalized Device Space)内,其各分量的取值范围为[-1.0, 1.0]。在这个范围内的图元部分都是可见的,在这个范围外的部分都将会被丢弃。对于每一个图元,如果图元的所有顶点都在可视范围内,这个图元将直接输出到光栅化器;如果图元的所有顶点全部在可视范围外,整个图元将会被丢弃;如果图元部分顶点在可视范围内,将会执行额外操作,将在下文讲到剪切章节时解释。
7.2 视口变换
经过剪切后,所有的顶点都位于标准设备空间内(Normalized Device Space)。但是我们在屏幕上显示的窗口坐标空间,其距离单位为像素,范围从左下的(0,0)直至右上角的(w-1,h-1)。OpenGL执行视口变换(Viewport Transformation)操作,通过平移和和缩放的操作将顶点从标准设备空间转换到窗口坐标空间(Window Space)。其计算公式如下。
公式中xw,yw,zw为片段在窗口坐标系中的坐标,xd,yd,zd为顶点在标准设备坐标系中的坐标。px,py为视口的宽和高,ox,oy为视口的原点,可以通过函数glViewport()
设置,n和f为近平面和远平面的在窗口坐标系中的值,可以通过调用函数glDepthRange()
设置。
7.3 面剔除
在三角形真正被处理之前,可能会经过一个面剔除(Culling)阶段。该阶段计算了三角形是面向观察者还是背向观察者,同时确定三角形是否会被管道中下一个阶段的程序处理。OpenGL通过front-facing和back-facing来表示三角形的朝向,通常,背向观察者的三角形都会被丢弃,因为对于封闭的图形,这些表面都将被隐藏。但是需要注意在绘制有透明度的图形时这些表面不能被丢弃。
在决定三维空间中的一个三角形在z轴上是背向观察者或者是面向观察者时,OpenGL采用计算其在xy投影三角有向面积的方式。一种计算有向面积的方式是计算任意两条边的交叉相乘差的和。公式如下。
其中xiw和ywi 分别是第i个顶点的窗口坐标,i⊙1是(i+1)对3求余,n为顶点的个数。如果结果a是正数,这个三角形图元被认为是面向观察者的,如果是负数则是背向观察者,只有当三个顶点在同一直线上时,该值为0。
另外可以通过调用glFrontFace()
函数取得相反结果,其默认设置是GL_CCW
(三角形顶点出现顺序为逆时针的三角形图元被认为是面向观察者图元),当设置为GL_CW
表示三角形顶点出现顺序为正时针的图元是面向观察者,此时OpenGL会默认为每个有向面积取负数。如下图。
另外一种计算三角形有向面积的方式是通过行列式的方式计算。其公式如下,其中x0,y0,x1,y1,x2,y2分别代表顶点V0,V1,V2的坐标。A表示三角形的有向面积。
在计算出三角形的朝向状态后,程序中通过glEnable(GL_CULL_FACE)
函数启用面剔除功能,默认会剔除背向观察者的三角形图元(back-facing)。可以通过函数glCullFace()
和参数GL_FRONT
, GL_BACK
, GL_FRONT_AND_BACK
设置需要剔除的三角形面朝向。
点图元和线图元没有几何面积,因此面剔除阶段将会忽略这些图元。并且该阶段三角形图元的顶点顺序和顶点着色器中顶点输入顺序,以及曲面细分计算着色器中的顶点输入顺序相关,在使用默认面剔除功能时,会剔除掉顺时针顶点顺序的三角形图元。
7.4 光栅化
光栅化(Rasterization)模组会计算哪些片段(在此处可以理解为那些像素)在线或者三角图元内。大多数OpenGL系统在光栅化三角形时采用半空间(Half-space-based)计算方法,该方法能够并行处理大量计算任务。具体的计算方式是,OpenGL会为窗口坐标系中的三角形图元限定一个边界框,将在框里的每一个像素分别和三角形的三条边比较,向量的叉乘(点向量分别和三条连续相连边向量叉乘)和向量点乘(上一步三个向量分别两两点乘)确定点是否在三条边的同一侧。这种方式使得每个像素的计算都相对独立,它只考虑三角形某一边的两个端点和片段自己的位置,使得大规模并行计算得以进行。只有同时在三条边内的片段才会被传递到片段的下一个模组。下图简单的演示了光栅化过程,其中绿色的片段表示经过光栅化处理的到的片段。
8 片段着色器阶段
片段着色器(Fragment Shader)是图形管道的最后一个可编程模组,它计算出每个片段的颜色并将其输入到帧缓存中。这里是图形管道中计算量最大的一个模组。光栅化过程可能会为每个图元生成甚至上百万个片段。一个简单的片段着色器如下。
#version 410 core
in vec4 vs_color;
out vec4 color;
void main(void) {
color = vs_color;
}
上面的片段着色器代码非常简单,通常这里的逻辑会更复杂,它需要执行与光照(Lighting)、纹理贴图(Applying Materials)和片段深度(Depth of the Fragment)相关的计算逻辑。
gl_FragCoord
是一个重要的输入内部变量,它包括了片段在窗口中的坐标。片段着色器同样可以接受来自前一个着色器传入的变量,但是这里的输入变量不同于管道中其他模组接收的输入变量,该阶段的输入变量会在图元的不同片段之间进行插值。
示例程序渲染结果如下。源码传送门
9 帧缓存操作阶段
帧缓存操作(Framebuffer Operations)阶段是OpenGL图形渲染管道的最后一个阶段,它负责计算屏幕上可见内容,并且管理一块内存区域用于存储每个像素点的数据。在大多数平台上,帧缓存和操作系统(更准确的将是窗口系统)管理的当前窗口(当前窗口占满整个屏幕时即指代整个屏幕)相关联。窗口系统提供了默认帧缓存,当需要进行离屏渲染时也可以提供指定的帧缓存。帧缓存会保存多个状态,例如确定片段着色器产生的数据应该写入的位置,以及数据的格式等。这些状态保存在一个帧缓存对象(Framebuffer Object)中。
在片段着色器输出像素数据和将其渲染到屏幕上之间还要经过几个操作来确定其是否应该被放入窗口中,其中每个操作都可以在程序中开启或禁用。
9.1 剪切测试
第一个操作是剪切测试(scissor Test),它负责测试片段是否位于程序定义的矩形框内,矩形框内的片段输出到下一个任务,矩形框外的片段将会被丢弃。如下图。
9.2 模版测试
接下来是模板测试(Stencil Test),它通过一个模版来确定哪些像素应该被写入帧缓存,哪些像素应该被丢弃,具体方式是比较程序提供的参考值和模板缓存中对应的值。模板缓存为每个片段存储一个值。这些值没有任何特定的语义,它能被用于任何目的。模版缓存测试通过和失败的条件,以及模版缓存内部的值更新逻辑可以通过调用OpenGL的接口控制。
9.3 深度测试
接下来是深度测试(Depth Test),比较片段的z轴坐标分量和深度缓存中的对应的值,和模版缓存类似,深度缓存也会为每个片段保存一个值。深度缓存是帧缓存的另一部分,它包含了每个像素的景深(像素和观察者的距离)信息。通常景深缓存中存储的数据取值范围有近到远为0~1。
在对窗口中同一个坐标点进行多次渲染时,OpenGL会比较当前待处理片段的z轴坐标分量,和已深度缓存中对应的景深数据。如果当前片段的对应景深数据更小,当前片段就会替代原有片段。这个测试的逻辑可以调用OpenGL的函数设置。另外景深测试的结果同样也会影响OpenGL对模板测试的处理结果。
9.4 混合和逻辑操作
最后需要根据片段的颜色值的格式(浮点型,标准化整形,或者整型),将它们向后传递到混合和逻辑操作模组(Blending or Logical Operation Stage)。如果是小数或者标准化整型数据,混合模式将会被启用。OpenGL提供大量的函数用于计算片段着色器输出的结果和当前颜色缓存中值混合值,计算出的混合值将会被写入到帧缓存中。如果帧缓存包含的是整型数据,OpenGL将会对片段着色器的输出值和当前帧缓存中对应值执行AND、OR、XOR等逻辑操作,并将计算出新的值写入到帧缓存中。
10 计算着色器
前文分析了OpenGL图形管道的各个阶段,但是OpenGL中还独立于图形管道之外运行着计算着色器(Compute Shaders)。它可以被认为是独立于图像管道外的单模组管道。
每次计算着色器的调用都被在单个工作单元中处理,这个工作单元被称为工作块(work item),多个工作块形成一个工作组(local workgroups)。这OpenGL的计算管道就依赖于这些工作组运行。除了一些表明当前计算着色器允许工作组大小的内部变量,计算着色器不含任何固定的输出或者输入变量,其所有的计算结果都将写入内存中。一个简单的计算着色器如下。
#version 410 core
layout (local_size_x = 32, local_size_y = 32) in;
void main(void) {
// Do nothing
}
计算着色器的编译链接方法和顶点着色器的编译链接方法相同。上述代码定义了一个简单的计算着色器,其中规定了单个工作组的容量是32*32个工作块。关于计算着色器也会在后面的章节中详细介绍。
11 总结
本文的主要目的是对OpenGL的图形渲染管线进行简单介绍,以期望加深读者对整个3D图形渲染的流程有更深一步的了解。并不是详细的论述OpenGL每个特性的详细细节,这些工作是后续文章的任务。
因此看到这里希望能够对OpenGL图形渲染管道有一个大致的了解,并且知道独立于图形渲染管道之外还有计算着色器存在。
网友评论