Project 4

Path Tracing

The ray casting loop becomes a Monte-Carlo path tracer with GGX importance sampling, plus explicit light connections to emitting triangles, the sun, the moon and the sky dome, shared with BRDF sampling by multiple importance sampling

Course
CS 599, Ray Traced Real Time Rendering
Submitted
6 November 2026
Status
Under review

Changes made

#ChangeFileStatus
1PushConstantRay — temporary lights removed; frameSeed, rr, depth addedshared_structs.hdone
2raytrace() — frame seed, Russian roulette depth, clearvkapp_raytracing.cppdone
3Random seed per pixel and frame, tea / rndraytrace.rgendone
4EvalBrdf, SampleBrdf, PdfBrdfbrdf.glsldone
5The Monte-Carlo loopraytrace.rgendone
6Running average, count in alpharaytrace.rgendone
74b: the emitter listvkapp_loadModel.cppdone
84b: m_lightBuff, on m_rtDesc binding 2vkapp_raytracing.cppdone
94b: explicit light connection with a shadow rayraytrace.rgendone

The first part of this report covers the assignment as the two PDFs specify it: project 4a’s Monte-Carlo loop and project 4b’s explicit light connection. Where the implementation departs from the PDF, the section says so and why. The largest departures are a GGX BRDF that is importance sampled, in place of diffuse-only sampling, and multiple importance sampling in place of 4b’s fixed ½.

Beyond the framework covers the additions: per-path Russian roulette as an alternative to the PDF’s, sampling the sun, moon and sky dome, emissive textures, an HDRI environment, and a material override that was the outcome of chasing the Classroom’s fireflies.


The push constant

struct PushConstantRay
{
    ALIGNAS(4) bool  clear;               //  0
    ALIGNAS(4) float exposure;            //  4
    ALIGNAS(4) int   debugView;           //  8
    ALIGNAS(4) int   frameSeed;           // 12
    ALIGNAS(4) float rr;                  // 16
    ALIGNAS(4) int   depth;               // 20
    ALIGNAS(4) int   perPathRoulette;     // 24  (added)
    ALIGNAS(4) int   numLights;           // 28  (4b)
    ALIGNAS(4) int   explicitLights;      // 32  (4b toggle)
    ALIGNAS(4) float emitScale;           // 36
    ALIGNAS(4) int   envSampling;         // 40  (added)
    ALIGNAS(4) float metalRoughnessFloor; // 44  (added)
    ALIGNAS(4) int   alignmentTest;       // 48
};

Project 3’s three vec3 lights are gone, which removes the only 16-byte members. Everything left is 4 bytes on a 4-byte boundary, so the two languages cannot disagree about the layout. The ray generation shader still checks alignmentTest == 1234 first, and it does not fire.

raytrace() fills it each frame:

m_pcRay.frameSeed = frameCount;

m_pcRay.rr    = 0.7f;
m_pcRay.perPathRoulette = m_perPathRoulette ? 1 : 0;
if (m_perPathRoulette)
    m_pcRay.depth = m_maxDepth;
else {
    m_pcRay.depth = 1;
    while (float(rand())/RAND_MAX < m_pcRay.rr) m_pcRay.depth++;
    m_pcRay.depth = std::min(m_pcRay.depth, m_maxDepth);
}
  • frameSeed — the frame counter rather than the PDF’s rand() % 32768. The counter never repeats. 32,768 values can, and two frames with the same seed add the same sample twice.
  • depth — the PDF’s Russian roulette, drawn on the CPU once per frame. The cap is the PDF’s “correct way”, std::min, set from a Max depth slider (1–16, default 8). See Russian roulette.
  • clear — see Accumulation.

Random numbers

uint seed = tea(gl_LaunchIDEXT.y*gl_LaunchSizeEXT.x + gl_LaunchIDEXT.x, uint(pcRay.frameSeed));

The seed is a local in the ray generation shader, not the payload field the PDF suggests. Only this shader draws random numbers; the closest-hit, miss and any-hit shaders never do. A payload field would send it to every one of them and back for nothing.

The first two numbers jitter the ray within the pixel:

const vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(rnd(seed), rnd(seed));

Project 3 traced through the pixel centre. Jittering means the accumulated average covers the whole pixel. That anti-aliases edges and, as project 3 predicted, filters the textures the ray tracer reads at mip 0. The grain from normal maps that project 3 found in Sponza now averages out once the camera is still.


The BRDF

