GPU Lightmapper:技术深度剖析

照明团队正在全力加快迭代速度。我们在设计渐进式 Lightmapper 时就考虑到了这一目标。我们的目标是就您对项目中的照明所做的任何改动提供快速反馈。在 2018.3 版中,我们推出了 GPU 版本的渐进式光源映射器预览版。现在,我们正朝着在功能和视觉质量上与 CPU 同类产品看齐的方向迈进。我们的目标是使 GPU 版本比 CPU 版本快一个数量级。这为艺术工作流程带来了交互式光绘,极大地提高了团队的工作效率。
有鉴于此,我们选择使用RadeonRays:一个来自 AMD 的开源光线追踪库。Unity 和 AMD合作开发了GPU Lightmapper,实现了多项关键功能和优化。即:功率采样、射线压缩和自定义 BVH 遍历。
GPU Lightmapper 的设计目标是提供与CPU Lightmapper相同的功能,同时实现更高的性能:
- 无偏见的交互式灯光制图
- CPU 和 GPU 后端功能均等
- 基于计算的解决方案
- 波前路径跟踪,实现最高性能
我们知道,迭代时间是艺术家提高视觉质量和释放创造力的关键。交互式光线映射是这里的目标。不仅仅是整体烘烤时间令人印象深刻,我们还希望用户体验能提供即时反馈。
为此,我们需要解决一系列有趣的问题。在本篇文章中,我们将探讨我们做出的一些决定。
为了让 Lightmapper 向用户提供渐进式更新,我们需要做出一些设计决定。
在进行直接照明时,我们不会缓存辐照度或可见度(直接照明可以缓存并重新用于间接照明)。一般来说,我们不会缓存任何数据,而是倾向于选择足够小的计算步骤,以避免产生停滞,并在烘焙时提供渐进的交互式显示。

场景可能非常大,包含许多光照图。为确保工作用在能为用户带来最大利益的地方,必须将工作重点放在当前可见的区域。为此,我们首先会检测屏幕上哪些光贴图包含了最多的未收敛可见色块,然后渲染这些光贴图,并优先处理可见色块(一旦所有可见色块收敛,屏幕外的色块将被烘焙)。
如果一个色点位于当前的摄像机光锥内,并且没有被任何场景静态几何体遮挡,则该色点被定义为可见。
我们在 GPU 上进行这种删减(以利用快速光线追踪)。以下是剔除工作的流程。

剔除工作有两个输出:
- 剔除贴图缓冲区,存储光照贴图的每个像素是否可见。然后,渲染工作就会使用这个剔除映射缓冲区。
- 一个整数,代表当前光贴图的可见色块数。CPU 将异步读回这个整数,以便在未来调整光绘调度。
在下面的视频中,我们可以看到删减的效果。烘烤中途停止,以便演示。因此,当 "场景 "视图移动时,我们可以看到从初始摄像机位置和方向看不到的尚未烘焙的色块(即黑色)。
出于性能考虑,可见度信息只在摄像机状态 "稳定 "时更新。此外,超采样也没有考虑在内。
GPU 可用于大型数据集,它们能够以延迟为代价实现高吞吐量。此外,它们通常由 CPU 提前填满的命令队列驱动。持续不断的大型命令流的目的是确保 GPU 能够饱和工作。让我们来看看我们用来最大限度提高吞吐量和原始性能的关键配方。
我们处理 GPU 光映射数据管道的方法基于以下原则:
1.我们只需准备一次数据。
此时,CPU 和 GPU 可能会同步,以减少内存分配。
2.一旦开始烘烤,就不允许 CPU 与 GPU 同步。
CPU 向 GPU 发送预定义的工作负载。这种工作负载在某些情况下会过于保守(例如,使用 4 个弹跳,但所有间接射线都在第 2 个弹跳后完成,那么我们仍有排队的内核将被执行,但会提前退出)。
3.GPU 不能生成射线或内核。
相反,它可能被要求处理空作业(或非常小的作业)。为了高效处理这些情况,内核的编写方式是最大限度地提高数据和指令一致性。我们通过数据 "压实 "来处理这个问题,稍后将详细介绍。
4.我们不希望出现任何 CPU 和 GPU 同步点,也不希望烘烤开始后出现任何 GPU 气泡。
例如,一些 OpenCL 命令会产生较小的 GPU 气泡(即 GPU 没有东西可处理的时刻),如 clEnqueueFillBuffer 或 clEnqueueReadBuffer(即使是异步版本),因此我们尽可能避免使用这些命令。此外,数据处理需要尽可能长时间地在 GPU 上进行(即渲染和合成直至完成)。当我们需要将数据传回 CPU 进行额外处理时,我们将以异步方式进行,而不会再次将数据传回 GPU。例如,缝合目前是 CPU 的 PostProcessing。
5.CPU 将以异步方式调整 GPU 负载 。
当摄像机视图发生变化或光照图完全聚合时,更改正在渲染的光照图会产生一定的延迟。CPU 线程使用无锁队列生成并处理这些回读事件,以避免互斥竞争。

