OHAO · Implementation Monograph

Monograph · 06 · Path tracer

06

Pipelines · implementation

Path tracer

Chapter contract

Document the path tracer as coded in pt_raygen.rgen. The deep section below is a line-anchored walk of the analytic NEE sphere-light branch — every jargon term, formula, and step answers why.

Host lifecycle (before a single ray)

The path tracer is not constructed in VulkanRenderer::initialize. It appears on demand so 4K dual profiles do not OOM.

From mode switch to first pixel
  1. Device + TLAS host already exist (Ch. 03)
  2. Scene uploaded: VB/IB, matColors, lights, bindless, buildBLASTLAS
  3. setRenderMode(RTOffline|RTRealtime)ensureRTRenderer(mode)
  4. Construct PathTracer; init device/instance/queue; allocate beauty + AOV images for width×height
  5. Create RT pipeline from SPIR-V (raygen/miss/chit/ahit); build SBT
  6. Create descriptor set (bindings 0–35); bind TLAS, buffers, images, CDFs
  7. Apply profile settings (bounces, sampler, denoise mode, firefly clamp)
  8. Per frame / spp: push constants (invView/invProj, sample index, flags) → traceRays
  9. Optional denoise (Ch. 09); copy to staging / present
  10. Camera move → resetAccumulation / notify view changed
Why lazy profiles

Each PathTracer holds full-resolution HDR + many AOVs. Offline + realtime at once at 4K exhausted 8 GiB (Fox.glb hang). Lazy create + 1080p default fixed it.

WORKFLOW · PATH TRACER HOST Scene up TLAS ensureRT Pipeline+SBT Descriptors traceRays Denoise Readback FIG. PT-HOST · LAZY CREATE THEN TRACE
Fig. PT-HOST

Integrator math (NEE walk below) only runs after this host path succeeds.

Visual plates

Cornell box path-traced beauty, real engine export 16 spp + OIDN
Plate PT-R1

Real engine output Cornell box beauty. Exported from ./build/cornell_box @ 16 spp + OIDN — not AI art.

Conceptual diagram of NEE connecting hit to sphere light
Plate PT-C1

Conceptual illustration — not engine output NEE idea sketch. Grok Imagine diagram for intuition only; shading below is from real GLSL.

Role

PathTracer is the physical image-formation path for RTOffline / RTRealtime. It estimates the rendering equation by shooting rays, connecting to lights, and accumulating samples.

Jargon · rendering equation

The balance of light leaving a surface point: emission plus the integral of incoming light weighted by the BRDF and the cosine to the normal. Path tracing estimates that integral with random samples instead of solving it analytically.

\[ L_o(x,\omega_o)=L_e(x,\omega_o)+\int_{\mathcal{H}} f_r(x,\omega_o,\omega_i)\,L_i(x,\omega_i)\,(\mathbf{n}\!\cdot\!\omega_i)\,d\omega_i \]
Why this equation

It is the definition of “correct” lighting for opaque surfaces. Offline profiles chase it with more bounces and better samplers; realtime profiles approximate it under a time budget.

Integrator stages

Stage A

Primary + first hit + direct NEE

Camera ray, material unpack, AOV write, then analytic light connection (this chapter’s deep walk).

Stage B

Specular indirect chain

GGX-oriented bounces; NEE/env attribute to specContrib.

Stage C

Diffuse indirect chain

Cosine hemisphere; contributions to diffContrib.

Why dual-lobe split

Denoisers (NRD/DLSS) need separate diffuse and specular statistics and hit distances. One mixed path makes demodulation ambiguous.

Deep walk · NEE sphere branch

Source: shaders/rt/pt_raygen.rgen lines 304–435 (304 = comment; analytic direct NEE body 305–435). We walk the sphere light branch only (lightType < 0.5 at L327), the general solid-angle case.

Jargon · NEE (Next Event Estimation)

Instead of hoping a random bounce hits a light, we explicitly sample a point on a light and cast a shadow ray to test visibility. That direct connection is the “next event.” Without NEE, small bright lights are almost never hit by pure BSDF sampling → noisy or black direct lighting.

Jargon · PDF (probability density function)

