游戏引擎中的渲染管线

游戏引擎中的渲染管线

前言

渲染管线是做图形的同学最常听到的名词,它描述了游戏中的一帧渲染的流程顺序,有关渲染的所有环节可以说都被囊括其中。这篇文章不打算综述完整的渲染流程,对这个部分比较感兴趣的同学,可以看看这两篇文章[1][2]分别介绍了时下流行的Unity和UE的渲染管线,还有这两篇文章讲述GTA V[3]和巫师3[4]的帧分析。总体来说渲染管线的组成大同小异,本文主要关注直接照明部分的管线差异。

一帧由哪些渲染流程组成?

不管是什么样的渲染管线,它们的目的都是尽可能地呈现真实世界中的各类材质,从这个目的出发,一帧渲染要完成的任务可以用这张图比较简单的解释:

实际上,绝大部分渲染引擎要解决的无非就是直接照明间接照明后处理。在现代的实时渲染引擎中,直接照明也往往是管线的核心。尽管许多人大概已经听腻了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次数翻倍。这篇文章[5]就比较详尽地分析了这个问题。

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[6]和其他一些normal的压缩方案[7],以及基于YCbCr(或者YCoCg)的色彩空间把三通道的RGB信息压缩到两通道[8](当然色彩压缩的方案由于最终着色的时候还需要多读一个pixel的值去重建,所以得失如何还不好说)。G-Buffer的压缩方案一个缺陷是,它可能会导致硬件的blend mode失效(主要影响decal blend)

Crysis3用的Thin G-Buffer
YCbCr的压缩方式

另外一个优化带宽的方法称之为deferred lighting,它的思路是,进一步解耦光照和最终着色的流程,我们以一个简单的Blinn-Phong的shading model为例,它的着色计算可以用这个公式描述:

L_o=\sum_{i=0}^{k}L_i*(\boldsymbol{n}\cdot\boldsymbol{l})*(K_d+K_s*(\boldsymbol{n}\cdot\boldsymbol{h})^{gloss})

对于k个光源,我们需要读k次albedo和specular的值,但实际上,对同一个像素来说, K_d K_s 作为常数可以提取出来,于是方程改成如下形式:

L_o=K_d\sum_{i=0}^{k}L_i*(\boldsymbol{n}\cdot\boldsymbol{l})+K_s\sum_{i=0}^{k}L_i*(\boldsymbol{n}\cdot\boldsymbol{l})*(\boldsymbol{n}\cdot\boldsymbol{h})^{gloss}

于是我们在lighting时分别存储 \sum_{i=0}^{k}L_i*(\boldsymbol{n}\cdot\boldsymbol{l})\sum_{i=0}^{k}L_i*(\boldsymbol{n}\cdot\boldsymbol{l})(\boldsymbol{n}\cdot\boldsymbol{h})^{gloss} ,之后再加一个shading的流程,在shading时读取K_dK_s 的值,这样,albedo和specular就只需要读取一次,减少了带宽;相应地,我们需要存储两张lighting buffer。当然,我们也可以只存储RGB(Diffuse)和Lum(Specular),然后用 RGB(specular)=\frac{RGB(diff)*Lum(specular)}{Lum(diff)+\epsilon}

来恢复RGB(Specular),以节约lighting buffer的写入带宽。此外,当我们使用菲涅尔项去更好的模拟specular的时候,deferred lighting又变得不可行了,这时候我们只能把 (l\cdot{h}) 近似为 (n\cdot{v}) 。有关deferred lighting的更多细节,可以参考这篇文章[9]和这篇slide[10]

Visibility Buffer/Deferred Texturing[11][12]是在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中曾被使用[13],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中也有使用[13]。另外,这篇silde[14]提到了一种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做相交测试[15]

基于frustum plane的剔除和AABB剔除的结果差异,可以看出基于AABB的方法大大减少了无效的tile

另一个相交测试不准的原因是,场景里每个tile的depth的分布并不是连续的,在[zmin, zmax]区间内的很多深度范围可能是没有像素的,而光源可能正好分布在这个区间内。对于这个问题,分别有两个解决方案,其一是depth split[16],简单说就是把一个tile根据[zmin, zmax]再一分为二,分别计算每个区间的light list,另一个方案叫作2.5D culling[15],就是把当前tile的depth区间分成32段(刚好是一个uint32),再把tile里每个像素的depth映射到其中一段,然后写入一个depthMask,然后根据light bounding box,也把它的深度映射成一个lightMask,根据(depthMask & lightMask == 0)来判断一个tile是否真的和一个light相交。

2.5D culling的算法示意图

除了一般性的相交测试的准确性问题和depth discontinuous的问题之外,这篇文章[17]还提到了结合light volume(也就是光栅器)的结果进一步提高light culling准确率的方法,感兴趣的同学可以读一读。