GPU 架构的主要特点之一是支持广泛的 SIMD 指令。SIMD 代表单指令多数据。在所谓的 warp/wavefront 内,一组指令将在给定的数据量上按顺序执行。这些波面/warps 的大小为 64、32 或 16 个值(取决于 GPU 架构)。因此,一条指令将对多个数据进行相同的转换--单指令多数据。不过,为了获得更大的灵活性,GPU 还能在其 SIMD 实现中支持不同的代码路径。为此,它可以在处理子集时禁用一些线程,然后再重新加入。这就是 SIMT:单指令多线程然而,这样做是有代价的,因为波阵面/warp 中的不同代码路径只能从 SIMD 单元的一小部分中获益。阅读这篇出色的博文,了解更多信息。
最后,SIMT 理念的一个巧妙延伸是,GPU 能够在每个 SIMD 内核上保留多个翘曲/波阵面。如果一个波阵面/warp 正在等待缓慢的内存访问,调度器可以切换到另一个波阵面/warp,并在此期间继续工作(前提是有足够的待处理工作)。不过,要想真正做到这一点,每个上下文所需的资源量必须较少,这样占用率(待处理的工作量)才会较高。
总结一下,我们的目标应该是
- 多线飞行
- 避免分歧分支
- 良好的占用率
良好的占用率与内核代码息息相关,这个话题过于宽泛,不适合在本博文中讨论。这里有一些很好的资源:
- 了解 GPU 上的延迟隐藏(英伟达™ Vasily Volkov 著)
- Francesco Cifariello(Unity Technologies)的GPU Scalarization 简介
一般来说,目标是稀疏使用 Localization 资源,尤其是矢量寄存器和本地共享内存。
让我们来看看在 GPU 上烘焙直接照明的流程。本节主要介绍光照图,但光照探针的工作方式与光照图非常相似,只是它们没有可见度或占用率数据。

这里有几个问题:
- 该示例中的光贴图占用率为 44%(9 个像素中占用了 4 个像素),因此只有 44% 的 GPU 线程会实际产生可用的工作!此外,内存中的有用数据非常稀少,因此即使是未被占用的像素,我们也需要支付带宽费用。在实际应用中,灯光贴图的占用率通常在 50% 到 70% 之间,因此潜在收益巨大。
- 数据集太小。为简单起见,示例中显示的是 3x3 光图,但即使是常见的 512x512 光图,对于最近的 GPU 来说也是一个太小的数据集,无法达到最高效率。
- 在前面的章节中,我们谈到了视图优先级和筛选工作。上述两点更加真实,因为一些被占用的色元不会被烘焙,因为它们在 "场景 "视图中是不可见的,这就进一步降低了占用率和整体数据集。
如何解决这个问题?作为与 AMD 合作的一部分,还增加了光线压实功能。这个想法大大提高了光线跟踪和着色性能。简而言之,我们的想法是在连续内存中创建所有射线定义,从而使翘曲/波阵面中的所有线程都能处理热数据。
在实际操作中,每条射线还需要知道与之相关的像素的索引,我们将其存储在射线有效载荷中。此外,我们还存储了全局压缩光线计数。
下面是压实后的流动情况:

现在,对光线进行着色和跟踪的内核都只能在热内存上运行,而且代码路径的偏差最小。
未来计划我们还没有解决对 GPU 来说数据集可能太小的问题,尤其是在启用视图优先级的情况下。下一个想法是从 gbuffer 表示法中对射线的生成进行去相关化。采用最简单的方法时,我们只能为每个像素生成一条射线。既然我们最终还是要生成更多的射线,那么我们不妨先为每个图元生成几条射线。这样,我们就能为 GPU 创造出更多有意义的工作,让 GPU 得以咀嚼。流程是这样的

在压缩之前,我们会为每个像素生成许多射线,我们称之为扩展。我们还生成元信息,用于收集步骤,将其累积到正确的目标文本格式中。
扩展内核和收集内核的执行频率都不高。在实践中,我们会对每一束光(直接光)进行扩展和遮光,或对所有反弹光(间接光)进行处理,最后只收集一次光。
有了这些技术,我们就能实现目标:生成足够多的工作,使 GPU 达到饱和,并且只将带宽用在重要的像素上。
这就是每个色素拍摄多条射线的好处:
- 即使在视图优先模式下,活动射线集也始终是一个庞大的数据集。
- 准备、跟踪和着色都是在非常连贯的数据上进行的,因为扩展内核将在连续内存中创建针对同一色元的光线。
- 扩展内核处理占用率和可见性,使准备内核更简单,从而更快。
- 扩展/工作数据集缓冲区的大小与光线贴图的大小无关。
- 我们拍摄每个像素的光线数量可以由任何算法驱动,自适应采样就是一种自然扩展。
间接照明采用了非常相似的理念,尽管更为复杂:

