The ordinary GPU path tracer fuses primary-ray generation and path integration into one compute dispatch. It uses the scene representation from the preceding article and writes one device-local color sample per pixel.

CUDA VolPT reviews efficient GPU path-tracing implementations and compares single-kernel and multi-kernel control. Those sections explain the scheduling alternatives; this commit deliberately starts with the simpler single-dispatch implementation before considering wavefront compaction or material reordering.

Commit contents Files discussed in this chapter gpu_path_trace_pass.cpp · gpu_path_trace_frame_kernel.cpp · path_trace_frame.slang · path_trace_mis.slangh
Complete commit diff ↗

Pass lifetime and scene snapshot

The pass owns a GpuSceneBuilder for one uninterrupted Hydra render:

ctx.buffers.AddOrGetReusableGPU(kGpuPathTracerColorOutputName, sizeof(GfVec4f), count,
                                GPUFrameBufferMemoryType::DeviceLocal, "HdRestir.PathTracer.gpuColorOutput");
auto &fb{ctx.buffers.GetGPUFrameBuffer(kGpuPathTracerColorOutputName)};

// ...

if (!_scene.has_value())
{
    _scene.emplace(*ctx.scene);
}
const auto input{_scene->Input()};

The output is reusable frame storage, not persistent estimator history. _scene is constructed once because camera samples do not alter immutable geometry. A Hydra scene edit must rebuild the renderer pipeline before another dispatch; otherwise the cached GPU snapshot would no longer match the CPU scene.

Per-frame parameters

Camera matrices and estimator settings change without rebuilding geometry. They are copied into a plain parameter structure:

params.Width                  = width;
params.Height                 = height;
params.FrameIndex             = static_cast<std::uint32_t>(ctx.frameIndex);
params.RenderSeed             = ctx.renderSeed;
params.MaxDepth               = static_cast<std::uint32_t>(std::max(1, _maxDepth));
params.MaxReflectionBounces   = static_cast<std::uint32_t>(std::max(0, _settings.MaxReflectionBounces));
params.MaxRefractionBounces   = static_cast<std::uint32_t>(std::max(0, _settings.MaxRefractionBounces));
params.RouletteAggressiveness = _settings.RouletteAggressiveness;
params.EnableSubsurface       = _settings.EnableSubsurface;
params.RenderIblBackground    = _settings.RenderIblBackground;

The std::max calls encode host-side range constraints before unsigned conversion. A negative maximum depth must not silently wrap to a large uint32_t. Camera matrices are downcast from double to float and stored as explicit rows, following the convention documented in the scene ABI.

Shader entry and bounds

The kernel uses 8×8 workgroups. Dispatch dimensions are rounded up, so every invocation first checks image bounds:

[shader("compute")][numthreads(8, 8, 1)] void path_trace_main(uint3 tid : SV_DispatchThreadID)
{
    FrameConstants *fc = (FrameConstants *)g_pc.frameConstantsAddr;

    if (tid.x >= fc.Width || tid.y >= fc.Height)
    {
        return;
    }

    uint pixelIndex = tid.y * fc.Width + tid.x;

    // ...
}

Without the check, widths or heights not divisible by eight would write beyond the output buffer. This is a dispatch-domain condition, not an image-quality choice.

Random-number order and camera sample

The GPU preserves a defined draw order:

Pcg32Rng rng = MakeRng(pixelIndex, fc.FrameIndex, fc.RenderSeed);

// ...

float           px = float(tid.x) + rng.NextFloat();
float           py = float(tid.y) + rng.NextFloat();
GpuCameraParams camera;
camera.Flags              = fc.Flags;
camera.FocalLengthWorld   = fc.FocalLengthWorld;
camera.FStop              = fc.FStop;
camera.FocusDistance      = fc.FocusDistance;
camera.BokehBlades        = fc.BokehBlades;
camera.LensDistortion     = fc.LensDistortion;
camera.ExposureMultiplier = fc.ExposureMultiplier;
GpuCameraRay ray =
    GenerateGpuCameraRay(fc.InvViewRow0, fc.InvViewRow1, fc.InvViewRow2, fc.InvViewRow3, fc.InvProjRow0,
                         fc.InvProjRow1, fc.InvProjRow2, fc.InvProjRow3, px, py, fc.Width, fc.Height, camera, rng);

