02 Lumen
UE5的另一大功能Lumen,是全新的全动态GI和反射系统,支持在大型高细节场景中无限次反弹的漫反射GI,以及间接的高光反射,跨度可以从几公里到几厘米,一些CVar的设置甚至可以到5厘米的精度。
美术和设计师们可以用Lumen创建更加动态的场景。譬如做实时日夜变化、开关手电筒,甚至是场景变换。比如炸开天花板后,光从洞里射进来,整个光线和场景变化都能实时反馈。所以Lumen改善了烘焙光照带来的大量迭代时间损失,也不需要再处理lightmap的uv,让品质和项目迭代效率都有了很大提升。
为了跨不同尺度提供高质量GI,Lumen在不同平台上也适用不同的技术组合。但是目前Lumen还有很多功能不足正在改善。我们先来简单了解下Lumen的大框架:为了支持高效追踪,我们除了支持RTX硬件的ray tracing,其他情况下我们也用Lumen在GPU上维护了完整的简化场景结构,我们称之为Lumen scene。
其中部分数据是离线通过mesh烘焙生成一些辅助的信息,包括mesh SDF和mesh card,这里的card只标记这个mesh经过grid切分之后,从哪些位置去拍它的一些朝向,和Bounding Box的一些标记。
利用刚刚这些辅助信息,和Nanite的多view高效光栅化生成Gbuffer,以及后续需要用到的其他数据,运行时会通过两个层面更新LumenScene:一层是CPU上控制新的Instance进来,或者一些合并的streaming的计算;另一层是更新的GPU数据,以及更新LumenScene注入,直接和间接Diffuse光照到光照缓存里面。
我们会基于当前屏幕空间放一些Radiance Probe,利用比较特殊的手段去做重要度采样。通过高效的Trace probe得到Probe里面的光照信息,对Probe的光照信息进行编码,生成Irradiance Cache 做spatial filter。
当然,接着还会有一些fallback到global世界空间,最后再Final Gather回来,和全屏幕的bentnormal合成生成,最终全屏幕的间接光照,再在上面做一些temporal滤波。这就是我们Diffuse整个全屏的光照,最后再跟Direct光照合起来,就得到了最终的渲染结果。
Lumen中的Tracing
Lumen的整体框架是软件追踪,靠Mesh SDF来做快速的Ray Tracing。在硬件允许时,我们会用RTX,这个今天不展开讲。Lumen的追踪是个Hybrid的方案,包括优先利用HZB做屏幕空间的Trace,如果失败的话,我们在近距离用一个全屏做Mesh SDF的Trace,这里因为Mesh SDF的instance做遍历效率其实还比较低。
因为用bvh在GPU上访问时,树形结构的缓存一致性很不好,所以我们只在很近距离1.8米内做第一层级的加速结构,这时我们利用一个简单的Froxel去做grid划分,快速求交所有instance的Bounding Sphere和对应cell相交结果,并存在对应cell的列表里,这是全屏做一次的。
接下来在tracing时,我每次只需要访问当前tracing点,比如marching以后所在的位置,所在的cell就能很快算出来,然后直接查询里面的instance列表,将第二层加速结构实际的,以及查出来列表里instance的SDF,都做一遍marching,取一个minimum值。
对于稍远一点的,我们会对场景做一个合并生成Global的SDF,它是个clipmap。但因为提高精度以后,数据存储等各方面每翻一倍精度会有8倍增加,我们会有一些稀疏的表达,我之后会简单讲一下。
在都没有trace到的情况下,我们会循环Global SDF的clipmap,对每一级clipmap做loop,直到Global SDF。比如二百多米全都没有trace到,那就是miss。当然,我们在之前的Demo里也用了RSM做最后的fallback,现在这个版本我们还没有放进去。
在SDF生成时,tracing我们都会做一些保守的处理,保证不会有薄墙被穿透。SDF其实是个volumetric,按voxel间隔来采样的生成过程,如果我的面很薄,在你的voxel精度以内,其实我们会有一些保守处理。
Lumen与场景结构
随之而来的问题是,我们trace到了某个表面之后,SDF里面没有办法拿到我们实际需要的数据,只能帮助快速找到交点位置,这个时候我们能拿到什么?近场MeshSDF时MeshId是我知道的,因为遍历列表的时候存了;另外我还知道SDF,所以可以靠SDF的gradient算出对应的normal,但是我有ID、normal和位置,要怎样得到我要的Radiance呢?包括Gbuffer的一些数据,这时我们是没有三角面片数据来插值计算的,没有各种材质的属性,所以我们需要一种高效的参数化方法。
我们使用了一种平铺的CubeMapTree结构:首先在Mesh导入时我们会预先处理,刚刚提到生成一组Card的描述,在runtime的时候,我们对放在地图里的每个实例,会根据mesh的Card信息实际利用Nanite高效光栅化,生成对应的Gbuffer。
Atlas在一张大的Atlas里面,其实是几张里面存了MRT,存了三张——包括albedo,opacity,normal,depth这样的信息。存的这个Atlas我们叫做Surface Cache,其实就是大家最终看到的LumenScene。当然,LumenScene还会经过SDF tracing,然后做tri-planar reprojection,这其实就是我们 tracing的结果。
我们tracing时tracing到哪个位置,就会找到它对应三个方向的Lumen card,把光栅化完的那些信息tri-planar reproject出来,得到的就是这个点要的信息。包括Gbuffer、Radiance信息。
Radiance信息从哪里来呢?是在生成这个card时,还会做直接的光照注入,然后生成它Irradiance的Atlas,并且这个Atlas中会根据维护的budget更新对应的Card,从texel出发,利用GlobalSDF去trace上一帧的lighting状态,也就是上一帧LumenScene的信息。
所以我们用屏幕空间Probe去trace时,trace到的那个Irradiance cache里的东西,就是多次反弹的结果。这个Atlas里card存的cache,其实都是2的整数次幂,为了方便我们做mip。因为我们有些阶段要用prefilter的mip,利用conetracing快速地做prefiltering结果的tracing。对于更远的Ray,我们其实在trace的时候,就已经借助的GlobalSDF,超过1.8米时,这个时候我们也没有对应的MeshID了。
所以类似地,在对应生成GlobalSDF的clipmap时,我们也会用Surface Cache生成一个voxel Lighting Cache,也就是LumenScene更低精度的voxel的表达。这个voxel Scene就是来自Cube Map Tree预处理后,radiance合并生成出来的。
这时我们每一帧都会重新生成voxel Lighting Cache,整个Lumen的结构是持续存在GPU上的,在CPU上维护对它的增减。我们哪些东西重新Streaming进来了,视角调整以后哪些card变得可见,为了控制开销,我会每帧固定更新一定数量的card,并且根据对应的Lighting类型,对这个Surface cache做一些裁减。对于那些tracing时不在屏幕中的shadow遮挡,我们都是靠Global SDF Trace来做的。
Final Gather
有了Tracing的手段,又从中获得了想要的数据的信息后,我们就要解决最终的GI问题了。传统模式中,比如Cards里存的是Surface Cache,已经有了多次反弹的照度信息,这里我们已经把追踪到的表面缓存不一致的求解计算分离到Card Capture和Card光照计算部分,就只需要在屏幕空间直接来Trace Ray,Trace这些Surface Cache里的Irradiance就可以了。
传统做RTX GI时,往往只能支撑1-2spp在Gbuffer发出BentNormal半球空间均匀分布的光线,如果靠SpatialTemporay,方差引导的这种滤波,在光线相对充足的情况下效果会非常好,但是当光线很不充足,譬如只有一束光从门缝或小窗口照进来时,离远一点的地方你Trace出来的Ray能采样到,实际有光源的地方概率太低,导致在滤波前的画面信息实在太少,最终滤波完的品质也是非常差、不能接受的。
我们的方法,是利用远低于Gbuffer分辨率的Screen Space的Probe,约每16个像素,根据实际像素插值失败的情况下,我们在格子里面还会进一步细化放置,放到一个Atlas里,我的每个Probe其实有8×8个Atlas,小的一个八面体投影的就是半球,自己World Space normal的半球,均匀分布我的立体角朝向的那个Tracing的方向,每一帧我还会对这个采样点做一些jitter,之后再去插值。
我们也会在像素平面,将最后全屏每个像素按照BRDF重要度采样,找周围Screen的Probe做跟我方向一致的weight调整,再去做插值,然后在计算probe的时候,我们利用半球投到八面体的方式,存了8×8的像素全都Atlas到一起,在细化时一直往下放。
所以最坏的情况,是比如每个像素都是一个前景,下一个像素就是一个后景——这其实不太可能,只是极端情况。这种情况我就变成要细化到每个像素,又变成逐像素去做这个tracing的Probe Cache。为了避免这种情况,我们其实是粗暴地限制了整个Atlas的大小,也就是最细化的东西,我填不下就不要了。
这样的好处是,我按照1/16的精度去做的Screen Probe,其实是1/256的精度,即使8×8我处理的像素数还是以前的1/4或者1/8,在做Spatial Filter最后每个像素插值时,我只要做Screen Probe3×3的filter,其实就相当于以前48x48的filter大小,而且效率很高。并且在求解间接的环境光蒙特卡洛积分时,可以靠上一帧这些ScreenProbe里reproject回来的Incoming Radiance的值,作为lighting的importance sampling的引导。
同样,BRDF也可以这样做。譬如BRDF值小于0的部分,无论入射光如何都不会贡献出射,随便这个方向上lighting在上一帧的incoming radiance。在这个点上有多少,这个朝向有光过来,我贡献也是0——我不需要它,所以我最终就把这两个东西乘到一起,作为我新的这一帧probe的importance sampling的方向。
最后,我就会根据这个方向去tracing,之后radiance会存到跟它对应起来另外一张8×8的图里,Atlas到一起。对于小而亮的部分离的表面越远,每帧又有jitter又有方向,引导方向不一样。有时没追踪到,它的噪点就会比较多,并且trace长度越长光线的一致性也不好,所以相反离得远的光源,相对贡献得光照变化频率也比较低。因为我离的很远以后局部光有一些位移,对我这里的影响是很小的。
所以我们可以用一个世界空间的probe来处理,因为这个时候可以做大量的cache,这里我的世界空间也是一个clipmap,它也是稀疏存储的。因为只有我Screen Space的Probe Tracing访问不到的东西,我才会去布置更多的World Space的Probe去做更新处理,这里就不展开讲了。
最终,我们需要在全分辨率的情况下做积分,这时有一个办法,就是根据全分辨率像素得到BRDF采样,方法就是我刚才说的,从Screen Probe里面找。比如8×8像素周围的都去找跟它方向一致的weight去插值,但这样噪点还是很多,所以我们其实是从它的mip里面去预处理,从filter过的结果里去找。
这样还会有一个问题:我自己朝向的平面,比如8×8像素周围的都去找跟它方向一致的weight去插值,所以最终我们把八面体的radiance转成了三阶球谐,这样全分辨率的时候能非常高效的利用球谐系数做漫反射积分,这样的结果质量和效率都很好。
最后的最后我们又做了一次,我对每个像素都做完之后,再做一次temporal的滤波,但是会根据像素追踪到的位置的速度和深度来决定我这个像素的变化,是不是快速移动物体区域投影过来的,来决定我这个temporal filter的强度。
我temporal filter越弱,其实就相当于前面我去采样的时候积分起来的时候,我采样周围3×3 Spatial Filter效果就越强。整体上Lumen的框架就是这样,我略过了大量细节和一些特殊处理的部分。譬如半透明物体的GI没有讲到,Spectular我也没有特殊讲,但是像spectular在粗糙度0.3到1的情况下,和这里importance sampling的diffuse其实是一致的。
Lumen的未来
在未来,我们也希望能做进一步改进,比如镜面反射,Glossy反射我们已经能很好处理,但是镜面反射在不用硬件追踪的情况下,现在Lumen效果还是不够的,包括SkeletalMesh的场景表达方式、破碎物体的场景表达方式,以及更好处理非模块化的整个物体。因为现在模块化整体captured card或者SDF的各种精度处理,可能还不够完善。
我们希望提升植被品质,以及更快速地支持光照变化,因为我们有很多hard limiter的更新,比如card数量之类的,会导致你过快更新时跟不上。最后,我们还希望能支持更大的世界,譬如可以串流SDF数据,以及做GPU driven的Surface Cache。关于Lumen我们今天就先讲到这里。
03 其他功能与Q&A
讲完两大招牌功能,我们快速过一下别的功能:比如最常被大家提到的大世界支持。从UE5开始我们有了更好的工具,比如World Partition就升级成了全新的数据组织方式,配合一套streaming系统,我们不需要手动处理runtime的streaming,引擎会帮你自动切分出不同的Partition,自动处理加载策略。
而且在这个基础上,我们又有Data Layer对于不同逻辑的处理,有World Partition Stream Policy根据layer对不同的Policy的定制,有Level Instance——可以把Level看成Actor、嵌套组成模板、模块化搭建地图,并且在Level Instance层级上设置Hlod的参数。
为了协同工作,我们还引入了One File Per Actor,大家每次在地图上编辑或新增时,其实只改到了一个独立的actor所对应的文件,文件锁的粒度比较细,就不会去动整个地图文件,这样引擎也会自动帮你管理这些散文件的changelist生成。
最后,我们还做了大世界的精度支持,把整个Transform的各种计算都改到了双精度浮点支持。另外,我们在Mobile上也做了更多支持,比如Turnkey全新的打包工作流程,移动端延迟渲染也进入了beta阶段。
除此之外,iOS我们也做了很多改进,在正式版本我们新增了opengles延迟渲染管线的支持,比如mali上的pixel local storage。同时我们也加入了DFShadow支持,以及一些新的shading model:例如和pc统一利用Burley SSS参数驱动的移动版本的preintegrated皮肤。
同时我们终于对DXC下的半精度做了支持,而且把所有的Metal Vulkan openGLES都用DXC做了转换。同时我们还加入了point light shadow、CSM cache和带宽优化过的565的RVT,做了全新的 gpu instance culling和更高效的auto-instancing等功能。
Q&A
Q:UE5.0正式版会在什么时候发布?
王祢:目前预计是明年上半年,可能在4月份左右发布。
Q:UE5.0之后还会支持曲面细分吗?
王祢:由于不少硬件平台曲面细分效率的问题,我们打算彻底去掉。未来我们会尝试用Nanite去做,但是目前还没有做到。所以现在的workaround如果不做变形,那就只能靠Nanitemesh或者靠Virtual Heightfield Mesh来处理。
2021腾讯游戏开发者大会链接:
https://gameinstitute.qq.com/tgdc/2021/?adtag=article