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.

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. Combining that selection probability with the retained sample’s contribution gives

\[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.

Proof Merging representatives preserves the categorical distribution

Assumptions: The two source streams are disjoint, use the same receiving target and measure, and have finite non-negative weights with positive combined mass.

Let the first stream contain weights \(a_i\) with sum \(S_a\), and the second contain weights \(b_j\) with sum \(S_b\). Its reservoir selects representative \(Y_a=x_i\) with probability \(a_i/S_a\); the other selects \(Y_b=x_j\) with probability \(b_j/S_b\).

Now select between the two representatives with weights \(S_a\) and \(S_b\). For a candidate \(x_i\) from the first stream, the complete probability is

\[\Pr(Y=x_i) =\Pr(Y=Y_a)\Pr(Y_a=x_i) =\frac{S_a}{S_a+S_b}\frac{a_i}{S_a} =\frac{a_i}{S_a+S_b}.\]

The same calculation gives \(b_j/(S_a+S_b)\) for the second stream. These are exactly the probabilities obtained by streaming all original candidates into one reservoir.

For a numerical check, take \(S_a=3\) and \(S_b=7\). The merge chooses the representatives with probabilities \(0.3\) and \(0.7\). A candidate of weight 2 inside the first stream was selected there with probability \(2/3\), so its final probability is \(0.3(2/3)=0.2=2/(3+7)\).

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
The renderer's internal relative-noise estimate is plotted against cumulative time, so reservoir maintenance and candidate reuse are included in the horizontal coordinate. Measurement scope: This is not error against a reference image. It is one historical run from commit e6bc4dc; temporal correlation can make the internal variance diagnostic optimistic, so the graph supports only the stated on/off comparison. Source: reservoir implementation commit e6bc4dc

The graph plots the renderer’s internal noise estimate against cumulative time, not only sample count. Reservoir reuse adds state and merge work, so equal-time comparison is the relevant test. It does not establish image accuracy because it contains no independent image reference and does not include covariance between temporally reused samples. The generalized assumptions and reuse theory are developed further in Generalized Resampled Importance Sampling.

The historical and final GPU implementations should not be conflated:

Reuse obligation Commit e6bc4dc Final GPU checkpoint
History lookup same pixel same pixel
Material/object validation incomplete explicit IDs
Normal/depth-plane validation incomplete explicit thresholds
Finite-light reconnection target re-evaluation endpoint reconnection plus Jacobian
Visibility at receiver stored result can be reused retained candidate is tested
History bound no equivalent 20-frame cap represented history capped

The proof of weighted selection is therefore necessary but not sufficient for temporal reuse: selection can be correct while the transported sample is invalid in the receiving context.

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.

Many-lights render comparing GPU ReSTIR with temporal reuse disabled and enabled Reuse off Reuse on
One new candidate per pixel and frame, eight accumulated samples, the same seed, and no denoising. At the final sample the renderer reports mean estimator variance 0.02989 without history and 0.01310 with history. Working tree based on revision dbca10b, many_lights_scene.usda, GPU ReSTIR, candidateCount=1, seed 0, firefly filtering enabled. The reported quantity is the renderer's internal variance estimate; it is not reference-image error, and temporal correlation can affect its interpretation.

The stationary comparison isolates history accumulation. It does not show reprojection or rejection under camera motion; the current implementation has no motion-vector reprojection, so such a clip must be interpreted together with the acceptance rules described in step 3.