Spectral path tracing on the GPU
4648adc · 2026-07-26 ↗
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.
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.
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.