The PDF suggests importance sampling only the diffuse part: SampleBrdf draws a cosine-weighted direction and PdfBrdf returns |N·L|/π. That is unbiased for any BRDF, but here it would waste most samples. Since project 2, EvalBrdf has been Cook-Torrance GGX, and a smooth metal’s lobe is a few degrees wide. A cosine sample almost never lands in it, so polished surfaces would take thousands of samples to show a reflection.

So all three functions follow the same glTF material:

FunctionDoes
EvalBrdf(N, L, V, mat)mix(dielectric, metal, metallic); the BRDF without N·L, as in project 3
SampleBrdf(u, lobe, N, V, mat)picks the specular lobe with probability BrdfSpecularChance, else diffuse
PdfBrdf(N, L, V, mat)the exact density SampleBrdf draws L with
  • Diffuse lobe — the PDF’s cosine-weighted hemisphere, built in an orthonormal basis around N rather than with SampleLobe.
  • Specular lobe — a GGX microfacet normal drawn from the distribution of visible normals (Heitz 2018, in Dupuy and Benyoub’s spherical-cap form), with V reflected about it. Sampling only normals that face the viewer avoids drawing reflections that go below the surface, which plain GGX sampling does often at grazing angles.
  • Choosing the lobe — metallic + (1 − metallic) · max(F), with the dielectric’s Fresnel taken at N·V. A probability that depended on the sampled half vector would make the pdf of a given L depend on which lobe produced it. Taken at N·V, it is the same for every direction, so PdfBrdf is the mixture’s exact density:
float pSpec = BrdfSpecularChance(N, V, mat);
return (1.0 - pSpec) * NoL / BRDF_PI
     + pSpec * G1_GGX(NoV, a) * D_GGX(NoH, a) / (4.0 * NoV);

The visible-normal density is G1(V)·D(H)·(V·H)/N·V. Reflecting about H divides by 4·V·H, which cancels. The exact pdf matters twice: the estimator divides by it, and multiple importance sampling compares it against the light’s pdf.

SampleBrdf takes its random numbers as arguments rather than the seed, so that the same function could take low-discrepancy numbers later.


The Monte-Carlo loop

The loop that ran once in project 3 now runs pcRay.depth times:

for (int i=0; i<pcRay.depth; i++) {
    traceRayEXT(...);
    if (!payload.hit) { /* the sky, below */ break; }

    GetHitObjectData(rayDirection, mat, nrm, dbg);
    vec3 P = payload.hitPos;   // the payload is reused by the shadow rays
    vec3 N = nrm;              // normalized and flipped to the viewer
    vec3 V = -rayDirection;

    if (dot(mat.emission, mat.emission) > 0.0) {
        C += weight * W * mat.emission * pcRay.exposure * pcRay.emitScale;
        break;
    }

    if (nee) C += W * <explicit light connections>;   // 4b

    vec3  Wi   = SampleBrdf(u, rnd(seed), N, V, mat);
    float NoWi = dot(N, Wi);
    float pdf  = PdfBrdf(N, Wi, V, mat);
    float p    = pdf * pcRay.rr;
    if (NoWi <= 0.0 || p <= 1e-6) break;
    W *= NoWi * EvalBrdf(N, Wi, V, mat) / p;
    brdfPdf = pdf;

    rayOrigin    = P;
    rayDirection = Wi;
}

The loop invariant from the PDF holds at the top of every iteration: rayOrigin and rayDirection are the next untraced segment, C is the light found so far, and W is the product of f/p along the path.

  • f — N·Wi · EvalBrdf. The PDF writes the N·L into EvalBrdf; here it stays outside, as project 3 defined EvalBrdf.
  • Below the surface — the specular lobe can reflect V into a direction under the surface. The loop ends there, along with the PDF’s p < 10⁻⁶ guard.
  • P and N are copied out of the payload straight away, because the shadow rays in 4b overwrite it.
  • A hit on a light adds its emission and ends the path, as the PDF has it. weight is 1 without 4b; with it, it is the MIS weight below.

Russian roulette

The PDF draws one path length per frame on the CPU and divides by rr at every bounce. A path reaches bounce i in a fraction rr^i of frames, and when it does, W carries 1/rr^i, so every bounce counts at full strength on average.

Depth drawnFrames
130%
221%
315%
410%
8 (draws of 8 or more, capped)8%

That averages 3.2 bounces. The cap gathers the 8% of draws past 8 into 8, which is the bias the PDF warns about. Draws that long are rare and each later bounce carries less light, so the loss is small. Any cap of this kind would be bias, so it is a slider, not a constant.

