Path Tracing#
The path-tracing mode after the 2026-07-22 overhaul (commits 27fddfd8 → 81799ea2).
Everything here is shipped behaviour; the PT survey’s status is at the end.
Pipeline shape#
One compute kernel, Resources/ShadersSlang/path_tracing/path_tracing.slang,
dispatched by PathTracing::recordCommands between two image barriers that
move the rasterizer’s offscreen target (rgba16f since the HDR unit,
OUT_IMAGE_BINDING) to eGeneral for the compute write and on to
eShaderReadOnlyOptimal for the post pass. Both sit in the frame’s graphics
command buffer, so no queue-family ownership transfer is involved. The post
pass samples that target like any other mode: path tracing replaces the
lighting, not the presentation path.
Rays traverse with GL_EXT_ray_query against the RT mode’s TLAS;
geometry/material data arrive via buffer-device-address (ObjectDescription
→ vertex/index/material-id/material arrays). The record path dispatches only
when a TLAS exists: during the async model load there is nothing to trace
against, and dispatching anyway used to consume never-written descriptor sets.
Neither the bounce ray nor the NEE shadow ray traces RAY_FLAG_FORCE_OPAQUE.
A ray query has no any-hit stage, so glTF MASK cut-outs are alpha-tested inline
in each query’s candidate loop, via the ray_hit_masked_out helper shared with
raytracing/raytrace.rahit.slang (Resources/ShadersSlang/common/alpha_test.slang).
A candidate triangle is committed with CommitNonOpaqueTriangleHit() only when
the test passes; not committing is the discard. The shadow ray keeps
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH: it ends the search at the first
committed hit, so a masked-out candidate does not end it early.
The estimator#
Per pixel and frame, samples_per_pixel independent paths (GUI slider,
default 8), each up to max_bounces segments (GUI slider, default 8).
Changing either slider resets the accumulation (a mean over two different
estimators is biased).
Primary ray from the precomputed
inv_view/inv_projectioninGlobalUBO(the kernel used to invert both matrices per sample: 24 4x4 inversions per pixel per frame), jittered uniformly within the pixel’s unit square (a box filter) instead of always through the exact centre, sosamples_per_pixeland the temporal running mean anti-alias geometric edges rather than just denoise a point-sampled image.Hit: throughput *= albedo (texture, or
material.diffusewhentextureIDis -1). Normals are transformed as directions (inverse-transpose via row-multiplyingworldToObject); the hit normal is face-forwarded against the ray.Next-event estimation: one shadow ray per bounce toward the GUI directional light; unoccluded hits contribute
throughput * NdotL * (1/pi) * dirLight.color.rgb * dirLight.color.w. The throughput carries the albedo, NEE the Lambertian 1/pi: without it lit surfaces measured 208.5 crop luminance vs 188.2 correct (forward: 158.1, the gap being PT’s indirect sky). NEE is the only connection to the GUI light; before it, the mode’s sole light source was an accidental radiance-1 “white furnace” on miss. The BOUNCE path needs no 1/pi: cosine-weighted sampling of a Lambertian cancels the pi and the cosine exactly.Bounce: RTIOW-style cosine-ish sampling (normal + random unit vector), with the degenerate-scatter guard (a near-zero sum falls back to the normal). The sampled direction is renormalized before it becomes the next segment’s
rayDirection: the unnormalized sum has length in[0, 2], andTMin,TMaxand the miss gradient below are all measured in units of that vector’s length. From the fourth segment on, Russian roulette terminates low-throughput paths and reweights survivors (unbiased).Miss: a soft RTIOW gradient sky at half intensity ends the path.
Self-intersection is guarded by a 1e-4 normal offset and ray-query
t_min = 0.001 (matches the RT path).
KATAGLYPHIS_PT_FURNACE=<radiance> forces every hit’s albedo to 1 and
replaces the gradient sky with a uniform <radiance> environment; the shader
gates on pc_ray.clearColor.w > 0.5. It is read via getenv once per
recordCommands call, not cached, so several tests sharing one process each
see their own setting. This is the classic white-furnace test: an unbiased
estimator must converge every pixel to exactly the environment radiance, for
any geometry. GoldenRender.PathTracingPassesTheWhiteFurnaceTest is the
oracle.
Temporal accumulation#
The kernel folds the frame index into the RNG seed (without it, every frame
drew bit-identical samples) and keeps a running mean in a dedicated rgba32f
history image (ACCUMULATION_IMAGE_BINDING): full float, because averaging in
the output target (rgba8 before the HDR unit) would quantize and stall after
a few frames. There is exactly one history image, deliberately not per
swapchain image; a compute→compute barrier orders each frame’s
read-modify-write against the previous frame’s dispatch across command
buffers.
The history resets whenever its premises die:
Trigger |
Where |
|---|---|
Camera moved (view-matrix compare) |
PT branch of |
Quality sliders changed (different estimator = biased mean) |
same |
Swapchain resize (extent-sized image is recreated) |
|
AS rebuilt - model load/reload OR GUI transform moved the traced world |
|
The AS-rebuild trigger was found the hard way: without it, the mean kept blending frames of the half-loaded scene until the camera happened to move, and that healing motion mimicked convergence well enough to make a naive golden pass with per-frame sampling disabled.
Verification#
Eight PT- and RT-facing goldens in Test/commit/VulkanEngine/goldenRenderSuite.cpp,
each red/green-proven against the pre-fix shader. Numbers are post-HDR: before
the HDR unit, lit surfaces clamped at the UNORM ceiling (186 after tonemap)
and hid most of every light response; lifting it widened several margins
dramatically.
PathTracingAccumulatesAndConverges- consecutive-frame changed-pixel fraction in a GUI-free crop: 7.6e-3 early, 3.1e-5 late; the frame-invariant seed measures exactly 0 early and fails.PathTracingAntiAliasesGeometricEdges- the shadow rig’s slab against the sky: a jittered, accumulated primary ray blends that edge in 312 of 312 columns, a pixel-centre-only kernel in none, so it fails. The oracle, and why every edge in the crop stopped being it on 2026-10-12:gpu-golden-testing.md§ The riscv64 light variant.PathTracingRespondsToTheDirectionalLight- radiance 10 vs 0 on the shadow rig: swung fraction 0.751 (0.027 pre-HDR - the ceiling hid three quarters of the response) vs exactly 0 for the pre-NEE kernel.PathTracingHonorsTheQualityControls- bounce cap 8 vs 1 swings 0.255 (all indirect sky light vanishes); hardcoded-bounds kernel: 7.3e-5.RaytracedWorldFollowsTheModelTransform- a GUI move must change the traced image: 0.397 vs exactly 0 on a stale TLAS.PathTracingPassesTheWhiteFurnaceTest- furnace-mode crop converges to mean luminance ~231.6 (linear_to_srgb(aces_tonemap(1.0)) * 255, sincepost.slangencodes for the UNORM swapchain) with >98% of pixels within +-6 of that value; a biased estimator either drifts off 231.6 or leaves visible geometry in the (supposedly uniform) image.RaytracedLargeMeshDoesNotLoseTheDevice- the RT pipeline must survive 10 frames against the shipped large dinosaur-mesh debug scene; it scopes theVK_ERROR_DEVICE_LOSTthe PT reproducer hit on the same mesh topath_tracing.slang/RayQueryrather than the shared vertex-upload/BDA path (gpu-golden-testing.md§ Known issue: path-tracing compute device-lost on the large dinosaur mesh, BACKLOG.md).RaytracedShadowsAreDarkerThanLitGround- the RT closest-hit path, not PT: on the shadow rig the darkest decile of a GUI-free crop must stay under 5% of the brightest decile’s luminance. Measured 0 vs 244 with the fix, 41.6 vs 248 whenraytrace.rchit.slangseeds its payload with the unlit albedo (e852ac99).
The instrument cautions these goldens were built on are in
gpu-golden-testing.md’s cautions section.
Editing the kernel needs no C++ rebuild: the Slang compile script regenerates
the .spv and the engine loads it at startup.
scripts/windows/Build-SlangShaders.ps1 is the build/CI path.
Open work (BACKLOG, “PT survey” section)#
None: all 11 items of the 2026-07-22 PT survey are resolved (8cf33378).
The last, RNG decorrelation (item 11), closed as a measured null result:
lag-1 autocorrelation of the depth-2 noise field is -0.012 (lag-16: +0.015),
because the LCG pre-step plus the PCG output hash already decorrelate
adjacent linear seeds. PathTracingAccumulatesAndConverges logs both lags,
unasserted, so a future seed change shows its noise character.