1 综述
渲染流水线的工作任务在于由一个三维场景出发、生成(或者说渲染)一张二维图像,换句话说,计算机需要从一系列的顶点数据、文理等信息出发,把这些信息最终转换成一张人眼可以看到的图像。
一个渲染流程分为三个阶段:应用阶段(Application Stage)、几何阶段(Geometry Stage)、光栅化阶段(Rasterizer Stage)。这里,每个阶段本身也包含了自流水线阶段。
1.1 应用阶段
应用阶段通常由CPU负责实现,开发者有3个主要任务: (1)准备场景数据,例如相机位置、视锥体、场景模型、光源等; (2)粗粒度剔除,在这个阶段把不可见的物体剔除,就不需要再交给几何阶段处理了; (3)设置每个模型的渲染状态,包括但不限于使用的材质、纹理、Shader等,此阶段最重要的输出是渲染所需要的几何信息,即渲染图元(Rendering Primitives),也就是点、线、三角面等,这些渲染图元将会被传递到下一个阶段——几何阶段。
1.2 几何阶段
几何阶段用于处理所有和我们要绘制的几何相关的事情。例如,决定需要绘制的图元失身,额,怎样绘制,在哪里绘制,这一阶段通常会在GPU进行。
几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形操作。几何阶段的一个重要任务就是把顶点坐标变换到屏幕空间中,再交给光栅器进行处理。通过输入的渲染图元进行多步处理后,这一阶段将会输出屏幕空间的二维坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。
1.3 光栅化阶段
光栅化阶段将会使用上一个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像,这一阶段也是在GPU上运行。主要任务是决定每个渲染图元中哪些像素应该被绘制在屏幕上,它需要对上一个阶段得到的逐顶点数据进行差值,然后再进行逐像素处理。
2 CPU和GPU之间的通信
渲染流水线的起点是CPU,即应用阶段,可分为下面三个阶段: (1)把数据加载到显存中。 (2)设置渲染状态。 (3)调用Draw Call。
2.1 把数据加载到显存中
所有渲染所需的数据都需要从硬盘加载到系统内存中,然后网格和纹理此等数据加载到显存中。这是因为,显卡对于现存的访问速度更快,而且大多数显卡对RAM(系统内存)没有直接访问的权利。
2.2 设置渲染状态
这些状态定义了场景中的网格是怎样被渲染的,例如使用哪个顶点着色器/片元着色器、光源属性、材质等,如果我们没有更改渲染状态,那么所有的网格都将使用同一种渲染状态,下图显示了当使用同一种渲染状态是,渲染三个不停网格的结果: 准备好上述工作后,CPU就需要调用一个渲染命令来告诉GPU,可以开始渲染了,这个命令就是Draw Call。
2.3 调用Draw Call
Draw Call 就是一个命令,他的发起方是CPU,接收方是GPU,这个命令仅仅会指向一个需要被渲染的图元列表,而不会再包含任何材质信息,这是因为我们已经再上一个阶段中完成了。 当给定了一个Draw Call 时,CPU就会根据渲染状态和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的那些漂亮的像素。
3 GPU 流水线
当GPU从CPU那里得到渲染命令后,就会进行一系列的流水线操作,最终把图元渲染到屏幕上。GPU渲染的过程就是GPU流水线。
对于几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其实现的载体是GPU。GPU通过实现流水线化,大大加快了渲染速度,虽然我们无法完全控制这两个阶段的实现细节,但GPU向开发者开放了很多控制权。
几何阶段和光栅化阶段可以分成若干更小的流水线,这些流水线阶段有GPU来实现,每个阶段GPU提供了不同的可配置性或可编程性。 上图中,颜色表示了不同阶段的可配置性和可编程性,绿色表示该流水线阶段是完全可编程控制的,黄色表示该流水线阶段可以配置但不是可编程的,蓝色表示该流水线阶段是由GPU固定实现的,开发者没有任何控制权,实线表示该Shader必须由开发者编程实现,虚线表示该Shader是可选的。
顶点着色器(Vertex Shader) 是完全可编程的,它通常用于实现顶点的空间变换,顶点着色等功能。
曲面细分着色器(Tessellation Shader) 是一个可选的着色器,它用于细分图元。
几何着色器(Geometry Shader) 同样是一个可选的着色器,它可以被用于执行逐图元的着色操作,或者被用于产生更多的图元。
裁剪(Clipping) 目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的,例如,使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面。
屏幕映射(Screen Mapping) 是不可配置和编程的,他负责把每个图元的坐标转换到屏幕坐标系中。
三角形设置(Triangle Setup) 和三角形遍历(Triangle Traversal) 阶段都是固定函数(Fixed-Function)的阶段。
片元着色器(Fragment Shader) 则是完全可编程的,它用于实现逐片元的着色操作。
逐片元操作(Per-Fragment) 阶段负责执行修改颜色、深度缓冲、进行混合等,它不是可编程的,但是具有很高的可配置性。
3.1 顶点着色器
顶点着色器是流水线的第一个阶段,它的输入来自CPU,每个顶掉都会调用一次顶掉着色器,它不可创建或销毁顶点,且无法得到顶点间的关系。
顶点着色器的主要工作有:坐标变换、逐顶点光照、输出后续阶段所需的数据。 顶点着色器可以有不同的输出方式,最常见额输出路径是经光栅化后交给片元着色器进行处理。而现代的Shader Model中,它还可以把数据发送给曲面细分着色器或者几何着色器。
U
n
i
t
y
、
O
p
e
n
G
L
的
N
D
C
(
N
o
r
m
a
l
i
z
e
d
D
e
v
i
c
e
C
o
o
r
d
i
n
a
t
e
s
?
归
一
化
的
设
备
坐
标
)
的
z
分
量
的
范
围
是
[
?
1
,
1
]
,
D
i
r
e
c
t
X
的
N
D
C
z
分
量
范
围
是
[
0
,
1
]
。
\color{#FF0000}{ Unity、OpenGL的NDC(Normalized Device Coordinates - 归一化的设备坐标)的z分量的范围是[-1, 1],DirectX的NDCz分量范围是[0, 1]。}
Unity、OpenGL的NDC(NormalizedDeviceCoordinates?归一化的设备坐标)的z分量的范围是[?1,1],DirectX的NDCz分量范围是[0,1]。
3.2 裁剪
一个图元和摄像机的关系有3种:完全在视野内、部分在视野内、完全在视野外。完全在视野内的图元继续传递给下一个流水线阶段,完全在视野外的图元不会继续向下传递,而部分在视野内的图元就需要进行下一个处理:裁剪。 这一步不可编程,是硬件的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。
3.3 屏幕映射
屏幕映射(Screen Mapping) 的任务是把每个图元的x和y坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标不会对z坐标进行任何处理,屏幕坐标系和z坐标,构成了窗口坐标系(Window Coordinates),这些值会被一起传递到光栅化阶段。 OpenGL和DirectX坐标系存在差异,OpenGL是以左下角为原点,而DirectX是左上角。
3.4 三角形设置
从三角形设置(Triangle Setup) 这一步开始,进入了光栅化阶段。光栅化阶段有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。
要得到整个三角形网格对像素的覆盖情况,就必须计算每条边上的像素坐标,所以我们就要得到三角形边界的表示方式,这样一个计算三角形网格表示数据的过程就叫做三角形设置。
3.5 三角形遍历
三角形遍历(Triangle Tranersal) 阶段将会检查每个像素是否每一个三角网格所覆盖,如果覆盖就会生成一个片元,而这样找到哪些像素三角网格覆盖的过程就是三角形遍历,也被称作扫描变换(Scan Conversion)。 这一步的输出就是得到一个片元序列,但一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色,这些状态包含但不限于:屏幕坐标、深度信息、顶点信息、法线、坐标文理等。
3.6 片元着色器
光栅化阶段并不会影响屏幕上每个像素的颜色值,而是产生一系列数据信息,每个偏远负责存储这些信息,真正对像素产生影响的是下面的逐片元操作。
这一阶段最重要的技术之一就是纹理采样,也就是美术们制作的各种颜色,法线,金属度,粗糙度等等贴图。 这里,个人理解是,通过对上一步得到的颜色、深度等各种信息进行计算,得到该片元的输出颜色。
3.7 逐片元操作
这是渲染流水线的最后一步。OpenGL称之为逐片元操作(Pre-Fragment Operations),而DirectX中则称为输出合并阶段(Output-Merger)。 这一阶段的几个主要任务: (1)决定每个片元的可见性。这涉及了深度测试、模板测试等。 (2)如果一个片元通过了所有测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区的颜色进行合并,或者说是混合。 深度测试和模板测试的简化流程:
3.8 模板测试和深度测试
与模板测试相关的是模板缓冲(Stencil Buffer),如果开启了模板测试,GPU会首先读取模板缓冲区中该片原位置的模板值,然后将这个值与参考值进行对比(这里,比较函数可以自定,例如小浴室舍弃该片元或者大于等于时舍弃),如果没有通过这个测试,则舍弃该片元。
模板测试通常用于限制渲染的区域,另外,模板测试还有一些更高级的用法:渲染阴影、轮廓渲染等。
如果一个片元通过了模板测试,那么它会进行下一个测试——深度测试(Depth Test),测试过程和模板测试类似,同样是可高度配置的。
两个测试有一个区别,模板测试不管通过不通过,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,可以设置不同的修改操作,例如失败时模板缓冲区保持不变,通过是将缓冲区中的位置值加1等。而深度测试则不一样,如果未通过,就没有权利修改深度缓冲区中的值,而通过测试,就可以指定是否要用这个片元的深度覆盖掉原有的深度值。
如果一个片元通过了所有测试,就可以到合并步骤。合并要解决执行本次渲染时,颜色缓冲区如果有了上次渲染后的颜色结果,我们要怎样处理,是覆盖还是其他。
对于不透明物体,可以关闭混合(Blend)操作,这样会直接覆盖掉颜色缓冲区中的像素值,但对于半透物体,我们就要使用混合操作来让这个物体看起来是透明的。
混合操作的简化流程图: 当模型的图元经过层层计算和测试后,就会显示在屏幕上,屏幕显示的就是颜色缓冲区中的颜色值。但是为了避免我们看到正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略,也就是说对场景的渲染是发生在幕后的,即在后置缓冲(Back Buffer)中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲区(Font Buffer)中的内容,二前置缓冲区是之前显示在屏幕上的图像,由此,保证了看到的图像总是连续的。
4 疑难点
4.1 OpenGL和DirectX
概括的讲,应用运行在CPU,调用OpenGL或DirectX的图形接口将渲染所需的数据,存储到显存中。开发者可以通过图像编程接口发出渲染命令,也就是Draw Call,然后由显卡驱动翻译成GPU能理解的代码,进行真正的绘制。 CPU、OpenGL/DirectX、显卡驱动和GPU之间的关系:
4.2 Draw Call
Draw Call就是CPU调用图像编程接口(OpenGL中的dlDrawElements命令、DirectX中的DrawIndexedPrimitive命令),以命令GPU进行渲染的操作。
Draw Call 中造成性能问题的元凶,其实是CPU。
4.2.1 CPU和GPU是如何实现并行工作的
需要实现流水线化,需要让CPU和GPU并行工作,而解决这个问题的方法就是使用一个命令缓冲区(Command Buffer)。
命令缓冲区包含了一个命令队列,由CPU向其中添加命令,GPU读取命令,缓冲暖气使得CPU和GPU相互独立工作。
4.2.2 为什么Draw Call 多了会影响帧率
每次调用Draw Call 之前,CPU会向GPU发送很多内容,这一阶段CPU需要完成很多工作,而GPU渲染能力很强,渲染速度往往大于CPU的提交命令速度,如果Draw Call 太多,CPU就会把大量时间花费在提交Draw Call 上,造成CPU过载。
4.2.3 如何减少Draw Call
(1)批处理(Batching),由于网格合并过程比较耗时,所以批处理更适合静态物体。 (2)尽量避免使用很小的网格。 (3)合并贴图、材质。 (4)UI使用图集。
|