《Unity Shader 入门精要》第2章 渲染流水线
第2章 渲染流水线
2.1 什么是渲染流水线
渲染流水线的工作在于由一个三维场景出发,生成一张二维图像。换句话说,计算机需要从一系列的顶点数据、纹理等信息出发,把这些信息最终转换成一张肉眼可见的图像,而这个过程通常由CPU
与GPU
共同完成。
《Render-Time Rendering, Third Edition》一书中将一个渲染流程分成3个阶段:应用阶段(Application Stage)
、几何阶段(Geometry Stage)
、光栅化阶段(Rasterizer Stage)
。注意,这里仅仅是概念性阶段,每个阶段本身通常也是一个流水线系统,即包含了子流水线阶段。
应用阶段
这个阶段是由我们的应用主导的,因此通常由 CPU 负责实现,开发者具有这个阶段的绝对控制权。
在此阶段,开发者主要有3个任务:
- 首先,我们需要准备好场景数据,比如摄像机位置、视锥体、光源等信息
- 其次,为了提高渲染效率,我们往往需要一个粗粒度
剔除(culling)
工作,以剔除不可见的物体 - 最后,我们需要设置好每个模型的渲染状态,这些渲染状态包括不限于它使用的材质、纹理、shader 等
这一阶段最重要的输出是渲染所需的几何信息,即渲染图元(rendering primitives)
,这些渲染图元将会被传递到下一阶段–几何阶段。
几何阶段
几何阶段负责和每个渲染图元打交道,进行逐定点、逐多边形的操作,决定需要绘制的图元是什么、怎样绘制它们、在哪里绘制它们,此阶段通常在 GPU 上进行。
几何阶段的重要任务就是把顶点坐标转换至屏幕空间中,再交由光栅期进行处理。
光栅化阶段
这一阶段将使用几何阶段传递来的信息来产生屏幕上的像素,并渲染出最终的图像,这一阶段也是在 GPU 中进行的。
光栅化阶段的主要任务是决定每个图元中的哪些像素应该被绘制在屏幕上。
2.2 CPU 与 GPU 之间的通信
应用阶段大致可分为3个阶段:
- 把数据加载到显存中
- 设置渲染状态
- 调用
Draw Call
命令
把数据加载至显存
所有渲染所需的数据都需要从硬盘加载到系统内存(Random Access Memory, RAM)
中,然后网格和纹理等数据又被加载在显存(Video Random Access Memory, VRAM)
中,因为显卡对显存的访问速度最快,而且大多数显卡对于 RAM 没有直接访问权限。
设置渲染状态
什么是渲染状态?通俗的讲,这些状态定义了场景中的网格是怎样被渲染的,比如使用了哪个顶点着色器(Vertex Shader)
、 片元着色器(Fragment Shader)
、光源属性、材质等。
在完成上述工作后,CPU 就需要调用渲染命令来通知 GPU,这个命令就是 Draw Call
。
Draw Call
Draw Call 就是一个命令,它的发起方是 CPU,接收方是 GPU,这个命令仅仅指向一个需要被渲染的图元列表。当给定一个 Draw Call 时,GPU 会根据渲染状态(材质、纹理、着色器等)和顶点数据来进行计算,最终输出成屏幕上显示的像素。这个计算过程,就是我们接下来要讲的 GPU 流水线。
2.3 GPU 流水线
概述
对于几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,它们被分为若干更小的流水线阶段,这些流水线阶段由 GPU 来实现,每个阶段 GPU 提供了不同的可配置性和可编程性:
- 顶点着色器(Vertex Shader)是完全可编程的,它通常用于实现顶点的空间变换、顶点着色等功能。
- 曲面细分着色器(Tessellation Shader)是一个可选的着色器,用于细分图元。
- 几何着色器(Geometry Shader)是一个可选着色器,它可以被用于执行逐图元的着色操作,或者被用于产生更多的图元。
- 裁剪(Clipping)的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的,比如我们可以使用自定义的裁剪平面来配置裁剪区域。
- 屏幕映射(Screen Mapping)是不可配置和编程的,它负责把每个图元坐标转换至屏幕坐标。
顶点着色器
顶点着色器的处理单位是顶点,输入的每个顶点都会调用一次顶点着色器。顶点着色器无法创建或销毁任何顶点,并且其无法知道顶点之间的关系,但正因为这样的相互独立性,GPU 可以快速的并行处理这些顶点数据。
顶点着色器需要完成的主要工作有:坐标转换和逐顶点光照。
顶点着色器可以改变顶点的位置,这在顶点动画中非常有用,比如我们可以通过改变顶点位置来模拟水面和布料。
需要注意的,无论我们在顶点着色器中如何改变顶点位置,一个最基本的顶点着色器需要完成的工作是,把顶点坐标从模型空间转换至齐次裁剪空间。我们经常内在顶点着色器中看到如下代码:
o.pos = mul(UNITY_MVP, v.position);
类似的代码功能,就是把顶点坐标转换到齐次裁剪坐标系,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标(Normalized Device Coordinates, NDC)
。
裁剪
一个图元和摄像机视野的关系有3种:完全在视野内、部分在视野内、完全在视野外,完全在视野内的图元继续传递给下一个流水线阶段,完全在视野外的不会继续向下传递,部分在视野内的图元需要进行一个处理,这就是裁剪。
由于我们已知在 NDC 下的顶点位置,即顶点位置在一个立方体,因此裁剪就变得很简单,只需要将图元裁剪到单位立方体内。
我们无法通过编程来控制裁剪过程,这是硬件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。
屏幕映射
这一步输入的坐标仍是三维坐标系下的坐标(范围在单位立方体内),屏幕映射的任务是把每个图元的 x 和 y 坐标转换为屏幕坐标系下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。
屏幕映射不会对输入的 z 坐标做任何处理,实际上,屏幕坐标系和 z 坐标一起构成了一个新坐标系,叫做窗口坐标系(Window Coordinats)
,这些值会被传递到光栅化阶段。
三角形设置
光栅化阶段的第一个流水线阶段是三角形设置(Triangel Setup)
,这个阶段会计算光栅化一个三角网格所需的信息。
具体来说,上一个阶段输出的是三角网格的顶点,但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。
三角形遍历
三角形遍历(Trangle Traversal)
将检查每个像素是否被一个三角网格所覆盖,如果被覆盖则会生成一个片元(fragment)
。此阶段也被称为扫描遍历(Scan Conversion)
。
三角形遍历会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并且使用三角网格3个顶点的顶点信息对覆盖的像素进行插值计算。
这一步的输出是一个片元序列,需要注意的是,一个片元并不是真正的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。
片元着色器
片元着色器是另一个非常重要的可编程着色器阶段。
前面的光栅化阶段实际上并不影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的,而每个片元就负责存储这样的一系列数据。
片元着色器的输入是上一阶段对顶点信息插值得到的结果,输出是一个或多个颜色值。这一阶段可以实现很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行差值后,就可以得到其覆盖的片元的纹理坐标了。
虽然片元着色器可以完成很多重要效果,但它的局限性在于它仅能影响单个片元。
逐片元操作
这一阶段有几个主要任务:
- 决定每个片元的可见性,这涉及很多测试工作,例如
深度测试
、模版测试
- 如果一个片元通过了所有测试,就需要将这个片元的颜色值和已经存储在颜色缓冲区的颜色进行合并,或者说混合。
逐片元操作是高度可配置性的,即我们可以设置每一步的操作细节。
我们先来看模版测试(Stencil Test)
。如果开启了模版测试,GPU 会首先读取(使用读取掩码)模版缓冲区中该片元位置的模版值,然后将该值和读取(使用读取掩码)到的参考值进行比较来决定这个片元是否被舍弃,这个比较函数可以由开发者指定。不管一个片元有没有通过模版测试,我们都可以根据测试结果来修改模版缓冲区,这个修改操作也是由开发者指定的。
如果片元通过了模板测试,那么它将进行下一个测试–深度测试(Depth Test)
。这个测试同样是可以高度配置的。如果开启了深度测试,GPU 也会把该片元的深度值和已经存在于深度缓冲区的深度值做比较,以确定是否舍弃该片元,这个比较函数也可以由开发者设置。和模版测试不同的是,如果一个片元没有通过深度测试,它就没有权利更改深度缓冲区中的值;如果它通过了测试,开发者可以指定是否用这个深度值覆盖原有的深度值,这是通过开启/关闭深度写入来设置的。
如果一个片元通过了上面的所有测试,它就自豪地来到了合并
功能前。
为什么需要合并?我们知道,渲染过程是将一个个物体依次画到屏幕上,每个像素的颜色信息被存储在一个名为颜色缓冲区的地方。当我们执行渲染时,颜色缓冲区中通常已经有了上次渲染的颜色结果,那么我们要怎么使用之前的颜色值,便是合并需要解决的问题。下面是一个简化版的混合操作流程图:
混合操作是高度配置的,开发者可以选择开启或关闭混合功能。如果没有开启混合功能,就会直接使用片元的颜色覆盖掉缓冲区中的颜色;如果开启了混合,GPU 会使用一个混合函数将源颜色(片元的颜色值)和目标颜色(缓冲区中的颜色值)进行混合,这个混合函数可以通过设置混合模式来进行选择配置。
上面的测试顺序并不是唯一的,虽然从逻辑上这些测试是在片元着色器之后进行的,但对于大多数 GPU 来说,它们会尽可能在片元着色器之前进行这些测试。这是可以理解的,当 GPU 在片元着色器花了很大力气计算出了片元的颜色,最终却发现这个片元没有经过测试被舍弃,那么之前的计算成本都被浪费了,作为一个想充分提升性能的 GPU,它会希望尽早知道哪些片元是被舍弃的。
在 Unity 给出的渲染管线中,我们可以发现它给出的深度测试在片元着色器之前,这种技术通常被称为 Early-Z
技术。
但是如果将这些测试提前的话,其检测结果可能会与片元着色器中的一些操作冲突(比如片元着色器中的透明度测试),因此现代 GPU 会判断片元着色器中的操作是否和提前测试冲突,如果有冲突,则会禁用提前测试,但这样会导致性能下降。
当模型的图元经过了上面层层计算和测试后,就会显示到我们的屏幕上。我们的屏幕显示的就是颜色缓冲区中的颜色值。但是,为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)
的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)
中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲(Front Buffer)
中的内容,而前置缓冲区是之前显示在屏幕上的图像。由此,保证了我们看到的图像总是连续的。
总结
虽然渲染流水线比较复杂,但Unity作为一个非常出色的平台为我们封装了很多功能。更多时候,我们只需要在一个 Unity Shader 设置一些输入、编写顶点着色器和片元着色器、设置一些状态就可以达到大部分常见的屏幕效果。这是 Unity Shader 吸引人的魅力之处,但这样的缺点在于,封装性会导致编程自由度下降,使很多初学者迷失方向,无法掌握其背后的原理,并在出现问题时,往往无法找到错误原因,这是在学习 Unity Shader 时普遍的遭遇。
2.4 一些容易困惑的地方
什么是 OpenGL/DirectX?
开发者要进行渲染管线的相关开发,如果直接访问 GPU 是一件非常麻烦的事情,我们可能需要和各种寄存器、显存打交道,为了简化这个过程,图像编程接口应运而生。图像编程接口在硬件的基础上实现了一层抽象,可以更方便地渲染二维或三维图像,这些接口架起了上层应用和底层 GPU 的沟通桥梁,而 OpenGL、DirectX 就是最流行的两种图像编程接口。
概括来说,我们的程序运行在 GPU 上,应用程序可以通过图形接口将渲染需要的数据,如顶点数据、纹理数据、材质参数等数据存在显存里,然后调用 Draw Call 渲染命令让 GPU 进行绘制。
从显卡的角度看,它只需要和显卡驱动打交道就行了,而显卡驱动负责和图像编程接口以及 GPU 打交道,因此一个显卡制作商为了让他的显卡同时支持 OpenGL 和 DirectX,就必须提供支持这两种接口的显卡驱动。
什么是 HLSL、GLSL、CG
在可编程管线出现之前,为了编写着色器代码,开发者们必须使用汇编语言。为了给开发者们打开更方便的大门,就出现了更高级的着色语言(Shading Language)
。着色语言是专门用于编写着色器的,常见的着色语言有DirectX 的HLSL(High Level Shading Language)
、OpenGL 的GLSL(OpenGL Shading Language)
以及 NVIDIA 的CG(C for Graphic)
。HLSL、GLSL、CG都是高级(High-Level)语言
,但这种高级是相对于汇编语言来说的,而不是像 C# 相对于 C 的高级那样。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言(Intermediate Language,IL)
。这些中间语言再交给显卡驱动来翻译成真正的机器语言,即 GPU 可以理解的语言。
GLSL 的优点在于它的跨平台性,它可以在Windows、Linux、Mac 甚至移动平台等多种平台上工作,但这种跨平台性是由于 OpenGL 没有提供着色器编译器,而是由显卡驱动来完成着色器的编译工作。也就是说,只要显卡驱动支持对 GLSL 的编译它就可以运行。这种做法的好处在于,由于供应商完全了解自己的硬件构造,他们知道怎样做可以发挥出最大的作用。换句话说,GLSL 是依赖硬件,而非操作系统层级的。但这也意味着 GLSL 的编译结果将取决于硬件供应商。要知道,世界上有很多硬件供应商——NVIDIA、ATI等,他们对GLSL的编译实现不尽相同,这可能会造成编译结果不一致的情况,因为这完全取决于供应商的做法。
而对于 HLSL,是由微软控制着色器的编译,就算使用了不同的硬件,同一个着色器的编译结果也是一样的。但因为其他平台上没有可以编译 HLSL 的编译器, 所以支持 HLSL 的平台相对比较有限,几乎完全是微软自已的产品,如 Windows、Xbox 360、PS3 等。
Cg 则是真正意义上的跨平台。它会根据平台的不同,编译成相应的中间语言。Cg 语言的跨平台性很大原因取决于与微软的合作,这也导致 CG 语言的语法和 HLSL 非常相像,Cg 语言可以无缝移植成 HLSL 代码,但缺点是可能无法完全发挥出 OpenGL 的最新特性。
对于Unity平台,我们同样可以选择使用哪种语言。在 Unity Shader 中,我们可以选择使用Cg/HLSL
或者GLSL
。但需要注意的是Unity里的这些着色语言并不是真正意义上的对应的着色语言,尽管它们的语法几乎一样,但有极少部分语法是不支持的。
什么是 Draw Call
在前面的章节中,我们已经了解了Draw Call 的含义。Draw Call本身的含义很简单,就是 CPU 调用图像编程接口,以命令 GPU 进行渲染的操作。一个常见的误区是认为 Draw Call 中造成性能问题的元凶是 GPU, 认为 GPU 上的状态切换是耗时的,其实不然,真正拖后腿的其实是 CPU。在深入 Draw Call 前,我们需要了解 CPU 与 GPU 是如何并行工作的。
CPU 和 GPU 是如何并行工作的
CPU 和 GPU 是通过一个命令缓冲区(Command Buffer)
来并行工作的。命令缓冲区包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令,添加和读取的过程是互相独立的。命令缓冲区中的命令有很多种类,而 Draw Call 是其中一种,其他命令还有改变渲染状态等(例如改变使用的着色器,使用不同的纹理等)。
为什么 Draw Call 多了会影响帧率
在每次调用 Draw Call 之前,CPU 需要向 GPU 发送很多内容,包括数据、状态和命令等。在这一阶段,CPU需要完成很多工作,例如检查渲染状态等。而一旦 CPU 完成了这些准备工作,GPU 就可以开始本次的渲染。GPU 的渲染能力是很强的,渲染200个还是2000个三角网格通常没有什么区别,因此渲染速度往往快于 CPU 提交命令的速度。如果 Draw Call 的数量太多,CPU 就会把大量时间花费在提交 Draw Call 上,造成CPU的过载。
如何减少 Draw Call
尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理(Batching)
的方法。
我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准备 Draw Call 的工作上了。那么,一个很显然的优化想法就是把很多小的 Draw Call 合并成一个大的 Draw Call,这就是批处理的思想。
需要注意的是,由于我们需要在 CPU 的内存中合并网格,而合并的过程是需要消耗时间的。因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物体我们只需要合并一次即可。当然,我们也可以对动态物体进行批处理。但是,由于这些物体是不断运动的,因此每一帧都需要重新进行合并然后再发送给 GPU,这对空间和时间都会造成一定的影响。
利用批处理,CPU 在 RAM 把多个网格合并成一个更大的网格,再发送给 GPU,然后在一个 Draw Call 中渲染它们。但要注意的是,使用批处理合并的网格将会使用同一种渲染状态,如果网格之间需要使用不同的渲染状态,那么就无法使用批处理技术。
在游戏开发过程中,为了减少Draw Call的开销,有两点需要注意:
- 避免使用大量很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们。
- 避免使用过多的材质。尽量在不同的网格之间共用同一个材质。
什么是固定管线渲染
固定函数的流水线(Fixed-Function Pipeline)
,也简称为固定管线,通常是指在较旧的GPU上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的完全控制权。随着时代的发展,GPU 流水线越来越朝着更高的灵活性和可控性方向发展,可编程渲染管线应运而生,固定管线已逐渐退出历史舞台。如果读者不是为了对较旧的设备进行兼容,不建议继续使用固定管线的渲染方式。