前言
略
1 定义BentNormal
UE: Distance Field AO produces a bent normal which is the direction of least occlusion.
BentNormal是一个向量,具有方向属性和模长属性,它的朝向一定会落在物体表面宏观法线所指向的半球空域内,但又与宏观法线含义不同,它指向的是该空域内最开阔(不受遮挡)的一片区域的中心处,而其模长的大小又对应了周围几何环境遮蔽能力的强弱,模长数值越大说明遮蔽效果越弱,附近环境越开阔,反之则说明遮蔽越强,附近空域被遮挡得越明显。
如果只从BentNormal的定义出发理解,我们可能很难具象的客观的认识到其重要性,似乎挺“有用”,但所谓的“有用”到底体现在哪儿呢?在我们深入分析如何计算和构建BentNormal之前,不妨先从应用端入手作一番梳理,对BentNormal赋予光影,特别是环境光形成的光影的重要意义做一点了解。
2 BentNorm与全局光照
2.1 回顾主流实时全局光照的底层原理
在渲染实时全局光照时,继续计算性能的限制,人们必须对原始光照模型进行大刀阔斧的简化。比如UE在Uber_GI_Pass中应用的方案就是基于业界主流的IBL(Image Based Lighting)理论实现的。所谓IBL,就是基于图片的光照技术,把复杂的光线传播过程的最终结果离线烘焙成不同方位上的辐照度,使用时直接按照方向采样即可。具体而言,原始的环境光光照模型可以被归类为两类,其一是漫反射项,公式如下:
kd
是染色项,fd
是漫反射的BRDF项,NoL
代表入射光与宏观法线夹角的余弦。
其二是高光反射,公式如下:
fs
是BRDF项,一般采用Cook-Torrence模型,其余与漫反射项相似。
因为两式共有的光照项“Li”(incident lighting)与其余项(主要是表面材质属性)之间彼此独立,故而可以拆分和转化[1]为两个独立的积分结果的乘积,加速计算的key idea就是分别将包含入射光强的部分和包含BRDF项的部分预积分,再将所得数据存入预制纹理中供运行时快速读取。具体细节不是今天重点,就不再赘述了,总之只要知道,在实时运算时,所有烘焙出来的场景或天空盒对应的纹理贴图(一般存放在TextureCube中),都是用来保存与辐照度(Li)有关的数值的;于此对应的另一种红绿相间双通道纹理图(习惯叫做LUT查找表)存放的则是预计算好的BRDF积分项,使用时通过粗糙度Roughness
和法线与视方向的点积NoV
进行采样,返回的2个通道数值分别代表了反解公式 : fs = F0 * Scale + Bias
中的Scale
和Bias
。当然在计算Ld
(diffuse lighting,或环境光漫反射辐射出射度)时是不会使用到LUT查找表的,因为漫反射出射光沿着各个方向的分布是均匀的,故而其fd
项(BRDF项)为均值函数。
[备注1] 原本在统一的积分号下面求解Li
和fs
项,鉴于入射光强Li
并不依赖于任何表面属性,且积分求解不同方向的光强之和在实时运算时开销巨大。因此被拆分了出来,同时拆分后产生的2个独立积分项分别使用蒙特卡洛积分将原先对半球空域的积分转化为基于某项概率密度的采样求和,使问题最终离散化,让预积分成为可能。
2.2 BentNorm在环境光中的定位
就像实时的直接光照那样,实时的全局光照也有“光源”,只不过这个光源的本质是环境中各种物件多次反射和折射外界入射光照的结果,对应了光照公式中的Li
,需要提前积分到纹理中。对于直接光照,我们有类似PCSS, PCF, CSM,ContactShadow这样的技术去生成阴影衰减(Shadow Attenuation)从而为直接光提供遮蔽;同样的道理,全局光照的“光源”也需要类似的Shadow Map提供遮蔽,而BentNorm(相同的还有SSAO,LightMap中的某些通道等)就是干这活的,只不过这些遮蔽本身的计算还有参与运算的方式远比Shadow Attenuation复杂得多。
下图简述了UE在整个全局光照pass的大体流程,以及所使用的主要数据源,其中就有BentNorm,不难看出它的定位与SSAO类似。
我们先从最宏观的角度切入,如右图全流程所示,环境光Pass的主体在“数据准备阶段”之后,UE把整个环境光计算(GI Lighting)分成了漫反射部分和高光反射部分,分别对应名为SkyLightDiffuse和ReflectionEnvironment的两个函数。这两个函数的主要入参来自左图数据准备阶段的产物,也就是漫反射底色的DiffuseColor,可作为菲尼尔系数F0
的SpecularColor,屏幕空间环境光遮蔽SSAO,以及 BentNorm(我将一直加黑加斜它以示区别)。在处理环境光漫反射时(SkyLightDiffuse函数),使用了除了SpecularColor之外的所有参数(DiffuseColor,AO,BentNorm),采用的光源为全局唯一的光照探针,以球谐形式存储,对应天光的分布;另一方面,在处理环境光高光时(ReflectionEnvironment函数),UE使用了除DiffuseColor之外的所有其余参数(SpecularColor,AO,BentNorm),使用的光源包含了多组反射探针以及天空盒探针,均以TextureCube的形式存放。由此可见AO和BentNorm作为重要的光照遮蔽参数,通体贯穿了整个GI的计算流程。
2.3 BentNorm与GI中的漫反射项
我们首先来看漫反射部分,精简的计算流程可参考下图:
相对来说环境光漫反射项的求解比较简单,我们只需要关注2个重要的中间变量:Irradiance和ScalarFactor即可。
Irradiance定义了渲染点所在的那个几何空间内单位平面在法线确定的半球空域内的总的辐射入射通量,也就是辐照度(Irradiance),由于是均匀积分的半球空域,所以Irradiance一般反映的都是低频信息,UE将其编码到了一组球谐系数中(完整1+3+5 = 9个)。不过在采样时UE并没有简单的使用世界空间法线,而是使用了BentNorm和WorldNorm经过某种可见性参数(SkyVisiblility)调和后的方向,可以这么理解,当区域环境遮挡复杂时,选择BentNorm所指向的方向,而当区域开阔时选择宏观法线方向,而可见性参数(SkyVisiblility)本身又是一个与BentNorm的模长还有SSAO相关的量。
ScalarFactor类似可见性参数(SkyVisiblility),可以理解其为某种综合性的AO,在数值上被归一化到了[0,1]区间,而使用它的目的则是为了控制辐照度的强弱,非常类似Shadow Attenuation之于直接光照的作用。如图所示,计算ScalerFactor同样需要用到BentNorm的模长(本身就反映了可见性程度),通过复杂的函数映射关系(用到了自然数为底的幂函数),最后将结果与SSAO相乘求得ScalarFactor。UE除了对采样得到的环境光进行了衰减处理外,还对被衰减掉的部分做了染色和补偿,使用一个预设的遮蔽色调常量(OcclusionTint)对暗部进行颜色再填充,从而拉开了暗部颜色与光照颜色之间的差距,丰富了色彩层次,同时避免死黑。当然到此为止计算的只是输入光照(Incident Lighting),我们在最后还需要乘上一个反映可漫反射颜色的量(DiffuseColor),这个数值计算自MRT Pass,源于Albedo贴图,此颜色一方面起到压制光强的效果(对应了电介质对光能的吸收),另一方面也给环境光染上材质固有色。最后得到的就是漫反射的辐射率(radiance),它代表了单位面积上沿着视方向立体角内出射和反射的总光通量。
2.4 BentNorm与GI中的高光反射项
高光项的计算相对而言比较复杂,我将其拆分为三个方面单独叙述,分别是:
- 来自实时计算的镜面反射,
- 来自场景的预烘焙IBL,
- 以及天空盒预烘焙的IBL。
其中场景IBL可能涉及多张纹理的采样,因此需要在运行时确定不同IBL之间的作用范围,这部分操作比较繁琐和复杂,简言之,UE会提前将NDC空间划分为若干个3D Tile,再利用Tile与散布在场景中的IBL包围体逐个求交,从而确定实际影响范围,形成快速查找表,从而在Uber Pass的像素着色阶段可以加速定位到合适的IBL纹理队列。当然这只是一些工程化的问题,与今天的主题并无太大关系,所以具体就不再赘述了。
镜面反射是最简单的部分,它只需用屏幕UV采样一张在先前Pass中完成渲染的全屏RT即可,RT内容基于屏幕空间反射(SSR),此SSR的计算介于Uber GI Pass之前Uber AmbientCube Pass之后,而此RT的采样返回值可以直接作为反射的环境光的一部分呈现出来(直接累加在最终输出结果上),因此并无过多可说。
之前的SSR只针对具有镜面反射属性的材质才有效,而绝大部分游戏场景中的环境光高光反射都来自于对预烘焙的反射纹理。我们以上图中的Scene IBL为例,公式的前两项合并起来所求得的是环境光高光的辐照度(Incident Lighting),其中变量AccumulatedIrradiance是基于当前渲染点的视方向反射方向采样IBL获得,Accumulated代表累加,意味着如果有多张IBL共同作用到同一个渲染点,不同IBL的采样返回值会累加到这个变量上。另一项EnergyBalanceWeight则是UE为了配平能量而引入的权重值,这两项都与我们的BentNorm有关,具体稍后探讨。
上图公式的后半部分是非常标准的IBL计算流程:fs = F0 * Scale + Bias
,此项用于解码预积分的BRDF函数。
承接上文提及的 AccumulatedIrradiance 和 EnergyBalanceWeight 两个参数,它们的计算过程被我总结到了“环境光高光项计算(IBL #2)”中,包含上下两部分:上方的主循环用于遍历所有受影响的IBL,过程中负责迭代和累加关键数据。
循环内具体任务可依据图中Loop框内从左到右的顺序理解:UE首先使用视方向的反射方向[2]采样一张记录着IBL数据的TextureCube,所得返回值(预积分的入射辐照度)则被累加到2个不同用处的工作变量上。其中一支通过Lumen函数提取到了入射辐照度的光强(一个标量),随后累计到全局的平均亮度(AverageBrightness)上备用,可以认为全局平均亮度是当前像素点实际上受到的光照能量的总和。另一支则会让间接光高光遮蔽因子(IndirectSpecularOcclusion)作用到采样返回的辐照度上,然后将这个经过衰减的辐照度累加到上文提及的AccumulatedIrradiance变量上,注意这里是第一次对采样光源进行衰减,但不是唯一的一次。
那么这个叫做IndirectSpecularOcclusion的间接光高光遮蔽因子到底长啥样呢?参考图中下半部分左侧,这个因子自然和BentNorm离不开关系:间接光高光遮蔽是一种反映了BentNorm所在椎体(cone)和采样反射方向所在椎体(cone)之间相似性的度量衡,而遮蔽强弱与之呈现逆相关的趋势。举个例子就很好理解,当BentNorm与反射方向(Reflection Dir)趋同时,说明反射方向视野相对开阔无遮挡,因此遮蔽强度就低下,反之亦然。
至于循环采样中产生的另外一个变量AverageBrightness则是用来计算上文提及的EnergyBalanceWeight的必要参数,具体方法参考图中下半部分右侧:UE引入能量守恒权重主要是为了平衡不同像素点,以及不同数量IBL光源下产生的能量差异,将总的输入光能控制在一个合理幅度内。从代码分析,UE使用的方法相对简单粗暴:守恒因子直接来自于“预估能量”和“真实能量”间的比值。UE以预估能量(图中IndirectIrradiance)为基准,如果真实能量超过了这个临界,那就需要通过权重下压实际接收的光强,反之则通过权重弥补光强缺失,使之趋向预估能量。而所谓的“预估能量”也不是什么后处理阶段获得的高科技,其主体来自于GBuffer,这意味着在MRT Pass阶段UE就对每个像素点估算了这一数值,换言之,“预估能量”来自于由艺术家控制的各种参数,这些参数需要具有全局统一性,从而使游戏整体色调保持一致。当然“预估能量”也有一部分来自于编码在球谐系数中的天光成分(采样向量是BentNorm),不过占比不大就是了。
[备注2] 反射方向是基于标准反射方向和BentNorm的插值获得,插值比例基于粗糙度。
总的来说,BentNorm在整个GI pass中主要用于2个方面,其一是确定采样方向,其二,通过构造各种遮蔽因子直接或间接的影响入射光强,相对于色彩层面而言,UE利用BentNorm极大丰富了游戏画面中层次变化和明暗起伏,同时相较于SSAO,BentNorm能提升物理正确的视觉立体感。我们参考如下对比,仅仅将BentNorm替换成普通的归一化后的WorldNormal(下右图),对比正常流程(下左图)展现出来的效果,能明显感受到画面在立体层次和真实感上的退化。
[注意,上面对比图都包含有SSAO,差异仅限于BentNorm的有无]
构造BentNorm
3.1 基于屏幕空间信息的BentNorm计算方案
在非烘焙前提下,计算BentNorm的流派主要分两类:
- 基于SDF和光线步进的;
- 基于存粹屏幕空间信息,无需光线步进的。
什么是不基于光线步进的?我们拿《Bent Normals and Cones in Screen-space》这篇来自Intel Visual Computing Institute的论文来解析下便知,思想很简单,不费脑。
BentNorm指向最开阔的区域,那么怎么判断哪里最开阔呢,正常来说需要首先知道周围区域的可见性概率分布,再通过积分或采样求加权和的方式找到那个方向。论文基于屏幕空间信息,因此直接使用了SSAO的结果来近似这种概率分布,于是有了下面的公式:
其中作用域P代表像素点及其相邻的一圈周边区域,Δij代表了从当前像素点i(所在世界坐标)指向周围邻域像素点j(所在世界坐标)的向量,一旁的d代表了可见性概率分布函数(Probability Distribution),其本质是像素点j对应的SSAO遮蔽强度,最终的BentNormal来自于所有相关邻域内朝向和强度的归一化累加和。
这种方式实现BentNorm的好处是简单高效,无需复杂的光线步进计算,也不用费神去维持三维空间中的可见性概率场(等效于SDF)。但是劣势也是显而易见的,首先此类BentNorm在本质上是脱胎于SSAO的,而其效果类似于对SSAO做了一次滤波和虚化,并没有从根本上改善SSAO本身的局限性。具体来说由于计算和采样均来自于屏幕空间,输出结果自然是基于目标几何体位于相机观察方向上的投影,这种切片信息并不能正确的反应几何体之间的遮蔽关系,这就使得据此计算的BentNorm本身会带来较大误差,这种误差随观察角度不同而起伏不定,给实时渲染带来新的挑战。
3.2 基于SDF和光线步进的BentNorm计算方案
基于光线步进生成的BentNorm可以得到更加物理正确的结果,然而通常意义上的光线步进代价巨大,直到SDF(有向距离场)被广泛应用前,不依赖硬件加速的实时光线步进仍然是奢侈和昂贵的代名词。我们来看看UE自4.25版本后通过DFAO(Distance Field Ambient Occlusion)构建的BentNorm是如何利用SDF、光线步进以及多种其他手段加速计算的。
3.2.1 BentNorm计算流程
上图是UE从无到有计算出一张完整的BentNorm全屏RT的过程,中间略去了动态生成SDF的过程(稍后会说)。总的来讲执行流程可以分为3个阶段:
- 光线步进采样阶段; <--
- 方向可见性纹理的合并阶段;
- 以及最后的上采样+滤波阶段。
首先是光线步进采样阶段,整个过程是在SDF加速的情况下完成的,但远不止此,因为UE如果构建SDF只是想去渲染所谓的“静态”BentNorm,那么真还不如直接预烘焙BentNorm到模型的顶点数据上,这样在运行时性能是最高的。事实上,UE充分利用了SDF在处理静态和动态物体上的便利特性,将光线步进采样过程拆分成了:
1)专门处理全局“静态”有向距离场(Global SDF)的ConeTraceGlocalOcclusionCS,以及,
2)负责整合“动态”物体对当前BentNorm产生影响的ConeTraceObjectOcclusionCS,
这两个计算Pass。
当然在进入光线步进算法前,还有一些UE做出的性能优化点值得一提,因为即便有SDF加速,在GPU里循环步进仍然消耗颇大。
UE的优化方案中里个人感觉效果最显著的就属降低计算/采样分辨率了。确切的说只要是涉及到光线步进的Pass,都是在相对于原生分辨率的1/8尺寸上进行的。举个例子,假设原生游戏运行在[1706 * 960]的分辨率上(差不多是旗舰手机运行原神的分辨率),那么实际上执行光线步进采样时的屏幕分辨率会降为[214,120],从实际生成效果来看,1/8的尺寸渲染的结果经过升采样后任然能够保留环境光遮蔽的大部分低频信息, 而环境光遮蔽的高频信息或者叫细节信息相较于低频信息而言并不那么重要,对画面层次和立体感提升亦不明显,因而这种大胆降低采样分辨率舍弃细节提高性能的做法是一种比较成功的取舍(Trade Off)。当然这也得感谢场景美术的支持,感谢他们没有把环境搭建得过分复杂凌乱,制造太多引人注意的细节 :)
UE的另一个优化点大家应该也很熟悉,既“细节级别渐变”或LOD(Level of Details)。对全局静态的物体来说,UE在ConeTraceGlocalOcclusionCS计算Pass的最开始会优先确定当前渲染点所属的LOD层级,并从三张相同像素尺寸(128^3)但是覆盖不同范围热点区域的Texture3D中选出一张进行光线步进采样。
计算LOD的过程大致可归纳如下:
1)计算渲染点(图中Shading Point)到每一层LOD对应的AABB的最小距离,如果渲染点位于AABB之外,这个最小距离就是负数,将不参与后续运算。上图中黄色渲染点针对蓝色LOD1和红色LOD2的AABB分别有最小距离,记为:{min distance 1}和{min distance 2}。
2)然后从内到外先后比较{min distance}距离,如果满足大于某个预设的阈值,比如要求{min distance 2} > DFAOSearchingDistance,就选择这个LOD级别的SDF进行采样,对应图中也是{min distance 2}。
这样做的理由是:确保选择的LOD能包裹住以渲染点为圆心,DFAOSearchingDistance为半径的圆,如此一来在后续光线步进步进中,如果以DFAOSearchingDistance为最远探索距离,那么就不会在运算过程中超出当前SDF对应AABB的边界,从而避免采样数据失真,引发跳变。
确定了工作分辨率和采样纹理之后就进入光线步进阶段,所谓光线步进只是手段,目的是为了寻找出射光路上潜在遮蔽物之于步进点的距离,从而评估遮蔽效果的强弱。UE认为从渲染点引出的一条长度为DFAOSearchingDistance的射线所产生的遮蔽强度,与这条射线形成的光锥被周边障碍物所侵占的程度成正比。如下图所示:
这里有3个疑问点:
- 如何定义侵占程度,
- 如何定义光锥角度,
- 如何定义最大搜索距离?
首先侵占程度是这样计算的:由于光锥是在光线步进过程中逐渐向射线方向前进的,因此每次步进点确定后,UE都可以从步进点出发,以当前光锥的底边为直径画一个球体,假设半径为R。由于光锥同样处于有向距离场附近,UE能通过采样SDF确定当前步进点到最近障碍物的最短距离D,那么对于当前步进点而言,这个侵占程度就可以表示为一个关于R和D的函数的返回值:Func(R, D),这个函数是开放的,最简单的方式可直接定义为:saturate(R/D - 1) ,最后通过比对每次步进过程中计算得到的侵占程度采用最大的一组数值,这就是可见性(Visibility)强度了。
光锥的角度其实和采样射线的多寡有关,UE的目的是通过少数几条采样射线覆盖竟可能多的法向半球空域,那么如果只有一条探测射线的话,这个Cone Angle势必会很大,如上图所示,非常接近90°;而如果有多达9条类似的探测射线,这个Cone Angle可以相对较小,大约在30°附近。
最后是最大探索距离,这与游戏场景的设计尺寸相关,以Kena为例,人物的身高大约在1.5米左右,那么DFAOSearchingDistance被设置为10米是相对合理的。
总之光路上展开的光锥(Cone)是否能向远处延伸足够开阔的距离而不被遮挡,是衡量可见性强度(Visibility)的必要条件。
接下来我们不妨看看SDF加速光线步进时,在处理全局“静态”有向距离场(Global SDF)过程中计算Pass: ConeTraceGlocalOcclusionCS的具体示例,参考下图:
该Pass在寻找遮蔽强度时是围绕着两层For循环展开的:
1)第一层循环需要遍历预设好的9个朝向,这些朝向被统一存放在了切线空间中,使用时需要用到当前渲染点的宏观法线,构建出TBN矩阵,然后才能将这9个预设朝向变换到世界空间中。每个朝向都确定了一道采样射线,对应上图中Ray marching #1 ~ 9,配合上各自的Cone Angle,可以覆盖物体表面法向半球的大部分区域,所得结果汇总后用于评估渲染点的真实遮蔽强度。注意,为了便于后续的修改和累加计算,每一个方向上的遮蔽强度都会被单独保存起来,这相当于创造了9张[214,120]尺寸的纹理,具体后文会展示和说明。
2)第二层循环负责具体执行光线步进(Ray marching),相对于传统的固定步长或固定比例的步进方法,利用SDF数据确定步进幅度能极大提高光线与物体碰撞检测的效率,同时也能自适应各种复杂环境,彻底避免因为固定数值步长带来的采样不连续问题(摩尔纹)。如上图Ray marching #1的蓝色步进箭头所示,假设起始步长step1,我们可以在SDF数据立方阵中确定一个具体的体素,由距离场定义可知体素内存放的是距离当前位置最近的物体的距离,比如求得{SDF:1.23},图中step2就可以使用1.23作为步长,这是个安全的步进距离。可以想见,如果光路开阔,那么每次迭代采样的SDF数值将非常大,可能1~2次步进就能达到预设的最长探索距离DFAOSearchingDistance,从而提前完成取样和遮蔽估算;另一方面如果光路崎岖狭小,那么也会很快遇到SDF非常小的数值,从而满足碰撞阈值,也会触发提前退出;最后对于中不溜的情况,UE设置了最大10次迭代的上限用于控制总的步进耗时。正常迭代过程中,每次采样的SDF值都会转换为可见性强度,通过与当前记录中的强度对比,保留较小的强度值作为最终输出。
前文提及过,UE还会整合“动态”物体对当前BentNorm产生的影响,这是通过计算Pass:ConeTraceObjectOcclusionCS实现的,其基本原理仍然仰仗于SDF和光线步进,只是处理方式与前文的ConeTraceGlocalOcclusionCS有很大不同,一个很简单的道理:动态物体不适合强行塞入静态场(Global SDF Volume)中,否则必然频繁触发纹理的全面刷新。
UE在处理动态物体时,会通过逐物体计算的方式(For循环遍历所有关联物体),对前面生成的9个方向的输出纹理进行必要的更新和修改,从流程图上可知,ConeTraceGlocalOcclusionCS这个计算Pass依赖2份独立的数据源:
1)一份数据经由ObjectCullVS和PS获得,负责对动态物体执行基于Tile的剔除逻辑,它的输出是一份清单用来指导计算Pass中的每一个线程组(Thread Group),告诉它们到底有哪些屏幕空间瓦块(ScreenTiles)会与当前待处理的运动物体在空间中存在交集,需要重点关照。究其原因,UE为了加速计算必须做预过滤,尽可能去除无效计算,其结果就是UE会在原始分辨率[1706,960]的1/8尺度上再次降低1/4,既从[214,120]再次降低到了[54, 30]大小,形成所谓的ScreenTile,然后在这个尺寸上对每一个待处理的动态物体进行归类,形成记录了:物体索引 -> 关联ScreenTiles队列 的映射表,这份列表的生成逻辑我们后面会有梳理,目前先按下不表。
2)动态物体计算Pass所依赖的另一份数据源如下图所示,是一份预先烘焙好的巨大3D纹理,里面存放了成百上千份不同几何尺寸的物体的模型空间SDF数据。
可以认为这些被烘焙对象是参与计算SDF的所有(动态或静态)的游戏对象,除此之外还有一张索引表指向了这些物体在游戏场景中实例对象的transform信息。与处理静态对象时直接使用生成出来的能够完整覆盖热点区域的SDF有所不同,UE在面对动态物体时不使用全局的有向距离场(Global SDF),更加不会每帧去更新它,而是使用这张Local SDF Texture3D,同时匹配上每个运动物体在当前帧的transform信息(变换矩阵和坐标朝向等),具体做法是:在执行采样前,先一步将当前的光线步进点的世界空间位置变换到目标物体的“Local SDF 烘焙空间”中去(世界上等效于模型空间),这样后续的采样和计算只需要做到如下几步:
- 计算步进点到AABB的最短距离d,确定在AABB上的交点s;
- 读取交点s处SDF值作为包围盒内部到达物体表面的距离d';
- 将d'累加上包围盒外部最短距离d上,得到步进点到物体的“近似”距离。
具体参考下图:
图中展示了从渲染点发出的一条射线在经历4次步进采样,达到最远搜索距离后,估算最终“可见性强度”的过程。
与前文提及的可见性强度计算公式一致:遮蔽强度(OcclusionFactor)OF= saturate(R/D - 1)
,只不过作为步进点到物体的距离采用了D = d + d'
的估算值替代。回顾R,是以步进点为圆心形成的光锥剖面圆的半径(如上图中Half Cone所示)。所以具体而言,当障碍物距离步进点足够远时(D >> R
),OF
恒为0,表示没有遮蔽;当障碍物距离步进点非常近时(D -> 0
),OF
恒为1,表示完全遮蔽;当障碍物距离从R
滑向0时,OF
对应从0上涨到1,代表遮蔽强度随距离逼近(不断侵占光锥)而上涨。
具体参考下表:
可以看出,在第四次步进中,我们估算出了最小的可见性强度(0.44
),这主要是因为目标物体挤占了光锥较远处的空域,导致光路传播不畅,形成遮蔽。
最后对光线步进采样部分做个总结:在经历了ConeTraceGlocalOcclusionCS和ConeTraceObjectOcclusionCS这两个计算pass之后,UE生成了一张如下图所示的位于屏幕空间,它们分布基于物体表面9
个不同朝向,通过步进求解可见性方式获得的强度分布,期间先后考虑了静态物体和动态物体对可见性强度分布施加的影响。
接下来的2步相对简单许多,参考之前梳理BentNorm生成流程中的第二和第三项:
1)光线步进采样阶段;
2)方向可见性纹理的合并阶段;<--
3)以及最后的上采样+滤波阶段。<--
UE通过接下来的计算Pass:CombineConeVisibilityCS,合并可见性纹理,方式是对9
个方向的加权和,权重对应到每张纹理中记录的可见性强度,最终形成的是一张在1/8原始尺寸([213, 120]分辨率)上的BentNorm母图。另外强调一点,因为是方向向量参与合并,故而最终输出是带有RGB三个通道数值的彩色纹理,参考如下。
流程图中最后的2个pass分别完成了上采样 + 滤波。以母图为基础,还原出完整尺寸的BentNorm。
其中第一个pass叫做:UpdateHistoryDepthRejctionPS,负责全屏上采样,其核心又可分为两个部分:
首先是基于方法GeometryAwareUpsample的上采样,该函数的输入端主要由:BentNormal母图([213, 120]分辨率),全分辨率的深度(Depth)和法线(WorldNormal)构成。而上采样必然涉及到多邻域插值,一般是在当前ScreenUV相邻的4个低分辨率像素构成的PixelQuad之间进行,而插值又涉及了权重的调配,从代码分析,UE基于如下几类差异来确定权重的:
1)像素UV方向的差异,
2)像素对应的几何表面朝向(normal)差异,
3)像素对应的几何表面视深度(depth)差异,
4)邻域内采样点到目标采样点所在平面距离的差异。在几何上采样完成后,该Pass的另一个核心任务是将当前获取的BentNormal数据与历史帧数据做混合(参考下图),所谓历史帧数据主要有:HistoryBentNorm,VelocityTexture,PreClipMatrix等,而混合因子来自历史坐标和当前坐标的差异(由两帧对应点的距离和深度等因素综合计算得出)。正常情况下(对应像素点之间能够建立历史联系时),UE选择的混合比例为 8 : 2,历史占8,当前占2;而当不满足混合前提时(主要鉴别手段是深度差异,和颜色变化幅度),UE会利用纹理的A通道标记该像素,留到下一个过滤Pass进一步处理。
FilterHistoryPS是流程最后的Pass,顾名思义,它是一种滤波手段,用于处理混合历史帧过程中的不兼容情况,属于后处理逻辑。该Pass 过程也比较简单,通过识别上一个Pass记录在A通道的信息来判断当前像素的状态,当被认为无法与历史帧混合时,UE会抛弃历史数据,直接采用当前数据,但是会通过多邻域采样+平滑的方式对其做滤波处理。总结一点个人见解,历史帧混合的主要目的是平滑前后帧效果,尽可能减少BentNorm跳变,并不是为了将算力平坦到时间维度中去。
最终的上采样+混合+滤波结果参考下图右侧(左侧是低分辨率母图):
3.2.2 SDF计算流程(简介)
至此(3.2.1)整个BentNorm的生成流程结束了,不过回想光线步进阶段,UE使用了Global SDF和Local SDF两个3D纹理,这里面的Global纹理是如何获取的?在使用Local SDF时又是如何对数量庞大的模型对象做预处理和过滤的呢?
众所周知SDF的一大弊端是其巨大的存储体积,很多游戏引擎无法在大场景中支持和运用SDF也主要受制于此。事实上我认为UE之所以能把DFAO(Distance Field Ambient Occlusion)及其衍生的诸如BentNorm或DFSoftShadow等技术带入游戏,厉害之处正是在于其对SDF海量数据的创建、维护和管理上。UE利用多级分层过滤和分时计算,负载均衡[5]等技术,在游戏运行时完成对当前热点区域的有向距离场的实时重建和渲染,极大程度规避了SDF数据在存储和搬运上的天然劣势,同时也成功的压制了实时运算开销,利用GPGPU将计算高度并行化,控制总消耗在可以接受的范围内。
3.2.2.1 预处理和过滤
下面是我梳理的一些UE算法框架。首先回顾上文,在动态物体可见性采样之前,我提过UE需要优先面对如何过滤大量模型对象的问题,当时我是这么描述过滤过程的:
UE通过CPU发起的DrawObjects指令调用专门的顶点和片元逻辑来剔除无关模型对象,最终获得一个特定的数据结构。事实上这只是简略版流程,提供一些概念性的理解,如果要在流程逻辑上形成闭环需要参考如下:
从整体上看,直到流程最后一个计算Pass:ConeTraceObjectOcclusionCS之前,所有的计算都是在做当前帧的对象过滤和数据填充,为最后一个Pass提供正确且精简的计算入参。这部分从前到后的流程大致可归类为:
- 基于视锥的对象剔除,以及,
- 建立从模型对象到屏幕空间Tile(也叫ScreenGrid)的映射和绑定关系。
对象剔除没什么特别可说的,流程最开始叫做CullObjectsFromViewCS的计算Pass接收来自CPU整理的全场景中的模型对象[3],总数量可能很多(从截帧内Buffer暴露的数据看,有10000+)。此外这些模型对象都具有一些共同特点:
- 参与计算Global SDF[4],
- 预烘焙了Local SDF,
- 存有Local SDF到对象模板之间的映射关系。
经过第一波剔除后余下的实例对象(2000+)会被编码和命名,同时还会生成一个存有它们各自独立数据的StructuredBuffer,UE管它叫做CulledObjectData,里面的内容一般是变换矩阵,位置和作用半径等信息。
[备注3] 在工程上为了平衡效果和性能,实现预烘焙Local SDF之前需要尽可能将复杂而庞大的游戏场景拆分成若干相对简单且独立和可复用的模型组件,单场景多达10000以上的模型对象并不一定意味着该场景必须预先烘焙10000+ Local SDF资产,因为其中很可能包含有大量相同模板(模型)的不同实例对象,同时也存在大量相似但不同的模板(模型)共用同一个Local SDF的情况。GI的事情向来不需要那么精确,宏观上看起来像那么回事就好 :)
[备注4] 对象剔除整个流程的产出会与后面介绍的构建Global SDF公用,因此即便当前流程处理的是“动态”物体,原始计算对象也会有大量“静态”物体的掺杂其中。此外所谓“动态”与“静态”的区别在负载均衡前都必须让路,换句话说,UE会预估场景中不同属性物体比例,如果只有很少的“动态”物体,那么UE会将大量“静态”物体划归到“动态”行列,让BentNorm构建初期的两个光线步进Pass的负载相差不多。
在视锥剔除的基础上,为了建立从模型对象到屏幕空间Tile(也叫ScreenGrid)的映射和绑定关系,UE会先在屏幕空间中以一个很低的密度(1/32原始尺寸,或1/8原始尺寸的1/4)划分网格(Tile或ScreenGrid),然后通过高速相交测试,找出所有可能与当前网格所在视锥体重叠的模型对象实例。打个比较形象的比方,这就像将原始视锥体拆分成了百来个更小的窗口(小电视),然后分别对每个小窗口单独做相交测试,然后只留下当前小电视内出现过的模型对象。理论虽简单,如何高效准确的工程化这个想法呢?来看看UE的具体做法:
首先是一组顶点(ObjectCullVS)+ 片元(ObjectCullPS)的Pass,运行在ScreenGrid分辨率上[54,30],每个像素对应一个Tile。UE通过发送DrawMeshInstanceIndirect指令,一次绘制了所有通过第一波视锥剔除后余下的模型对象(2000+),不过它们原本的模型会被替换成类似下图所示的“低面数球”,球心和模型实例的中心重合,半径则恰好能覆盖Local SDF对应的AABB包围盒。所以整个顶点阶段(VS)的主要工作就是适当缩放和平移球形Mesh的顶点,调整中心坐标。
经过光栅化后,只有受影响的像素能够形成PS阶段的WorkLoad,其余部分自动被管线跳过了。随后再通过屏幕空间(Screen Space)到视空间(ViewSpace)的转换,UE为每一个光栅化后的像素构建出了一个虚拟的棱台(Frustum),如下图虚线间隔出的部分所示,进而可以再次简化为椎体(Cone)。下图中的红色圆形对应待测试模型对象的作用范围,由低面数球体投影而成。在具体求交计算时,UE会先一步将椎体(Cone)与球体(Sphere)求交,如果互有交集则还需要一次更加细致的求交测试:通过ScreenGrid范围内最小和最大深度值,可以确定唯一的一节包含有几何物体的光锥段,如下图浅绿色区域所示,只有当球体(Sphere)和光锥段(Cone segment)相交,才会认为当前模型对象实例会与像素对应的Tile存在联系,既该物体可能会影响到当前像素范围内实际场景表面的环境光遮蔽。
以上只是第一次执行ObjectCullVS + ObjectCullPS逻辑运算,UE通过这次Pass,获得了每个Object所能够影响到的像素(Tile)的总数,全部保持到了数组对象NumCulledTilesArray中,该数组的下标对应模型对象的索引(ObjectIndex),数值则对应了该模型对象可以影响到多少个Tile。如下图所示,编号为0的模型对象一共可能影响到3个Tile。
上述信息虽然只涉及影响个数,但是可以被后面的计算Pass:ComputeCulledTilesStartOffsetCS拿去构建了:
- 一套从ObjectId到TileId的映射表,名为:CulledTileDataArray,
- 此外还有与之对应的偏移查找表,名为:CulledTilesStartOffsetArray。
从索引到Tile的映射表定义了每个物体能够影响到具体哪些Tile(注意,此时还未完成映射表填写,只是通过先前Pass取得的Tile数目,先一步完成了GPU内存空间的开辟)。
而起始偏移表在映射表建立的同时也完成建立,其下标表示某个物体的索引Id(如Obj_0,Obj_1等),值对应了这个物体在CulledTileDataArray表内的起始偏移位置。
当在GPU内完成以上数据结构的建立后,UE会再一次发起DrawMeshInstanceIndirect指令,触发第二轮的顶点(ObjectCullVS)+ 片元(ObjectCullPS)逻辑,而这一次是要用来填充CulledTileDataArray这个UAV的,算法与上一次相当,区别在于PS阶段确定了Tile与模型对象相交后,会利用Shader Language的原子锁操作获取一个针对当前Object的自增的局部偏移量,进而确定到具体的填充位置,并据此写入CulledTileDataArray中。下图是完成填充后的展示,可见原先未知的瓦块索引(TileId)已经替换成了具体的id。
只有获得了上面这张表,UE才能在接下来的计算Pass中尽可能节约算力,专心处理潜在的可影响到当前渲染点的物体,求解可见性强度。Pass的具体工作流程前文已经详细阐述了,此处不再赘述。
3.2.2.2 Global SDF纹理
最后是全局静态SDF纹理(Global SDF Texture3D)的制备。回顾前文,这张(确切说是3个Lod等级共3张)全局静态SDF纹理主要用于加速计算场景中静态物体所引起的可见性强度分布。
生成它们的机理和动态物体的光线步进采样非常类似,UE也是以遍历模型对象(Objcet)的方式去判断和修改受影响SDF Volume数值,同样会涉及到分层过滤操作,比如:
- 将纹理空间内的体积转换成AABB碰撞体,利用相交测试对“静态”模型对象做粗粒度剔除;
- 构造出UpdateRegion(更新区域)概念,只在必要时对SDF Volume做局部更新;
- 在SDF Volume中定义最小计算单元,并使其贴合计算Pass中线程组的Size(一般为[8,8,8]),称为Grid3D,然后利用Grid3D对应的AABB与物体对应的Sphere求交的方式进行二次剔除。
说到UpdateRegion自然不能不提所谓的RingBuffer概念了。什么是RingBuffer?简言之它是一种能够以较小代价持续更新和维护一段位于时域或空域中的连续存在的数据集的容器。举个例子,由于Global SDF主要服务于摄像机周围一定区域的光照计算,而游戏摄像机在运行时往往具有时域和空域上的连续性,也就是说相机在前后2个时刻在空间上的变化不会太大,更不会像原子核周围的电子云那般以概率的方式随机出现在场景的任何地方,因而UE就可以利用RingBuffer技术,在某个时刻集中更新前一段时间以来“场数据”在空间上迁移上产生的变化量。参考下图:
假设原始SDF在空间域中覆盖了黄色框内的区域,而随着摄像机沿着x轴向位移了一段时间,为了让SDF能够始终完整的覆盖摄像机四周一定范围区域,就必须在原有数据的基础上弥补上红色虚线区域所标注的一块SDF数据。UE会在运行时密切关注摄像机运动是否满足了SDF纹理更新的最小需求,一旦满足,则会立即驱动预过滤处理流程,同时生成如图中红色矩形所示的一个或多个UpdateRegion,同时将每个UpdateRegion拆分成整数个如上图绿色区域所示的Grid3D,作为计算Pass一个ThreadGroup的工作负载。
[备注5]负载均衡主要体现在UE对待动态物理的梳理上。事实上根据前文的介绍,我们知道在计算BentNorm流程中的2个重要光线步进Pass中,UE分别处理了“静态”物体和“动态”物体。但是实际游戏过程中“动态”物体往往远少于“静态”物体,这就导致负责动态物体的Pass的计算利用率可能远低于负责“静态”物体的对应Pass。UE的解决方案是将符合条件的场景中的一批“静态”物体一并归类到“动态”计算流程中,从而控制2个平行计算Pass的总计算耗时。值得指出的是,所谓的归类“条件”,很可能是物体的体积和大小,越小的物体,越可能被归类到“动态”计算流程中。这是因为一般而言在计算“动态”物体对BentNorm投射的影响时,UE缺乏全局SDF数据,只能利用局部SDF进行估算步进点到物体的距离,这时候当物体的体积相对较小,形状相对规则对称时,对UE估算准确率会有很大的加成。
Profiling
XCode14.3.1
IPhone 13 mini @ A15 bionic [6] @ 2340X1080
Unity 2022.1.9f1c1
BN Only : 2.248 ms [7]
BN + MotionVectors : 3.102 ms
[备注7] 此耗时为手机原生分辨率下([2340,1080]),未经过算法精度优化的结构,理论估算:在1600 x 800分辨率,缩减光追方向(9 -> 4),适当简化上采样算法,下调采样频率的情况下,还能获得至少40%的性能提升。
[备注6] 手机GPU性能天梯参考如下:
5 Demo
6 Reference
[1] UE4.25源码