开篇
我认为学习某样东西最好的方式就是在使用中摸爬滚打带着目标去边学边做,恰逢彼时腾讯在GDC 2023上发布了HSGI的演讲,体素软件光追+ReSTIR算法大放异彩,在那一刻想要RTX ON的心情达到了顶点,于是不知天高地厚地立下了要做一个软件光追管线复刻一下HSGI的目标。
斗转星移,四季更迭。在帕鲁之余,笔者完成了自己定下的一个一个小目标,对UE引擎有了一些基础的认识,也取得了一些微小的成果。思前想后决定赶在今年的尾巴做一个小小的总结,辞旧迎新结束这一年。
一、理论铺垫
实时GI的求解,其实就是求解渲染方程的过程。回顾整个计算的伪代码,小小的渲染方程竟藏着实时渲染的三座大山!(众所周知三座大山有四座。)
第一座山:可见性求解。首先我们需要找到对当前像素有光照贡献的所有可见点;
第二座山:材质的评估。对于每一个可见点,需要以某种手段评估其表面的材质以得到Base Color、Normal等材质属性;
第三座山:光照计算。万事俱备我们要根据可见点的材质,计算其收到的直接+间接光照,作为递归方程的返回值;
第四座山:降噪。我们只能遍历有限的可见点,因此蒙特卡洛估计器会返回嘈杂的结果。
对于小米加步枪一穷二白的软件光追来说,这三座大山都要我们自己一点点铲平。因此本文的重点将围绕前三座大山展开。在后续的文章中,我们将重点突破降噪这座大山。
二、核心思路省流速览
2.1 场景表达
腾讯在GDC 2023上发表的HSGI演讲提供了很棒的思路,笔者也沿用HSGI的思路,使用体素来作为GI场景的表达。这样自然而然地解决了材质评估和光照评估两座大山。
对于光线的每一个命中点,我们根据命中位置查询体素,就能得到这一点的Base Color、Normal等材质属性,我们称之为Material Cache;有了材质信息我们为每个体素计算其收到的直接+间接光照,光照结果存储回体素中,我们称之为Radiance Cache。
与之对应需要有一个分辨率为“体素分辨率÷4”的页表,指明当前Block在体素池子中的实际位置。于是体素寻址的过程需要先根据体素的位置确定体素所属的Block,然后再访问页表拿到实际的地址偏移进行纹理采样。
在HSGI原文中使用的是基于光栅化的体素化,这需要在CPU端遍历所有的物体,挑选“与待更新的体素区域相交的物体”并发起Drawcall,此处笔者耍了一些花活,使用了一种不依赖光栅化的体素化方法,这样整个体素化的流程能完全被GPU接管。
受到Lumen的启发,我们为每个模型拍摄6个方向的Mesh Card,记录物体的深度、Base Color、Normal等信息。对于每个体素,我们将其位置转换到拍摄Mesh Card时的相机空间,像做Shadow Map一样判断体素深度和Mesh Card上记录的深度是否足够接近。如果两个深度足够接近我们认为该体素是有效的。
对于可见性查询这座大山,HSGI原文使用的是基于HDDA的光线步进,我这里使用的方案是无符号距离场。参考了Lumen生成Global DF的Mip Texture的策略,对于存在体素的格子我们标记其距离值为0作为初始值,然后进行距离场的传播,每个体素需要遍历自己周围上下左右前后6个体素来更新自己的距离值。每帧进行一次距离的传播,若干帧之后场景就会被距离场填满。
核心思路其实非常简单,每个体素遍历所有物体的所有Mesh Card,判断体素是否命中Mesh Card的深度值。如果一个Cell里面存在体素,我们为其分配一个4x4x4大小的体素页面,将Base Color、Normal等材质属性写入体素池子中。最后我们标记无符号距离场的值为0,然后执行距离场的传播。
犹如把大象装进冰箱里面,看似简单的步骤需要依赖一系列的基础设施。我们怎样高效地筛选对体素有贡献的物体?怎样访问每个物体的Mesh Card?又该如何为体素分配内存页面?拿到了体素的材质属性我们怎么点亮整个体素场景?我们接着往下走,一步一步实现这些小目标。
三、物体剔除与GPU Scene
场景中有成百上千的物体,而3D纹理中又有成百上千的体素,我们不可能暴力地用两个for循环,每个体素遍历所有Object。如果一个体素落在了Object的包围盒外面,显而易见其无法对体素产生贡献,则我们可以跳过这个Object的检查,这就需要引入Object剔除的策略。
3.1 剔除流程
和UE引擎中的距离场更新类似,笔者使用环绕相机的体素ClipMap来进行远景的LOD表达。因此我们会对场景中的Object进行两次剔除。第一次剔除会排除那些不在ClipMap内的Object,得到每个ClipMap层级可见的Object列表。
对于剔除的操作我们也是在GPU上完成的,这就需要把全场景每个Object的包围盒、位置等信息(称之为Object Info)上传到GPU Buffer中。我们在GPU上开辟一块内存来存放Object Info,每帧增量地将CPU上有变化的Object Info同步到GPU Buffer上。
首先我们在CPU上实现了简易的线性分配器,用Free List链表来管理Object Info的分配,CPU和GPU两端的Object Info都共享一个数组下标索引,记作Object ID。每帧CPU端会将所有Dirty的Object Info上传到紧凑的Upload Buffer,接着在Compute Shader中根据Object ID将Object Info放到GPU Buffer正确的下标位置。
以第一次剔除(针对ClipMap)为例,我们为每个Object分配一条线程,以N个物体(线程组大小)为一组进行剔除。我们总共需要派遣的线程组数目为“全场景物体数目÷N”。
首先通过Shared Memory记录线程组内每个Object的剔除结果;接着由线程组内第一个线程统计与ClipMap相交的Object数目,通过Interlocked指令在剔除结果Buffer中申请空间;最后各个线程将包围盒和Object ID写入Buffer。
四、Mesh Card管理
一组Mesh Card提供了物体的几何信息(深度图)与材质信息的快照,也是本文中不依赖硬件光栅化的体素化算法的基石。和Lumen不同,在笔者的方案中Mesh Card不负责存储Radiance,因此它与场景Object的实例并非一一对应关系。使用了相同Mesh和Material的多个Object是可以共享同一组Mesh Card,可以将其类比为硬件光追中的BLAS概念。因此我们需要单独实现Mesh Card的分配器。
五、体素注入与页面分配
5.1 体素注入
在笔者的实现中,将128x128x128的体素均匀划分为了8x8x8个更新区块(Update Chunk),也是上文提到的剔除和体素注入的最小单位。
当相机移动、场景中的Primitive移动时都要重新对相关联的Chunk进行体素注入。前者只需按照移动方向将前方Chunk置脏,后者则需要同时记录增加、删除的Primitive的两个包围盒所影响的Chunk,因为我们需要先擦除旧体素再注入新体素。在每帧盘点出Dirty的Chunk之后我们通过Upload Buffer将Chunk上传到GPU。
5.2 体素页面分配
只有Bit Occupy Map我们能得到场景的几何表达,这解决了可见性查询的问题,本小节将解决如何查询命中点的材质属性。Mesh Card不仅记录了物体的深度信息,还保存了物体的Base Color、Normal和Emissive等材质属性。我们使用和5.1小节体素注入中提到的“深度相交测试”相同的UV访问Mesh Card图集,即可拿到物体的材质属性。
首先我们根据体素所在的Block的位置访问Page Table得到Page ID,如果当前Block存在体素而没有分配页面我们访问Page Free List获取一个空闲的Page,如果当前Block不存在体素却占用了一个有效的Page,我们将其插入到Page Release List中等待释放。最后的最后将Page ID写回Page Table,并记录在共享变量中方便组内其他线程访问。
六、距离场
完成了体素注入,我们获得了一张1/4分辨率的Bit Occupy Map,其中的每一个像素都表达了4x4x4的Voxel Block中的体素占位情况。我们可以直接利用Bit Occupy Map进行光线追踪,上文的可视化就是这么来的。
经过性能的对比,最终笔者还是决定使用距离场的方案,因为距离场能快速跳过空白的区域。出于学习的目的,笔者的代码中仍保留了Bit Occupy Map纯体素追踪的算法,通过控制台变量r.RealtimeGI.UseDistanceField开关。
距离场是处处连续且有数值意义的,可是我们目前手头上只有非0即1的体素,怎么把场景的几何表达从体素转换到距离场呢?这里笔者参考了Lumen中Global Distance Field的做法:首先注入初始的距离值,然后进行若干次距离场的传播(类比于对体素做Bloom)就可以将距离场填充满整个场景。
有了距离场我们就可以愉快地RTX ON了,首先将采样点相对Volume的位置映射到0~1的UV空间,采样对应UV的距离场并计算步进距离。值得注意的是距离场的距离并不代表实际空间中的距离值,因为我们的距离传播算法在对角线时会产生误差,因此需要开一下根号修正这个误差。
七、体素光照
有了距离场和稀疏体素,我们解决了第一小节中提到的可见性查询、材质评估两座大山。最后一个问题则是怎么评估命中点的光照呢?
和材质属性类似,我们可以保存每个体素其接收到的Radiance,这样在我们光线命中体素的时候就可以像访问Base Color、Normal那样直接获取到体素的光照信息。我们将带有光照信息的体素称为Radiance Cache,而本节的内容就是怎么根据体素的材质去计算其受到的Radiance。
在开始之前,我们要申请一块双份加大的Voxel Pool来额外存储每个体素的Radiance,我们为每个体素存储法线方向+法线反方向两个方向的Radiance,这么做是为了保证厚度为1体素的墙壁,只有外面的一侧是接受阳光的,避免发生漏光。在光线命中时,我们也要根据光线的方向,选取合适的体素面的Radiance
每个体素我们都能拿到其世界空间的位置、Base Color、Normal等信息,那直接N dot L一发其实就可以得到每个体素的来自方向光的直接光照。我们的计算流程可以类比为离屏的延迟渲染,只是G-Buffer的数据是从Voxel Pool里面拿到的。
这里还有一个小技巧,对于128^3x4的ClipMap来说,我们每帧计算全部体素的Radiance开销还是太大了。我们会选择2x2x2的棋盘格的模式去分帧更新体素的radiance,这样每帧的开销就只有全量更新的1/8,下图展示了光照剧变时Voxel来不及更新的情况。
7.2 体素间接光照
细心的读者肯定发现了,在上面的小节我们只计算了体素的直接光照,因此没有光源的地方是死黑的。其实现在的体素已经准备进行后续的Final Gather流程了,只不过我们得到的是一次反弹的结果。如果我们为每个体素计算间接光照,我们就得到了无限反弹的结果。
我们使用基于Probe的方案来计算每个体素受到的间接光照。对于每一个Block(4x4x4 Voxel)我们放置一个Probe并均匀的Trace射线,从Voxel Radiance Pool中获取命中点的光照,最后投影为球谐向量存储到一张3D纹理中,用来在下一帧的Voxel Lighting Pass为体素提供间接光。
可视化一发Probe看看,可以发现Probe是紧紧围绕物体摆放的,空的地方不会生成Probe,因为有Relocation机制,有体素的地方也不会有Probe。
我们为每个Probe分配64个线程并发射64(或者128,取决于控制台变量)条射线,命中了体素则从Voxel Pool中取得Radiance,否则采样Skylight,将Radiance投影成SH系数存储到Shared Memory,最后进行Reduction并将结果存储到3D纹理。
八、性能
笔者的电脑为3060 Laptop(挣韭者),使用的体素精度为最小ClipMap层级0.2m,每帧允许的Update Chunk为64个,此外为了开发方便控制台变量默认开启r.Shaders.Optimize=0,没有试过Cook后的情况。
UE引擎编辑器以4档位进行不间断相机移动以触发体素更新,体素注入部分会产生0.1~0.2ms的开销(图1)剩下0.5ms为距离场传播的开销(图2)。
九、阶段性总结
到这里,历经艰难险阻。我们攻克了软件光追的三座大山,成功把大象装进冰箱。一起来看看我们都干了什么:
为了体素注入,我们实现了一套Mesh Card的生成和管理系统 为了高效地剔除Object,我们实现了一套简易的GPU Scene 为了存储体素的材质信息和Radiance,我们实现了一套简易的体素分页内存管理系统 为了可见性查询,我们实现了距离场和体素HDDA两种软件光线追踪算法 为了计算体素的直接光和间接光,我们实现了简易的离屏Deferred Shading,以及类似DDGI的Probe Gather系统
到这里我们的GI之旅其实才进行到一半。在后面的文章中我们将重点介绍如何对屏幕像素进行Final Gather、如何使用各种奇技淫巧来降噪。
十、代码仓库
https://github.com/AKGWSB/UnrealEngine/tree/4.27-akgi
引擎部分的代码位:
Engine\Source\Runtime\Renderer\Private\RealtimeGI
着色器部分位于:
Engine\Shaders\Private\RealtimeGI
十一、参考与引用
HSGI: Cross-Platform Hierarchical Surfel Global Illumination
https://www.gdcvault.com/play/1029169/LIGHTSPEED-STUDIOS-Developer-Summit-HSGI
Radiance Caching for Real-Time Global Illumination
https://www.youtube.com/watch?v=2GYXuM10riw
游戏引擎随笔 0x29:UE5 Lumen 源码解析系列
https://zhuanlan.zhihu.com/p/499713106
最强分析 |一文理解Lumen及全局光照的实现机制
https://zhuanlan.zhihu.com/p/643337359
UE5 Lumen实现分析
https://zhuanlan.zhihu.com/p/378119803
近期精彩回顾