综述

在理论上,一个渲染流程分3个阶段:

渲染流水线中的三个阶段

  • 应用阶段

应用阶段就是各种3D软件游戏等,在此阶段还有一个剔除(culling)工作,把不可见的剔除出去。在这一阶段设置模型的渲染状态(每个模型的材质、纹理、Shader)。并且在这一阶段还要输出渲染所需的几何信息,即渲染图元(rendering primitives)。渲染图元可以是点、线、三角面。

  • 几何阶段

几何阶段和后面的光栅化阶段都属于GPU流水线阶段,几何阶段的一个重要目的就是将顶点坐标转换到屏幕空间中,每个顶点还包含着深度值、着色等相关信息。

  • 光栅化阶段

使用几何阶段产生的数据来产生屏幕上的像素,在这一阶段决定渲染图元中的哪些像素应该被绘制在屏幕上,对几何阶段得到的逐顶点数据进行插值(包括纹理坐标信息,顶点颜色信息),然后再进行逐像素处理。

CPU和GPU之间的通信

渲染流水线的起点是CPU,即应用阶段,此时CPU要干的事是:

  1. 把数据加载到显存中
  2. 设置渲染状态
  3. 调用DrawCall

把数据加载到显存中

渲染数据先从硬盘(Hard Disk Drive,HDD)加载到内存(Random Access Memory,RAM),然后内存中的网格纹理等信息(还包括顶点位置和颜色、法线方向、纹理坐标等)再被加载到显存(Video Random Access Memory,VRAM)

如果CPU还需要网格数据来进行碰撞检测,在RAM中就可能保留网格信息,否则会移除这些渲染数据

设置渲染状态

通俗地讲,这些渲染状态定义了场景中的网格是怎样被渲染的,例如使用了哪个顶点着色器/片元着色器、光源属性、材质等。在一个场景中有很多网格公用相同的材质和渲染,我们可以描述为这些网格使用同一种渲染状态(注意,是在不开启混合的前提下)。

调用DrawCall

DrawCall就是一个命令,它的发起方是CPU,接收方是GPU,这个命令仅仅会指向一个需要被渲染的图元(primitives)列表,而不会再包含任何材质信息——因为设置渲染状态也属于一种命令,材质信息是在这一阶段完成的。

当给定了一个DrawCall时,GPU就会根据渲染状态(材质、纹理、着色器等)和所有输入的顶点数据进行计算,最终输出成屏幕上的像素。

GPU流水线

概述

GPU理论流水线

上图绿色表示该流水线是完全可编程控制的,黄色表示该流水线阶段可以配置但是不可编程,蓝色表示该流水线阶段是由GPU固定实现的。

顶点数据在应用阶段加载到显存中,由DrawCall指定后,就进入GPU流水线阶段。

顶点着色器(Vertex Shader)

顶点着色器不可以创建或销毁任何顶点,而且无法得到顶点之间的关系。

顶点着色器的主要工作:坐标变换和逐顶点光照。除此之外还可以输出后续阶段所需的数据。

顶点着色器

GPU在每个输入的网格顶点上都会调用顶点着色器,顶点着色器必须进行顶点的坐标变换。需要时还可以计算和输出顶点颜色。例如,我们可能需要逐顶点的光照。

  • 坐标变换:在这一步可以创建顶点动画,来模拟水面、布料等。无论怎么设置顶点,在这一步必须完成的一个工作是:把顶点坐标从模型空间转换到齐次裁剪空间

    1
    o.pos = mul(UNITY_MVP,v.position);//UNITY_MVP:Unity坐标空间矩阵,v.position:顶点坐标

    齐次裁剪空间变换

顶点着色器会将模型顶点的位置变换到齐次裁剪坐标空间下,进行输出后再由硬件做透视除法得到NDC下的坐标

顶点坐标转换到齐次裁剪坐标系后,再经过透视除法,最终得到归一化的设备坐标(Normalized Device Coordinates,NDC)。

上图中给出的坐标范围是OpenGL同时也是Unity使用的NDC,它的z分量范围在[-1,1]之间,而在DirectX中,NDC的z分量范围是[0,1]。

裁剪(Clipping)

在渲染流水线的应用阶段,会有一个剔除(culling)工作,会把不可见的物体剔除出去,然后在GPU流水线中,会再进行一次裁剪,把摄像机视野范围外的物体剔除出去。

我们已知在NDC下的顶点位置,即顶点位置在一个立方体内,只要将图元裁剪到单位立方体内即可。

(猜测:在裁剪阶段,需要考虑顶点之间的关系了,否则不知道那条边裁剪,在这一阶段会生成新的裁剪后的顶点,后期三角形设置阶段是在二维屏幕坐标中重新设置三角形,要再理一遍顶点关系)

裁剪

红色图元被舍弃,绿色图元被保留,和单位立方体相交的黄色图元被裁剪,新的顶点会生成,外部的顶点会被舍弃。

裁剪可以进行自定义操作来配置,但是它不能编程。

屏幕映射(Screen Mapping)

屏幕映射的任务把每个图元的x和y坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标系是一个二维坐标系。

我们的NDC坐标范围在-1到1,所以映射到屏幕是一个缩放的过程。屏幕映射不会对输入的z坐标做任何处理。屏幕坐标系和z坐标一起构成了一个坐标系,叫窗口坐标系(Window Coordinates)。这些值会一起被传递到光栅化阶段。

屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。

OpenGL把屏幕左下角当成最小的窗口坐标值,DirectX把屏幕左上角当成最小的窗口坐标值。

