ue4 时间轴是什么意思_译:UE4是如何渲染一帧的(1)

原文链接:https://interplayoflight.wordpress.com/2017/10/25/how-unreal-renders-a-frame/

作者:Kostas Anagnostou, Lead Graphics Programmer at Radiant Worlds

这几天我在翻阅UE4的源码。受到一些分析著名游戏如何渲染一帧的文章启发,我考虑对UE4也做些类似的工作,分析UE4是怎么渲染一帧的。

由于UE4源码公开,我们可以分析源码来研究UE4的渲染器是如何工作的。不过UE4的渲染器十分复杂,并且根据场景的不同渲染流程有很多变化,所以有一个(渲染过程中的)清晰、底层的API调用看起来会更加方便一些(遇到搞不明白的地方再去看源码)。

我制作了一个简单的场景,场景包括一些动态的或静态的模型,一些光源,体积雾,透明物体以及粒子效果。这些东西会覆盖到UE4大部分材质和渲染方法。

a0c7db6bd85f825b3db566558e1c7a8f.png

我使用RenderDoc在编辑器中捕捉了一帧。一个实际游戏里的渲染流程和这个可能不一样,但通过捕捉到的数据我们可以粗略地窥见UE4是怎么渲染一帧的。

47eeb28116af5f99958a840cc79227f9.png

声明:以下分析基于GPU捕捉数据以及UE 4.17.1的渲染器代码,(作者)本人先前没有使用UE的经验。如果我漏掉了什么东西,请在评论中让我知晓。

幸运的是UE4的draw call列表非常整洁,并且有良好的注释,这会使我们分析起来更简单。如果你的场景中缺了些材质或者你的渲染质量设置得较低,你捕捉到的draw call列表可能和我的不一样。例如如果你的场景中没有粒子效果,那么ParticalSimulation这个 render pass就不会出现。

SlateUI这个pass包括了所有UE编辑器用于渲染UI的渲染调用,这一部分本文将会忽略,重点关注Scene下的所有render pass。

粒子模拟

UE4的一帧以ParticleSimulation pass开始。这一步在GPU上计算了场景里所有的粒子发射器(emitter)的粒子运动以及其他属性,并将结果输出到两个渲染目标(rendertarget)上,一个格式为RGBA32_Float,保存位置,另一个为RGBA16_Float,保存速度以及其他一些和粒子时间/生存周期相关的数据。下图展示了RGBA32_Float格式的渲染目标保存的数据,每一个像素代表了一个sprite的世界坐标。

43a3fe9bea7bd8ae983f030412cec7b3.png

我在场景中添加的粒子效果似乎有两个emitter在GPU上模拟时不需要进行碰撞检测,所以可以在每一帧较早的时候运行其对应的render pass。

Z-Prepass

接下来就是PrePass流程,这一步其实就是z-prepass,将所有不透明物体渲染到一个R24G8的深度缓冲中。

5b168033edc7f986b539d412cf4154c6.png

值得注意的是UE4使用reverse-Z来保存深度,意味着近裁面的深度值为1,远裁面的深度值为0。这使得深度缓冲的精度更高,避免在远处发生z-fighting的现象。从该pass的名字可以看出这一步是由“DBuffer”触发的。DBuffer是UE4用来保存延迟贴花(deferred decal)的缓冲,这一步需要场景深度,所以会启动Z-prepass。这个Z-buffer还会用在其他地方,例如遮挡检测和屏幕空间反射,这些我们会在之后提及。

Draw call列表中的一些渲染pass似乎是空的,例如ResolveSceneDepth,这一步我猜测是用于那些需要在使用纹理前resolve渲染目标的平台(PC平台不需要);又比如ShadowFrustumQueries,这一步看起来是个傀儡标记,因为真正的阴影遮挡测试发生在写一个渲染pass中。

遮挡检测