// ...

SampledWavelengths wavelengths = SampleWavelengthsUniform(rng.NextFloat());

Two random values choose a subpixel location. Depth of field may consume additional values inside GenerateGpuCameraRay; the wavelength draw occurs after that function returns. CPU/GPU comparisons must therefore distinguish equal seeds from equal random streams. A branch that consumes a different number of draws can produce statistically equivalent but sample-by-sample different images.

The fused path loop

The outer kernel prepares one camera ray and one wavelength packet; the transport estimator lives in TracePath. The following excerpt retains the ordering of the code in commit 4648adc while omitting material setup that is repeated after every accepted hit:

float4 TracePath(/* scene, ray, wavelengths, RNG */)
{
    float4 totalRadiance = float4(0.0);
    float4 throughput    = float4(1.0);

    PathHit firstHit = TraceClosest(accel, scene, origin, dir);
    AcceptedPathHit accepted =
        ResolveOpacity(accel, scene, firstHit, origin, dir, materials, rng);
    PathHit hit = accepted.Hit;
    if (!hit.Found)
    {
        if (HasFlag(
                settings.Flags, kPathTraceFlagRenderIblBackground)
            && scene.Environment.Type != 0)
        {
            return RGBToSpectrum(
                EnvironmentSample(scene.Environment, dir), wavelengths);
        }
        return totalRadiance;
    }

    // ... resolve material and primaryIsInside ...
    ApplyBeerAbsorption(
        throughput, material, accepted.SegmentDepth, primaryIsInside, wavelengths);
    totalRadiance +=
        throughput * RGBToSpectrum(material.Emission, wavelengths);

    for (uint bounce = 0; bounce < settings.MaxDepth; ++bounce)
    {
        BounceResult br = SampleBounce(
            material, shadingNormal, hitPos, currentDir, isInside,
            config, wavelengths, state, rng);

        bool connOk = br.Error == 0;
        PathHit connHit;
        if (connOk)
            connHit = TraceClosest(
                accel, scene, br.Sample.NextRayOrigin, br.Sample.NextRayDir);

        totalRadiance += throughput * DirectLightMIS(
            accel, scene, hitPos, shadingNormal, currentDir, material,
            connOk, br.Sample, connHit, connEmission, connEnv,
            connHitDist, wavelengths, rng);

        if (!connOk)
            break;

        throughput *= br.Sample.ThroughputMul;
        if (!connHit.Found)
            break;

        AcceptedPathHit nextAccepted = ResolveOpacity(
            accel, scene, connHit,
            br.Sample.NextRayOrigin, br.Sample.NextRayDir, materials, rng);
        if (!nextAccepted.Hit.Found)
            break;

        // ... update hitPos, material, currentDir, and nextIsInside ...
        ApplyBeerAbsorption(
            throughput, material, nextAccepted.SegmentDepth,
            nextIsInside, wavelengths);

        if (!br.Sample.SkipRoulette && bounce > 3)
        {
            float maxThroughput =
                max(max(throughput.x, throughput.y),
                    max(throughput.z, throughput.w));
            float p = clamp(
                maxThroughput * settings.RouletteAggressiveness, 0.0, 1.0);
            if (rng.NextFloat() > p)
                break;
            throughput *= 1.0 / p;
        }
    }
    return totalRadiance;
}

This order encodes several estimator decisions:

  1. The continuation direction is sampled before direct lighting. Its already traced connection is reused as the BSDF proposal in DirectLightMIS, so the code does not cast a second ray for the same direction.
  2. Direct lighting is multiplied by the throughput arriving at the current vertex. ThroughputMul belongs to the sampled continuation and is applied only afterwards.
  3. Opacity resolution may advance through several intersections. Beer absorption uses the accepted segment length rather than the distance to the first rejected alpha-tested surface.
  4. An environment reached by the sampled continuation is handled by MIS before !connHit.Found terminates the geometric path.
  5. Russian roulette preserves expectation only because a surviving path is multiplied by \(1/p\): if its pre-roulette contribution is \(C\), then \(p(C/p)+(1-p)0=C\).

