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.

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.

Empty-geometry path

The shader handles a scene with no acceleration structure before requesting a ray-tracing object. If the background is enabled, it samples the environment, uplifts it to the active wavelengths, reconstructs RGB, and writes the pixel. This branch is necessary because an empty scene is a supported render state, not an invalid kernel 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. Each test should state whether it expects exact bytes, a floating-point tolerance, a distributional result, or a converged image statistic.

Record this: Render the same fixed camera with PathTracer and GpuPathTracer. Show one deterministic debug AOV comparison, then an equal-sample convergence sequence with identical exposure and denoising disabled.
Caption to keep: GPU parity is evaluated at ABI, function, path, and converged-image levels; identical noisy pixels are required only when the random streams and arithmetic order are intentionally matched.