数据压缩
问题
DDGI需要用到许多buffer和Texture,如Irradiance、Distance、Probe pos offset、Probe State、Ray Data、TLAS,虽然DDGI与屏幕分辨率无关,但与场景有关,随着场景的不断变大,probe个数也会随之增长。有没有什么方法可以降低Probe pos offset的带宽开销呢?有的,根据命运扳机的演讲分享,它们将Probe pos offset压缩成UINT8,每个Probe pos offset字节数从12(笔者实现的Probe pos offset格式是float3)降到了1

实现算法
- 算法
- 因为Probe pos offset主要用于将Probe推出墙外,不需要特别精准,只要满足不在墙内,不特别贴近墙面即可,那么我们可以调换思路,不需要在RelocateProbes将精准的计算Probe pos offset存储在float3的buffer中,因为每个probe grid space的大小是恒定的,RelocateProbes的偏移范围也在其中,那么可以在probe grid space大小范围中,预生成64组斐波那契球面向量,每组4个向量,每组向量方向相同,不同的是向量缩放(RelocateProbes会一点点的平移probe,所以需要预制四种大小不同的向量),这些向量存储在LUT中,用于后续查表;再生成一个UINT8 Texture(叫做Probe Index Tex),用于记录在LUT表中找到的index,Probe Index Tex对应场景中的所有probe
float GetDistScale(int i) { // 分配 4 种不同的距离权重 int distStep = i / 64; float distScale = (distStep + 1) / 4.0f; return distScale; } std::vector<Vector4> GenerateRelocationLUT(Vector3 probeSpacing) { std::vector<Vector4> lut(256); const auto maxOffset = probeSpacing * 0.45f; // 限制在半个网格内,防止越界 // 第一项通常设为 (0,0,0),代表“不偏移” lut[0] = Vector4(0, 0, 0, 0); for (int i = 1; i < 256; ++i) { // 使用斐波那契球面算法生成 64 个方向 auto dir = GetFibonacciDir(i); float distScale = GetDistScale(i); Vector3 finalOffset = dir * maxOffset * distScale; lut[i] = Vector4(finalOffset.x, finalOffset.y, finalOffset.z, 0.0f); } return lut; }- 在RelocateProbes中依旧计算offset,但计算出offset后,将currentOffset + offset与LUT的向量一一比较大小,寻找大小最贴近的,找到后记录LUT Index
UINT currIndex = DDGI_Load_Probe_Offset_Index(texCoord); float3 currentOffset = relocationLUT[index].xyz; UINT bestIndex = 0; float minError = 1e27f; float3 normalizedOffset = fullOffset / max(g_GridSpacing * PROBE_MAX_OFFSET_FRACTION, 1e-6); if (dot(normalizedOffset, normalizedOffset) < 0.2025f) { // 找离位置最近的 for (uint i = 0; i < 32 * 4; ++i) { float d = distance(relocationLUT[i].xyz, fullOffset); if (d < minError) { minError = d; bestIndex = i; } } } else { bestIndex = currIndex; }
效果

下图是优化前的,上图是优化后的,可以看到降了接近20ms
再来看看性能指标数据:






正向收益:光追计算和SM Instruction计算、L2缓存与命中率、VRAM都有提升,Compute Warp Latency大量降低、Warp Can't Launch降低
SharedMemory
问题
在ProbeBlending中,需要从显存中加载许多数据,如:Ray Data、Distance,以及每个for循环都会计算一次斐波拉契球面向量,这样会很浪费L2、VRAM带宽
如果使用shared memory将这些数据缓存,不但不会浪费L2、VRAM带宽,还能降低延迟(SharedMemory属于SM内部的高速、低延迟内存空间)
算法
没什么好说的,将Ray Data、Distance,以及每个for循环都会计算一次斐波拉契球面向量存储在Shared Memory
效果

可以看到性能耗时从1.15ms降到了0.67ms


延迟、shader代码复杂度进一步降低
筛选光线
问题
目前光追的irradiance是在”closesthit“中计算的,每根光线只要未打中背面,都会计算direct light、shadow、indirect light,不仅开销大(类似前向渲染)、分支多,且这里的光线没有经过排序,不能充分利用L2缓存,大大增高了延迟
为了解决这一问题,需要将closesthit的光照计算移出,只记录hit data,再在compute shader中进行全局光照计算
算法
由于记录hit data包括back face hit、miss、hit,若直接将hit data用于后续的compute shader,compute shader会计算许多分支,导致线程等待;因此光追结束后,需要对ray data(光线)排序,筛选出有效光线,避免后续compute shader的分支导致的等待延迟
在筛选有效光线时,我们还会记录有效光线的个数,用于后续compute shader dispatch计算光照,这样分发的线程一定不会遇到无效光线导致L2的命中率
但有一个问题:如何高效的拿到有效光线的数量,笔者首先想到的是用回读堆,但回读堆的传输效率不如default heap高,也会导致等待。后来笔者想到了ExecuteIndirect,这小子就像导演一样,告诉GPU如何解析buffer及执行多少条指令,其中它也支持dispatch,这样就无需将有效光线的数量从GPU传回,直接使用ExecuteIndirect告知GPU 需要dispatch线程组
- 流程
- 计算有效光线数量、有效光线的index、有效光线的数据
这里可以使用Wave Intrinsics快速计算出每个warp内有效的光线数量,及index,可以进一步减少显存竞争
- 计算dispatch有效光线需要多少个线程组
-
使用ExecuteIndirect 访问记录有效光线数量buffer,并dispatch
-
效果

如上图所示,dispatch 的线程组3951个,有效光线3951 * 64,降低了1/3
LOD
问题
目前不管近处、远程处理probe的频率都是相同的,但实际上远处的probe处理频率不需要那么高,可以根据distance,做三级的分帧处理
远景光照变化频率较低,不用每次都跑一遍RT,可以复用上一帧附近probe的结果
算法
- distance多级处理频率
- 计算probe到camera的distance,根据distance远近,分成每4帧更新、每16帧更新、每64帧更新
-
当探针突然从每16帧更新进入到每4帧更新,会导致光照更新缓慢。为此,需要记录上一帧probe的更新频率,若从低频进入高频,将历史权重hysteresis 设为0.f
-
远景复用
- 在计算直接光间接光时,根据ray hit distance判断是否够远,够远,就复用上一帧的全局光照结果,并lerp周围几个probe的全局光照值
效果

时域重采样重要性采样
问题
现在光线生成是均匀分布,这对于明亮和暗处投射的光线数是相同的,但实际上明亮处需要更多光线,暗处不需要很多
算法
为此可以基于上帧计算的irradiance图,在raygeneration中probe生成光线时,采样irradiance,计算明亮度,让更多的光线偏向更亮的区域

由于重要性采样要使用蒙特卡洛,因此irradiance不能再使用均匀采样的公式,公式需要从:2 * \frac{\sum^N L_i *ω_i}{\sum^N ω_i}变成\frac{1}{N}\sum^N\frac{L_i * ω_i}{p(ω_i)},需要除以pdf和ray count
由于是均匀球面取其中一条最亮的,那么pdf公式为$pdf = \frac{finalweight}{sumweight}N\frac{1}{4PI}$
但为了防止过于黑,还是需要有部分光线走均匀采样
部分代码:
// 四根光线为一组
const uint N = 4;
float3 candidateDirs[N];
float weights[N];
float sumWeight = 0.0f;
for (UINT i = 0; i < N; ++i)
{
candidateDirs[i] = DDGIGetProbeRayDir(rayIndex + i * N, RAYS_PER_PROBE, g_RandomRotation);
float2 uv = OctEncode(candidateDirs[i]);
uv = (uv * 0.5f + 0.5f) * (DDGI_PROBE_IRRADIANCE_NUM_TEXELS - 2.f) + 1.0f;
uv = (float2(atlasPos * DDGI_PROBE_IRRADIANCE_NUM_TEXELS) + uv) * g_IrradianceTexSize.zw;
float3 irradiance = SampleTexture2D_LOD(g_IrradianceTexIndex, uv, ClampLinearSampler, 0);
weights[i] = Luminance(irradiance) + 0.1f; // 模糊后的
weights[i] *= Luminance(g_GIDataBuffer[probeIndex * RAYS_PER_PROBE + rayIndex + i * N].Irradiance) + 0.1f; // 准确的
sumWeight += weights[i];
}
// 亮度最大的胜出
float r = blueNoiseTex.SampleLevel(g_WarpPointSampler, bnUV, 0).r * sumWeight; // 随机概率
float cumulativeWeight = 0.f;
finalRayDir = candidateDirs[0];
float finalWeight = weights[0];
// 采用俄罗斯轮盘赌,累积分布函数
for (UINT i = 0; i < N; ++i)
{
cumulativeWeight += weights[i];
if (r <= cumulativeWeight)
{
finalRayDir = candidateDirs[i];
finalWeight = weights[i];
break;
}
}
pdf = (finalWeight / (sumWeight + 1e-4)) * N * INV_FOUR_PI;
RESTRI GI
问题
虽然重要性采样能将光线指向更需要光线的地方,但这还不够,因为重要性采样只在四个ray中抉择,依旧有可能指向暗处,这导致TraceRay依旧是大头,占有30ms
为了大幅减少光线数量,这里引进RESTRI。如果抉择的ray依旧不理想,RESTRI会从上一帧和隔壁probe白嫖,这样随着时间的累积,ray只会越来越好
算法
- 时域重采样重要性采样
- 创建两个Reservoir Buffer,存储找到的ray的相关信息Reservoir
struct Reservoir { Vector3 direction; float weightSum; float numSamples; // 见过的样本总数 float estimatorWeight; // 归一化权重 };- 选择n个样本,在n个样本中选出权重(亮度)最大的一个
-
分流采样
为了防止暗部过于暗,让1/4的ray均匀采样,3/4的ray重要性采样
-
计算权重
采样irradiance Tex并计算亮度,为每个样本计算权重
-
CDF轮盘赌
- bluenoise计算随机数
- 累加样本权重,应用累积分布函数CDF,选出优胜者ray
- 计算pdf
- 初始化Reservoir
-
direction:优胜者ray
-
weightSum:\frac{finalWeight}{pdf},finalWeight为优胜者ray的权重
保持无偏,让pdf高的,单次贡献降低,pdf低的,单词贡献提高(防止黑的地方过黑)
-
numSamples:1
-
时域重采样重要性采样
-
读取上一帧Reservoir
- 计算上一帧的权重
-
计算上一帧在这一帧累加的总权重(不能直接用weightSum,weightSum是基于上上帧的光照结果,需要评估上一帧的,否则会导致闪烁)
公式:preAccuWeight = preWeight * preReservoir.estimatorWeight * preReservoir.numSamples,因为estimatorWeight = \frac{1}{finalWeight}·\frac{preReservoir.preAccuWeight }{preReservoir.num}
-
Battle选出优胜者
- 依旧轮盘赌,计算上一帧在当前帧(当前帧权重+上一帧权重)的占比,占比大于则胜出,更新ray、weight
- 归一化
类似pdf,计算系数用于调整能量,防止画面爆炸
currReservoir.estimatorWeight = currReservoir.weightSum / (currReservoir.numSamples * finalWeight + 1e-6f); - 空间重采样
空间重采样比时间重采样更加复杂,因为空间重采样复用临近的probe,极有可能导致漏光,且probe位置不同,hit distance也不一样,如何矫正是个问题
- 计算要复用的临近的probe index,记为neighborProbeIdx
依旧使用blue noise
- 计算neighborProbe的world pos记为neighborPos、neighborProbe hit的world pos记为hitPos,根据这两个pos计算neighborPos到hitPos的向量与距离记为dirToHitNor、distToHit
-
执行shadow TraceRay,起点是raygeneration光线的起点,终点是hitPos,方向是dirToHitNor,距离是distToHit
如此可以解决漏光问题
- 计算neighbor的权重、neighbor在这一帧累加的总权重、雅可比修正,Battle选出优胜者
-
为什么需要雅可比修正
当复用probe时,两个probe看向某个位置的权重大概率是不同的, 直接使用权重肯定导致亮度出错。因此RESTRI GI提出雅可比修正,修正不同位置probe的权重缩放
-
什么是雅可比修正
雅可比修正描述了坐标变换时空间体积的缩放比例
在DDGI中,将hitPos从probe A的坐标系投影到probe B的坐标系,由于两个probe的位置不同,投影会导致拉伸,雅可比修正可以修正这种拉伸
- 公式:$G(P, X) = \frac{d^2_{neighbor}}{d^2_{curr}}·\frac{cosθ{curr}}{cosθ{neighbor}}$
效果

Reference
[UFSH2025]《命运扳机》的光与影](https://search.bilibili.com/all?vt=74086063&keyword=命运扳机 usf&from_source=webtop_search&spm_id_from=333.1007&search_source=5)





Comments | NOTHING