The MIS weights use pdf without rr. MIS compares the densities of two ways of choosing a direction, and rr is the chance the path continued, not part of choosing one.

One depth per frame means every pixel in a frame stops at the same bounce, which shows as dark frames while the camera moves. A Roulette combo in the GUI also offers per-path roulette by throughput; the PDF’s version is the default. See Per-path Russian roulette.


Accumulation

The PDF’s running average, with the count in alpha:

if (pcRay.clear)
    imageStore(colCurr, pixel, vec4(C, 1.0));
else {
    vec4  old = imageLoad(colCurr, pixel);
    vec3  Ave = old.xyz;
    float N   = old.w;
    Ave += (C - Ave)/(N + 1.0);
    N   += 1.0;
    imageStore(colCurr, pixel, vec4(Ave, N));
}

One addition before it: a sample that is NaN or infinite is set to 0. A degenerate sample (a zero-area triangle, a pdf that underflowed) would otherwise be averaged into every later frame and never wash out.

When accumulation restarts

The PDF sets clear from myCamera.modified. Here clear is set by ResetRtAccumulation(), which is called for every reason the accumulated image goes stale:

  • The camera — updateCameraBuffer compares the view-projection matrix with last frame’s. That covers the camera’s own movement and its animated moves between presets.
  • The window — the RT buffer is recreated, with undefined contents.
  • A scene switch.
  • The sky — a new bake (time of day, HDRI rotation) or a change in what the sky draws.
  • Raster mode — the ray traced image goes stale while the scanline path draws.
  • Settings — max depth, roulette, explicit sampling, emission scale, the debug view, the metal override.

The GUI shows the sample count. RTRT_DUMP_PFM=path saves the accumulated image as linear float RGB on exit, with the sample count in the log. Converged renders were compared that way, not by screenshot. Tone mapping and 8-bit output are far from linear: an early screenshot comparison read a 1% difference as 4×.


Project 4b: explicit light connection

The emitter list

readModel already had the @@ loop for the list of emitting triangles. It now builds it:

for (uint i=0; i<meshdata.matIndx.size(); i++) {
    const Material& mat = meshdata.materials[meshdata.matIndx[i]];
    if (glm::dot(mat.emission, mat.emission) <= 0.0f) continue;
    // ... the three vertices ...
    Emitter e;
    e.index    = i;
    e.v0 = a.pos;  e.v1 = b.pos;  e.v2 = c.pos;
    e.emission = mat.emission;
    e.uv0 = a.texCoord;  e.uv1 = b.texCoord;  e.uv2 = c.texCoord;
    e.emissionTexture = mat.emissiveTextureId >= 0 ? txtBase + mat.emissiveTextureId : -1;
    lightList.push_back(e);
}
  • Normal and area are not stored. The shader computes both from the vertices, which it needs anyway, so Emitter holds the vertices, emission, and the UVs and texture of a textured emission.
  • Scenes made of several files — a scene can be several readModel calls, so each file’s list is appended to m_lightList and createLightBuffer() uploads it once, at the end of loadScene.
  • San Miguel keeps only its last light, as the framework intended. The framework’s lightList.end()-1 erase is now guarded for an empty list, where it gave an inverted range.
  • Emission scale — the PDF suggests scaling emission by 4 or 5. Here the emission is stored as authored and each scene’s emit_scale in scenes.toml scales it, with a slider for the rest. The framework’s m_pcRay.exposure = 2, which doubled ray traced emission only, is now 1, so emitters are as bright in both paths.
SceneEmitting triangles
Sponza8,276
Bistro65,560

The light buffer

std::vector<Emitter> lights = m_lightList;
if (lights.empty()) lights.push_back(Emitter{});   // a buffer cannot be empty

