ReSTIR DI - 02 - Temporal Reuse
上一篇主要讲 no-reuse ReSTIR DI / RIS:对当前 shading point 多看几个 light candidates,然后用 reservoir 从里面挑一个更可能有贡献的 sample。
这一篇继续看 temporal reuse。
先给一个很短的直觉:
no-reuse 是“这一帧重新挑一个好 light sample”;temporal reuse 是“上一帧已经挑过一个不错的 sample,这一帧能不能在检查和重新加权之后继续用?”
注意这里复用的不是上一帧算出来的 radiance,而是上一帧 reservoir 里保存的 light sample。
0. 这篇想回答什么?
这篇主要想回答三个问题:
- no-reuse 每帧都重新选 light sample,为什么 temporal reuse 可以拿上一帧的 sample 继续用?
- 为什么不能直接把上一帧的结果搬过来,而必须重新计算 target?
- previous reservoir merge 到 current reservoir 时,为什么权重是:
$$
\mathrm{weight} :=
\mathrm{currentTarget}
\cdot
\mathrm{prevReservoir.W}
\cdot
\mathrm{representedM}
$$
如果用一个比喻:
no-reuse reservoir 像当前帧临时做的一次“面试”:来了几个 light candidates,我们选一个最像能干活的。
temporal reuse 像把上一帧面试通过的人也叫回来复试一次:但因为岗位变了,也就是 shading point 变了,所以必须重新评估它现在还合不合适。
1. 用一句话回顾 no-reuse
no-reuse ReSTIR DI 做的事情可以压缩成一句话:
多看几个 candidate,挑一个更可能有贡献的 sample,最后只 trace 一条 shadow ray,再用
W修正 sampling bias。
对当前 shading point x,一个 light sample 可以写成:
$$
y = (\text{light id}, \text{point on light})
$$
direct lighting contribution 是:
$$
\begin{aligned}
f(x, y) &:=
L_e(y)
\cdot
f_r(x, \omega_i, \omega_o)
\cdot
G(x, y)
\cdot
V(x, y)
\end{aligned}
$$
其中 V(x, y) 是 visibility,也就是需要 shadow ray 才能知道的部分。
但在 candidate selection 阶段,我们通常不想对每个 candidate 都 trace shadow ray,所以先用一个便宜的 unshadowed target:
$$
\hat{p}(x, y) :=
\operatorname{luminance}
\left(
L_e(y)
\cdot
f_r(x, \omega_i, \omega_o)
\cdot
G(x, y)
\right)
$$
直觉上:
target就是这个 candidate 的“看起来有多值得保留”。它不一定是归一化 pdf,只要能表达相对重要性即可。
如果从 source distribution p_src(y) 采了 M 个 candidates:
$$
y_1, y_2, \ldots, y_M
$$
每个 candidate 的 RIS weight 是:
$$
w_i = \frac{\hat{p}(x, y_i)}{p_{\mathrm{src}}(y_i)}
$$
也就是说:
p_src负责“这个 sample 本来有多容易被采到”,target负责“这个 sample 对当前点有多重要”。
用target / sourcePdf,就是在补偿原始 proposal distribution 的偏差。
reservoir update 的伪代码可以写成:
1 | Reservoir r; |
最后 reservoir 需要 finalize:
$$
W = \frac{w_{\mathrm{sum}}}{M \cdot \hat{p}(x, y)}
$$
对应到代码就是:
1 | if (r.M > 0 && r.wSum > 0.0f && r.y.target > 0.0f) { |
最终 shading 时才 trace selected sample 的 visibility:
1 | Vec3 visibleContribution = evalContributionWithVisibility(x, r.y); |
这里最关键的是:
reservoir 不是免费算了
M条 shadow ray。它只是用M次便宜的 unshadowed evaluation,帮我们更聪明地决定最后那一条 shadow ray 应该打向哪里。
2. Temporal reuse 为什么可以复用?
no-reuse 的问题是:每一帧都从零开始。
这一帧已经花了 K 个 candidates 挑出来一个不错的 light sample,下一帧如果 camera 没怎么动、surface point 也差不多,完全丢掉它有点浪费。
这里先回答一个最核心的问题:为什么上一帧的 sample 可以拿到当前帧来用?
原因是 direct lighting 的 sample space 本来就是所有可能的 light samples:
$$
y = (\text{light id}, \text{point on light})
$$
对相邻帧、相邻 surface point 来说,这个 sample space 通常没有变。上一帧 reservoir 里保存的 y_prev,本质上仍然是一个合法的 light sample。它不是上一帧的颜色,也不是上一帧最终算出来的 radiance,而是“某个光源上的一个采样点”。
所以如果 current frame 和 previous frame 对应的是同一个 surface / shading neighborhood,那么 y_prev 对当前点来说也可以作为一个额外的 candidate。
换句话说:
temporal reuse 不是扩大了数学上的 sample space;sample space 本来就是所有 light samples。
它扩大的是当前帧实际参与 reservoir selection 的 candidate set:除了当前帧新采的 candidates,还额外拿到了一个经过上一帧筛选的 history candidate。
所以 temporal reuse 的想法是:
上一帧的 reservoir 像一个“压缩过的候选池”。它虽然只存了一个 selected sample,但这个 sample 背后代表了一批已经看过、筛过的 candidates。
当前帧本来只看自己的 initial candidates:
$$
y_1, y_2, \ldots, y_K
$$
temporal reuse 会再加入一个上一帧留下来的 sample:
$$
y_{\mathrm{prev}}
$$
然后把它当成一个额外 candidate merge 进当前 reservoir。
但它不是普通 candidate。普通 initial candidate 只代表自己一个 sample;上一帧 reservoir 的 selected sample 代表的是上一帧积累过的一批 candidates。所以当前实现里会记录:
$$
M_{\mathrm{represented}} =
\min(M_{\mathrm{prev}}, M_{\mathrm{maxHistory}})
$$
其中 restirMaxHistory 是一个上限,防止 history 越滚越大。
可以把它想成:
initial candidate 像“刚刚新来的候选人”。
temporal candidate 像“上一轮筛选留下来的候选人”,它不是凭空来的,而是背后带着上一轮筛选的信息。
但这里也要马上补上一句:可以复用 light sample,不等于可以原封不动复用 previous reservoir。
previous reservoir 里的很多量并不是 light sample 自己的属性,而是上一帧 shading point 下算出来的属性。比如 target、最终 visible contribution、以及 reservoir finalize 后的解释,都和上一帧的 x_prev 绑定。因此 temporal reuse 必须同时做两件事:
- 用 reprojection / validation 尽量确认 previous reservoir 来自同一个 surface neighborhood。
- 把
y_prev放到当前 shading point 下重新 evaluate,再作为当前 reservoir 的候选项参与 merge。
3. Temporal Reuse复用的是什么?
- Temporal reuse 复用的是 light sample,不是 radiance。
这里有一个容易忽略的细节:如果想复用 previous reservoir,首先要尽量保证它来自当前帧对应的同一个 surface / shading neighborhood。因为 reservoir 里的信息不是全局的,它是 shading-point specific 的:target、selected sample 的重要性、以及最后的 contribution,都是相对于某个 shading point 定义的。
所以我们会先用 reprojection 和 validation 来检查这个约束,具体可以看第五部分。
但这又会带来一个自然的问题:如果 reprojection 之后基本确认 previous reservoir 来自同一个 shading neighborhood,那是不是可以直接把它原封不动 merge 到 current reservoir?
答案仍然是不行。
即使 reprojection 成功,previous reservoir 里保存的 target 仍然是上一帧的:
$$
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
$$
而当前帧真正需要的是:
$$
\hat{p}(x, y_{\mathrm{prev}})
$$
最简单的例子是 camera motion。即使 hit point 仍然落在同一个 surface 附近,outgoing direction wo 也可能变了;对于 glossy / anisotropic BSDF,BSDF evaluation 会随 wo 改变。因此 previous reservoir 里的 selected light sample 可以复用,但它对当前 shading point 的 target 必须重新 evaluate。
换句话说:y_prev 可以保留,prevReservoir.W 和 M 可以作为 history correction 参与 merge;但 y_prev.target 不能直接相信,必须替换成 current target。
这一步不是重新 sample light,而是把上一帧留下来的同一个 light sample,放到当前 shading point 下重新打分。
1 | // yPrev 是上一帧 reservoir 选中的 light sample |
公式上就是:
$$
\begin{aligned}
\omega_i &= \operatorname{normalize}(y_{\mathrm{prev}}.\mathrm{position} - x) \
G_{\mathrm{current}} &= G(x, y_{\mathrm{prev}}) \
f_{r,\mathrm{current}} &= f_r(x, \omega_i, \omega_o) \
\mathrm{currentTarget}
&=
\operatorname{luminance}
\left(
L_e(y_{\mathrm{prev}})
\cdot
f_{r,\mathrm{current}}
\cdot
G_{\mathrm{current}}
\right)
\end{aligned}
$$
如果 currentTarget <= 0,这个 temporal sample 对当前点就没有意义,应该 reject。
所以这一步可以记成:
上一帧只告诉我们“这个 light sample 可能不错”;当前帧必须自己判断“它对我现在这个 hit point 还不错吗?”
这里也可以顺便解释 temporal reuse 的近似性。理想情况下,如果 reprojection 真的是严格的一一对应,且所有 target / visibility 都在当前点重新 evaluate,那么 previous sample 就只是一个带权重校正的额外 candidate。但实际工程里,reprojection 和 validation 都是近似检查:normal threshold、position threshold、material id、target ratio clamp 都只能降低错误复用的概率,不能证明两个 pixel 对应完全相同的积分问题。
所以 temporal reuse 更像一个 variance-bias tradeoff:它用更强的时域相关性换取单帧降噪和稳定性,但也可能因为不完美的 history match、heuristic validation、history clamp 等因素引入一些偏差。对 realtime rendering 来说,这通常是可接受的;但对 offline progressive rendering 来说,它不应该被简单理解成“每帧都是独立无偏 sample”。
4. previous sample merge weight 怎么理解?
temporal merge 最核心的权重是:
$$
\begin{aligned}
w_{\mathrm{temporal}} &:=
\mathrm{currentTarget}
\cdot
W_{\mathrm{prev}}
\cdot
M_{\mathrm{represented}}
\end{aligned}
$$
也就是当前实现里的:
1 | uint32_t representedM = min(prevReservoir.M, restirMaxHistory); |
可以拆成三部分理解。
4.1 currentTarget:当前点觉得它重不重要
这是 temporal reuse 最重要的“重新面试”。
上一帧觉得 y_prev 好,不代表当前帧也觉得它好。所以 merge 时必须用当前点重新算出来的 target:
$$
\hat{p}(x, y_{\mathrm{prev}})
$$
而不是上一帧旧的:
$$
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
$$
4.2 W_prev:上一帧 resampling 带来的校正
previous sample 不是直接从 p_src 原始采样得到的。它已经被上一帧 reservoir 按 target 偏向性地挑选过。
上一帧 finalize 公式是:
$$
W_{\mathrm{prev}} :=
\frac{
w_{\mathrm{sum,prev}}
}{
M_{\mathrm{prev}}
\cdot
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
}
$$
所以 W_prev 可以理解成:
上一帧 selected sample 自带的“概率校正标签”。没有它,我们会忘记这个 sample 是被 target-biased resampling 挑出来的。
4.3 representedM:它代表的不只是一个 sample
temporal candidate 不是一个孤立 sample。它是上一帧 reservoir 压缩后的结果。
如果完全不乘 representedM,就会把 history 当成普通一个 candidate,上一帧积累的信息会被压得太弱。
但如果无限累积 M,history 又会越来越重,导致旧 sample 太难被新样本替换。所以实现里会 clamp:
$$
M_{\mathrm{represented}} =
\min(M_{\mathrm{prev}}, M_{\mathrm{maxHistory}})
$$
直觉上:
currentTarget决定“它现在值不值得用”。W_prev负责“它之前被 reservoir 挑中过,所以要带上校正”。representedM表示“它背后压缩了多少历史候选”。
5. Reprojection / validation 在防什么?
temporal reuse 最大的问题是:上一帧同一个 pixel,不一定还是当前帧同一个 surface point。
camera 一动,或者 surface 边界刚好扫过 pixel,上一帧的 reservoir 可能来自另一个物体、另一个材质、另一个 normal 方向。直接复用就会出现:
- ghosting:旧光照拖影还留在画面里。
- leaking:一个物体上的 light sample 漏到另一个物体上。
- wrong material reuse:上一帧是墙,当前帧是金属或者别的材质。
所以 temporal reuse 之前要做 reprojection 和 validation:
1 | current hit point |
当前 validation 主要检查:
1 | previous pixel inside framebuffer # 投影不能出屏幕 |
其中 target ratio clamp 是一个很实用的工程安全阀:
$$
\mathrm{targetRatio} =
\frac{
\hat{p}(x, y_{\mathrm{prev}})
}{
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
}
$$
如果:
$$
\mathrm{targetRatio} < 0.1
\quad \text{or} \quad
\mathrm{targetRatio} > 10
$$
就 reject。
这不是严格数学上必须的项,更像一个防呆规则:
如果同一个 sample 在当前点和上一帧点的重要性差了十倍以上,那很可能不是“同一个局部问题”了。与其冒 ghosting 的风险,不如丢掉。
6. 实验结果怎么解读?
目前实验里主要看了三类指标:
- per-frame accuracy:单帧和 reference 有多接近。
- per-frame stability:相邻帧之间有多稳定。
- accumulated frame accuracy:progressive accumulation 后和 reference 有多接近。
结果大致是:
1 | Static per-frame stability: |
所以如果是实时渲染场景,可以说 temporal reuse 的目标基本达到了:
单帧更干净,帧间更稳定,小幅 moving camera 下也没有完全失效。
但 accumulated RMSE 不一定更好:
1 | No temporal accumulated: |
原因是 progressive rendering 希望每一帧尽量像独立 Monte Carlo estimate:
$$
\bar{L}N = \frac{1}{N} \sum{t=1}^{N} L_t
$$
如果 L_t 之间相互独立,variance 大致按:
$$
\frac{1}{N}
$$
下降。
但 temporal reuse 让当前帧依赖上一帧:
$$
R_t =
\operatorname{merge}
\left(
\text{initial candidates at } t,
R_{t-1}
\right)
$$
所以 L_t 和 L_{t-1} 不再独立。画面看起来更稳,但 progressive average 的有效独立样本数下降了。
这也是为什么当前结论应该写成:
temporal reuse 更适合理解为 real-time temporal stabilization,而不是让 offline progressive path tracing 一定更快收敛的 unbiased estimator。
7. 实现上需要什么 buffer?
当前 temporal reuse MVP 至少需要四类状态:
- current / previous reservoir
- current / previous surface data
- previous camera
- 一些 debug / selection source buffer
核心结构大概是:
1 | // 被 reservoir 选中的 light sample。 |
GPU 侧需要 ping-pong:
1 | current reservoir buffer # 当前帧写入 |
每帧大概是:
1 | begin frame |
还需要保存 previous camera:
1 | // 用上一帧 camera frame 把当前 hitP 投影回 previous pixel。 |
所以可以把 temporal reuse 的实现流程记成:
1 | 1. 当前帧先正常生成 local reservoir |
这样看,temporal reuse 并不是一个神秘的新 estimator,而是在 no-reuse reservoir 之外,多加了一个“经过验证的历史候选”。