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\):

  1. increment \(M\);
  2. add \(w_i\) to \(S\);
  3. replace \(Y\) by \(x_i\) with probability \(w_i/S\).
Proof Streaming replacement selects each candidate in proportion to its weight

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.

Interactive model: one streaming reservoir realization Process the same four positive weights used in the RIS model, one candidate at a time.
Processed M 0 Weight sum S 0.000 Retained Y none

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

Convergence curves comparing reservoir reuse enabled and disabled
Error is plotted against cumulative time so that reservoir maintenance and candidate reuse are included in the comparison. Measurement scope: The result applies to the scene, renderer revision, settings, hardware, and error reference used to generate this committed plot. Source: reservoir implementation commit e6bc4dc

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

Commit contents Files discussed in this chapter buffer_provider.h · reservoir.h · ris_integrator.cpp
Complete commit 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.

Record this: Hold the camera still with denoising off and one new candidate per frame. Show reservoir reuse off, then on, at the same elapsed time. After convergence begins, move the camera so viewers can see the history reset or be rejected.
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.