前言
渲染管线是做图形的同学最常听到的名词,它描述了游戏中的一帧渲染的流程顺序,有关渲染的所有环节可以说都被囊括其中。这篇文章不打算综述完整的渲染流程,对这个部分比较感兴趣的同学,可以看看这两篇文章,分别介绍了时下流行的Unity和UE的渲染管线,还有这两篇文章讲述GTA V和巫师3的帧分析。总体来说渲染管线的组成大同小异,本文主要关注直接照明部分的管线差异。
一帧由哪些渲染流程组成?
不管是什么样的渲染管线,它们的目的都是尽可能地呈现真实世界中的各类材质,从这个目的出发,一帧渲染要完成的任务可以用这张图比较简单的解释:
实际上,绝大部分渲染引擎要解决的无非就是 直接照明 , 间接照明 和 后处理 。在现代的实时渲染引擎中,直接照明也往往是管线的核心。尽管许多人大概已经听腻了forward/deferred rendering,也大约知道他们的思路和差异,但是你真的了解这些管线实现中的每个细节吗?
从Foward Rendering说起
游戏引擎的设计之初,硬件的计算能力还没有那么强,所以能把一个模型的位置画对、有正确的纹理贴图,就已经谢天谢地了,光照?不存在的。所以那个年代的渲染,也基本不区分直接、间接照明,无非就是一堆trick怼上去,弄几个参数调一调,在一个shader里搞定就好了。后来大家渐渐意识到, 这个世界要有光 ,于是逐渐迭代出了我们如今比较流行的几种理想光源。但是受限于Shader Core的运算性能,一个场景能够渲染的光源仍然有限,每个物体通常只有一盏方向光(全局,用于模拟太阳光)和两盏局部光(点光、聚光灯)的预算,这种情况下,大家能够想到最直观的方案就是扩展原本的shader,把光源数目写死,这种方案就是我们熟悉的 Uber Shader :所有的事情都放在一个shader里做完。
Uber Shader直到今天,在一些性能受限的平台上,仍然是主流方案,比如绝大部分WebGL和手机平台上的引擎,都采用这样的方案。这个方案有一些明显的优点,比 如每个模型只绘制一次,每种材质可以使用不同的光照模型和渲染技术 (比如很容易同时实现卡通渲染、各向异性材质和普通材质),缺点当然也很明显,就是 光源数目受限 。
在具体实现的时候,Uber Shader通常会根据光源的数目,编译出几个不同的shader来,然后在CPU端提交渲染前,根据场景内灯光的位置半径以及被渲染物体的包围盒,粗略地算出所有影响它的光源,并根据光源的数目选取不同的预编译好的shader(超过最大数目,则使用最近的N个光源)。
Z-Prepass和多遍渲染
随着硬件性能的增强和画质需求的提高,固定数量的光源再也无法满足实际应用的需求;同时由于Depth Test的存在,大量在Uber Shader里被绘制的物体,其实都无法被真正的看到,也就带来了性能浪费。有没有更灵活,同时性能又更好的计算方案呢?大家发现,可以 借助当时大部分硬件都有的early z特性,来避免overdraw的问题 。具体来说,在实际渲染之前,加入了一个称之为 z prepass 的流程,这个流程关闭了color buffer的写入,同时pixel shader极为简单或者索性为空,可以非常快速的执行完毕并且获得场景中的z buffer;紧接着,我们再关闭z buffer的写入,改depth test function为equal。这样整个渲染过程中,那些没有通过depth test的像素,就不必再执行pixel shader的复杂运算和写入,对于overdraw比较大的应用来说,能够大大减少pixel shader的运算和带宽开销,所以已经几乎成为了渲染管线中的标配。
有了z prepass之后,人们就自然而然的想到了支持多光源的方案: 多遍绘制 。几盏光源就绘制几次物体,由于early z的存在,那些不是最终可见的像素并不会实际绘制,所以从性能的角度来讲尚可,灵活度上也大大提高,甚至我们可以结合uber shader和多遍绘制,在光源数目较多的时候,减少draw call的同时又不失灵活度(比如每遍计算四盏光源而不是一盏)。
实际上z prepass并不总是带来性能的提升,因为它本身会使得vertex shader的执行以及draw call次数翻倍。这篇文章就比较详尽地分析了这个问题。
Deferred Rendering
到多pass渲染为止,理论上我们已经可以支持无限数量的光源。但即使是结合uber shader,场景内的draw call数量也是随着光源数目的增加,呈O(m*n)的复杂度(m表示场景内物体数量,n表示灯光数量)。而现代游戏画质的提升,其中一个很重要的指标就是局部光源数目的增加,当场景内有数百个光源的时候,多pass也无能为力了。此外,多pass的另外一个缺陷是, 它对光源的剔除是per object的 ,这就意味着即使某个光源只照射到物体很小一部分,也仍然需要一次单独的draw call。有没有办法把物体的绘制和光照计算解耦开呢?在这样的需求下,得益于MRT技术的支持,诞生了延迟渲染。它的核心是G-Buffer,也就是是若干张贴图,存的是计算光照需要的所有参数,通常包含了depth,normal,albedo,roughness,specular和metallic。
传统的延迟渲染在G-Buffer生成之后,会根据光源的形状(light volume),对每个光源执行一次draw call,如果某个像素被light volume覆盖到了,我们就在该像素的位置执行一次当前光源的lighting计算。由于光照计算是线性可叠加的,所以我们只要把color buffer的blend mode设置为ADD,并将source factor和dst factor设置为ONE即可。
Light Volume
需要注意的是,为了防止同一像素被光源正反面计算两次,我们需要在绘制light volume的时候使用 单面渲染,如果摄像机在光源内,则需要开启正面剔除,并且将depth test设置为farOrEqual,如果摄像机在光源之外,则开启背面剔除,并且将depth test设置为nearOrEqual 。
Deferred rendering的优点是,解耦了mesh draw和light draw,保证物体只被绘制一次,光源也只绘制一次,场景的draw call复杂度变成了O(m+n)。另外,G-Buffer除了用于直接照明外,还能够被用于一些间接照明的效果,比如SSAO,SSR;也正是G-Buffer概念的提出,使得近十年来越来越多的算法从world space向screen space的演进;去年因为硬件加速而流行的ray tracing技术,得益于G-Buffer,也能够大大提高性能。延迟渲染的缺点主要在于带宽:G-Buffer要存的数据越多,带宽开销就越大。
带宽的优化来自于两个方面: 读和写 。写的部分主要是G-Buffer的压缩,在这个方向演化出了许多用于压缩和减小G-Buffer的方案,比如早年Crytek提出的Best Fit Normal和其他一些normal的压缩方案,以及基于YCbCr(或者YCoCg)的色彩空间把三通道的RGB信息压缩到两通道(当然色彩压缩的方案由于最终着色的时候还需要多读一个pixel的值去重建,所以得失如何还不好说)。G-Buffer的压缩方案一个缺陷是, 它可能会导致硬件的blend mode失效(主要影响decal blend) 。
Crysis3用的Thin G-Buffer
YCbCr的压缩方式
另外一个优化带宽的方法称之为 deferred lighting ,它的思路是,进一步解耦光照和最终着色的流程,我们以一个简单的Blinn-Phong的shading model为例,它的着色计算可以用这个公式描述:
对于k个光源,我们需要读k次albedo和specular的值,但实际上,对同一个像素来说,
和
作为常数可以提取出来,于是方程改成如下形式:
于是我们在lighting时分别存储
和
,之后再加一个shading的流程,在shading时读取
和
的值,这样,albedo和specular就只需要读取一次,减少了带宽;相应地,我们需要存储两张lighting buffer。当然,我们也可以只存储RGB(Diffuse)和Lum(Specular),然后用
来恢复RGB(Specular),以节约lighting buffer的写入带宽。此外,当我们使用菲涅尔项去更好的模拟specular的时候,deferred lighting又变得不可行了,这时候我们只能把
近似为
。有关deferred lighting的更多细节,可以参考这篇文章和这篇slide。
Visibility Buffer/Deferred Texturing 是在deferred lighting基础上更为激进的方案,它的思路很简单:既然G-Buffer这么费,我们索性不渲染G-Buffer,改成渲染Visibility Buffer,这个buffer上 只存primitive ID,uv和贴图ID ,我们根据这些属性,分别从UAV和bindless texture里面读取我们shading真正需要的vertex attributes和贴图的属性,根据uv的差分自行计算mip-map:
传统G-Buffer和Visibility Buffer在渲染流程上的差异
Visibility Buffer的layout
这个方法确实能够一定程度上减少G-Buffer的容量(所有材质相关的属性都不存了),但是它的缺点是需要bindless texture的支持,而且由于贴图是在screen space根据Material ID动态索引的,所以从cache friendly的角度来说,我觉得不见得好(毕竟像素之间可能出现贴图跳变)。
除了上述的方案去优化读写带宽,针对传统的deferred rendering,还有一些方法是旨在减少light volume中无效的像素。我们之前已经描述了light volume的方法,但是,试想这种情况:
当光源整个位于z buffer前面时,整个light volume的区域都不需要实际渲染
这种情况下,虽然整个光源的正面都通过了NearOrEqual的测试,但其实它的所有区域都不对场景产生光照贡献。针对这种情况,有两种优化方案:
(1) Depth Bounds Test :这个方案利用了硬件中的一个称之为depth bounds test的特性,在普通depth test之外,额外增加一次测试,这个测试从depth buffer里面读取深度,然后用这个深度和depth bound test设置的zmin和zmax值作比较,如果depth值没有落在[zmin, zmax]这个区间内,则直接reject这个像素不执行pixel shader。利用这个优化,我们可以在绘制每个光源的时候,计算它的depth bound,并设置给管线,这样,上图的情况就不会实际着色。这个方法在killzone2中曾被使用,cryengine也用过类似的优化。
(2) 2-Pass Stencil Culling :这个方案剔除更精确,缺点是每个光源要绘制两遍:我们需要真正着色的像素,其实是深度值小于light volume背面,大于light volume正面的那些位置。所以我们先绘制light volume的正面,pixel shader置为空,将NearOrEqual的像素用stencil buffer标记出来;然后绘制light volume的背面,pixel shader设置为光照的shader,depth test设置为FarOrEqual,并且只有被上一个pass标记的像素才通过stencil test,这样一来,就能够精确标记并减少light shader的浪费,在killzone2中也有使用。另外,这篇silde提到了一种 clustered stencil culling 的方法,能够一次绘制多个光源的stencil,结合instance rendering,能够进一步减少2-pass stencil culling的状态切换开销。
Tiled Based Deferred Rendering & Forward+
在上述方法之后,工业界开始继续寻找解耦光照和几何计算的方法。在上述方法的基础之上,发展出的就是 tiled based 方法。实际上TBDR和Forward+是tiled based方法在forward和deferred上各自的体现,相较于过去的管线,tiled based的方法增加了一个 light culling 的流程,这个流程把整个屏幕分割成若干个tile(通常每个tile是16*16个pixel),每个tile各自计算出一个单独的light list,找出场景中那些对当前tile有贡献的光源。然后对每个tile中的pixel,只需要计算其对应的tile中light list内的光源对该像素的贡献。
light culling需要一个额外的compute shader来计算,依据是当前tile的screen positon和depth bound(基于这两个信息可以计算出view frustum)。 对于foward方法来说,是在z-prepass之后,而对deferred方法来说,是在G-Buffer pass之后 。每个group内单独地对场景所有光源进行相较测试,每次可以并行测试256盏光源(16*16)。对于foward来说,计算出来的light list需要存储在一个UAV Buffer中,然后 再执行一次geometry pass ,根据每个pixel所在的tile去索引UAV Buffer中的light list,然后用动态的循环去计算每个光源对当前pixel的贡献;而对于deferred来说,light list可以存储在compute group对应的shared memory中(因为light culling就是以compute group为单位去运行的),light culling之后,在 同一个compute shader内 执行光照计算。
Tiled based方法可以说进一步减少了带宽,因为对于一个像素来说,相较于传统forward/deferred方法,涉及光照计算的pixel shader只执行一次,也就意味着 material data(比如albedo,normal,specular,roughness,metallic等)都只需要读取一次,并且最终结果也是一次写入 。但是它也有比较明显的缺陷:相较于传统的deferred shading,TBDR的方法对于光源的剔除更粗粒度了( 逐tile而不是逐pixel的 ),所以 当光源数量不够多的时候,这个方法并没有明显的性能优势 。
Tiled based基本的剔除方法是基于frustum的,它的缺陷在于, 基于frustum culling的方案对场景光源的剔除不够精确 ,这也是它在较少光源时性能不如传统deferred的原因之一。针对这个问题,很多方案提出了更精确的相交测试的方案,比如结合depth bound算出当前tile的AABB,然后基于AABB和bounding sphere做相交测试。
基于frustum plane的剔除和AABB剔除的结果差异,可以看出基于AABB的方法大大减少了无效的tile
另一个相交测试不准的原因是,场景里 每个tile的depth的分布并不是连续的 ,在[zmin, zmax]区间内的很多深度范围可能是没有像素的,而光源可能正好分布在这个区间内。对于这个问题,分别有两个解决方案,其一是 depth split ,简单说就是把一个tile根据[zmin, zmax]再一分为二,分别计算每个区间的light list,另一个方案叫作 2.5D culling ,就是把当前tile的depth区间分成32段(刚好是一个uint32),再把tile里每个像素的depth映射到其中一段,然后写入一个depthMask,然后根据light bounding box,也把它的深度映射成一个lightMask,根据(depthMask & lightMask == 0)来判断一个tile是否真的和一个light相交。
2.5D culling的算法示意图
除了一般性的相交测试的准确性问题和depth discontinuous的问题之外,这篇文章还提到了结合light volume(也就是光栅器)的结果进一步提高light culling准确率的方法,感兴趣的同学可以读一读。
针对Foward+和TBDR,这篇slide做了一个比较详尽的比较,总体来说结论还比较直观:当光源 并不是真的非常多(超过2048个) 时, 只有使用MSAA时,Foward+好于TBDR;否则均是TBDR好于Foward+ ,鉴于目前各种screen space的AA方案的成熟(SMAA,FXAA,TAA等),MSAA实际上已经变得越来越鸡肋,从性能的角度已是一个拖累。加上我们之前说到的G-Buffer带来的额外红利,目前市面上的引擎仍然是主流使用deferred框架。而针对无法表征多中shading model的问题, 目前的deferred引擎多数采用shading model ID + custom data的形式,再加上在lighting时做动态分支的方法来处理 。
Clustered Shading
尽管我们觉得tiled based的方法似乎已经能够比较好的解决多光源的问题(至少场景内几百上千个光源没什么问题),但是对现代的3A大作来说,这仍显得不够。现代3A大作的要求是,场景内要数千乃至上万个光源,鉴于我们已经提出了light culling的方案,那我们就需要进一步地思考 如何用更细粒度的数据结构来存储light list ,基于这个思考,比较直观的结果就是clustered shading,这个思路给light list的划分增加了一个维度,即depth(当然也可以再增加normal的维度),它根据view frustum的zmin,zmax把场景进一步根据depth划分成若干个slice(基于指数的划分,通常16个),然后在每个slice上对场景中的所有灯光进行light culling,具体的计算方案和tiled based提到的一些方案类似,只是这里 不再需要处理深度不连续的问题 。所以slice的light list被计算出来后,会经过一个sort和compact的流程(基于compute shader),最终形成的存储结构有三个:
(1)一个3D Texture,用于存储每个depth slice上的光源的起始位置和光源数量;
(2)一个UAV Buffer,存储每个光源的的具体索引,UAV中的offset和num lights由(1)决定;
(3)一个Constant Buffer,存储具体的光源数据。
整个light list的存储结构
depth slice的一些划分形式
在实际shading的时候,每个像素根据自己的depth和screen position,找到对应的depth slice,从3D Texture里拿到offset和num lights,再执行一个num lights次循环,从offset处取到UAV Buffer的索引,然后从constant buffer里拿到具体的光源数据执行光照计算。
Tiled Based和Clustered Based的方法除了能够提高bandwidth的利用率,另外一个重要的优势是, 它和forward/deferred方案是正交的 ,这意味着不管你是什么基本管线,都可以使用这个方法处理光源列表,而对于现代引擎来说, 它的渲染管线往往是以deferred为主,结合forward处理一些半透明和特殊材质的混合管线 ,因此tiled/clusterred的方案就更显示出了它们的优势。
结语
有关本文涉及的部分管线在移动端性能对比,可以参见这篇slide。在Siggraph 2017上,这篇slide历数了各种管线和光照的优化方案,并且在指令级别和数据结构的角度提出了许多光照剔除优化的方案,也非常建议大家详细阅读。
其实我常常觉得,哪怕是一个简单的技术,哪怕你每天在和它打交道,或许仍有些许内容你是不了解的。就像本文中的前向渲染和延迟渲染,很难说有那个图形工程师对此没有了解。然而有时候,当你从头梳理这项基本技术的时候,还是会发现许多前人为此付出的努力,并且许多看似过时的技术,当你清楚地理解了它产生的原因和解决的问题后,某些思路仍然能够迁移到其他问题上。