对于间接光,我们必须进行多次反弹,而每次反弹都可能丢弃随机光线。因此,我们进行迭代压缩,以继续处理热数据。
我们目前使用的启发式方法倾向于在每个像素上使用等量的射线。目标是获得非常先进的输出。不过,其自然延伸是通过使用自适应采样来改进这些启发式方法,从而在当前结果存在噪声的地方拍摄更多射线。此外,启发式还可以通过了解硬件的波阵面/warp 大小,提高内存和线程组执行的一致性。
使用 GPU Lightmapper 烤制ArchVizPRO中的资产。
透明/半透明有很多使用案例。处理透明度和半透明的常用方法是:投射光线、检测交集、获取材质,如果遇到的材质是半透明或透明的,则安排新的光线。不过,在我们的案例中,由于性能原因,GPU 无法产生射线(请参阅上文 "数据驱动流水线 "部分)。此外,我们无法合理地要求 CPU 提前安排足够多的射线,以确保我们能处理最坏的情况,因为这将对性能造成重大影响。
因此,我们采用了混合解决方案。我们以不同的方式处理半透明和透明,从而解决上述问题:
透明度(一种材料因有孔洞而不透明)。在这种情况下,射线可以根据概率分布穿过或弹开材料。因此,CPU 提前准备的工作负载无需改变,我们仍然是独立于场景的。
半透明性(当一种材料对穿过它的光线进行过滤时)。在这种情况下,我们采用近似值,不考虑折射。换句话说,我们让材料为光线着色,但不改变光线的方向。这使我们能够在行走 BVH 的同时处理半透明效果,这意味着我们可以轻松处理大量的剪切材料,并很好地扩展场景中的半透明复杂性。

但是,有一个问题;BVH 的遍历顺序不对:
在闭塞光线的情况下,这样做实际上是没有问题的,因为我们只对光线沿线每个相交三角形的半透明衰减感兴趣。由于乘法是交换式的,因此 BVH 的无序遍历不成问题。
然而,对于交叉射线,我们希望能够在一个三角形上停止(当三角形是透明的时候,以一种概率方式),并收集从射线原点到命中点的每个三角形的半透明衰减。由于 BVH 遍历是无序的,我们选择的解决方案是首先只运行交叉点来找到命中点,如果有任何半透明点被命中,则标记该射线。因此,对于每一条标记光线,我们都会生成一条额外的闭塞光线,从交点光线原点到交点光线命中点。为了提高效率,我们在生成闭塞光线时使用了压缩技术,这意味着只有当交点光线被标记为需要进行半透明处理时,才需要支付额外的费用。
这一切都要归功于 RadeonRays 的开放源代码特性,作为与 AMD 合作的一部分,RadeonRays 被分叉并根据我们的需求进行了定制。
我们已经看到了我们在原始性能方面所做的工作,非常好!然而,这只是谜题的第一部分。每秒高采样率固然很好,但真正重要的是烘烤时间。换句话说,,我们希望从每一束光线中获得最大收益。最后一句话实际上是数十年持续研究的结果。这里有一些很好的资源:
Unity GPU Lightmapper 是一款纯粹的漫反射光源贴图。这大大简化了光线与材料的相互作用,也有助于抑制萤火虫和噪音。不过,要提高收敛速度,我们还有很多工作要做。以下是我们使用的一些技术:
俄罗斯轮盘
在每次反弹时,我们都会根据累积反照率对路径进行概率杀伤。在埃里克-维奇的论文(第 67 页)中可以找到很好的解释。
环境多重重要性取样 (MIS)
HDR 环境中的高方差会在输出中产生大量噪点,需要大量采样才能产生令人满意的效果。因此,我们采用了专门针对环境评估的综合取样策略,首先对环境进行分析,确定重要区域,然后进行相应的取样。这种方法并非环境取样所独有,一般被称为多重重要性取样,最初是在 Eric Veach 的论文中提出的(第 252 页)。这项工作是与格勒诺布尔Unity 实验室合作完成的。
许多灯光
在每次反弹时,我们都会概率性地选择一个直射光,并通过空间网格结构来限制影响表面的灯光数量。这项工作是与 AMD 合作完成的。我们目前正在深入研究多光源问题,因为光源选择取样对质量至关重要。

去噪
使用根据路径追踪器输出结果训练的人工智能去噪器去除噪音。请看 Jesper Mortensen 的Unity GDC 2019 演讲。
我们已经看到,数据驱动的管道、对原始性能的关注和高效算法是如何结合在一起,通过 GPU Lightmapper 提供交互式光绘体验的。请注意,GPU Lightmapper 正在积极开发中,并将不断改进。
照明团队
PS:如果您觉得这篇文章很有趣,并且有兴趣接受新的挑战,我们目前正在哥本哈根招聘一名照明开发人员,请与我们联系!
---
想了解如何在 Unity 中优化图形?查看本教程。
