ReSTIR DI - 02 - Temporal Reuse

ReSTIR DI - 02 - Temporal Reuse

May 17, 2026

上一篇主要讲 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. 这篇想回答什么?

这篇主要想回答三个问题:

  1. no-reuse 每帧都重新选 light sample,为什么 temporal reuse 可以拿上一帧的 sample 继续用?
  2. 为什么不能直接把上一帧的结果搬过来,而必须重新计算 target?
  3. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Reservoir r;
r.y = invalid; // 当前 reservoir 选中的 light sample
r.wSum = 0.0f; // 到目前为止所有 candidate weight 的总和
r.M = 0; // reservoir 当前代表了多少个 candidates
r.W = 0.0f; // finalize 后用于 shading 的校正权重

for (int i = 0; i < initialCandidates; ++i) {
LightSample y = sampleLight(); // 从 p_src(y) 采一个 light sample
float sourcePdf = computeSourcePdf(y); // 采到这个 y 的概率密度

Vec3 unshadowed = evalUnshadowed(x, y); // 不 trace shadow ray,只估计潜在贡献
float target = luminance(unshadowed); // target = 这个 sample 看起来有多重要

float w = target / sourcePdf; // RIS candidate weight

r.wSum += w;
r.M += 1;

// weight 越大,越有机会替换当前 reservoir 里的 sample
if (rand() < w / r.wSum) {
r.y = y;
r.y.target = target;
r.y.sourcePdf = sourcePdf;
}
}

最后 reservoir 需要 finalize:

$$
W = \frac{w_{\mathrm{sum}}}{M \cdot \hat{p}(x, y)}
$$

对应到代码就是:

1
2
3
4
5
6
if (r.M > 0 && r.wSum > 0.0f && r.y.target > 0.0f) {
// W 用来抵消 reservoir 按 target 偏向选择 sample 带来的 bias
r.W = r.wSum / (float(r.M) * r.y.target);
} else {
r.W = 0.0f;
}

最终 shading 时才 trace selected sample 的 visibility:

1
2
3
4
Vec3 visibleContribution = evalContributionWithVisibility(x, r.y);

// r.W 是 reservoir 的概率校正项
Vec3 Lo = visibleContribution * r.W;

这里最关键的是:

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 必须同时做两件事:

  1. 用 reprojection / validation 尽量确认 previous reservoir 来自同一个 surface neighborhood。
  2. 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.WM 可以作为 history correction 参与 merge;但 y_prev.target 不能直接相信,必须替换成 current target。

这一步不是重新 sample light,而是把上一帧留下来的同一个 light sample,放到当前 shading point 下重新打分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// yPrev 是上一帧 reservoir 选中的 light sample
// 注意:这里只复用 yPrev 的 light position / normal / emission 等 sample 信息

Vec3 wi = normalize(yPrev.position - currentHitP);

float NoI = max(dot(currentNormal, wi), 0.0f);
float NoL = max(dot(yPrev.normal, -wi), 0.0f);

if (NoI <= 0.0f || NoL <= 0.0f) {
rejectTemporalCandidate(); // 当前点和这个 light sample 几何上连不上
}

float G = NoI * NoL / distanceSquared(currentHitP, yPrev.position);
Vec3 f = evalBSDF(currentHitP, wi, wo);

Vec3 unshadowed = yPrev.emission * f * G;
float currentTarget = luminance(unshadowed);

公式上就是:

$$
\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
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t representedM = min(prevReservoir.M, restirMaxHistory);

float weight =
currentTarget // 当前 shading point 觉得这个 sample 有多重要
* prevReservoir.W // 上一帧 reservoir resampling 留下的概率校正
* float(representedM);// 这个 sample 代表了多少个 history candidates

updateReservoirWithRepresentedCandidate(
currentReservoir,
temporalSample,
currentTarget,
prevReservoir.W,
representedM,
rng());

可以拆成三部分理解。

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
2
3
4
5
6
current hit point
-> project into previous camera # 当前 hitP 投影到上一帧相机
-> find previous pixel # 找到上一帧对应 pixel
-> read previous surface metadata # 读取上一帧 hitP / normal / material
-> validate same surface # 检查是不是同一类 surface
-> read previous reservoir # 验证通过后才读取 reservoir

当前 validation 主要检查:

1
2
3
4
5
6
7
8
previous pixel inside framebuffer        # 投影不能出屏幕
previous surface valid # 上一帧这个 pixel 必须真的 hit 到东西
material id match # 材质要一致,避免跨材质复用
normal agreement # normal 方向要接近,避免跨几何边界
world-space hit position threshold # hitP 不能差太远
previous reservoir valid # 上一帧 reservoir 本身要有效
current target re-evaluation valid # 当前点重新评估 target 要有效
target ratio in range # 当前/上一帧 target 变化不能太夸张

