今天分享的是Epic在Siggraph 2022上关于Lumen全局光照方案的分享,照例,原文链接放在文末的参考文章中。
1. 总结
2. Lumen介绍
理想的GI方案应该要满足:
- 全动态
- 支持室内外、白天黑夜等各种光照环境
- 室内效果要能跟烘焙的光照效果相媲美
然而由于计算的复杂性,想要达成这些条件变得十分困难,类比起来,就像是要用1 / 100000(十万)的离线计算时间来达到跟离线计算相媲美的效果,因此要想在实时下完成计算,需要做大量的优化。
2.1 Lumen相关的基本术语
1. Mesh Card
这是一个针对单个物体的概念,对于每个物体,我们希望对其进行简化,只获取一些给定视角下的数据(材质、光照),从而规避过于复杂的计算。
为了实现上述目的,会在离线的时候,为每个Mesh计算出一些恰当的投影参数,这些参数包括投影时相机的位置、朝向、viewport覆盖大小等Transform信息(称之为一个Card),每个物体会包含一套或者多套这类的数据,物件模型越复杂,需要的Card就越多,而为了性能考虑,这里会限制每个物件拥有的最大Card数(可改)
2. Surface Cache
GI计算首先需要拿到场景中各个点的直接光照,而由于场景很大,又是3D的,如果每一点都存储对应的数据,不但计算消耗会很高,而且内存消耗也会很高。
结合上面的Mesh Card,对于每个物体实例,我们只需要计算给定投影方向下的光照计算中间数据,这些数据包括:
- 世界深度
- 材质数据
- 光照Radiance等数据
这些数据的生成过程有点类似于延迟渲染中GBuffer的生成,需要通过多个RT进行承载,且为了进行统一管理,所有的贴图数据会统一由Virtual Texture来管理。
3. Lumen技术要点
GI的实现要解决若干个问题:
- 如何实现射线(视线,单次或多次反弹)的追踪
- 在获取到交点之后,如何实现间接光照的计算(包括多次反射计算以及镜面反射计算)
- 如何移除低频计算中的噪声(实际运算中,每个像素发射一根射线都会对帧率造成很大影响,而在室内的环境下,却需要拿到数百个采样结果,两者存在巨大的矛盾)
3.1 射线追踪
Lumen用的是一个混合的Tracing管线,目的是借助不同技术方案的优点,以较低的计算成本得到较高的GI表现,主要分为3个部分:
- 首先是屏幕空间的Tracing,这是因为屏幕空间的tracing精度是最高的,可以实现寒霜引擎SSR分享中的效果。为应对不同粗糙度的需要,这里的屏幕空间贴图会生成mipmap以应对不同粗糙度的反射需要
- 之后根据硬件的情况选择软件tracing或者硬件tracing(两者各有优劣,互斥而非组合)
- 最后如果tracing没有跟场景发生碰撞,那么就进行天光的采样计算
这三个部分是连贯的,顺序的,每一次的tracing都会给出射线传播的距离,以及是否命中,下一个环节的tracing会接着上一个环节(的终止点),继续做后续的tracing(前提是上一个环节没有命中?)
3.1.1 屏幕空间Tracing
屏幕空间Tracing在目前Lumen的GI管线中发挥了很大的作用:
- 有助于解决一些后续环节无法cover的问题
比如:Self-Intersection导致的瑕疵,这些瑕疵表现为结果的异常或者闪烁,具体原因为软硬件光追与GBuffer光栅化数据不一致导致,后面会详细介绍
覆盖主体追踪算法(软硬件光追)不能覆盖的场景或物件,比如蒙皮模型
具有很好的scalability,能够非常出色的表达出细节层面的间接光效果
当然,屏幕空间Tracing也不是万能的:
- 默认的屏幕追踪算法逻辑基于linear step实现,对于比较薄的物体处理不太好,表现为漏光,如下图所示
为了解决上述问题,Lumen对算法做了改进,采用了一种基于HZB的遍历追踪方式(Stackless Walk of Closest HZB,具体细节待补充),同时还做了一些优化:
- 为了避免grazing tracing ray(如射线与墙面平行)情况下tracing时间消耗过高,还在这里增加了一个最大tracing steps的约束
- diffuse ray使用半分辨率进行tracing(specular ray跟diffuse ray的tracing是分开的吗?)
屏幕追踪算法在实际使用的时候,还有一些细节需要注意:为了保证追踪结果的正确,需要对每一次的追踪进行判断,当发现当前追踪的位置已经位于某个平面之后(穿过物件),或者超出屏幕空间了,那就回退到上一个未被遮挡的位置点(即回退到上一次追踪的终点),等待进入下一轮的追踪(软硬件追踪),否则会导致漏光,如下图所示:
屏幕追踪可以得到下图所示的效果:开启了屏幕空间的追踪,可以轻易的看到车门上的反射效果
值得注意的是,即使是在HWRT开启的情况下,启用屏幕追踪依然能够提升整体的性能表现,这是因为屏幕空间追踪放在最前面,可以跳过很多复杂的几何表面的追踪,从而节省大量的计算消耗,如下图所示,经过屏幕空间追踪之后,有大量的射线已经完成了场景的相交测试(灰色表示命中),不需要再进入后续的复杂追踪逻辑,从而节省了大量的时间消耗,实际测试发现,增加屏幕空间追踪,可以有50%的追踪加速
UE在实现的时候,还做了一个保序Compaction的设计,可以保证同一个wave(硬件计算批次)中的射线尽可能分布在同一块区域,以提升计算效率,这里的一些细节,没有太看明白,先放在这里,后面有机会再补充:
The easiest way to do a compaction is to use local atomics to allocate the compacted index. That has the effect of scrambling the rays, which you can see on the left. The red lines are all the different rays within a single wave, and they’re now starting from different positions within the scene.
We solved that by doing an order preserving compaction, which does a fast prefix sum within a much larger threadgroup to allocate the compacted index.
3.1.2 场景Tracing
场景的追踪,主要解决的是视线(或光线)与场景的求交,以确定(世界空间或屏幕空间)各个点在光照下的Radiance结果,主要有硬件追踪跟软件追踪两种实现方式,两者各有所长,Lumen的实现中对这两种方式做了很好的兼容,同时支持硬件光追与软件光追两条路径。
3.1.2.1 硬件追踪
所谓硬件追踪,就是借助硬件为射线追踪定制的计算单元来实现射线与场景求交的计算,通常相对于软件的算法,硬件追踪计算上会有一些收益:
- 精确的场景相交检测结果,带来的精确计算结果
- 动态材质、动态光源的完美捕获(避免软件cache导致的滞后或异常)
- 可以得到更为锐利的反射结果
- 对于一些支持硬件光追的硬件来说,可以得到更好的效果
同时,采用硬件光追也会引出一些新的需要解决的问题:
- 不同硬件的追踪能力与用户需要(帧率)是不一样的,且硬件追踪可控性较低,要想根据情况进行适配,会有点困难
1.1 部分PC显卡还不支持硬件光追
1.2 主机平台的光追能力则并不是太强 - 硬件追踪在mesh重叠比较严重的情况下会变得比较慢
下面来介绍一下UE基于硬件光追能力所做的一些设计或思考。
因为UE4在4.22的时候,就增加了一个Ray-traced反射模型,基于这个模型可以实现动态材质、光照的即时计算,因此Epic准备从这个点入手来考虑如何在Lumen中添加HWRT能力。不过UE4的RT反射有一些问题,需要先拿出来讨论一下:
- 天光对场景物件施加影响的时候,是没有计算遮挡关系的,也就是说不论室内外,都会有影响
- 场景物体的specular计算,没有正确计算其occlusion(为啥?)
为了解决上述两个问题,第一个想法是借助Surface Cache的能力:
即每次与场景相交,不再继续往下追踪,而是通过Surface cache来获取光照结果,且这种方式完全可以理解成硬件追踪的一条fast path。
这里还添加了几点约束(没明白具体的含义是啥,后面等搞懂了再回来调整):
- 将BVH上的所有物件强制当成不透明物体来看,从而消除shader中的any-hit逻辑(当成不透明来处理,就只需要获取第一个相交点,可以避免任何相交点都处理?)
- 将material-dependent的closest-hit换成single closest-hit,即所有的物件都用closest-hit,不再考虑材质(透明与否)的影响,基于这个hit,需要获取交点处的法线跟Surface cache的参数
- 在求得交点之后,基于法线投影来获取光照结果
经过这个做法后,上述两个问题的改善效果对比如下(瑕疵在前,修复在后):
注意观察玻璃球中被阴影覆盖区域,前者带有蓝色的色调(天光效果),后者这部分数据就被成功移除了(这是符合逻辑的吗?太阳照不到的地方,天光也照不到?)
注意观察一些区域,如汽车底部背光面的一些异常高光效果(由于前面说到的射线追踪没有occlusion而产生)
在实现层面,原来Surface Cache的格式是需要64个字节,考虑带宽压力,这里将之做了压缩,将原来表达BaseColor, Normal, Roughness, Opacity, Specular以及其他数据的64字节,压缩成了上图中的20个字节,这里之所以要带上material数据,是因为后面需要用到。
在正常的硬件光线追踪管线中,需要将每个物件的每个材质的数据从CPU上传到GPU,其中会需要做大量的shader binding table的绑定逻辑,而使用了Surface cache之后,因为很多材质数据已经直接在Surface cache中了,因此绑定逻辑就可以大大简化,虽然还是需要一个for循环(场景内物件),但是只需要上传vertex/index buffer数据即可。
因为实现反射已经有了基于硬件相交(hit,持续的光线追踪)以及Surface cache(做一次相交,从cache中取结果)等两种方法,且两者存在一定的互补,一个自然的想法是能不能将两者整合起来,基于一个开关来调控效果跟性能,比如可以控制什么时候从Surface cache中取direct、Albedo、indirect等数据。
但是实际测试发现,部分基于射线检测(Surface cache+hit)的动态计算跟完全基于射线(即只走hit路线)的动态计算,消耗是一样的。
基于上述结论,这里就屏蔽了其他参数,直接暴露两种lighting模式出来,分别是cache模式跟hit模式(前面介绍的,改良过的UE4的ray-traced reflection模式,这种模式仅用于实现镜面反射):
需要注意的是,hit模式采用的是sorted-deferred方案(不知道咋实现的,干啥用的,后面了解了再补充)的改良版本,这个版本需要用到材质ID,所以这就是为何前面压缩后的cache payload中需要带上材质数据的原因了。
追踪管线就变成了上图所示的样子,Surface cache是hit的前置项目,按照这种设计,可以逐ray来评估是否需要进入到后续的hit逻辑中,更灵活可控,比如我们可以只对那些Surface cache数据是缺失的mesh调用hit逻辑等。
由于Skeletal Mesh本身是没有Mesh Card跟Surface Cache的,因此添加了Hit Cache之后,就能够得到skeletal mesh的镜面反射效果了(查看玻璃球的镜面反射)。
在最开始的时候,基于BVH的射线求交来采样Surface Cache,采用的是Any-hit算法,也就是说,会将所有的Surface Cache看成是不透明来处理,虽然速度会快一些,但是效果会存在问题。
为了应对这个问题,UE设计了两个策略:
- 对于Alpha blend的物件,会直接跳过,即认为不相交,同时,为了提高效率,这里还设置了一个MaxTranslucentSkipCount参数来控制跳过次数
- 对于Alpha test物件,则会在相交之后检查其alpha值,当低于0.5的时候就认为其是透明的,直接跳过即可
经过这个跳过处理后,可以得到如上图所示的效果。
开启了Alpha Test之后才能得到对应的透射效果。
这里还介绍了底层的一些相关细节,这里上下文信息不足,就不展开了,直接贴原文:
Lumen's GPU-driven pipeline required additional dispatch control that was not present in UE4. Instead of operating directly on screen pixels, Lumen passes operate on probes, surface cache texels, and screen tiles. In many instances, hardware ray tracing is a secondary trace type and operates as the fall-through technique. For these reasons, it was essential that we utilize indirect dispatch where supported. For this reason, Lumen prefers DXR 1.1 semantics on PC.
We should also point out another useful feature in DXR 1.1: Inline ray tracing. The RayQuery interface avoids the complexity of the shader binding table and this allows for the hardware traversal in standard compute and pixel shaders. Utilizing ray queries also offers the compiler significant opportunity to optimize. In the ray-generation case, it is strongly recommended to minimize the amount of "live state" spanning a TraceRay() call. With Inline ray tracing, the compiler can minimize this without developer intervention.
Except for the need to supply mesh-varying vertex and index buffer data to hit-group shaders, the Surface-Cache pipeline could use inline ray tracing. This auxiliary buffers are only a requirement for PC; however, as console ray tracing intrinsics already provide access to the geometric normal as part of the ray-tracing Hit structure. Because of this, we actually do leverage inline ray tracing on consoles and benefit from a noticeable speedup on certain platforms. To learn more about this console specialization, please see Aleksander and Tiago's talk.
下面来看看Lumen光照在黑客帝国Demo中的一些实现细节。
黑客帝国场景应用了Lumen方案,这个场景有如下的一些挑战:
- 实例众多
- 大量的动态物体,不能都通过Surface Cache来覆盖
- 有较多的镜面反射效果
此外,这个场景最开始是面向主机发布的,主机有较好的硬件光追支持,不过计算能力稍弱,这里针对主机的一些固有能力做了一下算法的调整,比如将一些静态场景的加速结构的构建放到离线做等。
虽然做了很多设计,还是遇到了一些问题:
- 材质复杂度过高,导致前面提到的hit-lighting方案变得不可行
- 母材质中应用了大量的virtual texture fetch,因此动态材质跟光照的evalution效率也比较低
这里给出了两种方案的性能消耗,同时也需要指出,由于在这个demo中,所有物件的渲染对skylight项有较强依赖(即大量的像素是直接反射到天空的?),这种情况下,大家都是从Surface Cache中进行数据读取的(天光是从Surface Cache中采的?),所以在效果上也就没有太大差异。
黑客帝国场景应用Lumen的另一个问题则是,场景中大量使用了Nanite的物件,而对于raytracing来说,暂时还没有哪种加速结构能够处理这么高分辨率的mesh。
为了解决这个问题,UE采取的是使用Nanite的fallback mesh(低分辨率,非Nanite版本)作为tracing的数据源,不过这种方式也会产生新的问题:
- 作为tracing的mesh跟实际渲染的mesh在拓扑结构上并不一致,可能会带来效果上的问题
- fallback mesh可能会导致异常的self-intersection效果,而这个问题无法通过常用的ray bias方式来消除
这里是demo中的一个场景,为了突出问题特意关闭了screen space的tracing
出现这个问题的原因是,渲染所使用的mesh跟tracing所使用的mesh存在精度上的差异,而在不同的LOD之间tracing导致的问题很早就被大家所关注到了,Tabellion跟Lamorlette在2004年给出了一个解决方案[Tabellion et al 2004],不过这个方案过于复杂,这里给出了一个修改过的简化版本:
- 先从tracing的起点开始,以一个较小的半径发起一个tracing,见上图的Epsilon Radius,这个tracing需要忽略backface
- 上面这个tracing通过之后(没有相交),才会发出一条长一点的射线
之后就能够得到正确的结果了
另外需要说一句的是,Screen tracing也可以消除self-intersection这个瑕疵,只要在硬件追踪开始之前,给它提供一个与bounding view volume平齐的t-value(相当于加了一个bias,只不过不明白这里的平齐是怎么做的)即可。
但是Screen Trace的固有缺陷是在屏幕边缘效果较差,因此最终两种方案都用上了
又一次来到了性能与效果的平衡。
由于上层的tracing加速结构是需要每帧更新计算的,按照划好的预算,这个过程只能支持到100k个物件实例的rebuild,而黑客帝国的实例数目到了500k,且有往1M发展的趋势,性能就不太能扛得住了。
另一边,出于性能考虑,tracing的距离不应该超过200m,但是如果限定了距离就会导致效果的变差,比如汽车上的镜面反射就无法展示出天际线的反射效果,同时GI中也会缺少天光的遮挡效果,这个会导致效果变得很假。
这里的解决方案是采用HLOD来覆盖远景的区域,不过新的问题在于,项目所需要用到的HLOD的起始距离跟前面为了保障性能而设定的距离并不一致,因此在场景中的部分区域,可能会出现,对于tracing的加速结构而言,同一个空间区域有两套mesh数据并存的情况,为了解决这种冲突,会对两套mesh进行标记,比如HLOD标记为far field数据,从而可以根据需要选择其中一套数据。
有了这样的设定,我们可以开始对场景进行trace了,这里会先trace near field的数据,如果miss了才会进入far field的trace。
同时,对于一些不需要过多关注trace距离与顺序的应用情景,比如shadow trace,则将这个顺序翻转过来,以轻微的效果损失换来更高的执行效率。
先将Hit-lighting隐藏,这里的管线路径总结如上图所示。
这里用颜色标注了黑客帝国场景中的不同距离。
同时将near-field跟far-field的数据添加到加速结构中会带来性能问题,因为会有些区域会有重叠,因此存在一些tracing的浪费,虽然前面说了,可以增加一个mask来避免不必要的遍历,但是这个方式也会打断加速结构的并行性,造成性能的损失。
一个更好的方式是将两套数据分开,不过由于此前已经将大体的框架都搞完了,重新实现会有较大的成本,这里需要想另一个办法来解决这个问题。
这里给出的方案是,给far-field数据一个translation bias(位置偏移,我理解应该是起始tracing的距离偏移),这样就能避免重叠了,虽然对于加速结构依然会有一定的成本,但是总体来看有较大的性能提升。
从效果上来看,还是挺明显的。
将前面的要点结合起来,就得到了这套被称之为Tiered-tracing的管线:
- 通过Surface Cache来提升Tracing效率
- 通过Hit-lighting来提高反射质量,尤其是镜面反射的质量,用作Surface Cache miss的补充
- 通过Far-field tracing来补齐远景的tracing效果,同时保证总体的渲染性能。
做一下总结:
- 硬件光追的实现算法跟UE4有所不同
- 通过Surface Cache,可以得到更高效率
- 通过Far-field tracing可以在效果跟质量上达成双赢
- 通过short first ray+Screen trace来消除Nanite/LOD tracing效果差异
3.1.2.2 软件追踪
软件追踪则是完全通过自定义的算法借助CPU或者GPU的算力实现的追踪方案,虽然相对于硬件追踪,计算速度会有所不如,但是出于如下考虑,软光追还是有其存在的价值:
- 算法执行的可控性高
- 能够扩大算法的可伸缩性:
2.1 部分硬件不支持硬件光追
2.2 主机平台的硬件光追能力则并不是太强
2.3 对于物件实例重叠比较密集的情况,硬件的两层BVH会不太够用
为了实现场景的加速追踪,UE用了两类Primitive数据来表示场景:
- 针对各个Mesh的SDF数据
- 针对Landscape Component的HeightField数据
这些Primitive数据在GPU中是通过一个双层结构来表示的:
- 底层是各个Primitive数据
- 上层则是一个Primitive Instance数组
下面分别看下SDF跟Heightfield的区别跟联系:
每个Component在Surface Cache中都会有一个与之对应的Heightfield,这个Heightfield跟MeshSDF是共用culling以及traversal逻辑的,但是在底层,两者是有区别的:
- MeshSDF是基于sphere tracing来求取交点的
- Heightfield则是通过raymarchign来计算交点的
Heightfield求得交点后,需要根据交点处的透明度来决定是否取用这一点的数据,还是取用更后面的数据(地形可以挖洞,如果这一点是透明的,那就要继续做下一个相交检测)
3.1.2.2.1 基于Heightfield的追踪方式
这种方式主要用于地形数据,其计算思路给出如下:
- 将相机调成正交投影模式,沿着某个方向(文中没有明说方向,但是推测是从多个方向)进行光栅化投影,得到多张(互不重叠)Depth贴图,代表各个位置的场景高度(地形+物件)
- 每张深度贴图称之为一个Card,之后在追踪的时候,就沿着这些Card进行追踪,当找到碰撞点后,就采集该点的光照数据(直接光照?)
这个方式的优点有:
- 2D模式下的数据存储,相对于3D的Voxel格式数据而言,在同样的显存消耗下可以得到更高的分辨率
- 可以仿造视差遮挡贴图的实现算法,对这里的追踪算法进行加速
这种方式的问题则是,由于不是场景中的所有物件或者所有像素都被纳入到Card投影结果中,对于没有在Card中体现(被遮挡或其他原因)的数据,其作用就会被忽略,从而导致漏光,如下面两图对比所示:
3.1.2.2.2 基于SDF的追踪方式
将场景用SDF(可以看成是一个3D贴图)来表示,有两种方式:
- 每个物件用一份SDF数据
- 整个场景用一份SDF数据
前者精度高,但是显存占用高,需要遍历计算,成本也相对高一些;后者精度低,但是成本也低,实际使用中会将两者结合起来。
SDF的生成 & 存储
每个Mesh的SDF是在导入的时候生成的,数据跟StaticMesh是分开存放的:
- 生成采用的算法是Embree point query,这个算法可以快速找到每个点到最近三角面片的距离
- 同时为了感知到每个点是在几何体内还是几何体外,这里会将几何体划分成voxel,之后从对应位置的voxel发射64根射线,并统计backface的命中次数来完成评估,这个数据会用作SDF的符号
- 在场景中的SDF会通过volumetric virtual texture(3D版本的Virtual Texture)来存储,为了避免体数据存储量过大,这里只存储一个非常窄的SDF数据(narrow band),volumetric virtual texture采用稀疏结构来存储,如下图所示,每级Mip覆盖范围正好是上一级的4倍(2x2),这样每个Page(3D)的尺寸就完全一样大了
- Mip0的分辨率取决于物件的尺寸以及导入时候的设置,而Mip1则是对Mip0的分辨率进行减半,同时将最大的SDF覆盖范围加倍,以此类推
SDF在实际使用过程中会遇到一些问题,下面来看下UE是如何处理的。
- 对于不封闭的mesh而言,如果不做处理,就会导致穿过mesh面片进入到另一边,SDF的数值就为负,跟预期不符;UE则是在SDF的生成过程中,当出现这种情况时,就会在达到4个voxel的位置插入一个virtual plane将这个mesh做成封闭的,虽然不是最佳方案,会导致光栅化(硬件trace)跟SDF trace结果存在差异,但是总好过完全不处理。。。
- 对于封闭但比较纤细的mesh,由于SDF的voxel分辨率不足,无法捕捉到所有的关键信息,会导致Tracing失败,或者基于SDF的gradient计算得到的法线数据存在异常,最常见的衍生问题就是漏光;UE的做法是在运行时对mesh进行拓宽(半个体素对角线尺寸),如下图所示,可以解决漏光、gradient计算失败等问题,但是会导致遮挡信息错误(本来没被遮挡,一加厚就挡住了),此外,由于Surface bias的加大,还会导致contact shadow的丢失
所谓的Contact Shadow,指的是屏幕空间基于深度buffer朝着光源(每一盏灯)ray marching求交来判断是否被遮挡的技术,这里当然就不用沿着深度buffer ray marching,而是直接借用SDF的数据来计算阴影。那为啥会导致contact shadow丢失呢?按照上述分析,这里推测是从下图的红点开始进行SDF tracing,结果发现当前SDF距离为负,于是就默认为阴影中,所以contact shadow丢失了?
这里给出的解决方案是,保留原始的SDF数据,在进行shadow tracing的时候,就从原始位置开始,采用原始的SDF,之后逐渐(线性)的增加tracing sample上SDF Expand Bias,到了之前设计的体素对角线的半长就不再增长,之后在快要靠近终点(这里的终点是咋定义的,快要相交?快要靠近光源?)的时候,则考虑对Bias进行衰减,直到0(使用原始数据),从而可以拿到更精确的结果。
不过这种方式在进行GI计算的时候还好,如果用于计算反射,就会在视线跟表面接近平行的时候出现自相交,从而导致反射效果异常。UE针对反射场景又打了一个补丁:从出发点开始,以到表面的距离作为下一次tracing的step(即在初始表面上,尽可能的扩大tracing step),从而快速逃离初始表面(这里描述有点粗糙,如果只是按照这个描述来做方案,大概率还是会遇到问题,比如初始表面前面是平的,后面凸起来,但又没有遮挡射线,这种情况由于前面已经扩大了tracing step,肯定就会导致相交,从而出现异常,所以里面或许还有一些细节需要处理,不过这里只是纯理论分析,就不做展开了,后续如果在实践中遭遇问题再做细究,下面看下效果对比:
- 采用膨胀(expand)的方法对于实心物体如墙体而言可以有效解决漏光问题,但是对于植被、树叶等半透物体来说,却会导致过遮挡,从而使得数目表面光照效果非常差(比如背光面全黑),针对这个问题,UE又打了一个补丁,增加了一个覆盖率(coverage)的概念。
UE在生成SDF的时候,会将two-sided的材质标识出来,之后在运行时构建一个单独的GlobalSDF通道(可以理解为一份额外的Global Volume体素数据),在tracing的时候,针对每个采样点,会判断当前点是否命中半透物件,据此可以做到:
A. 加大tracing step(先假设半透不提供遮挡)
B. 缩小膨胀距离(避免过大的自遮挡?)
C. 增加一些随机的抖动命中(即根据概率决定是命中,还是继续tracing),以实现半透效果
对于植被而言,还有一个问题需要解决,即通常树叶都是带有动画的,以实现随风摆动的效果,而SDF却是静态的,用静态的SDF来计算动态的植被的光照效果,带来的问题就是阴影表现不正常,最常见的是自阴影问题,这里UE的解决方案是为植被增加额外的表面偏移(Surface bias)来规避这个问题。
比如带有Coverage跟不带Coverage效果的差异:
3.1.2.2.3 软件Tracing的加速结构
上面将场景用SDF跟Heightfield表示,但是我们在实际tracing的时候不可能逐个物体进行遍历,否则性能扛不住,UE尝试过使用BVH跟Grids两种方式,发现虽然这个结构只需要一次构建多帧复用,但实际trace过程中却因为tracing ray在内存的不连贯而导致性能表现较差。
为了应对上述问题,同时考虑到off-screen(屏外)的diffuse间接光照不需要过于精确,这里UE给出的解决思路是分段tracing,即近距离采用一种tracing方式,稍远距离换另一种。
最先考虑的是场景的LOD方案:
- UE曾经考虑过用voxel来表示场景,远处则采用更粗粒度的voxel,但是实际验证发现,voxel的合并会导致漏光问题
- UE还考虑过用一个bit的voxel来标识voxel是否与物件重叠,但实践发现基于voxel的相交测试运行效率太低了(bit bricks raymarching算法)
- 经过上述多次尝试之后,UE最终选用的是基于SDF的合并方案
SDF的合并发生在运行时,会以相机为中心,生成一套clipmap(默认四级),每级clipmap的输入是Mesh SDF+Landscape的Heightfield。
而即使使用SDF,想要在一帧内完成整个场景的SDF的合并也是一件高消耗的事情:
- 首先UE做了时间片处理,会根据优先级对clipmap进行增量更新
- 其次,对于每一级clipmap也会做LOD处理,比如clipmap层级变得粗糙,合并的过程中就会忽略更大尺寸的Mesh SDF(SDF的分辨率应该也会有调整?)
- 对于静态场景而言,Global SDF是可以复用的,类似于Shadow Scroll算法,这里Global SDF会分成Static部分跟Dynamic部分,前者做增量更新,后者做全量更新
SDF的合并流程为:
- 增量更新Static Global SDF
- 全量更新Dynamic Global SDF
- 将Static叠加到Dynamic上
- 完成SDF的其他更粗糙的Clipmap数据的生成
在SDF合并的过程中也遇到了一些问题,这里也贴一下解决方案:
- 不同Mesh SDF的Scale可能是不一样的,使用统一分辨率的Global SDF来承载就会遇到问题,UE尝试基于gradient通过analytical的方式来计算最近距离,但是在实际测试发现由于SDF分辨率比较低,这种方法得到的结果不太好;后面分析发现,大多数采用非归一化scale的物体都是一些规则的简单物体(比如墙面),因此最后采用的是从某个点到物体边缘的距离来进行换算的方法,结果反而更好。
SDF的Streaming
运行时:
- GPU会在每一帧搜集SDF的访问请求,通过一个Shader遍历所有的物件实例实现,这个Shader会基于物件实例到相机的距离来计算这个物件对应的SDF所需要的用到的Mip Level
- 上述请求会转发给CPU
- CPU据此完成Page的StreamIn/StreamOut
所有的SDF数据都是存储在一个固定尺寸的内存池中的(默认320MB?),这个内存池分配的内存粒度是固定的(根据前面的分析我们知道,各个Mip的Page尺寸是相同的),因此这里也不用考虑内存碎片化问题
SDF的Tracing
SDF的tracing顺序是:
- Mesh SDF
- 精细的Global SDF
- 粗糙的Global SDF
做了Global SDF设计之后,Mesh SDF只分布在一段比较小的距离上,就不再需要BVH、Grid等加速结构了,但是在实现上,还是可以通过一些策略来做一下加速:
- 选取相机一定距离作为Mesh SDF的生效距离
- 基于上述距离将相机的frustum分割成一个个体素,称为froxel
- 统计与每个froxel相交的mesh SDF,方便跳过那些空白的froxel
Mesh SDF的tracing分为两部分:Camera View跟Light View。
- 在进行Camera View Mesh SDF的tracing的时候,就会加载一个froxel的数据,之后遍历与之相关联的所有Mesh SDF,直到发生一次相交(这里应该需要对Mesh SDF按照从前到后顺序进行排序)
- 在进行Light View tracing的时候(计算阴影),会需要先在光源视角下,划分一个类似于shadow cascade的volume,将这个volume中的物件的boundingbox投射到一张2D贴图上,在PS中对物件的Mesh SDF进行采样来计算上述2D贴图中每个cell都与哪些Mesh相交,之后在tracing shadow的时候就类似于上述做法,逐cell进行mesh sdf的相交检测
在做SDF Tracing的时候,还会通过Sphere Tracing的方式来实现求交的加速,为了避免一些特殊情况下的计算消耗过高,这里还会约束每条射线的最大迭代次数为64,当因为迭代次数到达极限而终止时,会以这个终止点作为ray tracing的交点。
相对于前面HeightField的计算方式,这里求交不但能得到(包含了可靠遮挡计算后的)交点的位置,还能基于梯度计算出对应点的法线数据(法线数据后面会被用于从Surface Cache中获取材质跟光照数据),不过还是不能拿到材质跟光照数据。
3.1.2.2.4 软件Tracing总结
软件光追主要基于SDF实现,有如下的一些优势:
- 不依赖任何的硬件,所有的平台都能支持
- SDF数据不只是Lumen可以使用,还可以作为基础数据供其他的场景如物理等使用,做到一鱼多吃,进一步摊薄成本
- SDF还具备很好的可伸缩性,对于复杂场景,可以考虑在运行时进行SDF的合并,从而降低计算复杂度
SDF实现的问题在于,镜面反射效果相对较差。
3.1.3 软硬件光追的性能消耗
多个Tracing方法按照精度跟消耗进行排序,可以得到上面的图:
- Global SDF精度跟消耗都很低,需要屏幕空间trace或者mesh SDF trace来补充精度
- 硬件Trace虽然精度高,但消耗也同样高,且不能跟随设备做scale
- 带有hit-lighting的硬件tracing消耗更高
基于这个图,我们可以给出实际工作中的决策依据:
什么使用使用软光追?
1.1 追求高帧率,比如60fps
1.2 场景比较复杂,mesh堆叠比较厉害,比如Nanite演示场景或者古代山谷场景(因为堆叠情况下,硬件光追的硬件加速结构就不那么有效,BVH的场景遍历消耗会很高)什么时候用硬件光追?
2.1 追求高质量
2.2 对镜面反射有较高要求,比如黑客帝国场景
2.3 对skeletal mesh的质量有要求(或者skeletal mesh对GI造成了较大影响的)
在堆叠严重的Nanite演示场景中,硬件光追性能消耗影响明显。
在Lyra场景中,硬件跟软件光追,无论是效果还是性能上都差别不大,如果有硬件光追可以用硬件光追,没有用软件光追也没问题。
黑客帝国场景有较多的镜面反射效果,且有远景GI需求,因此带有Far-field GI能力,且在光滑表面反射上有不错表现的硬件光追就更为合适。
3.2 间接光照计算
Lumen最终解决的是如何实现最终光照的计算,这里面间接光的计算又是其中的大头,不管是软件追踪,还是硬件追踪,都是其使用的工具。
间接光照计算,需要考虑多次反射,同时还需要考虑反射效果上的GI。
3.2.1 多次反射计算
由于第一次反射对效果的影响非常大,需要单独处理,因此Lumen将多次反射计算分为两部分:
- 第一次反射
- 后续的多次反射
3.2.1.1 First Bounce
针对不同的光照效果,这里采用了不同的方法:
- 针对Diffuse的计算逻辑叫Final Gather
- 针对Specular,则主要是需要降噪,Denoising
1. Final Gather
Final Gather顾名思义,就是从待计算点发射多条射线,这里需要获取多个射线的结果,而这就会面临前面提到的射线数目导致的计算消耗高企的问题,因此这里的实现,包括了多个技术点:
- 自适应下采样:尽可能的减少采样数目,只保留必要的射线追踪
- 基于空域或时域的数据复用
- Product Importance Sampling
Final Gather需要兼顾半透跟不透明物体,而针对不同的物体类型,计算方案会有所不同,根据光照计算的定义域来划分,我们有如下三种:
- 不透物件的计算,是放在屏幕空间进行的,可以看成是连续的2.5D计算
- 不透明物体比如fog,是放在跟相机对齐的3D Volume中计算的
- 最后,再考虑上前面所说的存储间接光结果的Surface Cache,这里的数据则是存放在2D空间的,不过数据本身不是连续的(Texture Atlas?)
下面来看下实现细节。
Light transfer noise
这里主要是性能跟质量需要之间的矛盾,下面来看看UE做了哪些尝试,首先是cone tracing:
cone tracing可以很好的消除噪声,提高整体Gather的效率
这个实现中,cone tracing的对象是Mesh SDF,在相交后会根据需要取用对应mip的数据来实现计算的简化。
不过这个相交如果跟某个mesh只发生部分相交的话,就会转化为一个半透问题,虽然可以基于cone的axis跟距离来预估遮挡比例,但是之后却需要考虑来自于多个mesh的多个部分的贡献,且需要考虑他们之间的顺序关系,这个复杂的问题,可以通过weighted blended OIT方案解决,如上图右侧所示,这个方案可以得到比较平滑的tracing结果。
这个方案的问题在于,不能消除所有场景下的漏光问题,且漏光跟过遮挡总是有一个是存在的,此外,这个方案也不能用于软件光追上。
第二个尝试的方案是蒙特卡洛积分:
这个方案的优点是伸缩性好,支持软硬件光追,不足在于会将噪声问题推到Final Gather阶段?
解决diffuse light transfer最常用的方案是Irradiance Field,这个方案会在场景中布置Probe,之后预计算各个Probe的irradiance积分,在需要的时候,对probe数据进行插值来得到各个像素位置的irradiance数据。
这个方案的问题在于,预计算过程是发生在probe而非像素层面,而评估则是发生在像素层面,从而导致漏光、过遮挡等问题的发生,要想解决或者缓解,就得想好怎么摆放probe,而实际上是很难给出一个完美的摆放方案的。
另一个问题是,probe是3D摆放的,因此不能过于密集,这个带来的问题就是表现过于flat。
接下来尝试的一个方案是在屏幕空间中进行光线的trace,之后再做一个全屏的denoise来消除噪声。
这个方案的问题在于:
- 需要一套独立的射线来获得半球上光照数据的完整覆盖(没有太get到意思),这个带来的问题是射线连贯性较差,cache miss比较严重,trace速度慢
- 由于denoiser是发生在屏幕空间的,消耗跟屏幕分辨率有关,无法降采样,且由于缺少足够的sample来实现新覆盖区域的收缩,还会出现过遮挡现象。
这里给出了期望方案的目标,简单来说就是表现跟性能都要。
最终给出的是一个屏幕空间的probe tracing方案:
- 在屏幕空间以16x16像素为一个单位摆放probe,从而实现对tracing的降采样
- 可以根据需要可对不同区域采取不同的probe放置密度
- 对于屏幕上的每个像素,会选择与之共面的多个probe,并对其进行插值来得到需要的数据,这样可以规避不同平面插值带来的漏光问题
- 在每一帧,对probe的位置进行抖动,从而借用时域的数据复用能力实现降噪
来看下21年的Radiance Cache方案在Lumen中的应用。
整个系统包含3个部分:
- 屏幕空间的Radiance Cache
- 世界空间的Radiance Cache,作为前者的补充与fallback,用于覆盖一些远景区域,probe的分辨率也相应更低
- 若干个全屏后处理
从无到有,先来看下各个环节的作用:
Contact AO用于补充一些Screen Space Cache下采样丢失的细节,下面来过一下每个环节的细节,先来看下Screen Space的Radiance Cache。
这个Cache可以用较为低廉的成本实现一个大尺寸的空间滤波结果,这是因为滤波发生的是probe空间,所以只需要一个小尺寸的滤波器,就能得到屏幕空间大尺寸空间滤波器才能得到的效果。
基于上一帧屏幕空间的Radiance Cache,我们可以清楚估计出入射光照的分布,从而可以通过importance samplling来以较低消耗得到较高的采样结果。
屏幕空间Radiance Cache射线查找失败的时候(reprojection fails,这里还有一点细节没搞清楚),会退回到世界空间的Radiance Cache。
因为是在降分辨率的空间进行计算的,因此对于每个probe可以使用稍微昂贵一点的计算逻辑,比如离线计算中用到的product importance sampling(这是啥?两个数据相乘之后结果的importance sampling)。
效果上来看,product importance sampling要比仅仅只对light或者BRDF进行importance sampling要好一些,也比多个importance sampling要好(何解?是分别针对BRDF跟light做importance sampling)。
如上图的三个小图所示:
- 左图是一个probe的BRDF图,这个probe是放在墙上的,可以看到只有半球是有数据的
- 中间图是上一帧的输入光数据,基于这个数据我们就知道输入光主要分布在哪些方向
- 基于上述两个数据,我们就可以将射线尽可能的分布在上述两个数据相乘之后结果较大的方向上
基于这个方法,我们可以用较低的消耗来得到更高的结果。
下面来看下世界空间的Radiance Cache方案,再次回顾一下这张图:
世界空间的Radiance Cache,处理的是远景处的光照,这些数据在空间上分布更稀疏,但是每个probe上的光线数目(directional resolution)却更高。
对于一个光线只来自于一个小入口的窗户的房间,这部分数据可以用于避免屏幕空间 Radiance Cache因为计算Radiance Field时外发射线数目较少(8x8,对应于前面的directional resolution)而导致光照数据miss的问题。
屏幕Radiance跟世界Radiance是按照这种方式混合的:
- 先计算得到世界空间Probe的Radiance Cache
- 再来计算屏幕空间Probe的Radiance Cache,不过这里会缩短Probe发射射线的求交范围,当miss的时候,就取用Radiance Cache的数据
因为世界空间Probe的位置是稳定的,因此带来的误差也就是稳定的,容易修正或者隐藏。
世界空间的Radiance Probe是按照clipmap的方式分布的,probe之间的距离没说怎么设计的(下一层的probe间距一般是当前层的间距的两倍,当然也可以是其他的倍率,不过第一层的间距怎么设计的倒是没说)。
这些probe的分配是固定的,数据呢也是可以跨帧存在的,因此可以避免每帧重复计算。
这里是世界空间Probe的Cache策略:
- 会复用上一帧的数据,只更新那些因为移动旋转新增的probe
- 对于场景光照导致的数据更新,则会采用固定的预算来进行增量更新
Matrix Awaken Demo中的光照都是通过自发光模型实现的, 通过GI来实现这么大量光源的处理就是一个很好的压力测试场景,而世界空间的Radiance Cache方案则能很好的实现对这些光源的直接光照的覆盖模拟。
接下来看看时域滤波环节。
因为probe的位置跟方向的变化,导致光照结果存在较多噪声,因此需要进行一次滤波,最方便的就是类似TAA的时域滤波。
为了保证滤波结果的精准性,这里同样采用了深度跟法线的差异来reject掉历史帧中的不和谐数据,从而得到更稳定的结果。
上述方案来的的问题是,因为时域累加的存在,导致动态物体的光照数据变化很慢,表现就是动态物体的拖影。
这里的修补策略为:
- 针对动态物体,调整时域叠加频率,快速完成累加,不要使用过久远的数据
- 在滤波完成之后,应用一个最小范围的ambient occlusion(没明白具体干啥的)
不同距离的光照数据对光照变化延迟的忍受度是不同的,越远就越高。
Final Gather中已经完成了这部分的适配,针对不同距离的数据会采用不同的策略,以尽可能低的消耗实现尽可能高的质量。
比如屏幕空间的数据可以通过Temporal Filter在时域上进行累加来实现数据复用;
世界空间probe则通过数据复用来降低计算消耗;
天光数据则可以通过降低更新频率来降低消耗。
半透物件如fog同样需要GI的加持,这里有几个问题:
- 需要支持任意层数的半透效果
- 需要实现天光的遮蔽阴影效果,否则如图中所示,fog效果就会异常
- 需要解决可见的深度范围内的GI采集问题,且这里不同于不透明物件,需要使用sphere而非hemisphere
- 时间预算只有不透明物件final gather的1/8,需要一个更快速的方案
这里介绍一下具体的实现细节:
- 会用一个probe volume来覆盖整个frustum,得到一个froxel grid,也就是说,这里会将frustum分割成多个froxel(类似于cluster forward rendering,将frustum沿着视线方向进行分割,近小远大),每个froxel中放置一个probe
- 之后基于HZB Test,剔除掉那些不可见的probe,只更新那些可见的
- 对于可见的probe,使用Global SDF进行tracing(使用世界空间的Clipmap结构组织的Radiance Cache获取数据),得到各个方向的Radiance
- 上述Radiance会通过空间滤波跟时域累加(Froxel数据怎么跨帧复用?将上一帧的数据reproject到当前帧中;这里应该还会如寒霜引擎一样进行时域滤波,即froxel中probe的位置会尽心jitter)进行降噪,从而可以用较低的射线数目获得平滑的光照结果
- 得到的Radiance,会经过一个积分运算转换成一个用二阶球谐表达的Irradiance数据
- 在半透物件如fog的渲染中(比如前向的半透fog渲染或者体积雾raymarching中进行采样)对上述二阶的irrdiance进行插值使用。
对于远处的半透光照,即使采用再多一点的射线进行tracing,噪声依然会很明显,如上图中,天光透过一个小洞照射在山洞中的情况。
对于这种情况,这里又采用了另一套世界空间的Radiance Cache,这里的Probe同样在方向上会有更高分辨率(采样的时候使用更多射线?),可以得到更为稳定的远光效果。
新增的世界空间probe要怎么跟之前的数据结合起来呢?
这套世界空间的probe是放置在可见的froxel周围的,这里有两个疑问需要后面解决:
- 世界空间probe是怎么排布的?
- probe的位置是固定的,类似clipmap,还是每帧动态调整的?
根据后面复用逻辑来看,应该还是clipmap分布的,每帧只需要增量更新
对于上述的probe,会开始发射射线进行trace,并将trace的结果写入到mipmap中,为什么需要使用mipmap?目前推测可能是方便在需要的时候根据粗糙度采样不同的mipmap来以低频采样得到高频采样效果。
怎么将世界空间数据结合到前面的froxel中?这里会在froxel中的probe进行trace的时候,缩短trace的距离,当这个距离没有命中物件时,就会从世界空间的probe中进行插值来得到光照数据,因为世界空间的probe的trace ray数目是froxel ray数目的16倍,因此通过mipmap可以降低噪声。
这里为半透设计的世界空间的radiance cache跟前面为不透明物体设计的世界空间radiance cache在作用上起始是有一定重合的,因此UE这里就直接将两者重叠起来(合二为一?),从而实现数据的复用,降低数据本身的成本。
2. Reflection & Denoising
Denoising这里主要介绍specular部分的方案。
降噪也需要通过算法来进行加速,主要有如下几个技术点:
- 基于空域或时域的数据复用
- 双边滤波算法
- 重用Diffuse的射线结果
光滑反射的实现用到了寒霜引擎中的屏幕空间反射方案,整体过程阐述如下:
- 首先,反射射线的生成,会基于可见GGX lobe+importance sampling算法实现
- 使用前面说到的tracing方案对屏幕+场景进行tracing
- 之后会基于屏幕空间上相邻像素的BRDF对他们的采样结果进行加权重用
- 最后,通过时域复用(jitter、reprojection等)来进一步降低噪声。
这是直接trace后的结果
之后加上相邻像素数据的加权复用。
加上时域复用
再来一次双边滤波
最后再加上TAA的作用。
这里来介绍一下双边滤波的细节。
由于时域+相邻数据复用还是会有一定的噪声,所以才增加了这个环节。
这个环节只会用在那些在相邻数据复用后,依然具有较大方差的区域;此外对于一些新的从被遮挡区域中露出的像素,由于缺少了时域历史,因此还会将滤波强度加倍
这里会采用tonemapped权重算法以移除firefly问题(避免边缘在滤波过程中被模糊掉?),这个算法在空间数据复用上回导致高光闪烁问题,但是在这个地方却没问题。
在一个场景中,有超过半数的像素粗糙度是高于0.4的,如上图左边的红色(高于0.3低于0.4)跟右边的红色(高于0.4)。
粗糙像素在反射计算的时候,就会导致射线的范围变得很广,从而使得射线命中结果并不那么一致,这里带来的问题就是,要么噪声大,要么trace效率低。
这里的做法是复用前面为Diffuse GI所准备的数据,前面我们提到的屏幕空间的Radiance Cache数据有足够高的方向分辨率(采用较多射线采样得到),可以重用于一个高粗糙度的specular lobe,具体做法是:
- 对GGX的lobe进行importance sampling
- 基于上面sampling得到的ray direction,对屏幕空间的Radiance Cache数据进行插值
这种做法可以降低specular反射的计算消耗,从UE提供的数据来看,大约可以降低50%~70%。
如果光滑度再高一点儿,屏幕空间Radiance Cache的分辨率也不够用了,这里的做法是缩短这种情况下屏幕空间射线的追踪范围,如果超出范围没命中就采用世界空间的Radiance Cache数据,这部分数据的分辨率是够的。
经UE测试,这种做法的性能消耗可以在上面的基础上再降低16%。
对于载具的clearcoat材质,也可以通过数据重用来节省性能:
- 底层的光滑表面可以重用屏幕空间的Radiance Cache数据
- 只对上层的ClearCoat层触发新的射线进行追踪
整体的tracing管线是基于屏幕tile进行的,这样设计的好处在于,可以灵活控制各个区域的射线密度并对射线数据进行重用,这对于在一帧中需要进行多次tracing(不透明一次,透明物件的反射一次,水面反射一次),每次tracing中可能又会触发额外的多次tracing的lumen来说,是非常有必要的
There’s a gotcha with implementing a tile based reflection pipeline - all of the denoising passes read from neighbors which may not have been processed. For the temporal filter’s neighborhood clamp, we could clear the unused regions of the texture, or branch in the temporal filter. It’s slightly faster to have the pass that runs before the temporal filter clear the tile border. We can only clear texels in unused tiles, to avoid a race condition with the other threads of the spatial reuse pass.
没太看懂,大概意思是,基于tile来进行射线设计,天然就可以利用这个点来进行部分算法的加速,移除部分不需要关注的区域的数据,比如在降噪算法中,移除不需要的相邻区域的数据或移除对应的分支代码执行路径来提升(一点)速度。
对于透明物件(玻璃)上的反射效果,由于需要支持无限多层,且不能直接在PS中进行射线tracing,因此就需要在PS之外完成相关计算,之后将结果传给PS来进行插值。
镜面反射由于背后不可穿透,因此在遇到这种情况时,就需要跳过上面的插值数据。
要得到玻璃上的前景反射,UE的做法是通过depth peeling的算法将最上层表面提取到一个mini GBuffer中,之后再在有效的像素上进行反射计算(tracing还是重绘?推测是tracing,毕竟后面说了移除denoiser),为了降低overhead,这里还会关掉denoiser。
其他层的反射则是借助世界空间的radiance cache来得到。在这里,相当于将这些Radiance Cache看成是不透明数据的Final Gather来使用。
不过要想满足需要,还要加大Radiance Cache的密度,这里的做法是将半透物体表面以一个较低的分辨率进行一次光栅化,标注出表面的位置,并在上面创建新的Probe,如上图中的左下角的图所示。
最后对于半透表面进行光栅化,这里可以支持任意层数的半透表面,这些表面在PS中会对前面的probe数据进行采样以得到反射效果。
3.2.1.2 Multi-Bounce
Lumen实现第一次反射之外的多次反射采用的是一种时域累计的方法,计算结果会存储在Surface Cache中:每帧的数据会使用上一帧的Surface Cache数据叠加上其他地方传过来的间接光数据,通过这种方式不断优化间接光结果,使之逼近Ground Truth。
3.2.2 光照计算
前面我们通过软件光追或硬件光追(还有前置的屏幕空间追踪,这里先不考虑)实现了射线与场景的求交计算,接下来我们需要拿到两套数据,分别是交点处的材质数据与输入光照数据。
SDF是无法存储上述数据的,我们需要额外一套数据结构来实现数据的存储与缓存,以避免频繁的更新消耗。
基于一系列的原因(详情参考PPT),排除了上面图中的方案,选择了Projected Cards,也就是常说的Mesh Cards方案。
1. Cards的生成
Cards是在Mesh导入的时候生成的,采用的是轴对齐的约束(说是也尝试过无方向约束的方案,但性价比较低),生成策略简要描述如下:
- 先对输入Mesh进行简化,通过体素化的方式将之抽象为若干套轴对齐的surfels
具体而言:
1.1 这里会从三个轴出发,选定一个轴,取其正交平面上的2D Cell(可以理解为WP的Cell)为单位,沿着轴发射64条射线与物体进行求交
1.2 基于相交射线所占的比例来决定是否需要生成一个surfel,这个占比在后面生成cluster的时候也会被用来评估surfel的重要度,同时射线的交点也会缓存下来,在后面会用于评估surfel对于cluster(card)的近平面而言是否是可见的
1.3 之后,对于每个surfel,还需要评估射线命中backface的比例,如果比例过高,可能就会被discard掉。
1.4 最后,还会存储射线命中的平均距离,据此来估算surfel的occlusion数据,这个数据在后面cluster中也会用来评估surfel的重要度
- 之后基于K-Means聚类算法对surfels进行聚类,得到的每个cluster后面就被转换为cards
具体的做法是选定一个没有被用过的surfel,逐渐往外扩散,对相邻surfel进行聚类(这里的surfel选定条件应该还要更细,不过文章里就没有深入介绍了):
2.1 计算每个没有用过的surfel的权重(针对给定cluster/card):这里会考虑cluster的距离(选择最近一个)、surfels的遮挡关系、cluster的长宽比(倾向于正方形的cluster,贴图利用率高?)以及surfel对于cluster的近平面是否可见
2.2 重复上述过程,直到有效的surfel被耗尽
2.3 基于cluster中的所有surfel重新计算cluster的中心点,并基于这个调整重复上述筛选过程,直到达到条件:重复次数达上限或者重复带来的变化已经微乎其微了
上述过程得到的cluster可能不是全局最优的,这里需要对所有的cluster并行采用上述流程再进行几轮迭代,迭代结束的条件跟前面单个cluster迭代结束条件一样,在这个过程中:会出现有些cluster抢不到surfel或者surfel太少的情况,需要将这些cluster移除;也会出现部分区域的surfel没有被任何cluster覆盖的情况,也需要相应的增加新的cluster。
最后选择具有最大覆盖率的N个Cluster来生成cards:
- 如果上述过程出现问题(mesh过小或者过复杂),会fallback到一个六面体
对于一些特殊的场景,可能会出现cards数目过高的情况,比如建筑(或者散布的鹅卵石),在近景处,需要较多的cards来填充细节,不过当拉到一定距离后,如果还保留这么多cards,消耗就吃不消。
这种情况下,UE会考虑对cards进行合并,实际执行的时候,会对card进行打组,这里的打组有两种方式:
- 一种是按照一定的规则自动打组
- 另一种是人工标注,运行时直接取用
对于处于同一组的cards,在运行时会被合并成六个新的card,对应于cubemap的六个面(这种情况认定角色处于物体外部,通常都能满足),之后针对这六个新的card,再进行一次采样即可:
跟前面的不同,这个过程是在运行时完成的。
2. Cards的表达
从需求层面来看,对于Cards有两个要求,而这两个要求存在一定的冲突:
- 由于Diffuse是低频数据,我们只需要其能覆盖场景的各个角落,也就是说,我们只需要数目比较多的小尺寸cards即可
- Specular对应的是镜面反射,具有这样表面的物体数目较少,也就是说,我们还需要数目较少,但分辨率较高的Cards
用UE的Matrix Awaken Demo中的数据来说话,某一帧大概需要1.5M的Cards(数目多,小尺寸),且其中有较多的Reflective Cards(大尺寸镜面反射)。
针对上述情况,UE采用Virtual Texture来做统一管理,这里称之为Virtual Surface Cache,这个Cache分为常驻(Resident)Page跟临时(Optional & Demand)Page两类:
- Diffuse数据存储在常驻Page中,运行时会根据物件到相机的距离远近不同,将之分配到不同尺寸的Page中(LOD策略,会根据需要实现Page之间的迁移),当物件距离相机过远,需要的Page尺寸过小时,就会将之从Page Cache中移除
- Specular Reflection数据存储在临时Page中,数据采用稀疏的方式进行存储,这些Page在光线命中光滑表面时分配(实际上是根据从GPU Request来判定),当不需要(什么时候?统计上一次使用时间,时间过久且显存不够时就清理)的时候清除
光滑表面的Page的分配、更新请求时按照如下流程来完成的:
Atlas的具体存储方式可以参考下图:
- 每张Atlas的分辨率是4k,会以128为单位分割成一个个的Page
- 对于实际尺寸超过一个Page大小的,会被分割到多个Page存储,这些Page之间需要留出0.5图素的边界以避免采样异常
- 对于吃鸡尺寸小于一个Page大小的,也需要在Page中做进一步划分,同样需要保留0.5图素的边界
- 对于同一个Card,由于设计了LOD机制,当距离突然拉近的时候,本来需要请求一张高分辨率贴图,但此时只有低分辨率的,为了避免重复的遍历以找到最接近当前需要的分辨率的贴图从而打破GPU的并行机制,降低效率,这里设计了一个PageTable,里面会自动将Card贴图跟Page的位置关联起来,对于没有streamIn的Page,会自动指向一张常驻的最低分辨率的Page,因此上述情况就可以只需要一次采样就获得需要的贴图数据
3. Cards数据的捕获
Material Cache数据的捕获
先来看下Cards中的Material数据是如何计算的:
- 这个计算过程是发生在运行时的,这样可以省去一些繁琐的预处理
- 具体的捕获方式是,针对每个Card,获取其中存储的相机的位置跟朝向以及需要渲染的Page的分辨率(作为RT的分辨率或Viewport Size),采用正交投影的方式完成一遍物件的绘制
- 绘制过程中需要采集的数据有材质属性(称之为Material Cache,包含Albedo、Normal等)(不知道Radiance Cache数据是否在这一刻采集)
- 运行时计算需要考虑消耗,这里的策略是控制预算,每一帧只完成512x512个图素的数据采集,这里会需要对各个Page的分辨率进行累加来控制
- 因为有预算,所以自然在更新的时候,需要设计Page的优先级,这里是通过到相机的距离、上次更新时间以及GPU Feedback来计算的
- 对于一些带有动画效果的材质,如果长期不更新的话,问题会很突出(考虑霓虹灯不闪,或者跳变),对于这种情况,就需要每帧更新
这种方式由于需要进行多个DP调用,因此当我们更新的Page尺寸较小的时候(物件的更高级别的LOD或者小尺寸物件),这里的消耗会相应增加,而如果使用Nanite的话,这种情况会有很大改善,因为可以通过一个Buffer来完成所有Cards捕获,且同一种材质只需要发起一个dispatch,另外考虑到Nanite的LOD是平滑过渡的,可以选择最佳的效果,因此对于Nanite而言,同样的消耗下可以捕获更多的Cards
Cards捕获的结果是类似于GBuffer的多张贴图:
- 可以看到,这里没有记录specular、subsurface渲染所需要的数据,对于这两种情况,UE的做法是直接调整Albedo来补偿因此损失的能量
- 生成的贴图需要标注出有效区域,在后面采样的时候才能得到正确的计算结果
- 为了能够正确区分被alpha test剔除的像素以及无效的像素,这里直接关闭掉alpha test,在捕获的时候还需要记录opacity,这样在后面进行光线追踪的时候,就不需要再重新对材质做一遍处理了
- 为了降低显存消耗,捕获得到的贴图还会在运行时采用BC方式做一遍压缩
上面得到的是Material Cache数据,那么Lighting Cache数据(直接光、间接光)呢?
不论是带阴影的直接光照数据还是多次反射的间接光照数据,计算消耗都非常高,不太可能运行时频繁更新,最好的策略就是使用Cache。
这里的Cache包括直接光跟间接光,光照数据的获取是通过tracing实现的。
tracing的起始位置是物体表面,为了避免跟自己相交,需要添加一个surface的bias(基于tracing方向跟surface normal计算得到)。
此外,texel可能处于几何体内部(室内),在tracing的时候,有可能会穿出几何体(back face),从而出现漏光(追踪到外部光源),这里会剔除掉穿透backface的射线从而缓解这个问题。
同样的,由于这个过程也是在运行时完成,因此也需要控制其消耗,这里依然采用固定时间预算的方式来实现。
对于每个Page,这里依然需要设定一个更新的优先级,不过这个优先级跟前面Material Cache的优先级并不相同,主要的算法是统计上一次使用时间与上一次更新时间的差值,越久则优先级越高。
上一次使用时间是通过GPU Feedback取到的,上一次更新时间则是在每次更新后记录的。这两个数据对于直接光跟间接光来说,是分开存储的(两套数据),这是因为两者的更新策略会不一样,比如直接光的更新频率会远远高于间接光。
基于优先级,这里会创建一个直方图(相当于将优先级按照区间进行排序),之后从高到低进行更新,直到预算耗尽为止。
另外,有时候我们需要对page进行LOD切换,这种情况下如果此前的page数据是有效的,那么这里会考虑对这个数据进行复用,也可以节省一部分计算消耗。
Lighting Cache数据的捕获
Lighting包括直接光跟间接光,直接光的计算逻辑参考下图:
在选定了需要更新的Cards Page之后,会将每个Page分割成8x8的Tile,之后将(所有Page的)Tile按照深度顺序进行输出,这样做的好处是保障trace时的coherency(空间数据命中率?)
对于每个tile,也不能无限制的统计对其产生影响的光源,也要做一下约束,目前是选择8盏灯,且目前的做法比较粗暴,直接选取遍历过程中的有影响的前8盏(后面可以考虑做一下优化)。
对于每盏灯,需要统计其对于每个像素的可见性,也就是阴影,这里是通过shadow map(屏幕内)+shadow ray trace(屏幕外)混合的方式实现,每盏灯占用一个bit,总共8个bit,构成的一张叫做shadow mask的贴图,接下来再对tile中的每个像素,遍历每盏影响光源,计算光照效果(相当于tile forward rendering),并叠加shadow mask来输出直接光结果。
再来看下间接光的计算逻辑,由于预算非常有限,除了Page更新数目需要控制之外,间接光的采集射线的数目也同样需要约束。
整体的间接光计算逻辑跟Final Gather实现有点像,不同的是第二次反射(将光线直接打在物体表面看成是第一次)是在surface空间中完成的。
这里说到了,Lumen间接光的前两次反射是每帧动态计算得到的,而后续反射则是基于一套时间复用逻辑,取用上一帧的数据得到的(feedback策略):
- 首先计算直接光照结果,接着取用上一帧的间接光照结果,两者通过一定的方式,整合得到一个虚拟的Final Lighting
- 基于这个Final Lighting完成对Card Surface上Probe(4x4个tile一个Probe)的更新与计算
- 基于上述Probe插值得到各个像素的间接光结果
理论上,每个像素需要发射64根射线,不过成本太高了,所以设计了一套Card Surface的Probe,Probe采集了Surface的光照数据,通过数据重用的方式来降低了整体的采样计算消耗。
为了进一步提升画质表现,这里针对probe的摆放位置还采用了TAA的jitter算法,通过时域复用来消除噪声。
对于Card上的每个像素,会采集最近的4个(半球)probe做插值,此处需要考虑遮挡关系、权重等,为了降低差值带来的跳变,这里还做了两点设计:
- 每个probe的权重会需要考虑probe的平面(上方向垂直平面),目的是跳过probe对其背后的像素的贡献(不然可能就漏光了)
- 基于probe的深度图来检测probe跟像素之间的可见性
由于间接光更新频率较低(预算有限),且这里有做时域累加平滑,为了避免重影,这里还做了控制,约束参与混合的时间帧为4帧(不知道是通过加权系数,还是数组的方式来控制的)。
这里还说到,虽然因为性能预算的约束,导致采用当前的二次反射(probe)的方式计算得到的结果跟原始的Final Gather(64射线trace)的结果存在一定的差异,但是对于低频的diffuse来说,效果是完全可以接受的,甚至对于镜面反射效果都基本够用了。
由于Global SDF是Mesh SDF合并得到的,也没有对应的Mesh Cards,因此不能走上面Surface Cache那一套,这里UE做了一套额外的设计,采用voxel lighting的方式来计算与存储光照数据:
- 整体的体积光通过4个clipmap(volume texture)来表达,分辨率为64x64x64 voxels
- 每个voxel需要存储6个方向的Radiance,在实际采样的时候,需要根据法线方向选择其中三个进行插值,这些Radiance也是通过对Mesh Card的Lighting Cache数据Merge得到的(具体算法还不明确)
- 为了表达card缺失(我理解是说某个voxel没有与card相交),这里还为每个voxel添加了一个alpha通道,权重的计算会需要用到这个数据
为了降低计算消耗,这里设计了一套缓存策略,只有当场景发生变化时,才会去更新这些影响到的bricks(voxel):
- 对于每个brick,会先统计其包含的所有Mesh(实际上需要更远一点,这里写的是4^3范围的brick中的物件)
- 之后发射6根射线进行trace,交点写入到visibility buffer中(这里只缓存交点,是因为光照变化频繁,不适合缓存)
这里之所以追踪场景的改动,是因为更容易监控,与之相比的是,光照的变化是高频的(每帧发生),监控也更难。
有了交点之后,光照数据的计算就可以通过每帧基于visibility buffer从mesh card中进行取用了。
Surface Cache目前总结起来有如下的一些问题:
- Voxel lighting光照比较粗糙,后续可能会优化这个问题
- 目前对于动态mesh(形变、骨骼等)的支持做的不是太好,所以对于植被来说,表现会有点糟糕,如果只是轻微形变的话,可以通过增加深度权重的偏移来减轻,但如果形变较大,就不行了
- 对于覆盖面积较大的物件,如树,会因为Surface layer数目不足而导致采样精度的不够,从而导致光照遮挡比较严重,效果偏暗,diffuse稍微好点,镜面反射效果比较差
4. Cards数据的采样
当射线命中一个物件的时候,我们可以拿到物件的MeshIndex,基于这个MeshIndex,我们可以获取到Mesh对应的Cards Grid(可以理解为该物件的Cards集合)。
基于交点的位置,我们可以找到交点对应的cell,从中可以得到cell对应的六个面(card),之后基于交点的法线,从上述六个面中挑选出3个来进行投影采样。
对于每个card,会用gather4指令来采得4个相邻的depth数据,基于depth数据,后面可以用手工方式进行双线性滤波(混合)。
对于每个card采样到的数据,这里会基于如下因素来计算权重:
- 交点的深度与card对应位置的深度的差异,此处还需要考虑两者的遮挡关系,如果card位置是被遮挡的,那这个数据还要被剔除
- 根据交点处法线的方向来计算三个card的权重
- 剔除掉被标注为无效像素的card的输入
经过上述逻辑之后,经过加权混合就得到了最终的采样结果。
4. Lumen结果
4.1 表现效果
自发光虽然不再需要美术同学手摆光源来模拟,但是对自发光物件的大小以及亮度还是有一些约束,如果过小或者过暗可能会导致噪声效果。
接下来看看Lumen的性能跟伸缩性。
1080P,TSR处理后输出4k,这比直接Lumen采用4k要有更高性能(废话)。
分别给出了两种配置,High/Epic,面向60/30fps,其他配置也有一些差别。
在Lumen演示场景中,由于没有太多光滑表面,所以反射消耗整体比较低,Lumen在High配置下只要2.8ms,完全够得上60fps,在Epic设置下,每个像素发射的射线数目是High的4倍,总体消耗达到了4.6ms。
Lyra场景,用的软件光追,High的耗时是4.3ms,场景虽然简单,但是由于有光滑反射以及一些干净的diffuse贴图,使得tracing的工作复杂度变得高了不少。
Epic采用的是全分辨率的反射,效果如上图右侧小图所示。
黑客帝国场景用的是硬件光追+Far Field方案,场景相对复杂,消耗也会更高一些。
室内装修场景,2080 Ti显卡。
目前在做的,后续有希望支持的特性有:
- 在下采样的Radiance Cache中增加对自发光mesh的显式采样
- 提高Surface Cache的覆盖率
- 支持骨骼模型
- 提供一个不需要Surface Cache支持的模式,虽然更贵,但是只用在高端设备上
- 持续提升画质,尤其是植被上的效果画质。
参考
[1]. Lumen - Real-time Global Illumination in Unreal Engine 5 - pdf
[2]. Lumen - Real-time Global Illumination in Unreal Engine 5 - pptx