引:为什么要学 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 权重必须满足两个条件 :
Partition of unity : 对所有
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 的方案 :
用 采 个候选样本
给每个候选赋一个权重 ,让权重比反映 的相对大小
按 比例随机选一个 输出
最终输出的 分布近似正比于一个我们指定的 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() W_i = 1.0 / pdf(X_i) m_i = 1.0 / M 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 : 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; float w_sum; int c; void update (Sample X_i, float w_i, int c_i) { w_sum += w_i; c += c_i; if (rand () < 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 ▶
Auto Run
Reset
Step: 0 / 12 | w_sum: 0.00 | Reservoir.Y: null
2.8 RIS 重采样可视化 下面这个 widget 让我们直观感受 RIS 如何把均匀分布”重塑”成接近目标 PDF 的分布。蓝色柱子 = 候选样本(均匀采的,柱高 = 重采样权重 ),红色曲线 = target function 。点击 Resample 按权重选一个,多次 Resample 累积出”重采样后的经验分布”(绿色直方图)。
🎯 RIS 重采样可视化
New Candidates
Resample ×1
Resample ×100
Clear Hist
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 struct ReservoirDI { LightSample Y; float W_Y; float w_sum; float c; };ReservoirDI initialSampling (uint pixel, float3 x1, float3 n1, MaterialData mat) { ReservoirDI r = {}; const int M_light = 32 ; const int M_bsdf = 1 ; const float M_total = float (M_light + M_bsdf); for (int i = 0 ; i < M_light; ++i) { Light light = sampleLightByPower (); float p_light = light.power / totalPower; float3 pos, normal; float p_area = sampleLightPoint (light, pos, normal); LightSample X = { pos, normal, light.id }; float p_combined = p_light * p_area; float p_hat = evalTargetFn (X, x1, n1, mat); float w = (1.0 / M_total) * p_hat / p_combined; r.update (X, w, 1 ); } 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 }; 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 ); } 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; }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 ); }
🔧 Tip :M_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 void combineReservoir ( inout ReservoirDI dst, in ReservoirDI src, float3 x1_dst, float3 n1_dst, MaterialData mat_dst, float c_max ) { if (src.Y.lightID < 0 ) return ; float p_hat_at_dst = evalTargetFn (src.Y, x1_dst, n1_dst, mat_dst); 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; } }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) { float2 offset = sampleDisk (radius); uint nbrPixel = pixel + uint (offset.x) * stride + uint (offset.y); if (!isCompatibleNeighbor (pixel, nbrPixel)) continue ; ReservoirDI nbr = readReservoir (nbrPixel, false ); combineReservoir (merged, nbr, x1, n1, mat, C_MAX); } 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 ; return merged; }
⚠️ 三个最容易写错的地方 :
测度转换 :BSDF 采样得到的是 solid angle PDF,必须转成 area PDF 才能与 light sampling 在同一 measure 下做 MIS。
target function 的位置 :合并时要在目标像素 重新评估 ,不能用源 reservoir 里存的旧值。
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; float2 mv = readMotionVector (pixel); int2 prevPixel = int2 (pixel) - int2 (mv); if (any (prevPixel < 0 ) || any (prevPixel >= screenSize)) return merged; float depthCurr = readDepth (pixel); float depthPrev = readDepth (prevPixel, true ); if (abs (depthCurr - depthPrev) > 0.05 * depthCurr) return merged; float3 normCurr = readNormal (pixel); float3 normPrev = readNormal (prevPixel, true ); if (dot (normCurr, normPrev) < 0.906 ) return merged; int matCurr = readMaterialID (pixel); int matPrev = readMaterialID (prevPixel, true ); if (matCurr != matPrev) return merged; ReservoirDI prev = readReservoir (prevPixel, 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 情况下,连续 帧时间复用:
第 帧新生成的样本 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 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 必须满足:
确定性 :同一输入永远得到同一输出(不能依赖 RNG)
单射 :源域两个不同样本不能映射到目标域同一样本
可逆 : 必须存在
逆映射相容 :若 ,则
可能未定义 :某些样本无法 shift(如遮挡、几何失败),返回 null
🔑 可逆性约束的实战意义 :当我们设计一个 shift mapping,必须考虑”反方向也能 shift 回来”,否则会引入难以察觉的偏差。Hybrid shift 中复杂的距离/粗糙度条件就是为了保证可逆性。
4.3 Jacobian 行列式:概率密度的修正项 当 时,PDF 需要按 Jacobian 修正:
直觉: 在 处把”局部体积”放大了 倍,那么概率密度就要”稀释”同样倍数,否则总概率就不是 1 了。
对应的 UCW 变换:
为什么 UCW 是反过来乘 ?因为 , 除以 Jacobian, 就乘以 Jacobian。
4.4 跨域 RIS 完整管线 替换 Section 2.5 的 RIS 流程:
从各域 取候选
shift 到目标域 :
在目标域计算 MIS 权重
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; float W_Y; float w_sum; int c; float3 omega_in_at_x1; };
5.3.2 ReSTIR GI 为什么有偏? 理论上 ,正确的实现需要在每次复用时:
重新连接
重新评估 处的 BSDF(用新方向 )
重新评估几何项
重新投阴影射线测
使用 处沿新方向 的出射 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 的核心) 思路 :组合上面三种的优点——
在镜面/低粗糙度顶点用 random replay
遇到第一对连续粗糙顶点 时切换为 reconnection
直接连到
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 把路径用其生成时用过的随机数序列 表示。
优点 :
Random replay 部分的 Jacobian 严格 = 1
路径吞吐量直接是 sampled throughput( 乘积),不需要单独算 PDF
避免浮点溢出( 和 都很大但比例稳定时)
路径积分在 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; float3 L_outgoing; uint triangleID; float2 barycentric; uint8 lobe_km1; uint8 lobe_k; }; ReconnectionVertex rcVertex; uint seed1; uint seed2; uint8 k; float J; 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 struct ReplayedSubPath { float3 yk_minus_1_pos; float3 yk_minus_1_normal; float3 throughput; int lobe_km1; bool valid; };ReplayedSubPath randomReplay ( float3 y0, float3 y1, float3 n_y1, uint seed1, int 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) { Material mat = sampleMaterial (currPos); currLobe = sampleLobe (mat, prng.rand ()); if (currLobe != getBaseLobe (i)) { 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; }struct ShiftResult { float3 contribution; float jacobian; bool valid; };ShiftResult hybridShift ( in PathReservoir base, in PixelData targetPixel ) { ShiftResult res = {}; const float D_MIN = 0.05 ; const float ALPHA_MIN = 0.25 ; 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; 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; Material mat_y_km1 = sampleMaterial (y_km1); float alpha_y = mat_y_km1. roughness (replayed.lobe_km1); if (alpha_y < ALPHA_MIN) return res; if (!testVisibility (y_km1, x_k)) return res; 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); float G_old = base.J; res.jacobian = G_new / max (G_old, 1e-6 ); 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 ; float normalDot = dot (normal (pixel), normal (neighbor)); if (normalDot < 0.906 ) return false ; if (matID (pixel) != matID (neighbor)) return false ; 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 float pairwiseMIS_canonical ( in Sample y_c, in float p_hat_c, in Sample neighbors[], in float c[], 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; for (int j = 0 ; j < M; ++j) { if (j == CANON_IDX) continue ; float p_hat_j_at_target = pHatFrom (j, y_c); 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; }float pairwiseMIS_noncanonical ( in Sample y_i, in float p_hat_i_at_target, in float p_hat_c_at_target, 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 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; }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; float3 lightNormal; float3 emission; int lightID; float W_Y; float w_sum; int M; };
压缩:
1 2 3 4 5 6 7 struct ReservoirDI_Packed { uint32_t lightID; uint16_t uv_x, uv_y; fp16 W_Y; fp16 w_sum; uint16_t M; };
@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 { uint triangleID; fp16 bary_uv[2 ]; fp16 omega_oct[2 ]; fp16 L_outgoing[3 ]; uint8 lobe_km1, lobe_k; uint32_t seed1, seed2; uint8 k; fp16 J; fp16 W_Y; fp16 w_sum; uint8 c; };
关键技巧:用 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 [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; } } ReplaySamplesGen.dispatch (compactedCount);
实测:在 Step 1 基础上再 -40%。
6.9 Visibility Reuse:偏差陷阱重灾区 ReSTIR DI 原始论文为了省 shadow ray 提出”重用 visibility”——历史 reservoir 已经测过 visibility 就不再测。但这会引入难以察觉的偏差(场景动态变化时尤甚)。
偏差源 :
上一帧的 是基于上一帧的几何,当前帧物体可能已移动
邻居像素的 测试相对当前像素可能不正确(物体之间的遮挡关系不同)
实战建议 :
默认在 target function 里始终包含
只有在能容忍 ghosting/light leaking 的场景下才关闭 visibility 重测
用 disocclusion mask 强制关键区域重测
如果一定要 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 void boilingFilter (inout ReservoirDI r, float3 neighborMeanW) { if (r.W_Y > 5.0 * neighborMeanW) { r = {}; } }
⚠️ 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 处理近场 / 高对比度细节。
八、参考资料 核心论文(按重要性)
[Lin et al. 2022] Generalized Resampled Importance Sampling: Foundations of ReSTIR — 统一理论框架,必读
[Bitterli et al. 2020] Spatiotemporal Reservoir Resampling for Real-Time Ray Tracing with Dynamic Direct Lighting — ReSTIR DI 开山之作
[Ouyang et al. 2021] ReSTIR GI: Path Resampling for Real-Time Path Tracing — 单弹 GI 扩展
[Bitterli 2022] Correlations and Reuse for Fast and Accurate Physically Based Light Transport — 博士论文,pairwise MIS 出处
[Wyman & Panteleev 2021] Rearchitecting Spatiotemporal Resampling for Production — 工程优化
[Lin et al. 2021] Fast Volume Rendering with Spatiotemporal Reservoir Resampling — 体渲染
[Talbot et al. 2005] Importance Resampling for Global Illumination — RIS 原始论文
[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 struct LightSample { float3 pos; float3 normal; uint lightID; };struct Reservoir { LightSample Y; float W_Y; float w_sum; uint c; uint pad; }; Texture2D<float4> g_GBuffer_Albedo; Texture2D<float4> g_GBuffer_Normal; 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; float spatial_radius; uint spatial_K; }; 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; } [numthreads(8 , 8 , 1 )]void InitialSampling (uint3 tid : SV_DispatchThreadID) { if (any(tid.xy >= screenSize)) return ; uint2 pix = tid.xy; uint idx = pixelToIdx(pix); 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) { 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 ); 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; } [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 ; } 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 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; 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, , , ); merged.W_Y = (p_hat > 0 ) ? merged.w_sum / (p_hat * merged.c) : 0 ; g_Reservoir_Curr[idx] = merged; } [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 ); 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 ; 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 ; 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 ); 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 关键发现
艺术家友好性的胜利 :ReSTIR 让灯光师不再需要手动放置 area light approximation——可以直接用真实 mesh emissive。Phantom Liberty 中 Dogtown 区域有数十万 dynamic emissive triangle,传统方案不可能实时。
依然需要 denoiser :ReSTIR 大幅减少 spp 需求,但 1 spp 仍有噪声,搭配 NRD ReBLUR / ReLAX。
Disocclusion 是最大挑战 :相机快速移动 + 街道密集物体导致频繁 disocclusion。需要:
多级 motion vector 验证
历史失效后用 wider initial sampling 弥补
与 DLSS 协同
Boiling 与艺术家审美的博弈 :完全 unbiased 实现下,霓虹光在远处会有”生物呼吸感”的低频噪声。最终他们选择了带有 boiling filter 的轻度 biased 版本,因为艺术家的视觉偏好优先。
Reservoir 内存预算 :1080p 下约 60 MB,4K 下 240 MB——是显存大头之一。压缩到 FP16 + octahedral 后能降一半。
测试覆盖度 :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。