其中 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
2
3
4
5
6
7
8
9
10
11
12
13
14
Static per-frame stability:
No temporal LumaRMSE mean=0.208880
Temporal LumaRMSE mean=0.189280
# temporal 降低了相邻帧差异,画面更稳

Static per-frame accuracy:
No temporal LumaRMSE mean=0.155184
Temporal LumaRMSE mean=0.145915
# static camera 下,temporal 单帧也更接近 reference

Moving camera per-frame accuracy:
No temporal LumaRMSE mean=0.162502
Temporal LumaRMSE mean=0.155823
# 小幅 camera motion 下,reprojection + validation 仍然有收益

所以如果是实时渲染场景,可以说 temporal reuse 的目标基本达到了:

单帧更干净,帧间更稳定,小幅 moving camera 下也没有完全失效。

但 accumulated RMSE 不一定更好:

1
2
3
4
5
6
7
8
9
No temporal accumulated:
RGB RMSE = 0.004208
Luma ratio = 1.000001
# no temporal 每帧更接近独立 sample,progressive average 更符合 MC 直觉

Temporal after weight fix:
RGB RMSE = 0.010192
Luma ratio = 0.992809
# temporal 单帧更稳,但相邻帧相关性更强,累积时不一定占优

原因是 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_tL_{t-1} 不再独立。画面看起来更稳,但 progressive average 的有效独立样本数下降了。

这也是为什么当前结论应该写成:

temporal reuse 更适合理解为 real-time temporal stabilization,而不是让 offline progressive path tracing 一定更快收敛的 unbiased estimator。


7. 实现上需要什么 buffer?

当前 temporal reuse MVP 至少需要四类状态:

  1. current / previous reservoir
  2. current / previous surface data
  3. previous camera
  4. 一些 debug / selection source buffer

核心结构大概是:

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
// 被 reservoir 选中的 light sample。
// 注意:temporal reuse 复用的是这个 sample,不是上一帧 radiance。
struct RestirLightSample {
int lightId; // 哪一个 light
Vec2f uv; // light surface 上的采样位置
float sourcePdf; // 这个 sample 原本从 p_src 被采到的 pdf
float target; // 针对当前 shading point 重新评估后的 target

Vec3f position; // sampled point on light
Vec3f normal; // light sample normal
Vec3f emission; // light emission
};

// 一个 reservoir 是一个“压缩候选池”。
// 它只保存一个 selected sample,但通过 wSum / W / M 记录概率校正信息。
struct RestirReservoir {
RestirLightSample y; // selected candidate
float wSum; // candidate weights 的总和
float W; // final shading 时使用的校正权重
uint32_t M; // 这个 reservoir 代表了多少 candidates
};

// surface metadata 用于 temporal reprojection validation。
// 没有这些信息,就不知道上一帧 reservoir 是否还能用于当前 hit point。
struct RestirSurfaceData {
vec3f hitP; // 当前 pixel primary hit 的世界坐标
vec3f normal; // hit surface normal
int materialId; // 用于避免跨材质复用
int valid; // 这个 pixel 是否有有效 primary hit
};

GPU 侧需要 ping-pong:

1
2
3
4
5
current reservoir buffer        # 当前帧写入
previous reservoir buffer # 当前帧读取上一帧 history

current surface data buffer # 当前帧写入 hitP / normal / material
previous surface data buffer # 当前帧读取,用于 validation

每帧大概是:

1
2
3
4
5
6
7
8
begin frame
read previous reservoir / surface data
generate current initial candidates
optionally merge temporal candidate
write current reservoir / surface data
end frame

swap current and previous buffers

还需要保存 previous camera:

1
2
3
4
5
6
7
// 用上一帧 camera frame 把当前 hitP 投影回 previous pixel。
struct CameraFrameGPU {
vec3f pos; // previous camera position
vec3f dir_00; // previous frame lower-left ray direction
vec3f dir_du; // previous frame horizontal ray delta
vec3f dir_dv; // previous frame vertical ray delta
};

所以可以把 temporal reuse 的实现流程记成:

1
2
3
4
5
6
7
8
9
1. 当前帧先正常生成 local reservoir
2. 用 current hitP 投影到 previous camera
3. 读取 previous surface data,检查是不是同一个 surface 附近
4. 读取 previous reservoir 的 selected light sample
5. 在 current hit point 重新评估这个 sample 的 target
6. 用 currentTarget * prevW * representedM merge
7. finalize current reservoir
8. 对最终 selected sample trace shadow ray
9. 写出 current reservoir / surface data,留给下一帧

这样看,temporal reuse 并不是一个神秘的新 estimator,而是在 no-reuse reservoir 之外,多加了一个“经过验证的历史候选”。