ReSTIR DI - 02 - Temporal Reuse
The previous post covered no-reuse ReSTIR DI / RIS: for the current shading point, we sample several light candidates and use a reservoir to keep one sample that is more likely to contribute.
This note continues with temporal reuse.
The short intuition is:
No-reuse means “pick a good light sample again this frame.” Temporal reuse asks, “the previous frame already picked a decent sample; after validation and reweighting, can the current frame keep using it?”
What we reuse here is not the radiance computed in the previous frame. We reuse the light sample stored in the previous reservoir.
0. What This Note Tries To Answer
This note focuses on three questions:
- If no-reuse selects light samples from scratch every frame, why can temporal reuse keep using a sample from the previous frame?
- Why can’t we copy the previous result directly? Why do we need to recompute the target?
- When merging a previous reservoir into the current reservoir, why is the merge weight:
$$
\mathrm{weight} :=
\mathrm{currentTarget}
\cdot
\mathrm{prevReservoir.W}
\cdot
\mathrm{representedM}
$$
As an analogy:
A no-reuse reservoir is like holding a small interview in the current frame: a few light candidates show up, and we pick the one that looks most useful.
Temporal reuse is like inviting last frame’s accepted candidate back for another interview. But the job has changed, meaning the shading point changed, so we must evaluate whether the candidate is still suitable.
1. A One-Sentence Recap Of No-Reuse
No-reuse ReSTIR DI can be summarized as:
Look at several candidates, pick one that is more likely to contribute, trace only one final shadow ray, and use
Wto correct the sampling bias.
For the current shading point x, a light sample can be written as:
$$
y = (\text{light id}, \text{point on light})
$$
The direct lighting contribution is:
$$
\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}
$$
Here V(x, y) is visibility, the part that needs a shadow ray.
During candidate selection, however, we usually do not want to trace a shadow ray for every candidate. So we first use a cheap 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)
$$
Intuitively:
targetmeans “how worth keeping this candidate appears to be.” It does not have to be a normalized pdf; it only needs to represent relative importance.
If we draw M candidates from the source distribution p_src(y):
$$
y_1, y_2, \ldots, y_M
$$
The RIS weight for each candidate is:
$$
w_i = \frac{\hat{p}(x, y_i)}{p_{\mathrm{src}}(y_i)}
$$
In other words:
p_srcdescribes how likely the sample was under the original proposal.targetdescribes how important the sample is for the current point.
Usingtarget / sourcePdfcompensates for the bias in the original proposal distribution.
The reservoir update pseudocode looks like this:
1 | Reservoir r; |
Finally, the reservoir is finalized:
$$
W = \frac{w_{\mathrm{sum}}}{M \cdot \hat{p}(x, y)}
$$
In code:
1 | if (r.M > 0 && r.wSum > 0.0f && r.y.target > 0.0f) { |
Only during final shading do we trace the visibility of the selected sample:
1 | Vec3 visibleContribution = evalContributionWithVisibility(x, r.y); |
The key point is:
The reservoir did not trace
Mshadow rays for free. It only usedMcheap unshadowed evaluations to make a better decision about where the final shadow ray should go.
2. Why Temporal Reuse Can Reuse Anything
The problem with no-reuse is that every frame starts from zero.
If this frame already spent K candidates to find a decent light sample, and the next frame has a similar camera and surface point, throwing that sample away completely can be wasteful.
The core question is: why is a sample from the previous frame valid in the current frame?
The reason is that the direct lighting sample space is the set of all possible light samples:
$$
y = (\text{light id}, \text{point on light})
$$
For neighboring frames and neighboring surface points, this sample space usually does not change. The y_prev stored in the previous reservoir is still a valid light sample. It is not the previous frame’s color, and it is not the final radiance from the previous frame. It is just “a sampled point on some light.”
So if the current frame and previous frame correspond to the same surface or shading neighborhood, y_prev can be treated as one more candidate for the current point.
Put differently:
Temporal reuse does not expand the mathematical sample space; the sample space was already all light samples.
It expands the candidate set that participates in current-frame reservoir selection: in addition to fresh candidates from the current frame, we also get one history candidate filtered by the previous frame.
The temporal reuse idea is:
The previous reservoir is like a compressed candidate pool. It stores only one selected sample, but that selected sample represents a group of candidates that were already considered and filtered in the previous frame.
The current frame first looks at its own initial candidates:
$$
y_1, y_2, \ldots, y_K
$$
Temporal reuse adds one sample from the previous frame:
$$
y_{\mathrm{prev}}
$$
Then it merges this sample into the current reservoir as an extra candidate.
But it is not an ordinary candidate. A fresh initial candidate represents one sample. The previous reservoir’s selected sample represents a set of previous candidates. So the implementation records:
$$
M_{\mathrm{represented}} =
\min(M_{\mathrm{prev}}, M_{\mathrm{maxHistory}})
$$
Here restirMaxHistory is an upper bound that prevents history from becoming too heavy.
Intuitively:
An initial candidate is like a candidate who just arrived.
A temporal candidate is someone who already passed the previous round. It did not appear out of nowhere; it carries information from previous filtering.
But this needs an immediate caveat: being able to reuse a light sample does not mean we can copy the previous reservoir unchanged.
Many fields in the previous reservoir are not properties of the light sample itself. They were computed under the previous frame’s shading point. For example, target, final visible contribution, and the interpretation of the finalized reservoir are all tied to x_prev. Therefore temporal reuse must do two things:
- Use reprojection and validation to check that the previous reservoir likely came from the same surface neighborhood.
- Evaluate
y_prevagain under the current shading point, then merge it as a current-frame candidate.
3. What Temporal Reuse Actually Reuses
Temporal reuse reuses the light sample, not radiance.
The easy-to-miss detail is that before using a previous reservoir, we first need to make sure it comes from the same surface or shading neighborhood as the current pixel. The reservoir is shading-point specific: target, the selected sample’s importance, and the final contribution are all defined relative to one shading point.
That is why reprojection and validation are needed. Section 5 covers the checks.
This naturally raises another question: if reprojection says the previous reservoir comes from the same shading neighborhood, can we merge it directly?
Still no.
Even when reprojection succeeds, the target stored in the previous reservoir is:
$$
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
$$
But the current frame needs:
$$
\hat{p}(x, y_{\mathrm{prev}})
$$
The simplest example is camera motion. Even if the hit point is still near the same surface, the outgoing direction wo may have changed. For glossy or anisotropic BSDFs, the BSDF evaluation changes with wo. Therefore the selected light sample from the previous reservoir can be reused, but its target for the current shading point must be recomputed.
In other words: y_prev can be kept, and prevReservoir.W plus M can participate in history correction, but y_prev.target cannot be trusted directly. It must be replaced by the current target.
This step does not resample a light. It takes the same light sample left by the previous frame and scores it again under the current shading point.
1 | // yPrev is the light sample selected by the previous reservoir. |
In formulas:
$$
\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}
$$
If currentTarget <= 0, this temporal sample is not useful for the current point and should be rejected.
So the rule is:
The previous frame only tells us “this light sample may be good.” The current frame still has to decide “is it good for my current hit point?”
This also explains why temporal reuse is approximate. In the ideal case, if reprojection were an exact one-to-one mapping and all targets and visibility were re-evaluated at the current point, the previous sample would simply be an extra weighted candidate. In practice, reprojection and validation are heuristic checks. Normal thresholds, position thresholds, material id checks, and target ratio clamps reduce bad reuse, but they cannot prove that two pixels correspond to exactly the same integration problem.
So temporal reuse is best viewed as a variance-bias tradeoff. It uses stronger temporal correlation to reduce single-frame noise and improve stability, but imperfect history matching, validation heuristics, and history clamps may introduce some bias. For real-time rendering this is often acceptable. For offline progressive rendering, it should not be understood as “each frame remains an independent unbiased sample.”
4. Understanding The Previous Sample Merge Weight
The core temporal merge weight is:
$$
\begin{aligned}
w_{\mathrm{temporal}} &:=
\mathrm{currentTarget}
\cdot
W_{\mathrm{prev}}
\cdot
M_{\mathrm{represented}}
\end{aligned}
$$
This corresponds to the current implementation:
1 | uint32_t representedM = min(prevReservoir.M, restirMaxHistory); |
We can understand it as three parts.
4.1 currentTarget: Whether The Current Point Wants It
This is the most important “re-interview” in temporal reuse.
The fact that the previous frame liked y_prev does not mean the current frame likes it too. Therefore the merge must use the target recomputed at the current point:
$$
\hat{p}(x, y_{\mathrm{prev}})
$$
not the stale previous-frame target:
$$
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
$$
4.2 W_prev: Correction From Previous Resampling
The previous sample did not come directly from the raw p_src proposal. It has already been selected by the previous reservoir with a target-biased procedure.
The previous frame finalization formula is:
$$
W_{\mathrm{prev}} :=
\frac{
w_{\mathrm{sum,prev}}
}{
M_{\mathrm{prev}}
\cdot
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
}
$$
So W_prev can be understood as:
The selected sample’s probability correction tag from the previous frame. Without it, we forget that this sample was chosen by target-biased resampling.
4.3 representedM: It Represents More Than One Sample
A temporal candidate is not an isolated sample. It is the compressed result of the previous reservoir.
If we do not multiply by representedM, history is treated as only one ordinary candidate, and the information accumulated in the previous frame becomes too weak.
But if M grows without bound, history becomes too strong and old samples become too hard to replace. So the implementation clamps it:
$$
M_{\mathrm{represented}} =
\min(M_{\mathrm{prev}}, M_{\mathrm{maxHistory}})
$$
Intuitively:
currentTargetdecides whether the sample is useful now.W_prevsays that the sample was previously selected by a reservoir and therefore needs probability correction.representedMsays how many history candidates are compressed behind it.
5. What Reprojection And Validation Prevent
The biggest problem in temporal reuse is that the same pixel in the previous frame may not correspond to the same surface point in the current frame.
When the camera moves, or when a surface boundary crosses a pixel, the previous reservoir may come from a different object, material, or normal direction. Directly reusing it can cause:
- ghosting: old lighting lingers in the image.
- leaking: a light sample from one object leaks onto another.
- wrong material reuse: the previous frame was a wall, but the current frame is metal or another material.
So temporal reuse first performs reprojection and validation:
1 | current hit point |
The current validation mainly checks:
1 | previous pixel inside framebuffer # reprojection must stay on screen |
The target ratio clamp is a practical safety valve:
$$
\mathrm{targetRatio} =
\frac{
\hat{p}(x, y_{\mathrm{prev}})
}{
\hat{p}(x_{\mathrm{prev}}, y_{\mathrm{prev}})
}
$$
If:
$$
\mathrm{targetRatio} < 0.1
\quad \text{or} \quad
\mathrm{targetRatio} > 10
$$
then reject the temporal sample.
This is not a strict mathematical requirement. It is more of a guardrail:
If the same sample changes importance by more than 10x between the current and previous point, it is probably no longer the same local problem. Dropping it is safer than risking ghosting.
6. Reading The Experiment Results
The current experiments mainly look at three metrics:
- per-frame accuracy: how close a single frame is to the reference.
- per-frame stability: how stable neighboring frames are.
- accumulated frame accuracy: how close progressive accumulation is to the reference.
The results are roughly:
1 | Static per-frame stability: |
For a real-time rendering scenario, temporal reuse basically achieves its goal:
The image is cleaner per frame, more stable across frames, and still useful under small camera motion.
But accumulated RMSE is not necessarily better:
1 | No temporal accumulated: |
The reason is that progressive rendering wants each frame to behave as much as possible like an independent Monte Carlo estimate:
$$
\bar{L}N = \frac{1}{N} \sum{t=1}^{N} L_t
$$
If the L_t values are independent, variance decreases roughly like:
$$
\frac{1}{N}
$$
But temporal reuse makes the current frame depend on the previous frame:
$$
R_t =
\operatorname{merge}
\left(
\text{initial candidates at } t,
R_{t-1}
\right)
$$
So L_t and L_{t-1} are no longer independent. The image looks more stable, but the effective number of independent samples in a progressive average is lower.
That is why the current conclusion should be:
Temporal reuse is better understood as real-time temporal stabilization, not as a guarantee that offline progressive path tracing converges faster as an unbiased estimator.
7. What Buffers The Implementation Needs
The temporal reuse MVP needs at least four kinds of state:
- current / previous reservoir
- current / previous surface data
- previous camera
- debug / selection-source buffers
The core structures are:
1 | // The light sample selected by a reservoir. |
On the GPU side, these buffers need ping-ponging:
1 | current reservoir buffer # written by the current frame |
Each frame roughly does:
1 | begin frame |
We also need to save the previous camera:
1 | // Used to project the current hitP back into the previous frame. |
So the implementation flow can be summarized as:
1 | 1. Generate a local reservoir normally for the current frame |
Seen this way, temporal reuse is not a mysterious new estimator. It adds one more “validated history candidate” on top of the no-reuse reservoir.