VkCommandBuffer cmdBuf = createTempCmdBuffer();
initBufferWrapFromData(m_lightBuff, cmdBuf, lights, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
submitTempCmdBuffer(cmdBuf);

initBufferWrapFromData uses a staging buffer rather than the PDF’s vkCmdUpdateBuffer, which is limited to 65,536 bytes: Bistro’s list is 5.2 MB, at 80 bytes per Emitter. A scene with no emitters gets one zeroed placeholder, since Vulkan has no empty buffers. numLights stays 0 there, so the shader never reads it.

BindingTypeStagesHolds
2storage bufferraygenm_lightBuff, the Emitter[]
layout(set=0, binding=2, scalar) buffer Lights_ { Emitter e[]; } lights;  // emitting triangles

The light count comes from pcRay.numLights rather than lights.e.length(), because the placeholder makes the buffer one entry long when there are no lights.

SampleLight, PdfLight, GeometryFactor

One triangle is chosen uniformly, then one point uniformly on it, as the PDF specifies:

int k = min(int(rnd(seed) * float(pcRay.numLights)), pcRay.numLights - 1);
float s  = sqrt(rnd(seed));
float r2 = rnd(seed);
vec3  b  = vec3(1.0 - s, s * (1.0 - r2), s * r2);   // barycentrics of the point

The square-root mapping is uniform over the triangle like the PDF’s fold-over, and uses the same two numbers.

The PDF converts the area pdf with GeometryFactor, which carries both cosines: |(D·Na)(D·Nb)| / (D·D)². Combined with an EvalBrdf that already includes N·L, as 4a defines it, the cosine at the shaded point would count twice. Here PdfLight converts the area density 1/(A·n) to a density over directions using only the light’s cosine:

float PdfLight(float area, float d2, float cosL)
{
    return d2 / (cosL * area * float(pcRay.numLights));
}

The shaded point’s cosine then appears once, in f = N·L · EvalBrdf. Working per direction, not per area, is also what lets the light sample and the BRDF sample be compared, below.

cosL takes the absolute value, so emitters light from both sides, like the PDF’s GeometryFactor.

The shadow ray

bool Visible(vec3 P, vec3 L, float tMax)
{
    payload.hit = true;
    traceRayEXT(topLevelAS,
                gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsSkipClosestHitShaderEXT,
                0xFF, 0, 0, 0, P, 0.001, L, tMax, 0);
    return !payload.hit;
}

The PDF’s flags, except gl_RayFlagsOpaqueEXT. Project 3 made the geometry non-opaque so the any-hit shader could cut out foliage, and noted that shadow rays would need the same treatment or leaves would cast solid shadows. Without the opaque flag, any-hit runs and a cutout texel lets the shadow ray through. tMax is 0.999 · d, so the ray stops just short of the light’s own triangle.

Combining the two with MIS

With 4b, a light can be found two ways: the explicit connection picks it, or a BRDF ray hits it. Counting both in full would count it twice. The PDF halves both. Here each is weighted by Veach’s power heuristic:

float PowerHeuristic(float a, float b)
{
    float a2 = a * a;
    return a2 / max(a2 + b * b, 1e-20);
}
  • The explicit sample is weighted PowerHeuristic(pLight, PdfBrdf(N, L, V, mat)).
  • A BRDF ray that hits a light is weighted PowerHeuristic(brdfPdf, PdfLight(...)), with the light’s pdf computed from the hit triangle (HitTriangleCross) and the distance travelled.
  • A camera ray has no previous hit to have sampled the light from, so it keeps its full weight.

For any direction the two weights sum to 1, so like ½ it is unbiased. Unlike ½, it follows whichever method is better at that point. A small light seen from a rough surface comes almost entirely from the explicit sample, and its reflection in a polished one almost entirely from the BRDF ray. With ½, each half carries the full noise of the worse method.


Result

Click to zoom
| Living room path traced, converging once the camera stops |
Click to zoom
| Living room path traced by day and at night |
Click to zoom
| San Miguel path traced by day and at night |
Click to zoom
| Sponza path traced by day and at night |
Click to zoom
| Bistro by day and at night, lit by its own emitters |

Fireflies in the Classroom

The Classroom scene, lit only by sun and sky through its windows, came out full of fireflies: isolated pixels many times brighter than their neighbours. They were still there after several thousand samples. The path tracer is meant to be the unbiased reference for later work, so the easy fix, clamping bright samples, was not an option until the cause was known.

Each test ran for 60 s at 15:00, compared as linear float images. A firefly counts as a pixel over 4× the median of its 5 × 5 neighbourhood. These measurements were made with the per-pixel roulette described above, before the switch to the PDF’s.

TestFireflies
As authored2,597
Sky dome sampling, sun MIS, KHR_materials_specular, NDF vs VNDF, depth 5 vs 8no real change
Without the sun’s explicit sample (screenshot metric)11,300 → 583
Metals forced to roughness ≥ 0.5603, and the brightest pixel 168 → 4.7
  • The sun’s explicit sample is unbiased. With it on and off (BRDF rays only), the image means are 0.0942 and 0.0931, 1.2% apart.
  • The fireflies are sun caustics off chrome. The chair frames (27,360 triangles at roughness 0.1, a stainless part at 0.0) and window frames are mirror-like metal. A diffuse bounce that happens to land on one, and happens to reflect toward the sun, carries the sun’s full radiance. That path is rare and correct, which is exactly what a firefly is.
  • Why only two scenes sparkle. The Classroom and Living room 2 have 35,568 and 32,548 triangles of metal at roughness 0–0.1. Sponza and San Miguel have none.
  • The reference renderer has them too. NVIDIA’s vk_gltf_renderer shows the same sparkle with its firefly clamp at 0 and its roughness carry-over off. It looks cleaner by default because of those two biased tools.

Sampling the sun better cannot fix this. A diffuse bounce that reaches the sun by way of a mirror is a caustic, and no direct-light strategy finds it. The fixes are bidirectional methods or biased ones. The outcome here is a Metal rough. slider that edits the material instead, and leaves the estimator unbiased.

Click to zoom
| Classroom: sun caustics off chrome, then the same scene with metals at roughness 0.5 |

Beyond the framework

Per-path Russian roulette

The PDF’s roulette draws one depth per frame, so every pixel in a frame runs the same number of bounces, unless its path escapes, hits a light or samples below the surface. That has two effects:

  • No idle threads. A GPU runs threads in groups of 32 and a group takes as long as its longest path. With one depth per frame, no thread waits on another’s long path.
  • Noise shared across the whole frame. On a 1-bounce frame, every pixel loses its indirect light at once. The average still converges, but while it does, indirect light varies frame to frame across the whole image rather than pixel by pixel.

In practice the shared noise is easy to see. Indoors, where most light arrives by bouncing, every depth-1 frame is much darker than the rest, so while the camera moves the image flickers dark. Frame times jump around too, since a depth-1 frame costs one bounce and a capped one costs eight.

So a Roulette combo in the GUI offers the common alternative, which ends each path by its own throughput. The PDF’s version is the default.

float p = perPath ? pdf : pdf * pcRay.rr;
...
if (perPath && i >= 2) {
    float survive = clamp(max(W.r, max(W.g, W.b)), 0.05, 0.95);
    if (rnd(seed) > survive) break;
    W /= survive;
}

Per path, depth is set to the cap and the shader decides when each path ends. It decides per pixel, never cuts a path before bounce 3, and keeps paths that are still carrying light. Every frame then has its indirect light and roughly the same cost, and the noise in neighbouring pixels is independent, which a denoiser handles far better than noise shared by the whole frame. The cost is threads finishing at different bounces. pbrt (Pharr, Jakob and Humphreys, 4th ed.) uses the same rule.

Click to zoom
| Moving through the living room with per-frame roulette, then per-path |

The sun, moon and sky in the path tracer

Project 3 lit ray traced hits with the sky the raster way: no visibility, so interiors were lit as if roofless. The path tracer treats the sky as three more lights, each sampled explicitly and each shared with BRDF sampling by MIS:

LightExplicit samplepdf
Sun, moonuniform in their angular cones1 / (2π(1 − cos r))
Sky domea texel from a luminance × solid-angle CDF over the environment cube, then a point in itthe texel’s probability over its solid angle
  • One light, two ways to find it. A bounce ray that escapes sees each disc as the same uniform cone the explicit sample draws from. If it saw the limb-darkened disc the camera sees, the two strategies would be estimating different lights.
  • Precision. The sun’s radius is 0.0046 rad, where 1 − cos r in single precision is off by about half a percent. It is computed as 2 sin²(r/2).
  • The dome’s CDF is rebuilt with every sky bake, uploaded beside the cube (m_scDesc binding 5). Each texel is weighted by its solid angle, since a cube’s corner texels cover less of the sphere than its centre ones. A small floor keeps every texel samplable, because bilinear lookups spread light into texels that are black on their own.

Emissive textures

glTF emission is emissiveFactor × emissiveTexture. The loader read only the factor. Bistro’s lights all have factor (1, 1, 1) and carry their colour and shape in the texture, so every string light glowed white and whole lamp housings emitted. Both paths now multiply by the texel. The explicit sample reads the texture at the point it chose, which is why Emitter carries UVs, so a sampled light and a hit light see the same emission.

Emission of 0.02 or less in its brightest channel is treated as a surface, not a light: a dimly glowing fixture, not a lamp.

KHR_materials_specular and ior

The dielectric part of the BRDF reads glTF’s specular factor, colour and texture, and the index of refraction. Several scenes depend on it: the Classroom and Living room 2 set specularFactor to 0, and San Miguel uses it throughout. It feeds BrdfSpecularChance too, so the lobe choice follows the material.

Metal roughness override

A Metal rough. slider raises every metal’s roughness to at least its value, scaled by metallic, on every ray. It changes the scene, not the estimator: the image is an unbiased render of a scene whose metals are rougher. 0, the default, renders the scene as authored.

HDRI lighting

An equirectangular .hdr can replace the physical sky. It is resampled into a 512² cube with the same mips, irradiance and sampling CDF, so neither the raster path nor the path tracer needed changes. A rotation slider turns it about the vertical. The cube is 32-bit float where the GPU can filter it: an HDRI’s sun reaches about 10⁵, past the 65,504 a half float can hold, and the overflowed texels had silently dropped the sun.

Auto exposure from the frame

Project 2’s auto exposure metered the sky, which is wrong indoors. It now meters the rendered frame: a 256-bin log-luminance histogram on the GPU, its 10–90% trimmed mean brought to 0.12 and adapted over time. While the ray tracer has fewer than 16 samples it holds, so the noise of the first frames does not pump the exposure.


Cleanup and resize

ResourceCreatedDestroyed
m_lightBuffcreateLightBuffer(), at the end of loadScenedestroyModel(), at shutdown and on a scene switch
m_envCdfBuffwith the environment cube; recreated when a bake’s face size differswith the cube

Neither depends on the window, so recreateSizedVulkanResources is unchanged from project 3. m_rtDesc, which it already recreates, writes binding 2 from m_lightBuff again.

On a scene switch, destroyModel() destroys the old light buffer with the rest of the model, and the new scene’s loadScene builds it before createRtDescriptorSet() runs.


Validation-layer verification

VK_LAYER_KHRONOS_validation stays on unconditionally. Sponza ran with no validation-layer messages through startup, path tracing to 240 samples per pixel, and shutdown.

[19:47:55.664] info  ---- Scene: Sponza -----------------------------------------------------
[19:47:58.230] warni Skipped 8140 triangles with alphaMode BLEND (dirt_decal); there is no blended pass
[19:48:13.482] info    3 textures decoded and uploaded in 647 ms
[19:48:13.498] info  Scene totals: 3 objects, 3 instances, 82 textures
[19:48:13.502] info  Light list: 8276 emitting triangles
[19:48:14.291] info  Acceleration structures built: 3 BLAS, TLAS of 3 instances
[19:48:14.851] info  ---- Draw loop ---------------------------------------------------------
[19:48:19.594] info  ---- Cleanup -----------------------------------------------------------

The warning is the loader’s own: Sponza’s dirt decal is alpha-blended and there is no blended pass.


Appendix A — where each @@ comment is addressed

@@ requestFileAnswered in
Pathtracing: remove the 3 temporary light valuesshared_structs.h, vkapp_raytracing.cppThe push constant
Pathtracing: write SampleBrdf, PdfBrdf, PdfLightraytrace.rgenThe BRDF; SampleLight, PdfLight, GeometryFactor
Pathtracing: initialize the pixel’s seedraytrace.rgenRandom numbers
Pathtracing: the Monte-Carlo loopraytrace.rgenThe Monte-Carlo loop
Pathtracing: break on a missraytrace.rgenThe Monte-Carlo loop
Pathtracing: accumulate a light’s emission times Wraytrace.rgenThe Monte-Carlo loop
Explicit: the same with a factor of ½raytrace.rgenCombining the two with MIS
Explicit light connection goes hereraytrace.rgenProject 4b
Pathtracing: accumulate f/p into Wraytrace.rgenThe Monte-Carlo loop
Pathtracing: accumulate C into the output pixelraytrace.rgenAccumulation
Build the list of lightsvkapp_loadModel.cppThe emitter list
4b: send the light list to the shadervkapp_loadModel.cppThe light buffer
frameSeed, rr, depth, clear4a-path-tracing.pdfThe push constant; Accumulation
m_lightBuff, binding 2, Emitter[] in raygen4b-explicit.pdfThe light buffer

Appendix B — destroy / resize registry

Project 4’s resources, added to project 3’s registry.

ResourceDestroyedResizedRebuilt on scene switch
m_lightBuffyes—yes
m_envCdfBuffyes—with the sky bake