Banner

ReSTIRGI:从 RIS 到 ReSTIR GI / PT

引:为什么要学 ReSTIR?

如果说 1990 年代是 PT,2000 年代是 BDPT/MLT,2010 年代是 Path Guiding,那么 2020 年代毫无疑问是 ReSTIR 的时代

ReSTIR 是过去五年间实时光线追踪领域最重要的算法突破之一,它的关键贡献并不是发明了一个全新的采样方法,而是把一个 2005 年就被提出但长期没有被实时社区注意到的算法 —— RIS (Resampled Importance Sampling) [Talbot 2005] —— 与 GPU 友好的水库采样 (Weighted Reservoir Sampling) 结合,并加入时空复用机制,使其在 1 spp 下也能产出近似离线渲染品质的结果。

它已经被部署在多款 AAA 产品中:

  • Cyberpunk 2077 (RT Overdrive Mode + Phantom Liberty)
  • Portal with RTX
  • Sword and Fairy 7 (国产首款全 RT 大作)
  • NVIDIA RTXDI / RTXGI SDK

一、核心动机:为什么样本可以复用?

1.1 实时路径追踪的根本困境

实时渲染的硬约束是 每帧 16 ms (60 FPS) 或 33 ms (30 FPS),每像素往往只能投出 1 条路径甚至更少。回顾蒙特卡洛积分器:

它的方差由 的匹配度决定。当 时方差为零;二者偏离越大,噪声越严重。

核心矛盾:要让 接近 需要”知道哪些路径携带能量”,但这本质上与渲染问题等价——是个鸡生蛋的悖论。

传统应对:

  • 加样本数 4× 样本只换 2× 噪声下降,性价比奇差(标准差按 收敛)
  • 更好的 PDF(光源采样、BSDF 采样、MIS) 仍然在单个像素内独立工作
  • Path guiding [Vorba 2019] 学习自适应 PDF,但需要预热与额外存储
  • 后置降噪(SVGF/OIDN) 在颜色空间盲目滤波,丢失能量、产生 ghosting

1.2 ReSTIR 的关键洞察

相邻像素看到的光照虽然不完全相同,但”好路径”往往也是邻居的好路径。

这意味着:如果像素 A 通过昂贵的随机投射”偶然”找到了一条携带能量的路径,那么相邻像素 B、C、D 没必要重新昂贵地试错——直接借用并稍作修正即可。

ReSTIR 的本质,可以理解为 “对采样分布而非颜色进行滤波”

维度 后置降噪 (SVGF) ReSTIR
滤波对象 已积分的颜色 采样分布本身
信息保留 只剩 G-Buffer + 颜色 完整 PDF / 路径信息
偏差性质 通常有能量损失 数学上可证明无偏
复用单位 像素颜色 单个采样路径

ReSTIR 的伟大之处在于:在丢弃任何信息之前进行重采样,因此可以在数学上严格保证无偏。

1.3 ReSTIR 的”聚合机器”模型

可以把 ReSTIR 想象成一个样本聚合机器

🔄 ReSTIR 样本聚合机器

等效样本数: 1  |  实际新样本: 1/帧

虽然实际上很多复用样本是高度相关的(不能简单相加),但等价于”几百到几千次独立采样”的图像质量是非常常见的——这是 ReSTIR 100× 加速比说法的来源。

1.4 ReSTIR 家族演化路线

Talbot 2005 RIS (offline) Bitterli 2020 ReSTIR DI 百万动态光源直接光 Ouyang 2021 ReSTIR GI 单次反弹漫反射 GI Lin 2021 Volumetric ReSTIR 参与介质 / 体渲染 Lin 2022 (GRIS) ReSTIR PT 完整路径追踪 + Shift Map 统一理论框架

ReSTIR 家族技术演化关系


二、数学基础:理解之前先打地基

⚠️ 本节是全篇最硬核的部分。如果我们已经熟悉 MC / IS / MIS,可以快速浏览跳到 2.4 节的 RIS。

2.1 蒙特卡洛积分回顾

要估计积分 ,使用 个独立同分布样本

性质

  • 无偏
  • 一致(依概率收敛)
  • 方差

完美 PDF 满足 (常数),此时方差为 0。这要求 且归一化常数 ——已知 才能构造完美 PDF,矛盾

2.2 Support:被忽视但极其重要的概念

ReSTIR 中违反 support 条件是 #1 的偏差来源。 这个看似简单的概念值得单独一节。

定义

  • 函数 support
  • 随机变量 support(连续)或可取值集合(离散)

蒙特卡洛积分无偏条件

证明:

如果 没问题(多采到的地方 )。但如果 ,就会漏积分—— 在某些地方非零但永远采不到,估计值偏小。

举个具体例子

像素 A 看到镜面,BSDF 只允许 一个方向。
像素 B 看到漫反射。
现在我们想把 B 的样本(一个随机半球方向)借给 A —— A 的 support 是单点,B 的样本几乎必然不在 A 的 support 内
这种复用必然 0 贡献,并且 MIS 权重会爆炸。

🔑 ReSTIR 中所有”奇怪的 bias”几乎都能归因到 support 不匹配。开发时养成习惯问自己:这个候选样本的支持集和当前积分域吻合吗?

2.3 多重重要性采样 (MIS)

MIS [Veach 1995] 把多个采样策略 组合:

MIS 权重必须满足两个条件

  1. Partition of unity 对所有
  2. Support 匹配

只要 (合并的支持集覆盖 ),就能无偏。

Balance Heuristic(Veach 证明渐近最优):

直觉:在 处,PDF 大的策略”更应该负责”采到这里,所以分到的 MIS 权重就大。

⚠️ 关键陷阱:MIS 权重 是关于 的函数,不依赖其他样本的具体取值,只依赖其他策略的 PDF。”把其他候选的真实采样位置代进 ”是常见错误,会破坏 partition of unity。

2.4 Unbiased Contribution Weight (UCW)

这是理解 RIS / ReSTIR 的第一个关键概念

传统 MC 用 作为权重。但很多时候 算不出来:

  • Woodcock tracking 中的距离采样
  • Photon mapping 中的密度估计
  • 以及 RIS 自身输出(多重积分维度爆炸)

我们需要的不是 PDF 本身,而是一个期望等于 的随机变量

只要这条性质满足,估计器

就是无偏的:

🔑 关键洞察:UCW 不是关于 的确定性函数!它是一个随机变量——同一个 ,因不同的候选样本集,可能对应不同的 。这是 ReSTIR 与传统 MC 最本质的差别。

课程中专门强调:写作 而不是 ,提醒它不是函数。

2.5 重采样重要性采样 (RIS)

问题:我们想以接近 的分布产生样本,但只有一个糙的 能采。

RIS 的方案

  1. 个候选样本
  2. 给每个候选赋一个权重 ,让权重比反映 的相对大小
  3. 比例随机选一个 输出

最终输出的 分布近似正比于一个我们指定的 target function (通常 或其便宜的近似)。

2.5.1 核心公式(Generalized RIS 形式)

各项含义:

符号 含义 实际取值
resampling MIS weight iid 时用 ;候选分布不同用 balance heuristic
target function 通常 (含 )或 的便宜近似
候选自身的 UCW 普通采样:;嵌套 RIS:上一层输出的
输出 UCW 用于最终积分

2.5.2 RIS 的无偏性证明(直觉版)

Lemma 是对 归一化常数的无偏估计。

由于选中样本 的概率正比于 ,所以:

代入:

完整证明见 [Lin 2022, Appendix A]。