BeginOcclusionTests负责一帧中所有遮挡测试。UE4默认使用硬件遮挡查询(hardware occlusion queries)来进行遮挡测试。简而言之分为3步:

  1. 将所有被标记为遮挡体的物体(例如一个较大的solid mesh)渲染进一个深度缓冲。

  2. 创建一个遮挡查询(occlusion query),提交该查询并渲染那些我们希望查询遮挡情况的模型。这一步使用硬件深度测试(z-test)以及我们在第一步中创建的深度缓冲。遮挡查询将返回通过深度测试的像素数量,如果结果是0就意味着该物体完全被solid mesh遮挡。由于为了深度测试而去把完整的模型渲染一遍的开销很高,这一步我们渲染模型的包围盒,而不是原模型,如果该包围盒不可见(也就是没通过深度测试),那么该包围盒所代表的模型肯定也不可见。

  3. 将查询结果读回CPU,根据被渲染像素的数量我们决定是否提交模型给GPU渲染(即便是有一小部分像素可见我们也可以不读渲染这个模型)。

UE4根据具体情况决定使用哪一类遮挡查询:

ee44756836510782ead558924e241539.png

硬件遮挡查询有诸多劣势,例如有drawcall粒度上的问题,渲染器需要对每一个模型(或者一个模型批次)提交一个drawcall来进行遮挡查询,这会使得每一帧的drawcall数量显著上升;还有一个问题是硬件遮挡查询需要将结果读回到CPU,这就需要在CPU和GPU之间同步,并且要求CPU一直等待到GPU完成查询处理的时刻。这对instanced物体并不友好,但在这里我们先忽略这个问题。

对于CPU与GPU间的同步问题,UE4使用和其他引擎类似的方法:将CPU对数据的读回操作延迟几帧进行。这个方法大部分情况下可行,但在摄像机高速移动的时候可能会导致物体的突然出现(pop in)(实践中这不是个大问题,因为物体在遮挡剔除时使用包围盒来计算遮挡,这一步是保守,即便完全不可见的物体也可能被标记为可见)。额外的drawcall开销依然存在,但这个问题也是可以解决的。UE4通过以下方法来减轻这个问题的影响:

  • 首先所有物体会被渲染到深度缓冲。(也就是之前提到的这一过程)

  • 对于所有需要遮挡测试的物体向GPU提交一个遮挡查询请求。

  • 在每一帧的最后,CPU从前一帧(或者更加前面的帧)读回物体的可见性结果。如果物体是可见的就将物体标为在下一帧需要渲染。对于不可见的物体,将其加入一个“分组”的查询中,该查询会以批次提交最高8个物体的包围盒组,测试这些物体在下一帧是否可见。

  • 如果整个分组在下一帧变为可见,那么再将整个组重新分离为独立的遮挡查询并提交。

如果相机和物体是静止的(或者缓慢移动),这一优化会将必要的遮挡查询数量减少8倍。唯一一个我注意到的奇怪地方是被遮挡物体的批次查询组合方式似乎是随机的,而不是基于物体在空间上的距离。

这一步对应于上图中的IndividualQueries和GroupedQueries标记。GroupedQueries在这一帧是空的,因为UE4没有在前一帧中找到任何需要这一操作的物体。

在整个遮挡剔除pass的最后,ShadowFrustumQueries提交所有针对本地光源(local light,也就是点光源或者聚光灯)的包围盒的遮挡查询(无论光源是否投影都会提交,和这一步的名字所表达的意思不同),如果某个光源被完全遮挡住了那么就没必要去对该光源进行任何光照/投影计算。值得注意的是我们的示例场景中有4个点光源(每一帧每个光源都需要计算shadowmap),但是ShadowFrustumQueries这一步提交的查询数量为3。我猜测这是因为其中一个光源的包围盒和相机近裁面相交,因此UE4认为该光源必然可见。另一点值得一提,对于一个需要计算cubemap shadowmap的动态光源,UE4会提交一个球体来进行遮挡测试。

309624818316e83835256d56ff6ac9d1.png

对于需要计算逐物体阴影的静止动态光源(之后会有更详细的介绍),UE4会提交一个视锥体来进行遮挡检测:

1e178de6ce7ca298b0b8a2a8c94ade2c.png

最后对于PlanarReflectionQueries这一步,我估计是指用于计算平面反射(planar reflection)的遮挡剔除计算(方法是将相机变换到渲染平面之后/之下在重新绘制物体)。

Hi-Z缓冲的生成

