Weighted reservoirs and temporal reuse
e6bc4dc · 2026-06-24 ↗
RIS chooses one candidate from a batch. A weighted reservoir makes the same choice while seeing candidates one at a time and storing constant state:
\[R=(Y,S,M),\]where \(Y\) is the retained sample, \(S\) the sum of weights, and \(M\) the number of represented candidates.
Streaming weighted reservoir sampling
For candidate \(x_i\) with weight \(w_i\):
- increment \(M\);
- add \(w_i\) to \(S\);
- replace \(Y\) by \(x_i\) with probability \(w_i/S\).
Assumptions: Candidate weights are finite and non-negative, with a positive total weight for the represented stream.
We prove by induction that after \(n\) candidates, \(\Pr(Y=x_i)=w_i/S_n\), where \(S_n=\sum_{j=1}^{n}w_j\).
For \(n=1\), the first positive candidate is selected with probability one, equal to \(w_1/S_1\).
Assume the claim after \(n-1\). An old candidate \(x_i\) survives step \(n\) with probability
\[\frac{w_i}{S_{n-1}} \left(1-\frac{w_n}{S_n}\right) =\frac{w_i}{S_{n-1}}\frac{S_{n-1}}{S_n} =\frac{w_i}{S_n}.\]The new candidate is selected with \(w_n/S_n\). Therefore the property holds for every \(n\). Only \(Y\), \(S\), and \(M\) are stored.
The interactive stream below follows one deterministic realization of that update. At every step it displays the new total and the replacement probability. One realization is not the proof; it is a way to inspect the state transition used by the induction argument.
At each step, the new candidate replaces the retained one with probability \(w_i/S_i\). This display follows one deterministic random sequence; the proof above concerns the distribution over all such sequences.
This is exactly the categorical selection required by RIS, so its final correction remains
\[W=\frac{S}{M\hat p(Y)},\qquad \widehat I=f(Y)W.\]The generic formula uses \(M\) for the number of unnormalized proposal weights
represented by \(S\). This commit uses a different internal convention:
per-technique candidate weights already contain their sample-count
normalization, and Finalize divides by the number of merged frames:
const int frameCount{
std::max(
1,
CandidateCount /
std::max(1, candidatesPerFrame))};
W = WSum /
(static_cast<float>(frameCount) * phat);
After \(K\) frames with a fixed candidate budget,
CandidateCount = K * candidatesPerFrame, so frameCount = K. This is
consistent only while candidate construction and finalization use the same
normalization convention. Replacing the candidate weights with raw
\(\hat p/q\) would require the generic candidate-count denominator instead.
Reservoirs can represent reservoirs
Suppose reservoir \(R_a\) summarizes one stream and \(R_b\) another. Their selected representatives can be merged with weights equal to their represented weight sums. The induction result means each representative stands in for its entire stream. Candidate counts must also be added; otherwise the final normalization forgets how many proposals the reservoir represents.
When targets differ between contexts, the reused representative must be reweighted for the receiving context. In the notation of the original ReSTIR paper, this includes evaluating the current target at the previous selected sample.
For previous reservoir \(R_p\) with selected sample \(Y_p\), generation target \(\hat p_p(Y_p)\), and current target \(\hat p_c(Y_p)\), this commit uses the merge weight
\[S_p\frac{\hat p_c(Y_p)}{\hat p_p(Y_p)}.\]The ratio converts represented weight mass into the current target. It does not by itself prove that two shading contexts are compatible. Geometry, visibility, and proposal-domain changes can require rejection or additional Jacobians. Generalized RIS gives the conditions for reuse across proposal and target changes.
Temporal reuse
The integrator gains access to named persistent buffers. A pixel can load the previous reservoir, add this frame’s candidates, finalize, and store the new reservoir. Candidate work is amortized across frames.
flowchart LR
P["previous reservoir"] --> M["merge"]
N["new frame candidates"] --> M
M --> C["current reservoir"]
C --> S["shade retained sample"]
C -. "persist" .-> P
The implementation’s reservoir logic is visible in
reservoir.h.
The theoretical reference is Bitterli et al.’s
ReSTIR paper.
History validity
A previous sample is useful only if it can be interpreted at the current shading point. The mature implementation checks primitive/material identity, normal agreement, and depth-plane compatibility; it reconnects finite light endpoints, re-evaluates the target, and traces current visibility. Current history is also clamped to prevent stale dominance.
At this checkpoint, regard temporal reuse as the beginning of that contract, not the final treatment of motion and disocclusion. There is still no spatial neighbor reuse.
The historical code has two explicit limitations:
- the previous reservoir is indexed by the same pixel rather than reprojected with a motion vector;
- the stored visibility result is accepted during temporal merge instead of tracing a new shadow ray for the receiving surface.
A stationary camera and static scene reduce, but do not eliminate, the need for compatibility checks: different stochastic primary samples within the same pixel can hit different geometry. The final GPU revision adds primitive, material, normal, and depth-plane checks and retraces visibility for the retained sample.
Performance evidence
The graph plots error against cumulative time, not only sample count. Reservoir reuse adds state and merge work, so equal-time comparison is the relevant test. The generalized assumptions and reuse theory are developed further in Generalized Resampled Importance Sampling.
Follow one reservoir through the diff
Buffer-lifetime interface
class IBufferProvider
{
public:
virtual ~IBufferProvider() = default;
// ...
[[nodiscard]] virtual bool Has(std::string_view name) const = 0;
// ...
[[nodiscard]] virtual FrameBuffer &GetChecked(std::string_view name) = 0;
// ...
[[nodiscard]] virtual void *GetOrCreatePersistent(std::string_view name, std::size_t stride) = 0;
// ...
[[nodiscard]] virtual void *Add(std::string_view name, std::size_t stride) = 0;
};
Add creates frame-local scratch storage. GetOrCreatePersistent identifies
history that may be read on the next frame. Putting this distinction in the
interface makes lifetime a reviewable dependency of the integrator.
Streaming reservoir update
void Update(const SampleT &sample, float weight, Rng &rng)
{
++CandidateCount;
if (weight <= 0.0f)
return;
WSum += weight;
if (rng.NextFloat() <= weight / WSum)
ChosenSample = sample;
}
CandidateCount increases even for a zero-weight candidate because it records
how many proposals the reservoir represents. WSum contains only positive
mass. The last branch is precisely the replacement probability
\(w_i/S_i\) proved above.
Previous-reservoir target conversion
const SampledSpectrum reeval{_reEvaluateForTemporalReuse(isect, prevCandidate)};
const float phatCurr{SpectrumLuminance(reeval, isect.shadingPoint->lambda)};
const float phatGen{static_cast<float>(prevReservoir.ChosenSample->targetFunction)};
if (phatGen > 0.0f)
{
// ...
prevReservoir.ChosenSample->targetFunction = static_cast<double>(phatCurr);
prevReservoir.ChosenSample->Throughput = reeval;
const float mergeWeight{prevReservoir.WSum * (phatCurr / phatGen)};
reservoir->Merge(prevReservoir, mergeWeight, rng);
}
The ratio \(\hat p_{\text{current}}/\hat p_{\text{generation}}\) translates the old represented mass to the current shading context. Without it, a sample that was important at the previous point would be treated as equally important at a different point. The guard also prevents division by zero.
Finalization and persistence
const ReservoirT finalized{reservoir.Release()};
reservoirBuf.As<ReservoirT>()[callId.id] = finalized;
if (!finalized.ChosenSample.has_value() || finalized.W <= 0.0f)
{
return SampledSpectrum{};
}
return finalized.ChosenSample->Throughput * finalized.W;
Only the compact finalized reservoir crosses the frame boundary. At this
checkpoint, callId.id < callId.stride restricts that persistent branch to the
primary per-pixel integration call; secondary-bounce calls use frame-local
state. This matters because one history slot must have one stable statistical
meaning.
There is also an important historical limitation visible in this diff: temporal reuse accepts the stored visibility result instead of tracing a fresh shadow ray for the reused winner. Step 3 makes winner visibility an explicit contract, and the final GPU implementation strengthens history validation again.
Caption to keep: A reservoir compresses many weighted candidates into constant state; reuse is valid only while the receiving shading context can evaluate that state correctly.