2.5.3 完整 RIS 伪代码(Algorithm 1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def RIS(M, target_fn, sample_generator):
candidates = []
weights = []
for i in range(M):
X_i = sample_generator() # 从 p 采候选
W_i = 1.0 / pdf(X_i) # 候选 UCW
m_i = 1.0 / M # iid: 1/M; 否则用 balance heuristic
w_i = m_i * target_fn(X_i) * W_i
candidates.append(X_i)
weights.append(w_i)
# 按权重比例选一个
s = weighted_choice(weights)
if s is None: # 全 0 → 返回 null sample
return None, 0.0
Y = candidates[s]
W_Y = sum(weights) / target_fn(Y)
return Y, W_Y

⚠️ 不要把当前选中样本的 直接当作输出的 UCW!这会引入偏差。必须使用 ,因为它编码了”这个样本是从一组候选里挑出来的”这个信息。

2.5.4 Null Sample 的处理

如果所有 (比如 target function 全 0),返回 null sample,

绝对不要因为返回 null 就再独立重采一次!这个重采决策依赖 RIS 内部状态,会引入条件偏差。

2.5.5 Example: BSDF + NEE 的 RIS

个 BSDF 候选(PDF )+ 个 NEE 候选(PDF ,转换到同一 measure),target function (完整被积函数)。

Balance heuristic for BSDF 样本:

Resampling weight

NEE 样本同理(分子分母 )。

💡 注意:这里就算改用 平均权重,积分依然无偏(因为两种采样都覆盖整个积分域),只是方差远大——相当于只用了 BSDF 采样的方差水平。MIS 权重在 RIS 中和在 MC 中同等重要

2.6 加权水库采样 (Weighted Reservoir Sampling, WRS)

动机:上面的 RIS 算法需要先生成所有 个候选再选一个,需要 内存。在 GPU 上很糟糕。

WRS [Chao 1982]:流式地处理样本,只维护一个”水库”,无需存储完整序列。

2.6.1 算法与正确性证明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Reservoir {
Sample Y; // 当前选中的样本
float W_Y; // 输出的 UCW
float w_sum; // 所有见过样本的权重和
int c; // 置信度(约等于已聚合样本数 M)

void update(Sample X_i, float w_i, int c_i) {
w_sum += w_i;
c += c_i;
if (rand() < w_i / w_sum) { // 关键:以 w_i/w_sum 概率替换
Y = X_i;
}
}

void finalize(float p_hat_Y) {
W_Y = (Y != null && p_hat_Y > 0) ? w_sum / p_hat_Y : 0;
}
};

正确性证明(数学归纳法):

记处理完前 个样本后,第 个样本()留在水库的概率为

  • 基底
  • 归纳:假设
    • 个样本以 概率被选入。
    • 之前已在水库的样本 ,需要” 没被选中”才能保留:

所以处理完所有 个后,第 个样本的留存概率正好是 ,与离线 RIS 等价。

🔧 工程提示:水库可以无缝衔接 RIS 与 ReSTIR——把”邻居水库的输出样本”作为新的候选喂给 update 即可。这是 ReSTIR 时空复用得以工作的根基。

2.7 交互式演示:WRS 怎么工作?

下面这个 widget 模拟了 WRS 的流式过程。每个柱子表示一个流入的样本(高度=权重),点击 Step 一次处理一个,红色边框表示当前水库选中的样本。多次 Reset/Auto Run 会发现:权重越大的样本,最终留在水库的概率越高

🎲 Weighted Reservoir Sampling 演示

Step: 0 / 12  |  w_sum: 0.00  |  Reservoir.Y: null

2.8 RIS 重采样可视化

下面这个 widget 让我们直观感受 RIS 如何把均匀分布”重塑”成接近目标 PDF 的分布。蓝色柱子 = 候选样本(均匀采的,柱高 = 重采样权重 ),红色曲线 = target function 。点击 Resample 按权重选一个,多次 Resample 累积出”重采样后的经验分布”(绿色直方图)。

🎯 RIS 重采样可视化

M=16 candidates uniformly sampled. Resample count: 0

🎯 观察重点:连续点击 Resample×100 让样本量到 1000 以上后,绿色经验直方图会逐渐贴合红色 target 曲线——RIS 把均匀候选”塑形”成了接近目标分布的样本流。这就是 ReSTIR 工作原理的视觉化版本。


三、ReSTIR DI:把 RIS 装上时空翅膀

3.1 直接光照场景设定

直接光路径长度为 3:,其中 在像平面, 是 primary hit, 是光源点。

固定相机射线后( 视为常数),问题简化为对 在光源表面 上的积分:

四项含义:

  • 处的 BSDF
  • :几何项
  • :可见性(0 或 1)
  • 处沿 方向的 emission

Target function 推荐

⚠️ 常见误区:[Talbot 2005] 为了性能省略了 (可见性),这会牺牲收敛分布质量。实现时务必先把 加上——调通后再考虑去掉换性能。

3.2 完整管线:4 个阶段

ReSTIR DI 单帧管线 ① Initial Sampling 从光源采样 M₁ 候选 从 BSDF 采样 M₂ 候选 RIS 选 1 个 输出:当前帧 reservoir ② Temporal Reuse 用 motion vector 找 上一帧对应像素 合并历史 reservoir M-cap 防过拟合 ③ Spatial Reuse 在 disk 半径内 选 K 个邻居像素 合并 reservoir (可多轮) 几何/法线/深度阈值 ④ Shading ⟨I⟩ = f(Y) · W_Y 用最终 reservoir 投出 shadow ray 实际 shading 写入下帧用的 reservoir buffer ∗ 每像素独立 ∗ 读上帧 reservoir buffer ∗ 读 G-Buffer 做 reject ∗ 输出最终 radiance 💡 实现顺序建议(Tip 4.4) 1. 先实现 ① 跑通,对比 ground truth path tracer 看是否无偏 2. 再加 ③ Spatial Reuse(无场景动态变化更易调试) 3. 最后加 ② Temporal Reuse 与 motion vector,并仔细测试 disocclusion 处理

3.3 阶段 ①:Initial Sampling 完整伪代码

Initial Sampling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Reservoir 数据结构(含 confidence)
struct ReservoirDI {
LightSample Y; // 选中的光源样本 (位置 + 法线 + lightID)
float W_Y; // UCW
float w_sum; // 累积权重和
float c; // 置信度
};

ReservoirDI initialSampling(uint pixel, float3 x1, float3 n1, MaterialData mat) {
ReservoirDI r = {};

// M_total = M_light + M_bsdf 个候选,1/M_total MIS 权重
const int M_light = 32;
const int M_bsdf = 1;
const float M_total = float(M_light + M_bsdf);

// 1) M_light 个 power-based 光源采样
for (int i = 0; i < M_light; ++i) {
// 1.1) 按 power CDF 选光源
Light light = sampleLightByPower();
float p_light = light.power / totalPower;

// 1.2) 在光源表面均匀采点
float3 pos, normal;
float p_area = sampleLightPoint(light, pos, normal);

LightSample X = { pos, normal, light.id };
float p_combined = p_light * p_area; // 联合 PDF (area measure)

// 1.3) target function: f_s * G * V * L_e
float p_hat = evalTargetFn(X, x1, n1, mat);

// 1.4) RIS resampling weight
float w = (1.0 / M_total) * p_hat / p_combined;

// 1.5) 流式 update
r.update(X, w, /*c_i*/ 1);
}

// 2) M_bsdf 个 BSDF 采样(处理 glossy 表面尤其重要)
for (int i = 0; i < M_bsdf; ++i) {
float3 wi = sampleBSDFDirection(n1, mat);
float p_solid = bsdfPDF(wi, n1, mat);
// 投射光线找命中
HitInfo hit = traceRay(x1, wi);
if (!hit.isLight) continue; // 没命中光源 → 跳过

LightSample X = { hit.pos, hit.normal, hit.lightID };
// 转换 solid angle PDF → area PDF(关键!否则 MIS 错误)
float p_area = solidAngleToArea(p_solid, hit.pos, x1, hit.normal);

float p_hat = evalTargetFn(X, x1, n1, mat);
float w = (1.0 / M_total) * p_hat / p_area;
r.update(X, w, 1);
}

// 3) 计算输出 UCW
float p_hat_Y = (r.Y.lightID >= 0) ? evalTargetFn(r.Y, x1, n1, mat) : 0;
r.W_Y = (p_hat_Y > 0) ? r.w_sum / p_hat_Y : 0;
return r;
}

// PDF 测度转换:solid angle → area
float solidAngleToArea(float p_solid, float3 lightPos, float3 x1, float3 lightNormal) {
float3 d = lightPos - x1;
float dist2 = dot(d, d);
float cosTheta_l = abs(dot(lightNormal, -normalize(d)));
return p_solid * dist2 / max(cosTheta_l, 1e-6);
}

🔧 TipM_bsdf = 1 在大多数实时场景已经足够,因为 ReSTIR 后续会通过空间复用扩大有效样本数。M_light = 32 是 [Bitterli 2020] 的经验值。

3.4 阶段 ②/③:合并 Reservoir 的核心算法

合并是 ReSTIR 的灵魂操作。iid 时用 1/M MIS(biased + neighbor reject)异分布时用 generalized balance heuristic(unbiased)

combineReservoir
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 通用合并:把另一个 reservoir 的样本作为候选喂给当前 reservoir
// 这版用 1/M MIS(依赖 neighbor reject 控制偏差,工业界常用快速版)
void combineReservoir(
inout ReservoirDI dst, // 目标 reservoir(积累)
in ReservoirDI src, // 源 reservoir(要并入的邻居/历史)
float3 x1_dst, float3 n1_dst, // 当前像素几何
MaterialData mat_dst,
float c_max // confidence cap
) {
if (src.Y.lightID < 0) return; // src 是 null sample

// 在 dst 像素上重新评估 target function
float p_hat_at_dst = evalTargetFn(src.Y, x1_dst, n1_dst, mat_dst);

// 简化的 1/M MIS(biased,需 neighbor reject)
// src.c 是 src 已聚合的样本数,src.W_Y 是其 UCW
float w = p_hat_at_dst * src.W_Y * src.c;

dst.w_sum += w;
dst.c += src.c;
if (rand() < w / dst.w_sum) {
dst.Y = src.Y;
}
}

// Spatial reuse 主循环
ReservoirDI spatialReuse(uint pixel, ReservoirDI initial) {
ReservoirDI merged = initial;

const int K = 5; // 邻居数
const float radius = 30.0; // 像素半径

for (int i = 0; i < K; ++i) {
// 在 disk 内随机选邻居
float2 offset = sampleDisk(radius);
uint nbrPixel = pixel + uint(offset.x) * stride + uint(offset.y);

// 几何/法线/深度兼容性检查(neighbor rejection)
if (!isCompatibleNeighbor(pixel, nbrPixel)) continue;

ReservoirDI nbr = readReservoir(nbrPixel, /*previousFrame=*/false);
combineReservoir(merged, nbr, x1, n1, mat, C_MAX);
}

// 最后封顶 confidence
merged.c = min(merged.c, C_MAX);

// 重计算 UCW
float p_hat_Y = evalTargetFn(merged.Y, x1, n1, mat);
merged.W_Y = (p_hat_Y > 0) ? merged.w_sum / (p_hat_Y * merged.c) : 0;
// ^^^^^^^^^^^^^
// 注意:1/M MIS 时分母需含 c
return merged;
}

⚠️ 三个最容易写错的地方

  1. 测度转换:BSDF 采样得到的是 solid angle PDF,必须转成 area PDF 才能与 light sampling 在同一 measure 下做 MIS。
  2. target function 的位置:合并时要在目标像素重新评估 ,不能用源 reservoir 里存的旧值。
  3. MIS 类型与 UCW 公式必须匹配:用 1/M 配 ;用 generalized balance heuristic 配

3.5 阶段 ②:Temporal Reuse 详细处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ReservoirDI temporalReuse(uint pixel, ReservoirDI initial) {
ReservoirDI merged = initial;

// 1) 用 motion vector 找上帧像素
float2 mv = readMotionVector(pixel);
int2 prevPixel = int2(pixel) - int2(mv);

// 2) 边界检查
if (any(prevPixel < 0) || any(prevPixel >= screenSize)) return merged;

// 3) Disocclusion 检查(4 个判据)
float depthCurr = readDepth(pixel);
float depthPrev = readDepth(prevPixel, /*prev=*/true);
if (abs(depthCurr - depthPrev) > 0.05 * depthCurr) return merged;

float3 normCurr = readNormal(pixel);
float3 normPrev = readNormal(prevPixel, /*prev=*/true);
if (dot(normCurr, normPrev) < 0.906) return merged; // > 25°

int matCurr = readMaterialID(pixel);
int matPrev = readMaterialID(prevPixel, /*prev=*/true);
if (matCurr != matPrev) return merged;

// 4) 读上帧 reservoir 并合并
ReservoirDI prev = readReservoir(prevPixel, /*previousFrame=*/true);
combineReservoir(merged, prev, x1, n1, mat, C_MAX);

return merged;
}

Disocclusion 时的处理

  • 把上帧 reservoir 视为 null
  • 重置为 0(或 initial 的
  • 这是允许的——因为 disocclusion 检查只看 G-Buffer,不看 reservoir 内部样本

3.6 置信度权重 / M-Capping 的数学含义

问题:纯无限累积时间历史会让新样本的相对权重指数衰减,最终图像”卡死”在过去状态。

解决:每个 reservoir 携带置信度 (约等于”等价独立样本数”)。合并时 ,但累积上限封顶

经验值: 是常见起点。 进入 MIS 权重:

为什么需要 M-Cap:考虑无 cap 情况下,连续 帧时间复用:

  • 帧 1:
  • 帧 2:

帧新生成的样本 MIS 权重为 ,旧累积样本占 当场景真的发生变化时,新样本太弱,无法覆盖错误的历史 → 出现 lag/ghosting/能量错误。

加 cap 后,旧样本权重最多占 ,给新样本留了”翻身”的余地。

经验法则

  • 静态场景、慢相机: 甚至更高
  • 快节奏 FPS 游戏:
  • 大量动态光源:

🔧 在不连贯事件(disocclusion、材质突变、相机大幅运动)时重置 为 0,但只能基于 G-Buffer 检测重置——绝不能基于 reservoir 内部样本细节重置,否则引入条件偏差。

3.7 改进的光源采样:Power-based + Light Tile

Power-based:选光源的概率正比于其总辐射通量 (漫反射光源):

1
2
3
4
5
6
7
8
9
10
11
// 离线构建 alias table
struct LightAliasTable {
float[] prob;
int[] alias;
};

Light sampleLightByPower(float u0, float u1) {
int n = numLights;
int i = int(u0 * n);
return (u1 < table.prob[i]) ? lights[i] : lights[table.alias[i]];
}

Light Tile(Section 6.6 详述):把光源预采样成 tile,避免每个像素独立挑光源带来的缓存抖动。是 RTXDI / Cyberpunk 量级场景的关键优化。


四、跨域复用理论:Shift Mapping 是什么

在讲 ReSTIR GI 之前必须先理解一个通用框架:当源样本与目标样本来自不同积分域时,怎么 RIS?这是 GRIS [Lin 2022] 最核心的贡献。

4.1 为什么需要 Shift Mapping?

ReSTIR DI 中所有像素的 都来自同一个集合(光源表面 ),不需要做任何变换。但在 GI 中:

  • 像素 A 的次级击中点 在场景表面上的某处
  • 像素 B 的次级击中点 在另一处
  • 借用 给像素 B 用,需要决定:保留 不动?重新追踪?或者更复杂的策略?

更进一步,对于多次反弹路径:

  • 经过镜面的路径,要保持反射定律——直接复用顶点会违反物理
  • 不同像素看到的 caustics 路径完全不同

Shift Mapping 就是:从源域 到目标域 的一个双射变换 ,把”邻居的样本”翻译成”当前像素能用的样本”。

4.2 Shift Mapping 的严格定义

[Lin 2022] 定义 shift mapping 必须满足:

  1. 确定性:同一输入永远得到同一输出(不能依赖 RNG)
  2. 单射:源域两个不同样本不能映射到目标域同一样本
  3. 可逆 必须存在
  4. 逆映射相容:若 ,则
  5. 可能未定义:某些样本无法 shift(如遮挡、几何失败),返回 null

🔑 可逆性约束的实战意义:当我们设计一个 shift mapping,必须考虑”反方向也能 shift 回来”,否则会引入难以察觉的偏差。Hybrid shift 中复杂的距离/粗糙度条件就是为了保证可逆性。

4.3 Jacobian 行列式:概率密度的修正项

时,PDF 需要按 Jacobian 修正:

直觉: 处把”局部体积”放大了 倍,那么概率密度就要”稀释”同样倍数,否则总概率就不是 1 了。

对应的 UCW 变换:

为什么 UCW 是反过来乘?因为 除以 Jacobian, 就乘以 Jacobian。

4.4 跨域 RIS 完整管线

替换 Section 2.5 的 RIS 流程:

  1. 从各域 取候选
  2. shift 到目标域
  3. 在目标域计算 MIS 权重
  4. resampling weight:
{% raw %} {% endraw %}

注意 是源域的,要乘 Jacobian 转到目标域
5. 按
6. 输出 UCW:

等价形式:用变换后的 直接代入:

{% raw %} {% endraw %}

这就回到了 Section 2.5 的标准形式,只是 经过 Jacobian 修正。

4.5 跨域 MIS:generalized balance heuristic

问题:源域 的 PDF 通常不可求(因为 也是 RIS 输出)。MIS 权重怎么算?

思路:用 target function 作为 PDF 的代理(因为 RIS 保证输出 )。

把” 蒸馏”到目标域:

{% raw %} {% endraw %}

注意 (逆函数定理),所以也可以等价写成:

{% raw %} {% endraw %}

广义 balance heuristic with confidence

{% raw %} {% endraw %}

4.5.1 跨域 MIS 的几何直觉

跨域复用:每个目标域点必须被恰好"覆盖一次总和" Ω₁ Ω₂ Ω₃ Ω₄ Ω (target) 每个目标域上的点 y,必须满足 ∑ᵢ mᵢ(y) = 1 重叠区域:多个源域贡献,MIS 权重把它们公平分配

每个目标域上的 ,可能由多个源域 通过 映射过来——也可能根本没有源域能映射到。MIS 权重要做的就是:

  • 被多个源域覆盖的点:权重之和 = 1(每个分一份)
  • 只被一个源域覆盖的点:那个源域权重 = 1
  • 不可达的点:必须有 canonical sample 兜底

4.5.2 Canonical Sample 的作用

为了保证 (覆盖性),ReSTIR 要求至少有一个 canonical sample

  • 它的源域就是目标域
  • 它用 identity shift ,Jacobian = 1
  • 它的 target function
  • 它的 support 完全覆盖 的 support

实战中:当前像素自己生成的 initial sample 就是 canonical sample。所有空间/时间复用进来的都是非 canonical 的。


五、ReSTIR GI 与 ReSTIR PT:扩展到全局光照

5.1 路径积分回顾

任意长度路径的全光路径积分:

{% raw %} {% endraw %}

路径写作 tuple:

直接光是 的特例。GI 要处理

5.2 ReSTIR GI vs ReSTIR PT:本质差异

维度 ReSTIR GI [Ouyang 2021] ReSTIR PT [Lin 2022]
Shift mapping Identity in area measure Hybrid (replay + reconnect)
路径长度 仅复用 (单次反弹) 完整路径
Glossy/specular 有偏(假设出射 radiance 不变) 无偏
内存/像素 小(约 24-32 B) 中(约 60-80 B)
性能 更快 略慢
适用场景 漫反射主导的 GI 全场景 PT

5.3 ReSTIR GI 的策略:Identity Shift in Area Measure

最简单的 shift——直接复用 不动:

{% raw %} {% endraw %}

也就是说:保留次级击中点 在场景中的位置不变,只是从新像素的 重新连到

这本质上是 reconnection shift 的特例(reconnection 点固定为 )。

Jacobian (area measure) = 1(域元相同,几何关系通过 BSDF/G 项体现在 里)。所以 ReSTIR GI 的 shift 数学上很简单。

5.3.1 ReSTIR GI 完整数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ReservoirGI {
// 次级击中点信息
float3 x2_pos; // 次级击中点位置
float3 x2_normal; // 次级击中点法线
float3 L_outgoing; // x2 沿 (x2 → x1) 方向的出射 radiance(缓存)

// 标准 reservoir 字段
float W_Y; // UCW
float w_sum; // 权重和
int c; // 置信度

// (可选) 缓存反向方向用于 fallback
float3 omega_in_at_x1; // x1 处的入射方向
};

5.3.2 ReSTIR GI 为什么有偏?

理论上,正确的实现需要在每次复用时:

  1. 重新连接
  2. 重新评估 处的 BSDF(用新方向
  3. 重新评估几何项
  4. 重新投阴影射线测
  5. 使用 处沿新方向 的出射 radiance

第 5 步是麻烦——出射 radiance 依赖入射方向。ReSTIR GI 的做法是:在生成样本时缓存沿原方向 的 outgoing radiance ,复用时假设它不变

这只对 Lambertian BSDF 严格成立(漫反射 outgoing radiance 是各向同性的)。对粗糙度低于 ~0.3 的 glossy 材质会出现可见错误。

🎨 何时 ReSTIR GI 仍然好用:游戏场景里漫反射占绝对主导(金属/玻璃/水通常单独走 mirror reflection),ReSTIR GI 的偏差对最终图像影响有限,且性能开销小。Cyberpunk 早期版本就用的 ReSTIR GI。

5.4 三种典型 Shift Mapping 详解

A. Reconnection Shift cam_i x1 y1 x2 (共享) 固定 x2,重连 y1→x2 ✅ 漫反射 ❌ Glossy B. Random Replay cam_i x1 y1 x2 y2 共享 RNG seed 重新追踪 ✅ 镜面 ❌ Jacobian 复杂 C. Hybrid Shift (ReSTIR PT) cam_i x1(光滑) y1(光滑) x2(replay) y2(replay) xₖ 先 replay,再到漫反射顶点连接 ✅ 综合最优

三种 Shift Mapping 对比示意(蓝=源像素 base path,绿=目标像素 offset path)

5.4.1 Reconnection Shift

操作:保留 base path 中 之后的所有顶点不动,只修改 为 offset path 的对应顶点。

{% raw %} {% endraw %}

Jacobian (area measure) = 1(域元相同,几何关系通过 BSDF/G 项体现在 里)。

Jacobian (solid angle measure)

{% raw %} {% endraw %}

失败模式

  • 处 BSDF 在新方向 接近 0(如低粗糙度 glossy)
  • 新连接段 被遮挡 →
  • 几何项剧烈变化(极短或极长连接段)

5.4.2 Random Replay Shift

操作:共享 base path 用过的随机数序列 ,从 开始用同一组 RNG 重新走一遍。每个顶点处用同样的随机数生成新方向。

Jacobian (Primary Sample Space) = 1(直接共享 RNG,PSS 域元不变)。

优势

  • 对镜面/低粗糙度顶点天然友好(同一切空间 half-vector → 同一镜面方向)
  • Jacobian 极简

劣势

  • 多次反弹后偏离 base path 越来越远,方差爆炸
  • 每个 random replay 步都需要采 BSDF 一次(开销)

5.4.3 Half-Vector Copy Shift

操作:在每个反弹处,复制切空间下的 half-vector(入射与出射方向的中线),而非随机数。

在镜面附近,BSDF 主要支持的方向集中在 half-vector 周围,所以 half-vector 复制能更好地保持路径”重要性”。

Jacobian 由 [Kettunen 2015] 给出,依赖 BSDF 类型(GGX、Beckmann 等)。

5.4.4 Hybrid Shift(ReSTIR PT 的核心)

思路:组合上面三种的优点——

  1. 在镜面/低粗糙度顶点用 random replay
  2. 遇到第一对连续粗糙顶点 时切换为 reconnection
  3. 直接连到

5.4.5 Hybrid Shift 的连接条件

为保证可逆性, 必须满足:

距离条件(避免极短重连段,几何项爆炸):

{% raw %} {% endraw %}

粗糙度条件(避免低粗糙顶点上的连接):

{% raw %} {% endraw %}

最小性约束(关键):不存在更早的 同时满足以上条件。否则 offset path 会做出不同选择,破坏双射。

经验值: scene 单位, GGX 粗糙度。

5.5 Lobe Tagging 与 Primary Sample Space

5.5.1 为什么需要 Lobe Tag?

复杂材质(如车漆 = 漫反射 + clearcoat glossy + flake metallic)在每个顶点会随机选择采样哪个 lobe。如果不记录”上次选了哪个 lobe”,random replay 时无法重现。

Extended path sample

{% raw %} {% endraw %}

表示 $\mathbf{x}j\ell{D-1} = N_\text{lobe}$ 表示 NEE light sampling。

Hybrid shift 中:复制 base 的 到 offset path 的 $\mathbf{y}k使\mathbf{y}{k-1}$ 上没有这个 lobe(材质不同),shift 失败。

5.5.2 Primary Sample Space (PSS)

PSS 把路径用其生成时用过的随机数序列 表示。

优点

  1. Random replay 部分的 Jacobian 严格 = 1
  2. 路径吞吐量直接是 sampled throughput( 乘积),不需要单独算 PDF
  3. 避免浮点溢出( 都很大但比例稳定时)

路径积分在 PSS 下变形:

5.5.3 Hybrid Shift 的完整 Jacobian (PSS)

只有 reconnection 那一步贡献非平凡的 Jacobian:

{% raw %} {% endraw %}

其中:

{% raw %} {% endraw %}

的几何 Jacobian 就是 reconnection shift 的标准公式。

注:当 是 NEE 采样的光源顶点时, 项消失。

5.6 ReSTIR PT 完整 Reservoir 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct PathReservoir {
struct ReconnectionVertex {
float3 omega_in; // 入射方向(base path 的 ω^x_k)
float3 L_outgoing; // 沿入射方向的出射 radiance(缓存)
uint triangleID; // 击中三角形 ID
float2 barycentric; // (λ1, λ2) → 唯一确定 x_k 位置
uint8 lobe_km1; // ℓ_{k-1}: 上一顶点选的 lobe
uint8 lobe_k; // ℓ_k: 此顶点选的 lobe
};

ReconnectionVertex rcVertex;
uint seed1; // 用于生成 [y_2, ..., y_{k-1}] 的 RNG seed
uint seed2; // 用于生成 [y_{k+2}, ...] 的 RNG seed(NEE 之后)
uint8 k; // reconnection 顶点索引
float J; // base path 部分的 Jacobian (cached)

// 通用 RIS 字段
float W_Y;
float w_sum;
float c;
};

总大小 ≈ 60-80 B(紧凑打包后),约为 ReSTIR DI 的 3-4 倍。

5.7 ReSTIR PT 的 Hybrid Shift 完整伪代码

Hybrid Shift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 共享 RNG 重放生成 offset path 的前半段
struct ReplayedSubPath {
float3 yk_minus_1_pos;
float3 yk_minus_1_normal;
float3 throughput; // β: y0 ~ y_{k-1} 的累积 throughput
int lobe_km1; // 在 y_{k-1} 处选的 lobe
bool valid;
};

ReplayedSubPath randomReplay(
float3 y0, float3 y1, float3 n_y1, // offset path 起点
uint seed1, // 与 base path 共享的 RNG seed
int k // 在第 k 个顶点停下来准备连接
) {
ReplayedSubPath result = {};
result.throughput = float3(1.0);
PRNG prng = initPRNG(seed1);

float3 currPos = y1, currN = n_y1;
int currLobe = -1;
for (int i = 1; i < k - 1; ++i) {
// 用同一 RNG 重新选 lobe + 方向
Material mat = sampleMaterial(currPos);
currLobe = sampleLobe(mat, prng.rand());
if (currLobe != getBaseLobe(i)) { // 如果 lobe 不一致,shift 失败
result.valid = false;
return result;
}
float3 wi = sampleDirection(mat, currLobe, currN, prng.rand2D());
float bsdfPdf = bsdfPDF(wi, currN, mat, currLobe);
float3 bsdfVal = bsdfEval(wi, currN, mat, currLobe);

// 投射
HitInfo hit = traceRay(currPos, wi);
if (!hit.valid) { result.valid = false; return result; }

float G_term = abs(dot(currN, wi)) / dot(hit.pos - currPos, hit.pos - currPos);
result.throughput *= bsdfVal * G_term / bsdfPdf;

currPos = hit.pos; currN = hit.normal;
}
result.yk_minus_1_pos = currPos;
result.yk_minus_1_normal = currN;
result.lobe_km1 = currLobe;
result.valid = true;
return result;
}

// Hybrid shift 主函数:把 base path 在 source pixel 的样本 shift 到 target pixel
// 返回:shifted UCW & 是否成功
struct ShiftResult {
float3 contribution; // 在目标域评估的 f(Y)
float jacobian; // |T'(X)|
bool valid;
};

ShiftResult hybridShift(
in PathReservoir base, // 源像素的 reservoir
in PixelData targetPixel // 目标像素几何
) {
ShiftResult res = {};
const float D_MIN = 0.05;
const float ALPHA_MIN = 0.25;

// 1) Random replay 到 reconnection 顶点之前
ReplayedSubPath replayed = randomReplay(
targetPixel.x0, targetPixel.x1, targetPixel.n1,
base.seed1, base.k);
if (!replayed.valid) return res;

float3 y_km1 = replayed.yk_minus_1_pos;
float3 n_km1 = replayed.yk_minus_1_normal;

// 2) 检查连接条件
float3 x_k = barycentricToWorld(base.rcVertex.triangleID, base.rcVertex.barycentric);
float3 d = x_k - y_km1;
float dist = length(d);
if (dist < D_MIN) return res;

// Roughness condition
Material mat_y_km1 = sampleMaterial(y_km1);
float alpha_y = mat_y_km1.roughness(replayed.lobe_km1);
if (alpha_y < ALPHA_MIN) return res;

// 3) Visibility test
if (!testVisibility(y_km1, x_k)) return res;

// 4) 重新评估 reconnection segment 上的 BSDF + G + 后续 throughput
float3 wi_new = d / dist;
float3 bsdf_y = bsdfEval(wi_new, n_km1, mat_y_km1, replayed.lobe_km1);
float G_new = abs(dot(n_km1, wi_new)) * abs(dot(base.rcVertex.omega_in, -wi_new))
/ (dist * dist);

// 5) Jacobian (solid angle measure)
float G_old = base.J; // 缓存的 base path 部分
res.jacobian = G_new / max(G_old, 1e-6);

// 6) 用缓存的 L_outgoing 作为剩余 path 贡献
res.contribution = replayed.throughput * bsdf_y * G_new * base.rcVertex.L_outgoing;

res.valid = true;
return res;
}

🔧 实现细节:缓存 base path 的 Jacobian 部分()能避免每次 shift 都重新算 base path——这就是 reservoir 里 J 字段的用途。

5.8 Volumetric ReSTIR:参与介质

体积渲染中,路径顶点可在 3D 空间任意位置,不再局限于 2D 流形。完整路径积分:

主要变化:

  • (表面 + 体积)
  • 通常无闭式
  • (体积内)或 (表面)
  • (体积内,无 cosθ)或 (表面)
  • 距离采样用 delta tracking,PDF 未知

Volumetric ReSTIR 的妙处:用低分辨率分段常数体积近似 ,使 transmittance 可解析积分;shading 时再用高精度 transmittance 评估实际贡献。

两种 shift

  • Vertex reuse:复制 (快但 firefly 严重)
  • Direction reuse:复制 序列重追踪(慢但鲁棒,默认)

六、性能优化与工程落地

6.1 优化总览

层次 优化点 复杂度变化 是否引入偏差
Sampler Neighbor Rejection ⚠️ 配 1/M MIS 时有偏
Sampler Contribution MIS ✅ 无偏
Sampler Pairwise MIS (defensive) ✅ 无偏
Sampler Generalized Balance Heuristic ✅ 无偏
Low-level Sample Tiling 减少 cache miss ✅ 无偏
Low-level Reservoir 压缩 内存带宽 ↓70% ✅ 无偏
Low-level Hybrid Shift Kernel 切分 Shader 时间 -60% ✅ 无偏
Low-level Visibility Reuse 阴影射线 -50% ⚠️ 经常有偏
Low-level Last-frame BVH 替换为当前帧 内存 / 引擎复杂度 ↓ ⚠️ 有偏

6.2 Neighbor Rejection 与 Veach Cutoff 的关系

Neighbor rejection 不是”任意启发式”——它本质上是 Veach cutoff heuristic 在 ReSTIR 上的应用:当某个采样策略在某个域上 PDF 接近 0 时,从 MIS 计算中剔除其贡献。

Veach cutoff heuristic

Neighbor rejection 用 G-Buffer 几何相似度作为 的 proxy:法线偏差大、深度差别大、材质不同的邻居,被认为对当前像素 PDF 极低,直接 reject。

实战阈值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool isCompatibleNeighbor(uint pixel, uint neighbor) {
float depthRatio = abs(depth(pixel) - depth(neighbor)) / depth(pixel);
if (depthRatio > 0.10) return false; // 10% 深度差容忍

float normalDot = dot(normal(pixel), normal(neighbor));
if (normalDot < 0.906) return false; // > 25° 拒绝

if (matID(pixel) != matID(neighbor)) return false;

// 进阶:roughness 差异(避免 mirror/diffuse 混用)
if (abs(rough(pixel) - rough(neighbor)) > 0.3) return false;

return true;
}

🔑 铁律:reject 决策只能看 G-Buffer,绝不能看 reservoir 内部的样本细节(如 selected sample 是否可见、贡献多大)。看样本就引入条件概率,立刻有偏。

6.3 Contribution MIS Weights:单点 MIS 评估

核心思想 [Bitterli 2020]:把 RIS 中的 拆成两部分——(用于贡献加权)和 (用于权重计算)。只对选中的样本评估 ,其他候选用 这样的廉价权重。

修改后的 UCW

只要 ,就保持无偏。

复杂度(每个候选算 )+ (最终算选中那个的 )= 总体。

适用场景:候选间 PDF 差距巨大时(如直接光中 NEE 与 BSDF 采样混合),contribution MIS 的方差比 1/M 好得多。

6.4 Pairwise MIS:四种变体完整推导

核心思想 [Bitterli 2022]:把每个邻居与 canonical sample(当前像素)配对计算 balance heuristic,复杂度 但收敛性接近

6.4.1 Non-defensive 形式

{% raw %} {% endraw %}

注意 因子:直觉上,”非 canonical”邻居被 个其他邻居”分摊”权重;canonical 在每个 pair 中重复出现,所以也要按 缩放保证归一性。

Sanity check:当所有 PDF 相同时, 对所有 ,与 1/M MIS 一致。

6.4.2 Defensive 形式

为防止 是不准确代理时给非 canonical 太高权重,加一个”defensive 常数”——保证 canonical 至少占

{% raw %} {% endraw %}

注意分母从 变成了

6.4.3 带 Confidence Weights 与 Shift Mapping 的版本

Non-defensive + confidence + shift (实际工业实现):

{% raw %} {% endraw %}

Defensive + confidence + shift (ReSTIR PT 默认):

{% raw %} {% endraw %}

6.4.4 Pairwise MIS 完整伪代码

CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 计算 canonical sample 的 MIS 权重
float pairwiseMIS_canonical(
in Sample y_c, in float p_hat_c, // canonical sample & 其 target fn
in Sample neighbors[], // 非 canonical 候选
in float c[], // 各候选 confidence
int M
) {
float c_total = 0;
for (int k = 0; k < M; ++k) c_total += c[k];

float m_c = c[CANON_IDX] / c_total; // defensive 常数项

for (int j = 0; j < M; ++j) {
if (j == CANON_IDX) continue;

float p_hat_j_at_target = pHatFrom(j, y_c); // shift y_c 回 j 的域
float num = c[CANON_IDX] * p_hat_c;
float denom = num + (c_total - c[CANON_IDX]) * p_hat_j_at_target;

m_c += (c[j] / c_total) * (num / denom);
}
return m_c;
}

// 计算非 canonical 样本 i 的 MIS 权重(已知 y = T_i(x), Jacobian 已算)
float pairwiseMIS_noncanonical(
in Sample y_i, in float p_hat_i_at_target, // shift 后的 sample
in float p_hat_c_at_target, // canonical 在目标域 target fn
in float c[], int i,
int M
) {
float c_total = 0;
for (int k = 0; k < M; ++k) c_total += c[k];

float num = (c_total - c[CANON_IDX]) * p_hat_i_at_target;
float denom = num + c[CANON_IDX] * p_hat_c_at_target;
return (c[i] / c_total) * (num / denom);
}

🔧 使用建议:ReSTIR PT 默认用 defensive pairwise MIS。在 ReSTIR DI 等候选间 差异不大的场景,non-defensive 也够用且稍快。

6.5 Biased MIS Weights:偏差换性能

场景:temporal reuse 中,要算 (前帧 PDF 对当前帧样本 的求值),需要:

  • 上一帧的 BVH(额外内存 + 维护成本)
  • 上一帧的灯光列表
  • 上一帧的纹理/材质

实战中常做近似:

偏差源 效果
用当前帧 BVH 代替上帧 BVH 移动物体处微小 ghosting
假设 (永远拒绝当前帧样本进入历史 MIS) 当前帧样本权重↑ → brightening bias
假设 (PDF 近似不变) 通常很小偏差
上帧 用上帧 visibility 缓存 偶发能量损失

💡 决策原则先实现无偏版本,对比 ground truth 验证。等基线工作后,逐项打开偏差近似,对比每一步对图像的影响。这样能精确定位每种偏差的接受阈值。

6.6 Sample Tiling:百万光源场景的关键

问题:场景有 300 万光源时(如 Amusement Park),每个像素随机挑光源会疯狂 thrash 缓存,仅”挑候选”就要 25 ms。

Wyman & Panteleev [HPG 2021] 的方案:

1
2
3
4
每帧:
1. 生成 N=128 个 light tile,每个 tile 内 K=1024 个预采样光样本(按 power 分布)
2. 屏幕划分为 8×8 或 16×16 像素块,每块绑定一个 light tile
3. 像素只从绑定的 tile 内挑候选 → 极强的缓存局部性

完整算法

CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// === 每帧前置 pass: 生成 light tiles ===
const int NUM_TILES = 128;
const int SAMPLES_PER_TILE = 1024;

[numthreads(64, 1, 1)]
void GenerateLightTiles(uint3 tid : SV_DispatchThreadID) {
uint tileIdx = tid.x / SAMPLES_PER_TILE;
uint sampleIdx = tid.x % SAMPLES_PER_TILE;
if (tileIdx >= NUM_TILES) return;

uint seed = hash(frameIndex, tileIdx, sampleIdx);
Light l = sampleLightByPower(seed);
float3 pt; float3 normal; float pdf;
sampleLightSurface(l, seed, pt, normal, pdf);

LightTileEntry e;
e.lightID = l.id; e.pos = pt; e.normal = normal; e.pdf = pdf;
g_lightTiles[tileIdx][sampleIdx] = e;
}

// === Initial sampling 中: 像素从绑定 tile 内挑候选 ===
ReservoirDI initialSampling(uint2 pixel) {
uint tileX = pixel.x / 16;
uint tileY = pixel.y / 16;
uint tileIdx = (hash(tileX, tileY, frameIndex)) % NUM_TILES;

ReservoirDI r = {};
for (int i = 0; i < M_LIGHT; ++i) {
uint sampleIdx = uint(rand() * SAMPLES_PER_TILE);
LightTileEntry e = g_lightTiles[tileIdx][sampleIdx];
// ...
}
return r;
}

实测效果:Amusement Park (3M lights) initial sampling 从 25 ms → < 1 ms

理论解释:sample tiling 是一个退化的 RIS——把”按 power 选光源”看作 outer RIS,”在 tile 内均匀挑”看作 inner uniform sampling。target function light power。

6.7 Reservoir 数据结构压缩

6.7.1 ReSTIR DI Reservoir

朴素:

1
2
3
4
5
6
7
8
9
struct ReservoirDI_Naive {
float3 lightPos; // 12B
float3 lightNormal; // 12B
float3 emission; // 12B
int lightID; // 4B
float W_Y; // 4B
float w_sum; // 4B
int M; // 4B
}; // 52B

压缩:

1
2
3
4
5
6
7
struct ReservoirDI_Packed {
uint32_t lightID; // 4B (索引到全局光源 buffer)
uint16_t uv_x, uv_y; // 4B (在光源参数化空间的 UV)
fp16 W_Y; // 2B
fp16 w_sum; // 2B
uint16_t M; // 2B
}; // 14B (压缩 73%)

@1080p:52 → 14 B/pixel,从 109 MB 降到 30 MB。

⚠️ FP16 用于 时要小心:极亮区域(如直射太阳)的 可能溢出。可以用 R11G11B10 或者用 log space 存储缓解。

6.7.2 ReSTIR PT Reservoir

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct PathReservoir_Packed {
// Reconnection vertex
uint triangleID; // 4B
fp16 bary_uv[2]; // 4B
fp16 omega_oct[2]; // 4B (octahedral 编码 + fp16)
fp16 L_outgoing[3]; // 6B (RGB 出射 radiance)
uint8 lobe_km1, lobe_k; // 2B

// RNG seeds
uint32_t seed1, seed2; // 8B

// Misc
uint8 k; // 1B
fp16 J; // 2B
fp16 W_Y; // 2B
fp16 w_sum; // 2B
uint8 c; // 1B (够用,c_cap < 256)
}; // ~36B

关键技巧:用 octahedral encoding 存方向(2 个 16-bit 浮点表示单位向量),用 triangleID + barycentric 存位置(避免存 float3)。

6.8 Hybrid Shift 的内核切分加速

问题:把 random replay + reconnection + visibility test 塞在一个大 kernel 里:

  • 寄存器使用爆炸 → occupancy 暴跌
  • 不同像素分支严重不一致 → execution divergence
  • 中间结果反复加载

优化路线

Step 1:Kernel 切分

1
2
3
4
5
原: [InitialSample → SpatialReuse(ReplayShift+Reconnect+Vis) → Shading]
新: [InitialSample]
[ReplaySamplesGen] ← 只做 random replay,写中间路径到 global mem
[ReconnectAndEvaluate] ← 读中间路径,做连接 + visibility + final RIS
[Shading]

实测:Veach Ajar 场景 spatiotemporal resampling 时间 -40%。

Step 2:Stream Compaction

1
2
3
4
5
6
7
8
9
10
11
12
// 压缩出"需要 replay"的像素索引
[numthreads(64, 1, 1)]
void CompactValidReplays(uint3 tid : SV_DispatchThreadID) {
bool needReplay = (g_reservoir[tid.xy].k > 1);
uint compactIdx = WaveActivePrefixCountBits(needReplay);
if (needReplay) {
g_compactedPixels[baseOffset + compactIdx] = tid.xy;
}
}

// 在压缩后的列表上 dispatch
ReplaySamplesGen.dispatch(compactedCount);

实测:在 Step 1 基础上再 -40%。

6.9 Visibility Reuse:偏差陷阱重灾区

ReSTIR DI 原始论文为了省 shadow ray 提出”重用 visibility”——历史 reservoir 已经测过 visibility 就不再测。但这会引入难以察觉的偏差(场景动态变化时尤甚)。

偏差源

  • 上一帧的 是基于上一帧的几何,当前帧物体可能已移动
  • 邻居像素的 测试相对当前像素可能不正确(物体之间的遮挡关系不同)

实战建议

  1. 默认在 target function 里始终包含
  2. 只有在能容忍 ghosting/light leaking 的场景下才关闭 visibility 重测
  3. 用 disocclusion mask 强制关键区域重测
  4. 如果一定要 visibility reuse:对每帧最终 selected sample 至少做一次 visibility verification(”deferred visibility validation”)

6.10 Boiling 与 Correlation:相关性的代价

症状:高时间复用下,相邻像素长期共享同一个样本,画面上出现块状的、随时间”沸腾”的低频噪声

根本原因:spatial reuse 把相同样本分发给整片像素后,所有像素的估计变得高度相关。MC 估计器原本依赖独立性来通过中心极限定理消除噪声——独立性被破坏后,方差不再按 衰减。

对策

方法 原理 副作用
Confidence cap () 限制单个样本被复用次数 收敛速度↓
Boiling filter 远超邻居均值的 reservoir 重置 引入轻微偏差
Permutation sampling 空间复用时邻居偏移用低差异序列 实现复杂度↑
降低空间复用半径 减少相关性范围 收敛速度↓
降低空间复用轮数 同上 同上
Disocclusion 时强制重生成 阻止错误样本传播 关键边缘可能 noisy

Boiling filter 实现

1
2
3
4
5
6
7
// 在 spatial reuse 之后、shading 之前
void boilingFilter(inout ReservoirDI r, float3 neighborMeanW) {
// 如果 W_Y 异常大(远超邻居均值),重置
if (r.W_Y > 5.0 * neighborMeanW) {
r = {}; // 弃掉这个 reservoir
}
}

⚠️ Boiling filter 是 biased(看了 reservoir 内部值做决策)。但实战中能极大改善画面观感。需要在艺术家把关下取舍。


七、与其他实时 GI 方案对比

7.1 ReSTIR vs SVGF vs OIDN

维度 SVGF OIDN/OptiX Denoiser ReSTIR ReSTIR + 后置降噪
工作位置 渲染后 渲染后 渲染时(采样阶段) 双重
复用单位 颜色 颜色 PDF + 样本 PDF + 颜色
偏差性质 能量损失 神经网络模糊 数学无偏 取决于 denoiser
Ghosting 严重 中等 轻微 取决于 denoiser
计算成本 中等 高(NN) 中等
与动态光适配 极好 极好
高频细节 模糊 较模糊 保留 保留

实战中通常 ReSTIR + 轻量级时空滤波器(如 ReBLUR / SVGF lite) 配合使用,得到既无偏又时空稳定的结果。

7.2 ReSTIR vs Path Guiding

维度 Path Guiding [Vorba 2019] ReSTIR
学习对象 显式 PDF (kd-tree / 高斯混合) 隐式(通过样本聚合)
内存开销 数百 MB(PDF 数据结构) 数十 MB(reservoir buffer)
适应速度 需要预热(数百帧) 即时(首帧就有效)
动态场景 重新学习 自然适应(temporal reuse)
实时性 一般为离线 60 FPS 可达
数学复杂度 中(PDF 拟合) 高(重采样 + shift)

7.3 ReSTIR vs Lumen (UE5)

UE5 Lumen 是另一种工业级方案,结合 SDF / mesh distance field、screen-space ray marching、surface cache。

维度 Lumen ReSTIR
哲学 多 LOD 数据结构混合 算法层重采样
间接光 表面缓存 + screen probe 路径追踪 + reservoir
硬件需求 RTX 可选 必须 RT 硬件
视觉一致性 偶有 light leaking 物理正确
开发友好 高(开箱即用) 中(需要自定义集成)

选择哲学:Lumen 适合 RTX 不强制的”全平台”游戏;ReSTIR 适合 RTX 旗舰 / PC exclusive / 影视实时预览。

7.4 ReSTIR vs DDGI / Probe-based GI

DDGI(Dynamic Diffuse Global Illumination)用 3D 网格上分布的 light probe 缓存间接光。

维度 DDGI ReSTIR GI
表示精度 受 probe 密度限制 像素级
高频细节 几乎丢失 保留
动态光适配 慢(probe 收敛需要多帧) 即时
内存 低(一组 probe) 高(每像素 reservoir)
适合的几何 大型开阔场景 复杂室内/精细几何

实战中也可以混合使用:DDGI 处理远距离/大面 GI 主导成分,ReSTIR 处理近场 / 高对比度细节。


八、参考资料

核心论文(按重要性)

  1. [Lin et al. 2022] Generalized Resampled Importance Sampling: Foundations of ReSTIR统一理论框架,必读
  2. [Bitterli et al. 2020] Spatiotemporal Reservoir Resampling for Real-Time Ray Tracing with Dynamic Direct Lighting — ReSTIR DI 开山之作
  3. [Ouyang et al. 2021] ReSTIR GI: Path Resampling for Real-Time Path Tracing — 单弹 GI 扩展
  4. [Bitterli 2022] Correlations and Reuse for Fast and Accurate Physically Based Light Transport — 博士论文,pairwise MIS 出处
  5. [Wyman & Panteleev 2021] Rearchitecting Spatiotemporal Resampling for Production — 工程优化
  6. [Lin et al. 2021] Fast Volume Rendering with Spatiotemporal Reservoir Resampling — 体渲染
  7. [Talbot et al. 2005] Importance Resampling for Global Illumination — RIS 原始论文
  8. [Veach 1995] Optimally Combining Sampling Techniques for Monte Carlo Rendering — MIS 鼻祖

课程与讲座

优秀解读 Blog

开源实现

进一步阅读

  • Path Guiding [Vorba 2019]:另一种自适应重要性采样方向
  • Manifold Exploration [Jakob 2012]:处理 specular chain 的全局方法
  • Gradient-Domain Path Tracing [Kettunen 2015]:Shift Mapping 的来源
  • NRC (Neural Radiance Caching) [Müller 2021]:与 ReSTIR 互补的 last-bounce 方案

附录 A:核心公式速查表

A.1 公式总览

公式 用途 备注
标准 MC 估计 基础
UCW 定义 是随机变量,非函数
UCW 形式 MC RIS 输出兼容
RIS resampling weight 候选权重
RIS 输出 UCW 不是
Balance heuristic PDF 已知
Generalized BH 含置信度
UCW 经 shift 变换 跨域复用
PDF 经 shift 变换 跨域复用
跨域 target fn “p̂ from i”

A.2 Reconnection Shift Jacobian (Solid Angle)

{% raw %} {% endraw %}

A.3 实战参数推荐起点

参数 推荐值 说明
Initial sampling: 32 光源候选数
Initial sampling: 1 BSDF 候选数
Spatial reuse: 5 邻居数
Spatial reuse: 半径 30 px 像素
Spatial reuse: 轮数 1-2 多了引入相关性
Confidence cap 20 静态可调高,动态调低
Disocclusion 深度阈值 5-10% 相对深度
Disocclusion 法线阈值 cos 25° = 0.906 点积
Hybrid shift 0.05 scene unit 防短距
Hybrid shift 0.2-0.3 GGX 粗糙度
Light tile 数 128 per-frame
Light tile 大小 1024 samples per tile
Screen tile 大小 16×16 与 light tile 绑定

附录 B:完整 ReSTIR DI Compute Shader 骨架

下面是一个最小可工作的 ReSTIR DI compute shader 骨架(HLSL 风格),可以作为自研引擎集成的起点。

ReSTIRDI.compute
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
// ================================
// ReSTIR DI - 最小可工作骨架
// ================================
// 三个 compute kernel:
// 1. InitialSampling
// 2. TemporalReuse
// 3. SpatialReuseAndShade
// ================================

// 共享数据结构
struct LightSample {
float3 pos;
float3 normal;
uint lightID;
};

struct Reservoir {
LightSample Y;
float W_Y;
float w_sum;
uint c; // confidence
uint pad;
};

// G-Buffer 与 reservoir buffer
Texture2D<float4> g_GBuffer_Albedo;
Texture2D<float4> g_GBuffer_Normal; // .xyz = normal, .w = depth
Texture2D<float2> g_MotionVector;
Texture2D<uint> g_GBuffer_MaterialID;

RWStructuredBuffer<Reservoir> g_Reservoir_Curr;
RWStructuredBuffer<Reservoir> g_Reservoir_Prev;
RWStructuredBuffer<Reservoir> g_Reservoir_Spatial;

RWTexture2D<float4> g_Output;

// 配置
cbuffer Constants {
uint2 screenSize;
uint frameIndex;
float c_max; // confidence cap, 默认 20
float spatial_radius; // 默认 30
uint spatial_K; // 默认 5
};

uint pixelToIdx(uint2 p) { return p.y * screenSize.x + p.x; }

float evalTargetFn(LightSample s, float3 x1, float3 n1, MaterialData mat) {
float3 d = s.pos - x1;
float dist2 = dot(d, d);
float3 wi = d * rsqrt(dist2);
float cosTheta_x = max(dot(n1, wi), 0);
float cosTheta_l = max(dot(s.normal, -wi), 0);
if (cosTheta_x <= 0 || cosTheta_l <= 0) return 0;

float G = cosTheta_x * cosTheta_l / dist2;
float3 fs = bsdfEval(mat, wi, n1);
float V = traceShadowRay(x1, s.pos) ? 0.0 : 1.0;
float3 Le = getLightEmission(s.lightID, -wi);
return luminance(fs * Le) * G * V;
}

// === Kernel 1: Initial Sampling ===
[numthreads(8, 8, 1)]
void InitialSampling(uint3 tid : SV_DispatchThreadID) {
if (any(tid.xy >= screenSize)) return;
uint2 pix = tid.xy;
uint idx = pixelToIdx(pix);

// 读 G-Buffer
float3 x1 = reconstructWorldPos(pix);
float3 n1 = g_GBuffer_Normal[pix].xyz;
MaterialData mat = readMaterial(pix);

Reservoir r = (Reservoir)0;
PRNG prng = initPRNG(pix, frameIndex);

const uint M_LIGHT = 32;
for (uint i = 0; i < M_LIGHT; ++i) {
// Power-based 光源采样
Light l = sampleLightByPower(prng.rand2());
LightSample s;
float p_area = sampleLightPoint(l, prng.rand2(), s.pos, s.normal);
s.lightID = l.id;
float p_combined = (l.power / totalPower) * p_area;

float p_hat = evalTargetFn(s, x1, n1, mat);
float w = (1.0 / M_LIGHT) * p_hat / max(p_combined, 1e-9);

// WRS update
r.w_sum += w;
r.c++;
if (prng.rand() < w / max(r.w_sum, 1e-9)) {
r.Y = s;
}
}

float p_hat_Y = evalTargetFn(r.Y, x1, n1, mat);
r.W_Y = (p_hat_Y > 0) ? r.w_sum / p_hat_Y : 0;

g_Reservoir_Curr[idx] = r;
}

// === Kernel 2: Temporal Reuse ===
[numthreads(8, 8, 1)]
void TemporalReuse(uint3 tid : SV_DispatchThreadID) {
if (any(tid.xy >= screenSize)) return;
uint2 pix = tid.xy;
uint idx = pixelToIdx(pix);

Reservoir merged = g_Reservoir_Curr[idx];

float2 mv = g_MotionVector[pix];
int2 prevPix = int2(pix) - int2(round(mv));
if (any(prevPix < 0) || any(prevPix >= int2(screenSize))) {
g_Reservoir_Curr[idx] = merged; return;
}

// Disocclusion check
float dCurr = g_GBuffer_Normal[pix].w;
float dPrev = readPrevDepth(prevPix);
if (abs(dCurr - dPrev) > 0.05 * dCurr) {
g_Reservoir_Curr[idx] = merged; return;
}
float3 nCurr = g_GBuffer_Normal[pix].xyz;
float3 nPrev = readPrevNormal(prevPix);
if (dot(nCurr, nPrev) < 0.906) {
g_Reservoir_Curr[idx] = merged; return;
}

// 合并历史 reservoir
Reservoir prev = g_Reservoir_Prev[pixelToIdx(uint2(prevPix))];
if (prev.Y.lightID != INVALID) {
float3 x1 = reconstructWorldPos(pix);
MaterialData mat = readMaterial(pix);
float p_hat = evalTargetFn(prev.Y, x1, nCurr, mat);
float w = p_hat * prev.W_Y * prev.c; // 1/M MIS
merged.w_sum += w;
merged.c += prev.c;
if (PRNG_rand() < w / max(merged.w_sum, 1e-9)) {
merged.Y = prev.Y;
}
}

merged.c = min(merged.c, c_max);
float p_hat = evalTargetFn(merged.Y, /*x1*/, /*n1*/, /*mat*/);
merged.W_Y = (p_hat > 0) ? merged.w_sum / (p_hat * merged.c) : 0;
g_Reservoir_Curr[idx] = merged;
}

// === Kernel 3: Spatial Reuse + Shading ===
[numthreads(8, 8, 1)]
void SpatialReuseAndShade(uint3 tid : SV_DispatchThreadID) {
if (any(tid.xy >= screenSize)) return;
uint2 pix = tid.xy;
uint idx = pixelToIdx(pix);

Reservoir merged = g_Reservoir_Curr[idx];

float3 x1 = reconstructWorldPos(pix);
float3 n1 = g_GBuffer_Normal[pix].xyz;
MaterialData mat = readMaterial(pix);

PRNG prng = initPRNG(pix, frameIndex + 1); // 不同 seed
for (uint i = 0; i < spatial_K; ++i) {
float2 off = sampleDisk(prng.rand2()) * spatial_radius;
int2 nbrPix = int2(pix) + int2(round(off));
if (any(nbrPix < 0) || any(nbrPix >= int2(screenSize))) continue;

// Compatibility check
float dN = g_GBuffer_Normal[nbrPix].w;
if (abs(dN - g_GBuffer_Normal[pix].w) > 0.05 * dN) continue;
if (dot(n1, g_GBuffer_Normal[nbrPix].xyz) < 0.906) continue;
if (g_GBuffer_MaterialID[nbrPix] != g_GBuffer_MaterialID[pix]) continue;

Reservoir nbr = g_Reservoir_Curr[pixelToIdx(uint2(nbrPix))];
if (nbr.Y.lightID == INVALID) continue;

float p_hat = evalTargetFn(nbr.Y, x1, n1, mat);
float w = p_hat * nbr.W_Y * nbr.c;
merged.w_sum += w;
merged.c += nbr.c;
if (prng.rand() < w / max(merged.w_sum, 1e-9)) {
merged.Y = nbr.Y;
}
}
merged.c = min(merged.c, c_max);
float p_hat_Y = evalTargetFn(merged.Y, x1, n1, mat);
merged.W_Y = (p_hat_Y > 0) ? merged.w_sum / (p_hat_Y * merged.c) : 0;

// Shading: ⟨I⟩ = f(Y) · W_Y
float3 radiance = 0;
if (merged.Y.lightID != INVALID && merged.W_Y > 0) {
float3 d = merged.Y.pos - x1;
float3 wi = normalize(d);
float3 fs = bsdfEval(mat, wi, n1);
float V = traceShadowRay(x1, merged.Y.pos) ? 0 : 1;
float G = max(dot(n1, wi), 0) * max(dot(merged.Y.normal, -wi), 0) / dot(d, d);
float3 Le = getLightEmission(merged.Y.lightID, -wi);
radiance = fs * Le * G * V * merged.W_Y;
}

g_Output[pix] = float4(radiance, 1);

// 写回,作为下帧的"上一帧 reservoir"
g_Reservoir_Spatial[idx] = merged;
}

⚠️ 这是简化骨架,省略了:proper random number generation、advanced MIS、boiling filter、light tile、BSDF 重要性采样等。仅作为理解管线流程的起点。生产实现请参考 RTXDI SDK。

附录 C:Cyberpunk 2077 集成经验摘要

来自 SIGGRAPH 2023 课程的 Pawel Kozlowski (NVIDIA DevTech) 与 Giovanni De Francesco (CDPR) takeaway。

C.1 关键发现

  1. 艺术家友好性的胜利:ReSTIR 让灯光师不再需要手动放置 area light approximation——可以直接用真实 mesh emissive。Phantom Liberty 中 Dogtown 区域有数十万 dynamic emissive triangle,传统方案不可能实时。

  2. 依然需要 denoiser:ReSTIR 大幅减少 spp 需求,但 1 spp 仍有噪声,搭配 NRD ReBLUR / ReLAX。

  3. Disocclusion 是最大挑战:相机快速移动 + 街道密集物体导致频繁 disocclusion。需要:

    • 多级 motion vector 验证
    • 历史失效后用 wider initial sampling 弥补
    • 与 DLSS 协同
  4. Boiling 与艺术家审美的博弈:完全 unbiased 实现下,霓虹光在远处会有”生物呼吸感”的低频噪声。最终他们选择了带有 boiling filter 的轻度 biased 版本,因为艺术家的视觉偏好优先。

  5. Reservoir 内存预算:1080p 下约 60 MB,4K 下 240 MB——是显存大头之一。压缩到 FP16 + octahedral 后能降一半。

  6. 测试覆盖度:CDPR 测试了上百种”corner case 关卡”——比如全黑暗房间只有一个屏幕发光、玻璃柜子里的物体、暴雨场景的湿地反射……每种都可能暴露新偏差。

C.2 工程团队的几条经验法则

  • Scope creep 要警惕:ReSTIR PT 的 hybrid shift 实现远比看上去复杂,不要为了”少量 specular case”放弃 ReSTIR GI 的简单性。
  • TAA / DLSS 协同:ReSTIR 已经做了类似 TAA 的时间累积,与 DLSS 的内置 TAA 有冲突。需要禁用 DLSS 自己的 jitter 或调整 history。
  • 平台分级:在低端 RTX (2060) 上降到 ReSTIR GI 即可,旗舰 RTX (4090) 才开 ReSTIR PT。
  • Profiling 工具:用 Nsight Graphics 看 reservoir buffer 的内存访问模式,定位 cache miss。