ReSTIR DI - 02 - Temporal Reuse

ReSTIR DI - 02 - Temporal Reuse

May 17, 2026

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:

  1. If no-reuse selects light samples from scratch every frame, why can temporal reuse keep using a sample from the previous frame?
  2. Why can’t we copy the previous result directly? Why do we need to recompute the target?
  3. 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 W to 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:

target means “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_src describes how likely the sample was under the original proposal. target describes how important the sample is for the current point.
Using target / sourcePdf compensates for the bias in the original proposal distribution.

The reservoir update pseudocode looks like this:

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; // the currently selected light sample
r.wSum = 0.0f; // sum of all candidate weights so far
r.M = 0; // how many candidates this reservoir represents
r.W = 0.0f; // correction weight used after finalization

for (int i = 0; i < initialCandidates; ++i) {
LightSample y = sampleLight(); // sample a light sample from p_src(y)
float sourcePdf = computeSourcePdf(y); // probability density of sampling this y

Vec3 unshadowed = evalUnshadowed(x, y); // estimate potential contribution without a shadow ray
float target = luminance(unshadowed); // how important this sample appears to be

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

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

// The larger the weight, the more likely it replaces the reservoir sample.
if (rand() < w / r.wSum) {
r.y = y;
r.y.target = target;
r.y.sourcePdf = sourcePdf;
}
}

Finally, the reservoir is finalized:

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

In code:

1
2
3
4
5
6
if (r.M > 0 && r.wSum > 0.0f && r.y.target > 0.0f) {
// W cancels the bias introduced by target-weighted reservoir selection.
r.W = r.wSum / (float(r.M) * r.y.target);
} else {
r.W = 0.0f;
}

Only during final shading do we trace the visibility of the selected sample:

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

// r.W is the reservoir probability correction.
Vec3 Lo = visibleContribution * r.W;

The key point is:

The reservoir did not trace M shadow rays for free. It only used M cheap 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:

  1. Use reprojection and validation to check that the previous reservoir likely came from the same surface neighborhood.
  2. Evaluate y_prev again 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// yPrev is the light sample selected by the previous reservoir.
// Only the sample information is reused: light position, normal, emission, etc.

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(); // the current point cannot geometrically connect to this 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);

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

float weight =
currentTarget // how important this sample is for the current shading point
* prevReservoir.W // probability correction left by the previous reservoir
* float(representedM);// how many history candidates this sample represents

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

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:

currentTarget decides whether the sample is useful now.
W_prev says that the sample was previously selected by a reservoir and therefore needs probability correction.
representedM says 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
2
3
4
5
6
current hit point
-> project into previous camera # project current hitP into the previous camera
-> find previous pixel # locate the corresponding previous pixel
-> read previous surface metadata # read previous hitP / normal / material
-> validate same surface # check whether it is the same surface neighborhood
-> read previous reservoir # only read the reservoir after validation succeeds

The current validation mainly checks:

1
2
3
4
5
6
7
8
previous pixel inside framebuffer        # reprojection must stay on screen
previous surface valid # the previous pixel must have hit something
material id match # avoid cross-material reuse
normal agreement # avoid crossing geometric boundaries
world-space hit position threshold # hitP cannot differ too much
previous reservoir valid # previous reservoir itself must be valid
current target re-evaluation valid # current target must be valid
target ratio in range # current/previous target cannot change too much

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
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 reduces adjacent-frame differences, so the image is more stable

Static per-frame accuracy:
No temporal LumaRMSE mean=0.155184
Temporal LumaRMSE mean=0.145915
# with a static camera, temporal is also closer to the reference per frame

Moving camera per-frame accuracy:
No temporal LumaRMSE mean=0.162502
Temporal LumaRMSE mean=0.155823
# with mild camera motion, reprojection + validation still helps

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
2
3
4
5
6
7
8
9
No temporal accumulated:
RGB RMSE = 0.004208
Luma ratio = 1.000001
# no temporal behaves more like independent per-frame samples, which fits progressive averaging better

Temporal after weight fix:
RGB RMSE = 0.010192
Luma ratio = 0.992809
# temporal is more stable per frame, but adjacent frames are more correlated, so accumulation may not win

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:

  1. current / previous reservoir
  2. current / previous surface data
  3. previous camera
  4. debug / selection-source buffers

The core structures are:

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
// The light sample selected by a reservoir.
// Temporal reuse reuses this sample, not the previous frame's radiance.
struct RestirLightSample {
int lightId; // which light
Vec2f uv; // sampled location on the light surface
float sourcePdf; // pdf of originally sampling this sample from p_src
float target; // target re-evaluated for the current shading point

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

// A reservoir is a compressed candidate pool.
// It stores only one selected sample, while wSum / W / M keep probability correction information.
struct RestirReservoir {
RestirLightSample y; // selected candidate
float wSum; // sum of candidate weights
float W; // correction weight used during final shading
uint32_t M; // how many candidates this reservoir represents
};

// Surface metadata is used for temporal reprojection validation.
// Without it, we cannot tell whether a previous reservoir is valid for the current hit point.
struct RestirSurfaceData {
vec3f hitP; // world-space primary hit position for this pixel
vec3f normal; // hit surface normal
int materialId; // used to avoid cross-material reuse
int valid; // whether this pixel has a valid primary hit
};

On the GPU side, these buffers need ping-ponging:

1
2
3
4
5
current reservoir buffer        # written by the current frame
previous reservoir buffer # read by the current frame as history

current surface data buffer # current frame writes hitP / normal / material
previous surface data buffer # current frame reads this for validation

Each frame roughly does:

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

We also need to save the previous camera:

1
2
3
4
5
6
7
// Used to project the current hitP back into the previous frame.
struct CameraFrameGPU {
vec3f pos; // previous camera position
vec3f dir_00; // lower-left ray direction in the previous frame
vec3f dir_du; // previous frame horizontal ray delta
vec3f dir_dv; // previous frame vertical ray delta
};

So the implementation flow can be summarized as:

1
2
3
4
5
6
7
8
9
1. Generate a local reservoir normally for the current frame
2. Project current hitP into the previous camera
3. Read previous surface data and validate the same surface neighborhood
4. Read the selected light sample from the previous reservoir
5. Re-evaluate that sample's target at the current hit point
6. Merge it using currentTarget * prevW * representedM
7. Finalize the current reservoir
8. Trace a shadow ray for the final selected sample
9. Write current reservoir / surface data for the next 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.