The last property also exposes a numerical precondition. The code must never execute 1.0 / p with \(p=0\). In this revision a zero-throughput path normally terminates when rng.NextFloat() > 0, but an RNG capable of returning exactly zero leaves an equality edge. A robust follow-up should terminate explicitly when p <= 0 before drawing or dividing.

Spectral transport and reconstruction

The final shader operations are:

RaytracingAccelerationStructure accel = g_pc.accel;

float4 spectrum = TracePath(accel, sceneAddrs, settings, origin, dir, wavelengths, rng);
float3 radiance = SpectrumToRGB(spectrum, wavelengths) * camera.ExposureMultiplier;

float4 *colorOut     = (float4 *)g_pc.colorOutAddr;
colorOut[pixelIndex] = float4(radiance, 1.0);

TracePath returns four spectral values associated with the sampled wavelength packet. Conversion to RGB occurs once after transport, as in the CPU implementation from step 0.a. Exposure is applied in scene-linear RGB after reconstruction.

The shader includes separate modules for camera generation, hit reconstruction, Preview Surface evaluation, GGX, environment sampling, MIS, spectra, and path continuation. “Fused dispatch” means these operations execute inside one kernel; it does not mean their interfaces or probability factors can be merged without review.

An empty scene is handled before requesting an acceleration structure: the kernel writes the enabled environment or black. This is a supported render state, not an invalid input.

Parity levels

Parity should be checked at several levels:

Level Comparison
ABI host and shader size, alignment, offsets, flags, and handles
deterministic function camera ray, spectrum conversion, GGX, light PDF
sampled function distributions and moments under controlled seeds
path hit sequence, throughput factors, emission, and termination
image converged mean and error tolerance

Matching one noisy frame is neither required nor sufficient when CPU and GPU consume different random streams. Matching deterministic functions and converged expectations provides a more specific diagnosis.

The commit includes tests for GGX, dome and rectangle lights, physical sky, hit reconstruction, multibounce transport, and full frames. The production frame test checks concrete invariants after each dispatch:

DBG_ASSERT(std::isfinite(pixels[i + 0]) &&
               std::isfinite(pixels[i + 1]) &&
               std::isfinite(pixels[i + 2]),
           "frame kernel produced non-finite radiance");
DBG_ASSERT(pixels[i + 0] >= 0.0F &&
               pixels[i + 1] >= 0.0F &&
               pixels[i + 2] >= 0.0F,
           "frame kernel produced negative reconstructed RGB");
DBG_ASSERT(std::abs(pixels[i + 3] - 1.0F) < kEps,
           "frame kernel alpha mismatch");
DBG_ASSERT(luminanceSum > 0.0F,
           "frame kernel must produce visible direct lighting");

These assertions detect invalid arithmetic, buffer-layout mistakes, and an entirely dark result. They do not prove CPU/GPU equality. A parity claim needs a test at the appropriate level: exact bytes for an ABI layout, a stated tolerance for a deterministic function, a statistical test for a sampled function, or reference-image error for a converged frame. The complete test is test_gpu_path_trace_frame.cpp.

Split-screen HdRestir render comparing CPU and GPU path tracers at 64 samples per pixel CPU Path Tracer GPU Path Tracer
One split-screen frame at 64 spp per side. The vertical line is the pipeline boundary, not a boundary in the USD scene. Revision 4648adc; seed 0; resolutionLevel=1; identical camera and exposure; denoiser, firefly filter, and chromaticity blur disabled. The visible noisy pixels need not match because CPU and GPU execution consume and combine random values differently.

This color comparison exercises the complete pipelines but is not the deterministic debug-AOV test described above. It can reveal a large framing, material, lighting, or exposure mismatch. It cannot establish statistical equivalence from one frame; that conclusion still requires the converged-image tests and error measurements. The interactive selection of CPU/GPU pipelines, debug views, and split screen is shown once in Initial plugin entry and render pipeline.

Code map