三角形设置(Triangle Setup)

从上一个阶段输出的信息是屏幕坐标系下的顶点位置以及它们相关的额外信息。如深度值(z坐标)、法线方向、视角方向等。

光栅化阶段有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。

三角形设置阶段会计算光栅化一个三角网格所需的信息。将屏幕映射过来的点重新生成合理的三角形,这样才能确定像素,来为下一步三角形遍历使用。

三角形遍历(Triangle Traversal)

这个阶段会检查每个像素是否被一个三角网格所覆盖,如果被覆盖的话,就会生成一个片元(fragment)。这样找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换(Scan Conversion)。注意每个符合条件的像素都会生成一个片元。

三角形遍历会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格的3个顶点信息对整个覆盖区域的像素进行插值。

三角形遍历

这一步输出就是得到一个片元序列,一个片元序列并不是真正意义上的像素,而是包含了很多状态(屏幕坐标、深度信息、法线、纹理坐标等等)的集合,这些状态用于计算每个像素的最终颜色。

片元着色器(Fragment Shader)

在DirectX中,片元着色器又被称为像素着色器(Pixel Shader)。

片元着色器的输入是上一步得到的各个片元(具体来说是顶点着色器输出数据的插值),输出是一个或者多个颜色值

这一阶段可以完成很多重要的渲染技术,其中之一就是纹理采样。纹理采样也是对顶点着色器顶点纹理坐标进行插值计算来采样的。

片元着色器仅可以影响单个片元。当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外,就是片元着色器可以访问导数信息(gradient,或者说是derivative)。

逐片元操作(Per-Fragment Operations)

OpelGL中称为逐片元操作,DirectX中称为输出合并阶段(Output-Merger)。

逐片元操作是可配置的,比如测试和混合的开关等。

这一阶段有几个主要任务:

  1. 决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等,如果片元没有通过测试就会被舍弃。
  2. 如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存在颜色缓冲区中的颜色进行合并,或者说是混合

模板测试(Stencil Test)和深度测试(Depth Test)

测试的过程是个比较复杂的过程,不同的图形接口实现的细节也不尽相同。这些测试和透明效果息息相关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
st=>start: 开始模板测试
e=>end: 模板测试结束
op1=>operation: 比较参考值(使用读取掩码)和已经参在于模板缓冲区中的模板值(使用读取掩码)
op2=>operation: 得到模板测试的结果
op3=>operation: 舍弃该片元
op4=>operation: 根据状态设置更新模板缓冲区中的值(使用写入掩码)

cond1=>condition: 是否开启了模板测试
cond2=>condition: 是否通过了模板测试

st->cond1
cond1(no)->e
cond1(yes)->op1->op2->cond2
cond2(no)->op3->op4->e
cond2(yes)->op4->e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
st=>start: 开始深度测试
e=>end: 深度测试结束
op1=>operation: 比较该片元的深度值和已经存在于深度缓冲区中的深度值
op2=>operation: 得到深度测试的结果
op3=>operation: 舍弃该片元
op4=>operation: 将深度值写入深度缓冲区

cond1=>condition: 是否开启了深度测试
cond2=>condition: 是否通过了深度测试
cond3=>condition: 是否开启了深度写入

st->cond1
cond1(no)->cond3
cond1(yes)->op1->op2->cond2
cond2(yes)->cond3
cond2(no)->op3->e
cond3(yes)->op4->e
cond3(no)->e

要注意,即使没有打开深度测试,也会判断一次有没有打开深度写入。

如果一个片元没有通过深度测试,它就没有权力更改深度缓冲区的值。和模板测试不同。

模板测试的比较函数可以由开发者指定,例如小于时舍弃该片元,或者大于等于时舍弃该片元。

不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试的结果来修改模板缓冲区,这个修改操作也是由开发者指定的。例如在失败时模板缓冲区保持不变,通过时模板缓冲区对应位置+1等。

模板测试通常用于限制渲染的区域,模板测试还有一些更高级的用法,如渲染阴影轮廓渲染

深度测试的比较函数通常是小于等于的关系,即如果这个片元的深度值大于等于当前深度缓冲区的值,那么就会舍弃它,这是因为我们总想只显示出离摄像机最近的物体。

合并

渲染过程是一个物体接着一个物体画到屏幕上的,而每个像素的颜色信息被存储在一个名为颜色缓冲的地方,当我们执行这次渲染时,颜色缓冲往往已经有了上次渲染的结果,那么我们是使用这次渲染的结果完全覆盖掉上次的还是进行其他处理,这就是合并需要解决的问题。

对于不透明物体,开发者可以关闭混合(Blend)操作。对于半透明物体,我们要开启混合来让这个物体看起来是透明的。

Blending

混合操作是使用一个混合函数。这个混合函数和透明通道息息相关,例如根据透明通道的值进行相加、相减、相乘等。很像PS中的图层混合。

Early-Z技术

将深度测试提前执行的技术通常也被称之为Early-Z技术。但是如果在片元着色器中进行了透明度测试,GPU会禁用Early-Z,从而导致性能上的下降。

双重缓冲(Double Buffering)

我们的屏幕显示的是颜色缓冲区中的颜色值,但是为了避免我们看到正在进行光栅化的图元,GPU会使用双重缓冲。

对场景的渲染,会放置在后置缓冲(Back Buffer)中,我们看到的完整画面是前置缓冲(Front Buffer)的。

当场景渲染完毕,GPU会交换后置缓冲和前置缓冲,前置变后置,后置变前置。