The GPU ReSTIR pass uses the same uploaded scene and spectral transport functions as the ordinary GPU path tracer. Its additional state is a device-local reservoir per pixel and the logic required to validate, reconnect, reweight, and merge previous history.

Commit contents Files discussed in this chapter gpu_ris_path_trace_pass.cpp · ris_path_trace_core.slangh · reservoir.slangh · resampling.slangh
Complete commit diff ↗

Persistent reservoir allocation

The pass distinguishes the current color sample from cross-frame history:

ctx.buffers.AddOrGetReusableGPU(kGpuRestirColorOutputName, sizeof(GfVec4f), count,
                                GPUFrameBufferMemoryType::DeviceLocal, "HdRestir.ReSTIR.colorOutput");
auto &fb{ctx.buffers.GetGPUFrameBuffer(kGpuRestirColorOutputName)};

// ...

ctx.buffers.AddOrGetPersistentGPU(kGpuReservoirBufferName, sizeof(Gpu::ReservoirData), count,
                                  GPUFrameBufferMemoryType::DeviceLocal, "HdRestir.ReSTIR.reservoirs");
auto &reservoirs{ctx.buffers.GetGPUFrameBuffer(kGpuReservoirBufferName)};

Reusable storage may be overwritten each frame. Persistent storage is injected back into the next frame’s pass graph. Both remain device-local.

The dispatch makes history optional:

_kernel.RunFrame(ctx.services.Get<IDynamicLibProvider>(), input, params, fb,
                 _enableTemporalReuse ? &reservoirs : nullptr);

Passing nullptr selects the no-history control experiment. The candidate generation and current-frame reservoir remain available; only temporal merge is disabled.

Finite-safe streaming update

The shader implementation follows the weighted reservoir proof from step 2:

void ReservoirUpdate(inout ReservoirData r, NeeCandidateData s, float weight, inout Pcg32Rng rng)
{
    r.CandidateCount += 1;
    if (!IsFinitePositiveWeight(weight))
    {
        return;
    }
    float updatedWeightSum = r.WSum + weight;
    if (!IsFinitePositiveWeight(updatedWeightSum))
    {
        return;
    }
    r.WSum = updatedWeightSum;
    if (rng.NextFloat() <= weight / updatedWeightSum)
    {
        r.ChosenSample = s;
        SetFlag(r.Flags, kReservoirFlagHasSample, true);
    }
}

CandidateCount increases for every represented proposal, including a non-positive or non-finite weight. WSum includes only accepted finite positive mass. If the updated sum overflows to infinity, the update is rejected before it corrupts the stored normalization.

The replacement comparison uses <=. For a positive weight, the ratio lies in \((0,1]\), so the first accepted candidate is selected. The isolated equality at zero does not occur because non-positive weights returned earlier.

Final normalization

The reservoir is finalized only when it contains a sample, a positive candidate count, a finite weight sum, and a finite positive target:

int   frameCount  = max(1, r.CandidateCount / max(1, candidatesPerFrame));
float denominator = float(frameCount) * phat;
r.W               = IsFinitePositiveWeight(denominator) ? r.WSum / denominator : 0.0;
if (!IsFinitePositiveWeight(r.W))
{
    r.W = 0.0;
}

This project convention differs from the generic \(S/(M\hat p)\) notation. Candidate weights already contain the per-technique sample-count normalization, so finalization divides by the number of represented frames. The identity

\[\texttt{frameCount} =\frac{\texttt{CandidateCount}}{\texttt{candidatesPerFrame}}\]

is valid only while every represented frame uses the recorded candidate budget and candidate construction retains the same normalization convention.

History compatibility

The previous reservoir is indexed by the same pixel because a camera or scene change resets persistent state. Subpixel jitter can still move a primary ray across an edge, so the shader validates the stored surface:

if (previous.MaterialId != current.MaterialId)
{
    return false;
}
if (previous.ObjectId != 0 && current.ObjectId != 0 && previous.ObjectId != current.ObjectId)
{
    return false;
}

// ...

if (dot(previousNormal, currentNormal) < 0.95)
{
    return false;
}

// ...

float depthTolerance = max(1e-3, 0.02 * max(abs(previous.Depth), abs(current.Depth)));
float planeDistance  = abs(dot(current.Position - previous.Position, previousNormal));
return !isnan(planeDistance) && !isinf(planeDistance) && planeDistance <= depthTolerance;

Material and object identity reject unrelated surfaces. The normal threshold requires an angle smaller than \(\arccos(0.95)\), approximately \(18.2^\circ\). The final plane-distance check scales with depth but has a minimum absolute tolerance of \(10^{-3}\) scene units.

These are acceptance heuristics, not a proof that the two shading points represent the same path-space domain. They reduce invalid reuse at edges and discontinuities while motion-vector reprojection remains unimplemented.

Reconnection and Jacobian

For a finite light sample, the stored endpoint is reconstructed from the previous surface, direction, and distance. A new direction and distance are then computed from the current surface. Infinite-light samples retain their direction.

Changing the receiving surface changes the solid angle subtended by a finite area-light endpoint. The shader applies

\[J= \frac{\cos\theta_{\mathrm{current}}} {\cos\theta_{\mathrm{previous}}} \left( \frac{d_{\mathrm{previous}}} {d_{\mathrm{current}}} \right)^2.\]

This follows from \(d\omega/dA=\cos\theta/d^2\). The implementation returns a zero Jacobian when distances, cosines, or the result are not finite and positive.

Visibility and wavelength packets

After reconnection, the target is re-evaluated at the current surface. Visibility is traced for the preliminary winner before it becomes valid persistent history. An occluded winner has both contribution and target set to zero.

The scalar resampling target is derived from RGB quantities and does not depend on the current four-wavelength packet. Once a sample is retained, its spectral contribution is evaluated for the current packet. This avoids storing spectral values tied to a previous packet, while wavelength-aware target construction remains a separate research question.

Remaining reuse limits

This checkpoint implements same-pixel temporal reuse with geometric validation. It does not implement:

  • motion-vector reprojection;
  • spatial neighbor reuse;
  • disocclusion filling;
  • a general many-to-one visibility cache;
  • wavelength-conditioned targets.

Those omissions define the algorithm being measured. They should not be described as full spatiotemporal ReSTIR.

Record this: On commit 4648adc, show GpuRestir with one new candidate per frame. Hold the camera still, then move across a depth and normal discontinuity. If debug data is available, display accepted and rejected history as separate colors.
Caption to keep: The GPU pass reuses one same-pixel reservoir only after material, object, normal, depth-plane, target, Jacobian, and visibility checks.