How likely a continuous random choice was. Monte Carlo divides the contribution by the PDF so rare samples are not under-counted and common samples are not over-counted. Units matter: solid-angle vs area measures must match.

Jargon · throughput

The running product of “how much light survives” along a path (BRDF × cosine / PDF factors). At bounce 0 after the primary hit, throughput is still 1 before we multiply the direct term.

Jargon · MIS (multiple importance sampling)

When two strategies can sample the same light path (e.g. “sample the light” vs “sample the BRDF”), MIS blends them with weights from their PDFs so neither strategy alone blows variance. Sphere NEE at bounce 0 here uses explicit light pick + count factor; env sampling nearby uses power/balance heuristics (Ch. 07).

L305–310

Step 1 · Pick a light uniformly

Line 304 is only the section comment. The branch starts at 305: if any lights exist, pick one index with a 1D sample (306), advance the sampler dim (307), clamp (308), load GPULight (310).

GLSL · pt_raygen.rgen (exact lines)
304// ==== Analytic direct NEE at bounce 0 ====
305if (lightBuf.lightCount > 0u) {
306  uint selectedLightIdx = uint(getSample1D(dimIdx) * float(lightBuf.lightCount));
307  dimIdx += 1u;
308  selectedLightIdx = min(selectedLightIdx, lightBuf.lightCount - 1u);
310  GPULight light = lightBuf.lights[selectedLightIdx];
Formula
\[ i \sim \mathrm{Uniform}\{0,\ldots,N_L-1\} \]
Why uniform over lights

Simple and unbiased given a later ×NL factor. Smarter light picking (power-proportional) is a future variance win; uniform is correct and easy to validate.

L312–322

Step 2 · Decode light + emission density

Read type, center, color, intensity, radius. Sphere surface area \(A=4\pi r^{2}\). Convert artist “color×intensity” into radiance density \(L_e\) by dividing by area so bigger spheres are not infinitely brighter per steradian.

GLSL
312float lightType = light.positionAndType.w;
313vec3 lightCenter = light.positionAndType.xyz;
314vec3 lightColor = light.colorAndIntensity.rgb;
315float lightIntensity = light.colorAndIntensity.w;
316float lightRadius = light.dirAndParam.w;
320float r = max(lightRadius, 0.01);
321float area = 4.0 * 3.14159 * r * r;
322vec3 Le = lightColor * lightIntensity / max(area, 0.01);
Formula
\[ A=4\pi r^{2},\quad L_e=\frac{c\cdot I}{A} \]
Why divide by area

We will later multiply by the geometry term that includes area. Keeping \(L_e\) as “radiance-like density” matches the sphere surface sampling measure. The 0.01 floors avoid division by zero for degenerate radii.

L327–343

Step 3 · Sphere branch: sample surface point

Only when lightType < 0.5 (sphere). Uniform sample on the unit sphere, scale by radius, form light point and outward normal (radial). Direction \(L\) from hit to that point; weight uses foreshortening on the light and inverse-square.

GLSL · sphere branch
327if (lightType < 0.5) {
328  vec2 u12 = getSample2D(dimIdx); dimIdx += 2u;
330  float cosTheta = 1.0 - 2.0 * u1;
331  float sinTheta = sqrt(max(0.0,
331      1.0 - cosTheta*cosTheta));
332  float phi = 6.2831853 * u2;
334  vec3 offset = vec3(sinTheta*cos(phi),
334      sinTheta*sin(phi), cosTheta) * r;
335  vec3 lightPoint = lightCenter + offset;
336  vec3 lightNormal = normalize(offset);
337  vec3 toLight = lightPoint - hitPos;
338  float lightDist = length(toLight);
339  L = toLight / lightDist;
340  float lightCos = max(dot(-L, lightNormal), 0.0);
341  float lightArea = 4.0*3.14159*r*r;
342  weight = lightCos * lightArea / (lightDist * lightDist); // ÷ d²
343  shadowDist = lightDist - 0.02;
344}
Formula · area → solid angle factor
\[ d=\|y-x\|=\texttt{lightDist},\quad \omega=\frac{y-x}{d},\quad w=\frac{|\mathbf{n}_L\!\cdot\!(-\omega)|\,A}{d^{2}} \]

Sphere radius is \(r\) (used only in \(A=4\pi r^{2}\)). Distance to the sample is \(d=\texttt{lightDist}\). GLSL divides by lightDist * lightDist — never by \(r^{2}\).

Jargon · solid angle / geometry term

How big a surface patch looks from the shading point. The factor \(A\,|\cos|/d^{2}\) with distance \(d=\|y-x\|\) (not the sphere radius \(r\)) converts a uniform area sample into solid-angle measure. If the back of the sphere faces you, \(\cos\le0\) and weight is zero — no contribution from that hemisphere of the light surface.

Why sample the whole sphere (not the visible disk only)

Implementation simplicity and a single code path. The \(\max(\mathbf{n}_L\cdot(-\omega),0)\) kills back-facing samples. Visible-disk sampling would lower variance but needs more geometry math; this branch prioritizes correctness and readability.

Why shadowDist = dist − 0.02

Trace slightly short of the light surface so the ray does not self-hit the light geometry as an “occluder.” Epsilon tradeoff: too large → light leaks/gaps; too small → acne.

L385–392

Step 4 · Shadow ray (visibility)

Only if surface faces the light (NdotL > 0) and weight is positive. Trace with terminate-on-first-hit and skip closest-hit for speed. Miss convention: payload.hitDist < 0 means visible.

GLSL
385float NdotL = max(dot(N, L), 0.0);
387if (NdotL > 0.0 && weight > 0.0) {
388  payload.hitDist = 999.0;
389  traceRayEXT(topLevelAS,
390    TerminateOnFirstHit | Opaque
390    | SkipClosestHit,
392    hitPos + N*0.01, 0.001, L, shadowDist, 0);
Formula
\[ V=\begin{cases}1 & \text{miss}\\0 & \text{any hit}\end{cases} \]
Why these ray flags

TerminateOnFirstHit stops at the first occluder — we only need yes/no visibility. SkipClosestHit skips material shading on the occluder — pure intersection test. Origin bias \(N\cdot 0.01\) reduces self-shadow acne on the shading surface.

L394–432

Step 5 · BRDF × light × weight × N_L

On miss (visible), evaluate microfacet GGX specular + Lambert diffuse, multiply by \(L_e\), \(N\cdot L\), geometry weight, and light count. Split into demod diffuse/specular for NRD. Optional firefly clamp in realtime.

GLSL (abridged)
409vec3 spec = D*F*G / (4*NdotV*NdotL + eps);
411vec3 diff = kD * albedo / PI;
414vec3 direct =
414  Le * (diff+spec) * NdotL * weight
414  * float(lightBuf.lightCount);
419radiance += direct;
422vec3 neeCommon = Le * NdotL * weight
422  * float(lightBuf.lightCount);
423diffContrib += neeCommon * diff;
424specContrib += neeCommon * spec;
Formula
\[ L_{\mathrm{NEE}}=L_e\,f_r\,(n\!\cdot\!\ell)\,w\cdot N_L\cdot V \]
Why multiply by light count \(N_L\)

We picked one light with probability \(1/N_L\). Unbiased estimators divide by the PDF of the discrete choice — equivalent to multiplying by \(N_L\). Forget this factor and the scene is \(N_L\times\) too dark.

Why split diff/spec here

Same NEE sample feeds two AOV buckets so REBLUR/DLSS can denoise lobes separately. Beauty still sums both.

Other branches (same function)

Directional (type < 1.5): fixed direction, weight=1, long shadow. Spot: sphere-like sample × cone falloff². Area: bilinear on edge1×edge2. Same shadow + BRDF tail.

Full-page binding plate

Full path tracer descriptor binding map
Fig. BIND-1

Descriptor set 0. Shipped bindings 0–35 from path_tracer_descriptors.cpp (ReSTIR 29–34, DLSS-RR hit-dist 35).

Source map

shaders/rt/pt_raygen.rgenNEE L304–435 · stages A/B/C
path_tracer_descriptors.cppBinding layout
gpu_light.hpp80-byte light struct
rt_settings.hpp / rt_meta.hppProfiles · traits

Design units in this module

Each card is a focused design page (what / how / why + sources). Full tree: Sitemap.