接下来,UE4会创建一个Hi-Z缓冲(passes HZB SetupMipXX),存储格式为16位浮点数(R16_Float)。这一步将Z-prepass阶段得到的深度缓冲作为输入创建一个深度值的mipmap链(mipmap chain)。这一步还会将深度重新采样为分辨率大小为2的幂次数的纹理,这样用起来更方便。

007f738abc711d3aaff29a2d18891433.png
c7dd3a8decde8a336f48e6fe026cdcc6.png
86e20da178bd2bb0cb2b930efca0c4e3.png
31cbb7ab20472222d34ebef6d5caa9f3.png

之前提到,由于UE4使用reverse-Z,pixel shader在降采样时使用最小值操作符(译者注:也就是指每次降采样时选取邻域内深度值最小的像素输出到下一个mipmap)。

阴影的渲染

接下来一步是阴影计算render pass(ShadowDepths)。

eaae7a34b933c188a7561d957c71b8cf.png

静态(stationary)的平行光,两个可移动(movable)的点光源以及一个静止(static)的点光源。所有光源都会计算阴影。

54814a2f672c467473a3e13f66a9f500.png

对于静态光源,渲染器会为静态物体烘焙阴影,并为动态物体计算阴影。对于可移动的光源每一帧都需要为所有物体计算阴影(完全动态)。最终对于静态物体其阴影会被烘焙入光照贴图(lightmap),所以这些阴影在渲染中不会出现。

对于平行光我添加了分三个层级的级联阴影(cascaded shadowmaps),以观察UE4是怎么处理这个功能的。UE4创建了一个3x1的格式为R16_TYPELESS的纹理(每行3个tile,每层阴影一个),每一帧清除一次(意味着每一帧所有层都要更新,而不会有隔帧更新之类的优化)。随后,在Atlas0 render pass中所有物体会被渲染进对应的阴影tile中。

8a6d3c09bedf052fa08a623c2607f77c.png

从上面的drawcall列表可看出只有Split0需要渲染一些物体,其他块是空的。阴影在渲染时无需pixel shader,这能使得阴影的渲染速度翻倍。值得注意的是无论平行光是静止的还是动态的,渲染器会将所有物体(包括静态物体)都渲染到阴影贴图中。

接下来是Atlas1 render pass,这一步将渲染所有静态点光源的阴影。在我的场景中只有那块岩石模型被标记为动态物体。对于静态光源的动态物体,UE4使用逐物体阴影贴图,保存在一个纹理图集(texture atlas)中,意味着对于每一个光源,每一个物体都会渲染一个shadowmap。

05dca42df21003d10b4f9fbcba9e9286.png

最后,对于动态光源,UE4使用传统的立方体阴影(cubemap shadowmap,在CubemapXX passes中),使用一个geometry shader来选择要渲染到cubemap的哪个面上(以减少draw call)。在这一步只渲染动态物体,所有静态物体会被缓存起来。CopyCachedShadowMap这一步会把阴影缓存复制进来,然后在此之上渲染动态物体的阴影深度。下图是一个动态光源的立方体阴影缓存中一个面的内容(CopyCachedShadowMap这一步的输出)

4e66524384b49b322fc6b66deabd6c39.png

这是渲染了动态物体(石头)后的结果:

5b09cf46d3d197c9d7f5ab403992777a.png

静态物体的阴影缓存不会再每一帧重新生成,因为渲染器知道(我们场景中的)这一光源没有移动(尽管被标记为动态光源)。如果光源移动了,渲染器会在每一帧渲染动态物体前把所有静态物体重新绘制入阴影缓存中(这一步我在另一个测试中证实):

45611d1ef8a9f2495629235f9acdb610.png

唯一一个静态光源(static light)完全没有出现在drawcall列表中,意味着这个光源不会影响动态物体,只会通过光照贴图去影响静态物体。

在本文最后提个建议,如果在你的场景中有静态光源(stationary light)请确保在编辑器中测试性能前烘焙光照(我不确定在standalone模式下运行时是否需要这样),如果不烘焙的话UE4会将它当做动态光源并渲染立方体阴影,而不是逐物体阴影。

在下一篇中我们会继续探索UE4的渲染流程,考察light grid生成,G-prepass和光照这些渲染步骤。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值