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.
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:
- 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. - Direct lighting is multiplied by the throughput arriving at the current
vertex.
ThroughputMulbelongs to the sampled continuation and is applied only afterwards. - 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.
- An environment reached by the sampled continuation is handled by MIS before
!connHit.Foundterminates the geometric path. - 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.
CPU Path Tracer
GPU Path Tracer
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
gpu_path_trace_pass.cpp: persistent pass object, scene snapshot, and dispatch parameters.path_trace_frame.slang: pixel bounds, camera sample, wavelength packet, and RGB output.path_trace_mis.slangh: fused path loop and direct-light MIS.test_gpu_path_trace_frame.cpp: production-kernel frame invariants.