针对Foward+和TBDR,这篇slide[16]做了一个比较详尽的比较,总体来说结论还比较直观:当光源并不是真的非常多(超过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[18],这个思路给light list的划分增加了一个维度,即depth(当然也可以再增加normal的维度),它根据view frustum的zmin,zmax把场景进一步根据depth划分成若干个slice(基于指数的划分,通常16个),然后在每个slice上对场景中的所有灯光进行light culling,具体的计算方案和tiled based提到的一些方案类似,只是这里不再需要处理深度不连续的问题。所以slice的light list被计算出来后,会经过一个sort和compact的流程[19](基于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[20]。在Siggraph 2017上,这篇slide[21]历数了各种管线和光照的优化方案,并且在指令级别和数据结构的角度提出了许多光照剔除优化的方案,也非常建议大家详细阅读。

其实我常常觉得,哪怕是一个简单的技术,哪怕你每天在和它打交道,或许仍有些许内容你是不了解的。就像本文中的前向渲染和延迟渲染,很难说有那个图形工程师对此没有了解。然而有时候,当你从头梳理这项基本技术的时候,还是会发现许多前人为此付出的努力,并且许多看似过时的技术,当你清楚地理解了它产生的原因和解决的问题后,某些思路仍然能够迁移到其他问题上。

PS新开了微信公众号,名字叫做“扶摇turbulance”,今后有新的文章大概也会转载到那里,欢迎关注。

参考

  1. ^HOW UNREAL RENDERS A FRAME https://interplayoflight.wordpress.com/2017/10/25/how-unreal-renders-a-frame/
  2. ^Leveraging Ray Tracing Hardware Acceleration In Unity https://auzaiffe.files.wordpress.com/2019/05/digital-dragons-leveraging-ray-tracing-hardware-acceleration-in-unity.pdf
  3. ^GTA V - Graphics Study http://www.adriancourreges.com/blog/2015/11/02/gta-v-graphics-study/
  4. ^Reverse engineering the rendering of The Witcher 3 https://astralcode.blogspot.com/2018/11/reverse-engineering-rendering-of.html
  5. ^Z-Prepass Considered Irrelevant http://casual-effects.blogspot.com/2013/08/z-prepass-considered-irrelevant.html
  6. ^CryEngine3: Reaching the speed of light http://advances.realtimerendering.com/s2010/Kaplanyan-CryEngine3%28SIGGRAPH%202010%20Advanced%20RealTime%20Rendering%20Course%29.pdf
  7. ^Compact Normal Storage for small G-Buffers https://aras-p.info/texts/CompactNormalStorage.html
  8. ^Rendering Technologies from Crysis 3 https://www.slideshare.net/TiagoAlexSousa/rendering-technologies-from-crysis-3-gdc-2013
  9. ^deferred lighting approaches http://www.realtimerendering.com/blog/deferred-lighting-approaches/
  10. ^light-prepass https://www.slideshare.net/cagetu/light-prepass
  11. ^The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading http://jcgt.org/published/0002/02/04/paper.pdf
  12. ^BINDLESS TEXTURING FOR DEFERRED RENDERING AND DECALS https://mynameismjp.wordpress.com/2016/03/25/bindless-texturing-for-deferred-rendering-and-decals/
  13. ^abthe-rendering-technology-of-killzone-2 https://www.guerrilla-games.com/read/the-rendering-technology-of-killzone-2
  14. ^Vulkan Multipass mobile deferred done right https://www.khronos.org/assets/uploads/developers/library/2017-khronos-uk-vulkanised/003-Vulkan-Multipass_May17.pdf
  15. ^abOptimizing-tile-based-light-culling https://wickedengine.net/2018/01/10/optimizing-tile-based-light-culling/
  16. ^abAdvanced Visual Effects with DirectX 11: Tiled Rendering Showdown; Forward ++vs. Deferred Rendering https://www.gdcvault.com/play/1017627/Advanced-Visual-Effects-with-DirectX
  17. ^Tiled shading: light culling – reaching the speed of light http://twvideo01.ubm-us.net/o1/vault/gdc2016/Presentations/Zhdan_Sjoholm_Light_culling_MGPU.pdf
  18. ^Practical Clustered Shading https://newq.net/dl/pub/s2015_practical.pdf
  19. ^Clustered Deferred and Forward Shading http://www.cse.chalmers.se/~uffe/clustered_shading_preprint.pdf
  20. ^Rendering Structures https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/5127.siggraph_2D00_2018_2D00_mmg_2D00_3_2D00_rendering_2D00_hanskristian.pdf
  21. ^Improved Culling for Tiled and Clustered Rendering http://advances.realtimerendering.com/s2017/2017_Sig_Improved_Culling_final.pdf
编辑于 2019-11-18

文章被以下专栏收录

    不定期分享自己觉得有趣的图形学内容,喜欢和图形学相关的一切技术,希望结识更多喜欢图形学的朋友,欢迎分享和指正。