赞
踩
https://zhuanlan.zhihu.com/p/531442379
游戏引擎的自我修养
44 人赞同了该文章
在《UE5 Lumen 源码解析》系列的前面几篇中已经将 Lumen 的光照准备部分基本讲解完毕,接下来应该进入 Lumen 的 Final Gather Pipeline 部分,包括 Screen\World Space Probe、Radiance Cache 等内容,不过基于 Monte Carlo 的实时全局光照一个重要组件是重要性采样(Importance Sampling),这是保证在满足实时性能的同时提供高质量 GI 的关键因素,也是 Lumen 的重要组成部分,并且在后续 Final Gather 的流程中嵌入了 Importance Sampling 流程,因此为了后续更好的讲解和理解,在进入 Final Gather 之前本篇先来讲解 Lumen 中的 Importance Sampling。
Importance Sampling 本质上是在样本数一定的情况下增加有效样本,减少方差,对于基于 Monte Carlo 的实时全局光照来说就是对 Tracing 的重定向,使光线尽可能朝向有效 BRDF 以及光照方向追踪,这样就可以在不增加光线预算的情况下降噪,加快收敛速度,提升 GI 质量。为了便于后续讲解,我们先来看看 Lumen 中的光线生成机制。
在《原理篇》中我们讲到 Lumen 是一个基于 Probe 的实时 GI 系统,因此是从 Probe 发出 Ray\Cone,每个 Probe 都会 Trace 一定数量的光线,这个数量由 CVars GLumenScreenProbeTracingOctahedronResolution 控制,默认为 8,即每个 Probe 可以进行 8x8=64 次 Tracing,64 条光线。
这些光线是以在 Probe 为球心的球面上进行分布的,为了使分布均匀及便于存储访问,Lumen 采用八面体法来实现球面方向与 2D 纹理空间坐标进行转换,这样可以将所有 Probe 的 Tracing 存储在一个 2D Texture Atlas 中,其中每个 Probe 的所有 Tracing 对应 Atlas 上的一块正方形 Texel 区域,其中每个 Texel 即对应 Probe 的一个 Tracing(Ray),如下图所示:
有了这样的映射关系,具体在 Compute Shader 的实现中,可以将每个 Probe 对应一个 2 维 Thread Group,Thread Group 的线程数为 Probe 的 Tracing 数量,例如 [8,8,1],这样就可简单的用 Shader 的系统语义值 SV_GroupThreadID 作为 Probe Altas Texels 坐标,再用这个坐标用八面体法计算出在球面上的 Tracing 方向。
Lumen 采用了一种称为结构化重要性采样(Structured Importance Sampling)的机制,其核心思想是将 Probability Density Function(下简称 PDF)高的样本分配给低 PDF 的样本,并且将高 PDF 样本进行细分(Subdivision)作为超采样,从而形成层次化结构,如下图所示,一个高 PDF 的 Tracing 被细分为 4 个:
为了将 Structured Importance Sampling 与前面所说的八面体光线生成的机制整合,Lumen 增加了一个间接层,这是一个名为 Lumen.ScreenProbeGather.RayInfosForTracing 的 Render Dependency Graph(RDG)2D Texture,用于存储每个 Probe Tracing 对应的八面体 Texel 坐标信息,这其中包括了需要重定向的 Tracing。当某个 Tracing(Octahedral Texel)被重定向时,只需修改这个 Tracing 的细分的八面体 Texels 坐标以及细分的 Level 即可,对于没有重定向的 Texel,其八面体 Texel 坐标即是当前 Texel 坐标。如下图所示:
回顾下 Monte Carlo 积分形式的渲染方程:
其中 为 , 为入射光照(Incident Radiance),传统上会对 Monte Carlo 积分的这两项进行重要性采样,Lumen 也是如此。对于 BRDF,Lumen 使用三阶球谐将每个 Probe 周围有效采样点的 Normal 投影到 SH 上,然后累加起来计算 SH 系数均值作为 Screen Probe 的 SH PDF,最后在生成光线时用 Tracing 的方向与 Probe 的 SH PDF 点积计算出每个 Tracing 的 PDF。
而对于 Incident Radiance 的 PDF 由于实时性能需求不能如传统方法对光源进行采样,而是通过重投影到上一帧的 Screen Space Radiance Cache 并平均四个相邻的 Screen Probe 作为 PDF,因此这样只是近似值并不准确,但由于 BRDF 是精确的,最终会结合 BRDF PDF 综合判断作为 Tracing 重定向的依据。下文会根据源码详细阐述 BRDF 和 Incident Radiance 的 PDF 计算过程以及重定向判断机制。
Lumen 的 Importance Sampling 的源码结构比较清晰,基于 GPU Driven。CPU 端逻辑都集中在 LumenScreenProbeImportanceSampling.cpp 中,基本都是 Shader 和 RDG 资源和参数的配置代码,其中 GenerateBRDF_PDF 是 生成 BRDF PDF 的入口函数,在 Final Gather 的流程中被调用。GenerateImportanceSamplingRays 函数中有两个 Pass,一个是生成 Incident Radiance PDF 的 ComputeLightingPDF Pass,一个是生成结构化重要性采样数据的 GenerateRays Pass。GPU 端源码都在 LumenScreenProbeImportanceSampling.usf Shader 中,由于是 GPU Driven,因此 PDF 生成的核心逻辑都在 Shader 中,下面主要是根据 Shader 源码进行讲解。
如上所述生成每个 Screen Probe 的 BRDF 的三阶球谐 PDF,此步骤在放置 World Space Probe (在之后的《Radiance Cache 篇》会详细阐述)之后执行。Pass 信息如下:
实际上这一步其实并没有真正生成 BRDF PDF,而是生成了每个 Probe 的 BRDF 的三阶 SH 系数,在后续的 GenerateRays Pass 中才使用这个 SH 计算真正的 PDF。主要思路是以 Screen Probe 为中心采样周围一定数量的 Pixel,计算对 Probe 的影响权重,如果有影响则将 Pixel 的 BRDF 转换为三阶 SH,最后累计所有对 Probe 有影响的 SH 系数,加权平均得到 Probe 的 BRDF 的 PDF,最终输出到名为 Lumen.ScreenProbeGather.BRDFProbabilityDensityFunctionSH 的 RDG Buffer 中,为了优化,存储格式为 float16。
每 Thread Group 对应一个 Screen Space Probe,每 Thread Group 有 8x8=64 个线程,每线程根据当前 GroupThreadId 计算 Pixel Offset,在当前 Probe 周围采样 G-Buffer,判断 Probe 是否与 Pixel 所在平面很接近,如果是则根据 Pixel 的 Normal 生成三阶 SH 基函数的 9 个系数,存储在 LDS 中,并累计 SH 数量。源码如下:
上述算法示意图如下所示,其中黄色圆圈表示 Probe,蓝色箭头为 Probe 所有的 Tracing 方向,虚线为 Sample 所在的 Plane,绿色圆圈代表对 Probe 有影响的 Sample(与 Probe 几乎共面),红色则代表没有影响的 Sample,黑色箭头为 Sample Normal:
接下来将 LDS 中所有 SH 基函数系数累加求和,最终将这些 SH 系数和再除以 SH 数量得到归一化的 PDF。注意这里不是一个线程进行求和,而是整个 Thread Group 的所有线程并行求和,具体算法如下:
每循环处理 4 个 SH 系数和,循环结束时将 4 个 SH 系数和存储到 LDS 对应的偏移位置中,这样每线程累加 4 个 SH 系数,所有线程并行执行,如下图所示:
注意在每次循环结束时还会判断是否还有可以处理的 SH 系数,如果有则继续循环处理。例如如果当前 Thread Group 有 64 个 SH 系数,则由线程 0~15 将 64 个 SH 每 4 个一组求和写入到 16 个 LDS 中,第二次循环则由线程 0~4 将 16 个 SH 系数和每 4 个一组求和写入到 4 个 LDS 中,第三次循环则只有线程 0 将 4 个 SH 系数求和写入到 1 个 LDS 中,这样就完成了所有 SH 的并行求和,代码如下:
完成了所有 SH 求和之后,最终将当前 Probe 的 BRDF SH 9 个系数和求平均后写入到 Lumen.ScreenProbeGather.BRDFProbabilityDensityFunctionSH 中。需要说明的是,在写入时也是并行执行,由每组前 9 个线程负责写入线程索引对应的 SH 系数,并且在写入前将这些 SH 系数和除以 SH 数量以得到归一化的 PDF。
BRDFProbabilityDensityFunctionSH 除了用于计算 BRDF PDF 之外,在 ScatterScreenProbeBRDFToRadianceProbes Pass 中还用于将 BRDF SH 扩散到 8 个 World Radiance Probe 中作为每个 Probe BRDF 的参数化系数,在后续的《Radiance Cache 篇》中会详细阐述。
Lumen 还实现了一套不使用 SH 的 BRDF PDF 计算机制,可以通过 BRDF_PDF_SPHERICAL_HARMONIC 宏控制是否启用,出于篇幅限制,这里就不进行源码解析,只阐述算法机制。前面部分与 SH 方式类似,选出与 Probe 共面的有效采样点,使用每个 Tracing 方向与有效采样点的法线点积作为 PDF,遍历所有采样点求出最大值作为当前 Tracing 的 PDF,保存在名为 Lumen.ScreenProbeGather.BRDFProbabilityDensityFunction RGD Texture 中,格式为 PF_R16F。在生成光线时直接从中加载每个 Tracing 对应的 PDF 进行计算。但是由于需要为所有 Probe 的所有 Tracing 都记录 PDF,因此这种方式所需的存储空间要比 SH PDF 方式大很多,以 64 个 Tracing 为例,SH 方法每个 Probe 仅需存储 9 个 16bit 系数,而这种方式每个 Probe 需要 64 个 16bit,所需内存比 SH 方法大了 7 倍。从上述可以看出,与 SH 方法相比本质上都是使用 Tracing 的方向与采样点法线点积计算 BRDF PDF,不同之处在于 SH 方式是计算 Probe 的 BRDF SH 的均值,且计算真正的 PDF 是在之后进行;而非 SH 方法则是先计算出 PDF,并选出最大值作为后续的判断依据。下面是 SH 和非 SH 方式的效果图对比,可以明显看出在噪点、漏光方面,SH 方式明显比非 SH 更好。
非 BRDF SH
BRDF SH
非 BRDF SH
BRDF SH
计算每个 Screen Probe 的所有 Tracing 的 Incident Radiance PDF,Pass 信息如下:
将当前帧 Screen Probe 重投影到上一帧,在当前 Screen Probe 周围范围内随机访问上一帧的 Neighbor Probes,判断每个 Neighbor Probe 是否对当前 Probe 有影响(共面),如果有则采样 Neighbor Probes 的 Radiance 作为 PDF。
这部分功能可开关,由 CVars GLumenScreenProbeImportanceSampleIncomingLighting 控制,另外 CVars GLumenScreenProbeImportanceSampleProbeRadianceHistory 用于控制是否采样上一帧 Radiance Cache 作为判断依据。最终 Incident Radiance 的PDF 输出到名为 Lumen.ScreenProbeGather.LightingProbabilityDensityFunction 的 RDG 2D Texture 中,大小为所有 Screen Probe 数量 x 每个 Probe 的 Tracing 数量,格式为 PF_R16F。
每 Thread Group 对应一个 Screen Probe,根据线程组 ID 计算出 Screen Probe 的屏幕坐标,并获得 Probe 对应的 Depth,以此计算出 Probe 的当前帧的 NDC 坐标,用此坐标重投影到前一帧得到 HistoryScreenUV,并判断是否超出重要性采样历史边界:
如果没有超出边界,则对 HistoryScreenUV 使用 Hammersley Points 生成低差异序列进行 Jitter,获取相对于 HistoryScreenUV 在左上方向的 0~15 个像素之间随机抖动的屏幕坐标,以此作为 History Probe 的起始坐标,这样会在以当前 History Probe 为中心的 9 个 Probe 中随机选择相邻的 4 个,其效果是每个 Thread(Sample)可能使用不同的 Probes 进行插值,从而达到进一步降噪,使结果更加平滑的目的。如下图所示,蓝色为 History Probe 所在位置,对于每个 Probe 的 64 个 Tracing 来说,在绿色的 9 个 Neighbor Probe 范围内随机选择 4 个进行插值,如下图所示:
代码如下:
接下来获取当前帧 Screen Probe 的 Normal, 构建 Probe 所在的 Plane,然后遍历四个随机选择的 Neighbor Probes,计算 Neighbor Probe 与 Screen Probe Plane 的距离,如果距离很则认为对当前 Screen Probe 有影响则 History Weight 为 1,否则为 0,然后根据当前线程计算 Neighbor Probe 的采样坐标,从前一帧的 Screen Space Radiance Cache 中采样 Radiance,乘以前面计算的 History Weight 作为最终 Radiance 并累加起来,同时累计 History Weight。代码如下:
接下来除以累计权重得到 Lighting,并计算透明值,这个值决定接下来是否采样当前帧的 Radiance Cache。如果累计权重大于等于 4,透明度为 0,说明所有 4 个 Neighbor Probes 都对当前 Screen Probe 都有影响,无需采样当前帧 Radiance Cache,否则说明至少有一个 Neighbor Probe 没有影响,需要采样当前帧 Radiance Cache 作为 Lighting 的补充。透明度计算如下红框内代码所示:
接下来是对 Radiance Cache 的采样,首先根据当前 Screen Probe 的屏幕位置获取需要采样对应的 Probe 坐标,这里同样也使用了 Hammersley Points 来随机 Jitter,这样每个 Thread 可能会对应到不同的 Probe 坐标。然后对这个坐标使用 Blue Noise 再次进行 Jitter,得到带有随机偏移的 Probe 像素中心点坐标,再用此坐标加上当前线程对应的偏移,除以 Probe 的八面体分辨率(每维度的 Tracing 数量),就得到了八面体的 ProbeUV:
如果透明度不为 0,则对 Radiance Cache 通过 Cone Tracing 进行采样,这需要计算 Cone 的方向和 Cone Half Angle,首先根据 ProbeUV 通过八面体法计算出球面方向,然后根据 Tracing 数量计算出均匀分布在球面上的 Cone Half Angle,先调用 GetRadianceCacheCoverage 函数判断 Cone Tracing 方向是否有效覆盖,如果有效则调用 SampleRadianceCacheInterpolated 采样 Radiance Cache,然后乘上透明值,等同于于乘上了采样 Radiance Cache 的权重,然后累加到 Lighting,否则直接将 Lighting 设置为 1:
GetRadianceCacheCoverage 和 SampleRadianceCacheInterpolated 函数属于 Radiance Cache 机制,为了不引入更多的复杂性,便于讲解,本篇不进行源码解析,之后的《Radiance Cache 篇》会详细讲解源码机制。
接下来使用 OctahedralSolidAngleLUT 函数(下文详细阐述)计算 ProbeUV 对应八面体在球面上的 Solid Angle,在八面体上的 Texel 不同 Solid Angle 也不同。再计算 Lighting 的亮度值乘上 Solid Angle,由于 Lighting 是采样自 Radiance Cache,也就是 Radiance,而 Radiance 定义是单位面积单位立体角接收到的能量,因此乘上 Solid Angle 就是在单位面积内接收到的能量,即 Irradiance,由于当 PDF 完全正比于被积函数时,方差为 0,因此将 Irradiance 直接作为 PDF 使用,最后由当前线程作为索引将 PDF 写入到 LDS 中。
接下来使用并行求和统计出在 LDS 中存储的所有 PDF 的总和,算法与前面阐述的 BRDF PDF 并行 SH 求和相同,不再赘述。最后由每个线程负责将当前线程的 PDF 写入到 Lumen.ScreenProbeGather.LightingProbabilityDensityFunction 中,注意还要除以 PDF 总和,这样存储的值为 0~1 之间的归一化的值,同时还增加了防止除零错误的错误。
最后说一下前面讲到的 OctahedralSolidAngleLUT 函数。顾名思义这个函数通过一张 Octahedral Solid Angle LUT 查询每个八面体坐标对应的球面方向的立体角,由于 LUT 是固定分辨率,因此需要根据八面体分辨率进行缩放,例如 LUT 为 16x16,八面体分辨率为 8x8,Solid Angle 需要放大 2x2=4 倍,代码如下:
Octahedral Solid Angle LUT 是一张名为 OctahedralSolidAngleTexture 的 RGD 2D Texture,格式为 PF_R16F,分辨率大小由 CVars GLumenOctahedralSolidAngleTextureSize 控制,默认为 16。LUT 可视化如下图,由于每个八面体 Texel 在球面的分布不同,Solid Angle 大小也不同:
LUT 由 Compute Shader 生成,Shader 源码在 LumenScreenProbeGather.usf 的 OctahedralSolidAngleCS 函数。每线程计算一个八面体 Texel 对应的 Solid Angle,调用 OctahedralSolidAngle 函数,写入到 LUT 中:
OctahedralSolidAngle 函数计算 Solid Angle 的机制是根据八面体 Texel 坐标计算出 Texel 的四个方向,作为球面四边形的顶点,从微分角度看可认为是球面上的正方形,即由 2 个球面三角形组成,然后调用 ComputeSphericalExcess 函数计算每个三角形的 Spherical Excess,即 Solid Angle,相加即得到这个球面四边形即八面体 Texel 的 Solid Angle。示意图如下:
源码如下:
ComputeSphericalExcess 函数使用 https://en.wikipedia.org/wiki/Spherical_trigonometry#Area_and_spherical_excess 中描述的算法,通过三个单位长度的球面三角形顶点向量来计算 Spherical Excess:
这一步的 Pass 名称起的有误导性,并非实际生成光线,而是根据 BRDF PDF 和 Incident Radiance PDF 生成结构化重要性采样数据,即前面原理中所述的中间层信息。Pass 信息如下:
每 Thread Group 处理一个 Screen Probe,每线程处理一个 Tracing,根据 BRDF PDF 和 Incident Radiance PDF 生成结构化重要性采样数据,并将结果保存在名为 Lumen.ScreenProbeGather.RayInfosForTracing 的 RDG 2D Texture 中,Texture 大小为总计的 Screen Probe 数量(包含 Uniform 和 Adaptive 放置的 Screen Probe)乘以每个 Probe 的 Tracing 光线的数量,格式为 UINT16。
首先根据 Thread Group ID 获取当前 Probe Index,以此获取 BRDF PDF 的 9 个 SH 系数。再根据当前组内线程 ID 计算八面体对应的平面 UV 坐标,转换成当前 Tracing 的世界空间方向,用此方向计算三阶 SH 基函数的 9 个系数,与 Probe 的 BRDF SH 系数进行点积,即得到当前 Tracing 方向 PDF。
如果没有使用 BRDF SH 机制,则只需简单的采样之前 Lumen.ScreenProbeGather.BRDFProbabilityDensityFunction 获取每个 Tracing 的 PDF 即可,如代码中灰色部分所示。
接下来是获取 Incident Radiance PDF,如果启用,则根据 Probe 索引以及当前 Tracing 坐标从 LightingProbabilityDensityFunction 中获取 Incident Radiance 的 PDF,使用 Probe Tracing 的数量进行缩放,再与前面得到的 BRDF PDF 相乘作为最终 PDF,由于 Incident Radiance 通过前一帧的 Radiance Cache 重投影插值计算得来,因此不够精确,不能作为 PDF Culling 依据,因此为了处理这种情况,当这条光线的 BRDF PDF 有效时,即使 2 个 PDF 相乘的结果很小,也认为是有效 PDF。最后将 Tracing 坐标和 PDF Pack 为 uint2 并写入当前 Tracing 索引对应的名为 RaysToRefine 的 LDS 中。这部分代码如下:
当所有 Tracing 的 PDF 完成写入 LDS 后,将 LDS 中的 Tracing 按照 PDF 升序排序,目的是为下一步细化(Refine)处理准备。这里进行的是并行排序,由每线程遍历 LDS 中所有 Tracing 的 PDF,将当前 Tracing 写入到所有小于当前 Tracing 之后,如果 PDF 相同,则按照 Tracing 索引降序排序,注意这里不是原地写入 LDS,而是偏移到所有 Tracing 之后写入,因此 LDS 是双倍大小。这样当所有线程执行完成之后,LDS 从偏移之处存储的自然就是按照 PDF 升序排序的结果。代码如下:
这一步是生成结构化重要性采样数据的核心逻辑,算法思想是将小于一定阈值 PDF 的 Tracing 细化匹配到大 PDF 的 Tracing 上,PDF 越小匹配到的 PDF 就越大,具体流程如下:
上述流程代码如下,红框标出流程序号:
接下来再修改被匹配到的光线信息。根据上面第 4 步得到的被细分光线匹配到的光线数量,将当前线程对应的被匹配到的光线信息中的 Level 降级(因为分辨率更高),同时设置 PDF 为 0。如果将被匹配的光线的局部坐标看作(0,0)的话,其实本质上就是与上面三个光线共同构成了细分的 2x2 Quad 结构。代码如下:
最后,每个线程将光线对应的八面体 Texel 坐标和 Mip Level 写入,这样在后续的步骤中可以根据这两个数据生成 Probe Tracing 方向,代码如下:
其中 WriteRay 将 Texel 坐标和 Level Pack 为 16 bit uint 存储到 Lumen.ScreenProbeGather.RayInfosForTracing 中,代码如下:
结构化重要性采样数据在 GetProbeTracingUV Shader 函数中使用,这个函数在 Screen Probe Tracing 时被调用,返回八面体 UV 和 Cone Angle,用于生成 Ray\Cone Tracing 的方向,因此也就实现了 Tracing 的重定向。当启用 STRUCTURED_IMPORTANCE_SAMPLING 宏时,会采样前面生成的结构化重要性采样数据 Lumen.ScreenProbeGather.RayInfosForTracing,获取每个光线对应的八面体 Texel 坐标和 Mip Level,根据 Level 计算八面体 Tracing 的分辨率(MipSize),级别越低分辨率越大,Level 数量由 CVars GLumenScreenProbeImportanceSamplingNumLevels 决定,默认为 1,即有 0 和 1 两个 Level。然后根据 Tracing 对应的八面体 Texel 和 MipSize 计算八面体 UV,这中间还加上纹素中心点的扰动。由于是 Cone Tracing,因此需要计算 Cone Half Angle,这里是根据 MipSize 来计算,MipSize 代表一个维度的 Texel 数量,每个 Texel 代表一个光线,因此总计光线数量为 ,除以这个数量既是每个光线的均值,由于计算的是 Half Angle,因此余弦值的范围在 0~1,且在这个范围内余弦值越大角度越小,因此用 1 减去这个值就是在半球上均匀分布的余弦值,再通过反余弦函数就可以计算出 Cone Half Angle,这样可以在球面上生成无重叠且均匀大小的 Cone,如下图所示:
这部分代码如下图红框所示:
结构化重要性采样数据的另一个用途在后续的 CompositeTraces Pass 中用于根据重定向后的 Tracing 方向和 Level 计算 Solid Angle,在后续的文章中会详细进行阐述。
编辑于 2022-07-10 17:03
赞同 443 条评论
分享
喜欢收藏申请转载
切换为时间排序
写下你的评论...
发布
大佬啥时候更新
赞回复踩 举报
赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。