Three glass spheres and two aimed mirrors casting caustics through fog

Light transport research · RadianceLab

The transport zoo

Eleven ways to estimate the same integral live in this renderer, and one of them is real-time bidirectional path tracing with every (s,t) family inside the megakernel — one of very few open real-time codebases that carry it. The photon-primitive family is another, and the sharp question about it is not whether it is the best but which axis it generalises, and which axis it does not touch at all.

The rest of this site is mostly about mode 9. This page is about the other ten, read from the source rather than from the mode names — because four of them are not quite what their name says, and the light tracer exists in two places, one of which is switched off every frame so the two do not both run.

Transport modes in the enum
10
plus ReSTIR DI and ReSTIR PT as post-passes
BDPT (s,t) cells implemented
all six
including interior connections, which a stale source comment denied until 2026-09-04
MIS methods offered
2→1
method A was specified, never landed; it resolves to flat 1/N, and since 2026-09-04 the panel says so
Integrators with a volume path
3 of 10
GPU tracer, UPBP, photon primitives

01 — What is actually in the box

Ten modes, one enum, seven enable flags

Mode selection is a single integer, m_ui.integratorMode, dispatched at EngineRender.cpp:2005-2265. Modes 0-2 always run the GPU path tracer. Modes 3-9 each carry a second boolean — vcmEnabled, mltEnabled, pssEnabled, upbpEnabled, pgEnabled, meEnabled, ppEnabled — and every one of them is declared false (UIManager.h:379-478). What ticks them is a separate control one panel above: Auto-enable on switch, which defaults on, so completing a mode switch turns that mode's integrator on for you. An earlier draft of this page said selecting mode 4 without ticking its box silently falls back to the GPU tracer. The declaration is right and the conclusion was wrong: at defaults you get MLT. Turn auto-enable off and the fallback is real — and it is not quite silent either, because the Integrators HUD writes "(Tracer for viewing)" after the mode's name — in the HUD's own one-based numbering, which calls this mode 5. That HUD also defaults off, so the honest sentence is that the mode number is a request, and whether anything honours it depends on a checkbox in a different panel.

That is the first thing to know about this zoo: the mode number is a request, not a state. The second is that the seven CPU integrators share exactly three things — the scene, the camera, and intersectScene. They do not share a film, an RNG, a light-sampling routine, a BSDF evaluator or a tone-mapper. Each one carries its own copy, written at a different time, and the copies have drifted.

#ModeWhat it actually implementsVolumeState
0RasteriserGL forward pass; LTC area lights (Heitz 2016), 64×64 tables generated at init—works
1GPU path tracerMegakernel with eight light strategies, from naive BSDF sampling to BDPT with interior connectionsyesthe reference
2Photon mapperCPU emission + KD-tree build, GPU kNN gather. Global and caustic maps. Surface onlynopartial
3VCMConnections + merging, but the weights are a photon-density heuristic, not the VCM MISnoapproximate
4MLTPrimary-sample-space Metropolis over a fixed-length random vectornonamed wrong
5PSSMLTKelemen-style PSS-MLT with lazy per-coordinate mutation timestampsnothe real one
6UPBPConnections + point merging in volume and on surfaces; delta tracking; heterogeneous media. Beams built and never queriedyesbeams stubbed
7Path guidingUniform 3D grid of 16×16 directional histograms, one-sample MIS against the cosine lobenobin count now pinned
8Manifold explorationPSS-MLT with a 0.1× perturbation on randoms that fed a specular bounce. No Newton solve existsnomislabelled
9Photon primitives31 strategy slots — 30 estimators and a rasterised gather; 21 analytic swept manifolds, six kernel estimators, three baselinesmedium onlythe research
—ReSTIR DIReservoir RIS + temporal + spatial reuse over the tracer's G-buffer; replaces the direct termnopost-pass
—ReSTIR PTScreen-space reservoir reuse over accumulated radiance. Its own header calls it an approximationnoexperimental
PT · strategies 3–4Light tracerWorks, and ships in the megakernel. Two entries in the light-strategy picker, “Light Tracer (Naive)” and “Light Tracer (NEE)”, running traceLightPath() with a camera splat. Its signature is visible and correct: a mirror renders black, because a light path cannot be connected to the camera through a delta lobe. The standalone LightTracer class is a superseded first implementation, deliberately disabled so the two do not both run.seen

The last row is worth stating plainly rather than burying. LightTracer is constructed, init()-ed at Engine.cpp:253, and then hard-disabled at the top of every frame:

m_lightTracer.enabled = false;
EngineRender.cpp:2087, unconditional, with the comment "handled inline in pathTrace.comp's traceLightPath() … disabled to avoid double light tracing". LightTracer::trace() has no call site anywhere in the tree. The shaders it owns — lightTrace.comp and lightTraceCombine.comp — are never dispatched. The feature is real and reachable — two entries in the light-strategy picker — and it lives in the megakernel; this class is the superseded first implementation. The distinction matters: an audit that greps for call sites and stops there reads “disabled every frame” and concludes the feature is gone, when the comment two lines above the disable says exactly why, and names a feature a user can select today.

The same applies one level down. photonGather.comp (357 lines) is loaded by Engine::applyPhotonGather, which has no caller; the gather that actually runs is photonGatherV2.comp via applyPhotonGatherV2 at EngineRender.cpp:2652, gated on pmV2Mode, which defaults true. Two full gather kernels, one live.

02 — The BDPT pyramid

A grid you can click a cell out of and watch the image lose exactly one kind of light

Bidirectional path tracing is not one estimator. It is a family of them, indexed by how many vertices come from the light and how many from the camera, all summed with MIS weights. That family is usually invisible: you get the combined image and take the decomposition on faith. Here it is a draggable panel with a clickable cell per technique, sitting over a real-time BDPT that implements all six (s,t) families — one of very few open real-time codebases that do — and two clicks reproduce Veach's figure inside one running render. It is the explanation of multiple importance sampling that no diagram can give: not a picture of the decomposition, the decomposition itself, live.

Light strategy 5 turns it on. The overlay lives at EngineRenderViz.cpp:1859 drawBDPTPyramidOverlay; the filter it writes, bdptFilterS / bdptFilterT, reaches the shader as uBDPTFilterS/T and is tested by two short functions in shaders/modules/pt_bdpt.glsl — bdptPass(s,t) at :53 for ordinary contributions and bdptSplatPass(s,t,px) at :68 for light-tracer splats. Between them they are called at 22 sites in pathTrace.comp — thirteen and nine — and those 22 are every place in the megakernel where a contribution is added: emitter hits on the eye path, both NEE branches, the light-to-camera splats, the light-to-camera connects, and the interior-connection loop. Isolating a technique is therefore exact rather than approximate; the other 21 sites return early.

l — light vertices (columns) e — eye vertices (rows) l=0 1 2 3 4 5 e=0 1 2 3 4 5 circled: l+e = 3 — BSDF sampling (l=0,e=3) and NEE (l=1,e=2) e=0 pure LT — the photon must physically enter the aperture. Black unless Pinhole > 0. l=1,e=1 LE — light point straight to the film e=1 LT + camera connect — where caustics arrive, and the only route through glass l=0 pure PT — the eye path hits an emitter l=1 NEE — the shadow-ray workhorse l≥2,e≥2 interior connection — implemented, diffuse endpoints only fewer than 2 vertices — no path exists Each anti-diagonal l+e = V is one path length. MIS mixes across a diagonal, never down it. The shader calls these s and t; the panel labels them l and e. Same numbers, pbrt's convention.
The grid, in the panel's own orientationColumns are l, rows are e — bdptTileS = px.x / tileW, bdptTileT = px.y / tileH at pathTrace.comp:3639-3640, and the overlay draws the same way, "l:" across the top at EngineRenderViz.cpp:2084 and "e0…e5" down the side at :2107. Every cell on one anti-diagonal builds a path of the same length by a different route, so those are the techniques MIS has to weight against each other. The e=1 row is where caustics arrive; the l=0 column is what an ordinary path tracer finds on its own.

Three ways to look at one grid

The pyramid has a tile mode with three settings, chosen by the [T] button on its own title bar. Off gives one full-screen image filtered to a single cell. Tiled divides the film into an (l,e) grid and lets each cell accept only its own technique, so the scene is cut into a technique checkerboard. Shrunken remaps the camera UV per tile (pathTrace.comp:3649-3657) so every tile is the whole scene rendered by one technique — a contact sheet of the decomposition. Light-tracer splats are remapped into the matching tile by eight lines inside bdptSplatPass (pt_bdpt.glsl:75-82), which is what stops a caustic splat from landing in the wrong cell.

Two affordances make the panel teach rather than merely filter. Hovering a cell highlights every cell on the same anti-diagonal, in the panel and in the tiled viewport at once (EngineRenderViz.cpp:2153 and :2314) — the UI draws "MIS mixes across a diagonal" for you instead of asserting it. And each cell carries prose written in the renderer's own voice, assembled at :1918-1975: "Useless for small/occluded lights", "Nearly useless unless aperture is huge", "Excellent for caustics (L->S*->D->E)".

One correction to this project's own shooting notes. docs/CAPTURE-SHOOTING-LIST.md:564 calls shrunken mode "expensive — it renders the scene once per cell". It does not. Every pixel still traces its full eye subpath and its light subpaths and then discards every contribution that does not match its tile, so a shrunken frame costs about what an ordinary BDPT frame costs. What you lose is samples per technique — one forty-ninth of the film at the default 7×7 grid. The constraint is convergence time, not frame rate, which is why the recipes below ask for a lot of passes and not for patience with the viewport.

Screenshot — GPU tracer (key `2`) · preset 40 "MIS: Veach Test" · DO NOT move the camera from (0,4.5,-12)->(0,3,1.5) — the plate normals are computed from it · Light Strategy = "BDPT (Bidirectional)" · MIS = "B balance" · BDPT Pyramid ON · Grid = 4 · Tile Mode = Shrunken · [M] = Raw · clean view G ON (it does not hide the pyramid) · accumulate ~1500 passes · frame so the 5x5 tile grid and the floating pyramid panel are both in shot
Shrunken mode — the whole decomposition in one frameEach tile is the same scene rendered by one (l,e) technique alone, columns l, rows e. The first column is what a unidirectional path tracer finds; the second row is what a light tracer finds; the green block is the transport that needs both ends. The top row is black on this rig, and correctly so: see the note on the aperture below.

Two cells, one anti-diagonal, and the reason MIS exists

Preset 40 is a purpose-built Veach rig — four metal plates at roughness .02 / .07 / .18 / .40 in a staircase, five emissive spheres from radius .90 down to .04 at matched power, each plate's normal computed as the half-vector between the camera position and the light row. The direct reflection off a plate is a three-vertex path, and exactly two cells of the pyramid can build it: (l=0, e=3), where the eye path bounces off the plate and happens to hit an emitter, which is BSDF sampling; and (l=1, e=2), where the eye path stops on the plate and sends a shadow ray, which is next-event estimation. Same anti-diagonal, l+e = 3, circled in the figure above.

The complementarity is designed into the rig and it is visible without measuring anything. Cell (0,3) is clean on the sharp plate under the big light and catastrophic on the rough plate under the tiny one, because BSDF sampling has to hit a small emitter by luck. Cell (1,2) is noisy in exactly the opposite places, because a shadow ray to a large light is a poor match for a nearly specular lobe. Neither is usable alone. Their weighted sum is clean across all four plates. That is the whole of multiple importance sampling on one screen, and here you produce it by clicking two cells of one running render — no change of integrator, no change of scene, and accumulation reset on each click so the noise you are looking at is that technique's own.

video · Two cells make the Veach figure
Preset 40, camera locked at (0,4.5,-12)->(0,3,1.5), GPU tracer, Light Strategy = BDPT, MIS = "B balance", pyramid ON, Grid 4, Tile Mode OFF, clean view G ON. Start unfiltered and let it converge ~20 s. Click cell (l=0, e=3) — first column, fourth row — and hold 8 s while it re-accumulates from zero. Click (l=1, e=2) and hold 8 s. Click it again to clear the filter and hold 10 s. Do not touch the camera or any slider.
Watch for Cell (0,3) is BSDF sampling: clean on the sharp plate under the big light, a mess on the rough plate under the small one. Cell (1,2) is next-event estimation and is noisy in exactly the opposite places. Neither is usable; the unfiltered frame is clean across all four plates. Each click resets accumulation, so what you are watching is each technique converge on its own terms.

And the half almost no renderer shows: the weights themselves

The decomposition above answers "why two techniques". It does not answer "why weight them rather than average them", and that is the part the pyramid can actually display. The [M] button cycles the display through raw → weighted → weight-only (EngineRenderViz.cpp:2004-2013). In a per-strategy view, applyBDPTMIS returns the contribution, or the contribution times its weight, or vec3(w) — the weight, rendered as an image (pt_bdpt.glsl:119-123). Method B honours the same three modes through applyBDPTMISW (:130-138), whose own comment reads "weight only (inspect the weight!)".

So the figure is a pair, one setting apart. Under Flat 1/N the weight image is a flat grey per anti-diagonal, because countActiveStrategies depends only on s+t and the uniforms — the structure you see is the technique's support, not its weighting. Under B balance the same image acquires structure: bright where this technique is the one that sampled the path well, dark where another technique wins. One is a picture of a constant; the other is a picture of a decision.

One qualification on reading it. Weight-only mode accumulates w at every site that actually fired, so what lands on the film is the weight masked by where the technique contributes at all, not a weight field defined everywhere. A dark region can mean "this technique is down-weighted here" or "this technique never fires here"; the raw view next to it disambiguates.

Screenshot — Same frame as the shrunken shot above, camera unmoved · Tile Mode = Shrunken · pyramid [M] = Weight only · shoot TWICE: once with MIS = "Flat 1/N", once with MIS = "B balance" · ~600 passes each · deliver as a left/right pair
Flat versus balance, as weight imagesLeft: 1/N, one grey level per anti-diagonal, masked by each technique's support. Right: the balance heuristic, where every tile shows which paths that technique is trusted with. This is the same design decision the photon-primitive side of this renderer measures numerically — flat versus balance over a frozen batch of swept primitives drops the film maximum from 4562 to 368.7, a 12.4× knockdown from the heuristic alone (docs/correlated/README.md:600). Two different families, one decision, and it is worth the same on both.

The correction: the code comment was wrong about its own capability, and has been fixed

Until 2026-09-04 the strategy map at pathTrace.comp:2471-2484 read, for the l≥2, e≥2 block, "NOT YET IMPLEMENTED. Requires storing light subpath vertices and connecting each to eye subpath vertices via shadow ray." That was true when it was written and had been false for a long time. Interior connections are implemented in full: both subpaths are stored (g_lightVerts and g_eyeVerts, pt_sky.glsl:382 and :384), the double loop over them runs from pathTrace.comp:3923, and each pair gets a shadow ray, a geometry term, a BRDF or phase function at both endpoints, and an MIS weight. The stale comment sat about 1,400 lines above the loop that contradicted it. It now reads "IMPLEMENTED", points at the loop, and records what it used to say; the strategy-5 tooltip that repeated the old claim was corrected the same day.

This matters beyond tidiness, because that block is the cell that separates BDPT proper from a light tracer bolted onto a path tracer. The renderer has it, and now the file says so.

The MIS selector offers a method that does not exist — and now says so

The Render panel exposes five MIS settings — Flat 1/N, A balance, A power, B balance, B power. Until 2026-09-04 its tooltip read "A = dVC/dVCM recurrence; B = explicit path-pdf sum. A and B must match (the correctness oracle)"; it now reads "A (dVC/dVCM recurrence) is NOT IMPLEMENTED … the A-vs-B cross-check they were meant to provide CANNOT BE RUN. Use B", and the two A entries carry [inert] in the combo itself (UIManagerRender.cpp:1039-1055). The design that specified them is docs/2026-06-07-bdpt-real-mis-surface-design.md, and its own progress log ends with "then (c) method A gated on A==B". Step (c) never happened.

What ships: method B is real. It assembles the full connection path into gBpos/gBnrm/gBmat/gBspec and runs the pbrt-style sumRi walk with a consistent light-origin pdf at every site, with beta = 2 selecting the power heuristic. It is compiled only when it is the selected path, behind FEAT_BDPT_MIS_B, because it does not fit alongside participating media on the measured GPU. Method A is one function:

float bdptMISWeight(int s, int t) {
  int N = countActiveStrategies(s + t);
  // TODO: balance/power heuristic weights require storing PDFs at each vertex
  // For now, all schemes use 1/N as the baseline
  return 1.0 / float(N);
}
shaders/modules/pt_bdpt.glsl:101-106, quoted whole. The scheme argument is not read. So "A balance" and "A power" both produce a frame byte-identical to "Flat 1/N", and the oracle the old tooltip described cannot be run. The dVCM / dVC fields A would have used exist on the vertex structs at pt_sky.glsl:357-358, 376-377, carrying the comment "0 until Task 4", and nothing in the tree ever assigns them. Method A is also the default (UIManager.h:329), so out of the box the pyramid has one MIS scheme, not five — one click to B gives it the real one.

The pyramid's own [S] button is stale in the opposite direction: its tooltip still labels the balance and power schemes "(TODO)" (EngineRenderViz.cpp:2033), which was true of method A and has been false of method B since it landed and was validated as unbiased and faster-converging. The Render panel's tooltip was corrected on 2026-09-04; this one was not, so the two now disagree about the same control. The precise statement is that the scheme control is live under method B and inert under method A.

Three more places the panel says more than the code does

  • The pure-LT row is dead at defaults, and the overlay does not admit it. pinholeSize defaults to 0.0f (UIManager.h:222), so uPinholeRadius is zero and aperFeasible is false (pt_bdpt.glsl:28). The shader handles this correctly — countActiveStrategies drops the e=0 techniques from N. The overlay does not: it paints the l≥2, e=0 cells with active = hasLT and no aperture test at all (EngineRenderViz.cpp:2142, with hasLT = (ls >= 3 && ls <= 5) at :1892). So those cells are coloured active, their hover text says "nearly useless unless aperture is huge", and they render pure black. The panel contradicts itself in two places and the image settles it. To make the row live, raise Pinhole Size above zero.
  • The grid can draw cells that cannot fire. MAX_EYE_VERTS and MAX_LIGHT_VERTS are both 8 (pt_sky.glsl:360, 379), so interior connections cap at l=8, e=9. The overlay's out-of-range shading is driven only by maxVerts = maxBounces + 2 (EngineRenderViz.cpp:1869), and Max Bounces goes to 32. Above maxBounces = 8 the panel marks cells in-range that no site in the shader will ever reach. Harmless at the default of 6, and the sort of thing that is only a problem the first time someone believes the grid.
  • "T = cycle mode" names a key that does not exist. The panel's hint line reads "Click cell to isolate | T=cycle mode | drag title" (EngineRenderViz.cpp:2261). There is no GLFW_KEY_T handler anywhere in src/; T is the on-panel button at :2036-2052. A one-word documentation bug, listed here because this page's rule is that a claim gets checked or it does not get made.

The last note on this panel is one the renderer already makes about itself, and it now has a cause. The Render panel's simplified-BDPT tooltip admits the image "may be slightly bright due to path overlap at diffuse vertices (no inter-technique MIS yet)" (UIManagerRender.cpp:914). The mechanism is flat 1/N: countActiveStrategies counts the techniques that could exist at that path length, not the ones that can actually sample the given path — a specular vertex makes NEE unable to — so the weights along a diagonal do not sum to one. The design doc names the same thing, "the strategy-count fudge can leave it slightly biased". Method B fixes it; on volume scenes method B is masked off (ComputePathTracer.cpp:88-93) and the flat-1/N bias comes back, which is why the volumetric bidirectional showcase declares itself BIASED — not a converged estimator in its own checkbox tooltip.

Three controls behind everything above, graded

Method B can vanish in two independent ways, and only one of them is the ladder. The first makes it statically unavailable: the feature mask never requests FEAT_BDPT_MIS_B when FEAT_VOLUMES is set, so on any scene with fog, selecting B renders flat 1/N before a runtime fallback could occur. The second is the degrade ladder, which drops B on a compile failure and keeps bidirectional transport. The panel now admits the first in the MIS tooltip, with the file and line in the tooltip text — one of the few places in this program where a control cites its own source at the user.

ControlWhat it doesCatalogue grade
Pyramid Grid sliderCaps the grid at 2 … maxBounces + 2, clamps itself down when Max Bounces falls, and resets to the full grid every time the pyramid is switched on. It is also the tile count: the shrunken contact sheet is (grid + 1)² tiles, which is why the recipe above sets Grid to 4 and expects 5 × 5never viewed
MIS method B — statically unavailable on a volume sceneNot a fallback but an omission: the mask function refuses the combination, so B is not compiled in at all and the selector silently gives you flat 1/Nscene-dependent
Feature-mask degrade ladderThe other route to the same place. Full mask, then drop FEAT_BDPT_MIS_B, then the minimal kernel, then keep the previously compiled program — each rung logged, each rung resetting accumulation. The megakernel page walks it rung by rungseen

03 — The density-estimation branch

Photon mapper, VCM and UPBP: three implementations, one shared shortcut

Connection-based estimators fail when the connecting segment has zero density — through glass, at a specular vertex. Density estimation is the standard answer: store what the light path did, and reuse a stored vertex when a camera vertex lands near it. All three of this renderer's density estimators work, and all three take the same shortcut in the same place.

Mode 2 — the photon mapper

Genuinely split across the two processors, and that split is the interesting part. Photons are emitted and bounced on the CPU into two maps, global and caustic (a photon is filed as caustic when a specular chain precedes its first diffuse hit, PhotonMapper.cpp:337-347). Each map builds a KD-tree, which is packed and uploaded as three SSBOs. Then a compute shader traces the camera rays and does a stack-based KD-tree radius search per pixel, with an Epanechnikov kernel and a per-photon radius so progressive batches with different radii can coexist in one tree.

Progressive photon mapping is here too, with both the PPM radius formula and a plain multiplier, plus an outer loop that restarts the sequence and averages across restarts. It is a real implementation. Three things it is not:

  • It is surface-only. opts.skipVolumes = true at PhotonMapper.cpp:56, with the comment "Photon mapper only traces surface geometry". There is a m_volumeMap; nothing anywhere in the tree pushes a photon into it. Its count is structurally zero.
  • The volume-method control has no implementation under it. The UI offers "points / beams / primitives". The beams branch is an empty block: if (config.enableVolume && config.volumeMethod == 1) { /* comment only */ } at PhotonMapper.cpp:378-382. Not a partial implementation — a comment and two braces.
  • "Final Gather" is a menu item, not a mode. Both gather shaders branch on uGatherMode == 0 and fall through everything else to the kNN path, so option 2 has always been option 1. Since 2026-09-04 the entry is named for what it does — "Final Gather (not built - renders kNN)" — and its tooltip says NOT IMPLEMENTED (UIManagerRender.cpp:5396-5402).

And the result is not combined with the path tracer by MIS. It is mix(tracerResult, photonIrradiance, uOverlayBlend) — a slider. That makes mode 2 an excellent teaching instrument and not an estimator you would quote a number from.

Its panel is the laboratory, and it has one control with two different matchers behind it

The photon tab offers two progressive shrink formulas, side by side, which is the useful shape for a teaching instrument: Hachisuka's R ← R·√((N + αM)/(N + M)), and a plain R ← R·α the tooltip calls fast and dramatic. The precise thing to say about the first is where its N and M come from. In the published formulation they are per-measurement-point photon counts and every pixel shrinks at its own rate; here they are the global map's photon totals before and after the batch, applied to one scene-wide radius, so the whole image shrinks in lockstep. It is PPM's schedule without PPM's per-pixel statistics — and the PPM branch also skips the shrink entirely on any batch that added no photons, where the multiplier would have shrunk anyway.

Beside it, one small piece of interface design worth more than its size: Max Iter sits behind a Confirm button. The slider writes a pending value and the engine reads a separate confirmed one, so dragging across the range cannot launch fifty photon passes on the way to the number you wanted. Two variables where one would do, because one would have made the control dangerous.

The path-expression box is the one to be careful about. It is read by two different matchers that do not implement the same language. Feeding the gather, it is compiled to five integers — a mode, plus a prefix and suffix as bit-masks of "specular here or not" with their lengths — and everything that is not L, S, D or the first * is dropped without comment, with any pattern not beginning in L discarded entirely. Feeding the on-screen photon-path overlay, the same string is handed to std::regex and matched case-insensitively against a built path notation, so it really is a regular expression there. The field is named pmFilterRegex, and in exactly one of its two readers that name is true.

Photon-mapper panelWhat it doesCatalogue grade
Progressive PPM, two shrink formulasThe PPM radius schedule and a direct multiplier, on one shared scene-wide radius. Each batch records the radius it was emitted at, so batches with different radii coexist inside one KD-treeseen
Max Iter behind a Confirm buttonPending value and effective value are separate variables; only the second reaches the engine's pass loop. The panel also does an optical calculation on the pair: it solves R₀·αN/2 = D·pixelAngle for the pass at which the gather disk stops covering a pixel, and prints "itr > N not recommended (disk < pixel at 2m)" — from a hard-coded 60° field, 720 lines and a two-metre subjectseen
PM path-expression filterFree text over L S D *, compiled to five integers for the gather and to a real std::regex for the overlayseen
PM storage: photons vs pathsNot a storage format but a choice of what the overlay draws: photons are dots coloured by which progressive batch emitted them, paths are polylines from the light through every stored vertex — and only the path view is filtered by the expression aboveseen
Accumulate when staticImage-space averaging over its own pass, with its own frame counter shown in the panel and its own exposure, reset the moment the camera moves. Defaults on. It is the photon mapper borrowing the path tracer's one good habitseen

And the other path-expression box, one panel away

The path tracer has its own filter over Heckbert notation, and it compresses even harder: up to four include-or-exclude patterns, each compiled to an ivec4 of the bounce type nearest the light, the bounce type nearest the eye, an exact length and a minimum length. Interiors are never inspected, and the panel says so in its own tooltip — LDSDE and LDDDE are the same filter. What the tooltip does not say is that the wildcard's position is not read either: * is a single boolean that turns "exact length" into "minimum length", so LD*SE, L*DSE and LDS*E all compile to the same four numbers. Two path-expression languages in one renderer, three matchers between them, and no two of the three agree on what the syntax means.

Tracer path filterWhat it doesCatalogue grade
Path-type regex filterFour patterns over L D S V E *, include or exclude, tested at six sites — all of them on the eye path, so the light subpath's own connections are not filtered by it. Turning it on is one of the five conditions that raise the pulsing orange "non-standard state" banner with its one-click reset, because a forgotten filter looks exactly like a broken renderernever viewed
— the language is weaker than the box impliesFour integers per pattern; interior vertices and the wildcard's position both discarded; characters outside the vocabulary dropped in silenceseen

Mode 3 — VCM, and what its weights actually are

VCM's whole contribution over its parts is the MIS weight: a connection and a merge that could both have produced a path must be weighted by their relative densities, which Georgiev's recurrence tracks with three running quantities per vertex, dVCM, dVC, dVM. This implementation computes all three, stores them on every light vertex, and never reads them again. The weight that is applied is this:

float density = (float)nearbyIdx.size() / (float)max(config.mergeK, 1);
w = 1.0f - density;    // connection
w = clamp(w, 0.05f, 0.95f);
…
color = directContrib + (connectionContrib + mergeContrib) * 0.5f;
VCMIntegrator.cpp:551-563 and :675. The merge gets density, the connection gets 1 − density, where density is the fraction of the kNN budget that the search filled — a proxy for "is the photon map trustworthy here", not a pdf ratio. Then both are halved against the BSDF continuation with a flat 0.5, described in the source as an approximation "without full BDPT MIS across all (s,t) strategies". Two helper functions, misWeight (balance) and misWeightPower (power), are defined at :230 and :235 and have no call site; config.misMode, the UI's balance/power selector, is never read — and since 2026-09-04 the selector is greyed and labelled [inert] in the panel (UIManagerRender.cpp:5948).

The BSDF at both ends of a connection is also approximated as Lambertian regardless of material (mat.albedo / π, :542-546). So mode 3 is a working connection-plus-merge integrator with a heuristic blend, and calling it VCM overstates it. It is also, like MLT and path guiding, surface-only: skipVolumes = true at VCMIntegrator.cpp:47.

"Fix Bias" is the one row where this page and the project's own catalogue disagree, and the source settles it. The catalogue grades the VCM fixBias checkbox seen and describes it as toggling the substitute complementary weights. It reaches VCMConfig::fixBias from the UI, and then the string fixBias does not occur once in VCMIntegrator.cpp. The field's own declaration says why — "always enabled; kept for UI compat" — so the weights described above are what you get in both positions and the box changes nothing. That matters more than an ordinary dead knob, because the greyed MIS-mode selector directly beside it carries a tooltip pointing at this control as the live one: "'Fix Bias' below is the MIS control that does change the image." One inert control is now vouched for by another.

And the weights it claims to fix are not complementary in the first place, for two reasons that compound. The clamp to [0.05, 0.95] breaks the sum wherever the density is extreme — which is exactly where a weight matters. And the two halves are not computed from the same count: the connection uses every neighbour the kNN search returned, while the merge uses only those that survived a bounce-0 rejection and a normal-compatibility test, so the merge's density is the smaller number and the pair sums to less than one whenever a single photon is rejected. The comment above each says "complementary". Grade carried from the catalogue; the reading is this page's.

Mode 6 — UPBP, which is the more complete of the two

Counter to the naming, the volumetric integrator in this renderer is UPBP, not VCM. It intersects volume shells when media are on (:397), delta-tracks through heterogeneous density, scatters in the medium, stores volume vertices with a null normal so the estimator falls back to isotropic, and does point merging at volume scatter events with a doubled search radius. That is P-P3D — the volumetric photon-point estimator — and it works.

What is missing is the part the acronym is named for. UPBP is unifying points, beams and paths; the beams are built and never used:

  • Beam segments are constructed and pushed into m_allBeams at UPBPIntegrator.cpp:537 and :581, and packed into a GPU SSBO at :1163 for the viewport overlay.
  • enableBeamMerge — "Beam-beam estimation", UPBPIntegrator.h:39 — is declared and read nowhere in the .cpp. There is no BB1D, no PB2D, no beam query of any kind in the radiance estimate. Since 2026-09-04 the header says so flag by flag in an audit comment above the declarations, and the panel's Beam-Beam Merge toggle is greyed [inert].
  • m_beamEvents, the visualisation record, is cleared twice and never filled.
  • enableBeams does do something, but not what it says: it enables a global homogeneous fallback medium when no volume entity is found (:506, :723), and it gates the analytic transmittance on connections at :974.

UPBP also inherits VCM's 1 − density / density weights and the flat 0.5 (:996), because the surface half was transcribed from it. And it is the one CPU integrator with headless output: pp.dumpCpuAt writes its film in the same PGR1 format the PP and GPU dumps use, so it is directly subtractable from them.

light y axis medium (fog) a = ∂x/∂t = ω b = ⊥ offset, |b| ≤ r K = 1/(2r·|ω×d|) glass ball (ior 1.5) x1 x2 x eye a b θ θ, φ traced t 2r 18 · tracer beam · traced chain + 1-D kernel (the reference for 20)
The beam UPBP does not haveThis is the camera-ray-versus-beam kernel, drawn from the photon-primitive family's strategy 18, which does implement it: a one-dimensional kernel of radius r around a traced photon segment, with K = 1/(2r|ω×d|). It is here to make the gap concrete — the estimator UPBP builds the geometry for and never queries exists in this renderer, validated at 1.048 against next-event estimation, in the other integrator.

04 — The Markov-chain branch

Three modes, one algorithm, and a bias fix that is a blur

Metropolis light transport is not an estimator in the sense the rest of this page uses the word. It is a sampler: it takes some existing way of turning random numbers into a path contribution and explores the high-contribution regions by mutating the random numbers instead of redrawing them. That distinction decides the superset question later, so it is worth being exact about here.

Modes 4, 5 and 8 are all primary-sample-space Metropolis over the same underlying path tracer. They differ in bookkeeping, not in kind.

  • Mode 4, "MLT." Bootstraps seedPaths candidates, takes the average luminance as the normalisation constant b, importance-samples a start state, then mutates a fixed-length vector of random numbers and replays tracePath on it (MLTIntegrator.cpp:210-225). Large step = redraw everything; small step = wrapped Gaussian per coordinate. Veach's dual splat: both the current and the proposed state are splatted, weighted (1−a)/L and a/L. This is Kelemen's algorithm, not Veach's path-space MLT — there is no lens perturbation, no caustic perturbation, no bidirectional mutation. A kelemenMLT flag exists in its config, commented "true=primary sample space, false=path space", defaults false, and is read nowhere. It is primary sample space either way.
  • Mode 5, "PSSMLT." The same algorithm done properly: primary samples are created lazily on demand and each carries a lastModIter so a coordinate the path never asked for is never mutated, plus explicit backup/restore on rejection (:415-461). This is the Kelemen implementation; mode 4 is a simpler sibling that happens to be named after the more famous algorithm.
  • Mode 8, "Manifold exploration." Manifold exploration is Jakob and Marschner's method: a Newton solve that walks a specular chain back onto the manifold where the half-vector constraints hold, letting a chain be perturbed without breaking. This is not that. It is mode 4's mutation loop with one change — a random number that fed a bounce flagged specular gets sigma × 0.1 instead of sigma (ManifoldIntegrator.cpp:234). newtonIterations and newtonStepSize are written into its config from the UI at EngineRender.cpp:2277 and appear in the .cpp exactly once, as cv.newtonSteps = 0 in a visualisation record. A smaller step near a specular vertex is a reasonable heuristic. It is not a manifold walk, and the name promises the thing the code does not do.

The border blur

PSSMLT carries a checkbox called "fix bias". MCMC renderers do have a real start-up bias problem, and there are real fixes for it. This is not one:

Vec3 c = m_film[i] * scale * m_exposure;
c = c / (c + Vec3(1.0f));  // Reinhard
c = pow(max(c, 0), 1/2.2);  // gamma
…
if (m_fixBias) { 3×3 box blur, border pixels only }
MLTIntegrator.cpp:815-847. It runs after tone mapping and gamma, on the display texture, on pixels within two of the film edge, and its stated purpose is "redistribute energy to under-sampled borders". It is a cosmetic pass over a symptom. Calling it a bias fix is the misleading part; the blur itself is harmless and does what it says.

All three chains are surface-only (skipVolumes = true at MLTIntegrator.cpp:36 and :395, ManifoldIntegrator.cpp:28), all three hold a 12 ms per-frame mutation budget so the viewport stays interactive, and all three expose acceptance-rate and chain-length statistics that the viewport can draw. The visualisers are the reason to keep them: watching the acceptance rate collapse when smallStepSigma is raised is a better explanation of Metropolis than a paragraph is.

Screenshot — Mode 5 (PSSMLT), enabled, on preset 3 or another surface scene · let it run ~30 s · frame the viewport together with the MLT panel showing acceptance rate, large/small step counts and the chain visualiser · clean view off
A chain, watchedThe number to look at is the acceptance rate. Too high means the small steps are too small and the chain is not moving; too low means it is rejecting nearly everything and only the large steps are doing work. Neither shows up in the image except as noise that does not go away.

05 — Learned and resampled

Two ways of remembering where the light was last time

Path guiding and ReSTIR both attack the same weakness — a sampler that has no idea which direction is worth trying — and they attack it from opposite ends. Guiding builds a persistent spatial cache and samples from it. ReSTIR keeps a one-sample reservoir per pixel and improves it by borrowing from neighbours in space and from the previous frame in time.

Mode 7 — path guiding

A uniform 3D grid over the scene bounds, resolution configurable, one GuidingCell per voxel holding a directional histogram. After trainingPasses passes of unguided tracing, each diffuse bounce draws from the local histogram with probability guidingStrength, and the throughput divides by a one-sample-MIS mixture of the guided pdf and the cosine pdf (PathGuidingIntegrator.cpp:332). The training signal is the path's luminance splatted back along its bounces with a learning rate, spread over a 3×3 angular neighbourhood so the histogram smooths rather than spikes.

That is a fair, simplified version of practical path guiding. Two things separate it from Müller's: there is no adaptive subdivision — no SD-tree, just a fixed grid — and the histogram is not fitted, so it cannot represent a lobe more sharply than one bin. And there is a mismatch in the bin arithmetic worth stating precisely, because it was invisible at the default and the panel has since been pinned to it:

// addSample / sampleDirection
int tBin = clamp((int)(theta / π * 16.0f), 0, 15);
int idx = t * 16 + p;    // dirWeights is std::array<float, 256>

// thetaPhi — the decoder
int tBin = bin / bins;  int pBin = bin % bins;  // bins = config.dirBins
The histogram is hard-coded 16×16 in GuidingCell::addSample and ::sampleDirection (PathGuidingIntegrator.cpp:12-56), but the decoder and the bin solid angle both use config.dirBins, which the UI used to expose as a slider from 4 to 32. At 16 they agree. At any other value the sampled bin index was decoded into the wrong direction and normalised by the wrong solid angle — silently, because guiding is MIS-weighted against the cosine lobe. On 2026-09-04 the slider was pinned to 16 and greyed, with a tooltip that says why (UIManagerRender.cpp:7391-7400). That closes the trap at the panel; the encode and decode sides still read two different constants, so it is closed rather than removed.

One more note on this mode: addSample ends with a per-bin cap at 15% of the cell's total weight, redistributing the excess by discarding it. That is a variance clamp on the learned distribution. It makes the guide better behaved and it is not part of any published formulation.

And one correction that runs the other way, since this page keeps score in both directions. This project's own capture notes carry a confirmed bug for this panel — "4.1: the Path Guiding tab's enable toggle is wired to the wrong variable", binding the enable switch to poisEnabled (Poisson gradient editing) so that revealing the field visualiser also ran a gradient filter over the frame. It is fixed. UIManagerRender.cpp:7386-7391 now binds pgEnabled, and carries the comment "pgEnabled, NOT poisEnabled — the latter is Poisson Gradient Editing". The shooting note is the stale document, not the code. The Dir Bins trap two paragraphs up has since been pinned shut the same way.

ReSTIR — the two passes that are not in the mode enum

ReSTIR DI is the full construction — resampled importance sampling into per-pixel reservoirs, temporal reuse against the previous frame, spatial reuse from neighbours — not a checkbox that blurs the direct term, and it is easy to miss because it is not a mode. It runs as a post-pass over the path tracer's G-buffer (EngineRender.cpp:2666-2682): restir.comp generates candidates and does temporal reuse against the previous frame's reservoirs, restirSpatial.comp combines each pixel's reservoir with its neighbours', and the result replaces the direct-lighting term while specular pixels pass the tracer's own value through. 907 lines of GLSL across the two passes.

ReSTIR PT is not the same thing, and its own file header says so: "screen-space multi-scatter reservoir reuse … uses the tracer's accumulated HDR radiance as the candidate pool … still an approximation of ReSTIR GI/PT rather than a full sub-path backend" (restirPT.comp:1-7). It reuses radiance between geometrically similar pixels. It does not resample paths, so it is not ReSTIR PT in Lin et al.'s sense; the UI labels it Experimental and the header labels it an approximation, which between them is an accurate description.

The debug surface underneath both, which nobody has switched on

Both ReSTIR passes carry an instrument panel that is larger than the algorithm's own controls, and it is the place a survey expects to find dead knobs. There are none. Every control below reaches a uniform and every uniform is read in the shader — the seven-way visualisation combo goes into both compute passes and each consumes all six of its non-zero modes; the BRDF overrides pack into one integer that both passes branch on; all nine multi-scatter damping controls arrive as nine separate uniforms and appear in the arithmetic. What is worth stating is not deadness but three details a reader would otherwise have to grep for.

The two BRDF overrides look independent and are not. They are drawn as two separate toggles and encoded as one integer 0–3: All Lambertian claims 1 or 2 depending on Keep Color, and Ignore Dielectric is reached only by the else. Tick both and you get All Lambertian; the second toggle stays lit and stops meaning anything. The visualisation combo is a replacement, not an overlay — any non-zero mode zeroes the path-traced colour first, so what reaches the film is the debug channel alone, so a visualisation frame is a picture of the reservoir field and not a tinted render. And three of the damping controls ship in the position that does nothing: reuse blend defaults to the top of its range, the radiance clamp defaults to the top of its range, and outlier rejection defaults to zero. Each of the three can only damp from there, and none of them is damping until you move it.

Which explains the button beside them. "Reset Legacy Profile" promises to restore "the older, stronger multi-bounce reuse profile before the newer damping controls", and the nine values it writes are byte-identical to the nine declared defaults. On a fresh session it is a no-op. That is not a defect — the older profile is deliberately what ships, so the damping is opt-in and the button is an escape hatch back from your own experiment — but the label describes a state you have to leave before the button can return you to it.

ReSTIR debug surfaceWhat it doesCatalogue grade
Seven-way visualisation comboOff, reservoir weight W, selected sample, shadow visibility, candidate quality, normals, material ID — into both the temporal and the spatial pass, replacing the image rather than tinting itnever viewed
BRDF-override debug switchesAll Lambertian, with Keep Color choosing albedo against a pure white 1/π, and Ignore Dielectric, which treats glass and mirror as diffuse instead of passing the tracer's own value through. Three states in one integer, so the second override is unreachable while the first is onnever viewed
Nine multi-scatter damping controlsMax bounces, candidates, spatial reuse, radius scale, centre weight, reuse blend, geometry strictness, radiance clamp, outlier reject — nine uniforms, all read. Geometry strictness alone drives three separate similarity terms in the reuse test: normal, position and luminancenever viewed
"Reset Legacy Profile" buttonWrites nine values identical to the nine defaultsnever viewed
ReSTIR CUDA / OptiX radiosA function-local static int, written by the radio group and read nowhere. Greyed on 2026-09-04, with a disabled tooltip saying the GL compute shader is the only backend that exists — the whole group, including the entry that looked liveinert
Screenshot — GPU path tracer (key `2`) on preset 0 (Cornell Box) - Technique, Tracer, ReSTIR (Direct Lighting): Enable ReSTIR on, Candidates 32, Spatial Reuse on (it snaps neighbours to 5), Temporal Reuse on - do not move the camera between frames - let it settle about 10 s, then shoot five frames changing ONE control each time: (1) Vis Mode = Off, the ordinary image; (2) Vis Mode = Reservoir Weight (W); (3) Vis Mode = Shadow Visibility; (4) Vis Mode = Candidate Quality; (5) Vis Mode back to Off, then Debug / BRDF Override: All Lambertian ON with Keep Color OFF - deliver the five in that order
The debug surface, switched on for the first timeFrames 2 to 4 are not overlays. Any non-zero visualisation mode zeroes the path-traced colour before the pass writes, so what you are looking at is the reservoir field alone — which is why the geometry disappears and only the structure the resampler built remains. Frame 5 is the other kind of instrument: every surface forced to white 1/π, so what is left in the image is the light and the geometry and nothing the materials contributed.

06 — The claim, scoped

Photon primitives are a superset along one axis and a sibling along the other

The tempting sentence is "photon primitives generalise the other estimators". Part of that is defensible and worth defending precisely; the rest is a category error a reviewer would find in about a minute. The two parts are cleanly separable, so this section separates them, states the construction as an arithmetic rather than an analogy, and then says what the arithmetic does not reach.

The construction, as a dimension count

A light subpath is generated by scalar decisions and nothing else: two for a point on an area emitter (three for a solid one), two for each direction, one for each free-flight distance. Sampling a scalar commits it to a number. Sweeping a scalar refuses to commit and integrates it analytically instead. Sweep k of them and the photon's final vertex stops being a point and becomes a k-manifold in space. The camera-side query is a ray — a 1-manifold. Everything else follows from one subtraction.

dim( photon manifold ∩ camera ray ) = k + 1 − 3
Negative: no generic intersection, so a blur kernel of dimension 3−(k+1) and a bias that goes with its radius. Zero: isolated crossings, admissible against the delta, weight f / |det[ ∂S/∂u₁ , ∂S/∂u₂ , −ω_cam ]| — no kernel anywhere. Positive: the crossing is a segment, still kernel-free, but a 1-D integral survives and has to be sampled or quadratured. Stated this way in docs/2026-06-10-photon-primitives-research.md §1.1, which is the repository's own reading of the published theory.
the ladder is one subtraction, and the renderer walks all four rungs red dot = sampled and fixed · yellow = swept · blue = the camera ray · white ring = an exact crossing SWEPT k WHICH SCALARS MANIFOLD ∩ CAMERA RAY KERNEL HERE k = 0 ∩ = −2 nothing swept y, ω, t all sampled 3-D box × chord 3/(4πr³) 19 · point k = 1 ∩ = −1 t the free flight 1-D, 1/(2r) ÷ sin θ 18 · beam k = 2 ∩ = 0 θ,φ · t,φ · t,θ or u,t · u,v · φ,r none co-area Jacobian 3–17, 20–24, 26, 27 k = 3 ∩ = +1 u,v,w a solid emitter none 1 chord sample 5, 6 · volumes strategy 21's MIS ledger prices the rungs by their radius: A19 = O(r²), A18 = O(r), A20 = O(1) — PPIntegrator.cpp-3944
The sweep ladderOne estimator at four settings. The kernel disappears exactly when the second dimension is swept, and the third dimension turns the crossing back into a segment. The photon point and the photon beam are not competitors to this family; they are its lower rungs, and this renderer implements them as such.

The roster is the same split, chosen differently

Two things make this an arithmetic and not a story. First, the direction-sweep trio is exhaustive: strategies 7, 8 and 9 are the three ways to choose two of the three scalars {θ, φ, t} at one vertex — sweep θ and φ and you get the sphere of radius t, sweep t and φ and you get a cone, sweep t and θ and you get a meridian half-plane. There is no fourth. Second, the crossings really are closed form and the Jacobians really are the ones the count predicts. The photon sphere's is t²·|ω·d| (PPIntegrator.cpp); the plane's is Cramer's rule on [d | −e | −ω] with determinant (e×ω)·d (:2762); the photon volume's chord is a genuine one-dimensional integral with a genuine extra random number, s = sIn + L·u (:2364, :2411) — the only per-sample draw in the family that exists because k went to three.

On anchors. File references on this page name the file, not a line. The integrator is under active development and every line number in an earlier draft of this page had already drifted by the time it was checked — one of them by twenty-seven lines, in a document that had been correct when written. Grep the symbol.

Whose family this is

The lineage the section above is arguing with, named, because a page that claims to generalise a published family and cites nobody from it reads as a bluff. Photon beams are Jarosz and colleagues, 2011: the light path's free-flight distance is swept rather than sampled, and the camera ray integrates against the resulting segment. Beyond points and beams, Bitterli and Jarosz, 2017, is where the higher-dimensional members come from — sweeping two or three consecutive distances gives the photon plane and the photon volume, and their own implementation is vendored in this repository under REF/correlated/, which is how the plane row in the table below was checked and corrected. Photon surfaces, Deng and colleagues, 2019, is the zero-blur two-dimensional case. Transient extensions by Marco and colleagues in 2019 and by Liu and colleagues in 2022 slice the same primitives temporally, and the non-exponential Tungsten fork those build on is also in REF/.

Venues and years here are given from memory of the literature rather than from a fetched bibliography, so treat the attributions as author-and-idea; the ideas are what the argument rests on. What this project adds to that lineage is the dielectric members — a swept manifold that passes through a refracting sphere and stays analytic — and a balance-heuristic mixture over a deliberately diverse set, which is a different contribution from adding another dimension to the sweep.

Which classical members are literally here, and which are only analogous

This is where an overclaim would hide, so each one gets a verdict rather than a family resemblance.

Classical estimatorIn the familyIn this renderer
photon pointthe k = 0 rungA literal member — strategy 19, same dispatch, 3-D box kernel × chord. But a fresh photon per camera sample and no stored map, which is the whole reason it is unusable: its own comment calls that "the user's inefficient, which is WHY real VPM stores photons" (:3011).
photon beamthe k = 1 rungA literal member — strategy 18, kernel 1/(2r)/sin θ at closest approach. It is the only strategy that traces a multi-vertex light path, and a diffuse surface absorbs the photon rather than bouncing it (:2999). Un-stored where the published beams are stored: same estimator, no data structure.
photon planek = 2, two swept dimensionsA sibling construction, not the published one — and the published one is structurally out of reach. Bitterli and Jarosz’s plane sweeps two consecutive free-flight distances at adjacent scattering vertices, so it needs a light path with at least two scatters; their own code is in this repo under REF/correlated/ and builds the parallelogram from segments p0→p1 and p1.dir·p1.sampledLength. Strategies 10 and 11 sweep one emitter-edge coordinate against one distance on a single-scatter path. That is a different split of the same dimension count. And the family cannot reach the published one at all: every row of its class table is declared with exactly one medium scatter, so a two-scatter primitive is not merely absent from the roster, it is inexpressible in the construction as built. The same bound rules out the published photon volume. Strategies 3, 4 and the arbitrary-orientation members 14, 15 and 16 are further k = 2 splits with the same provenance as 10 and 11, which is this project’s own earlier PathTracer.cpp revisions rather than a published construction.
photon volumethe k = 3 rungLiteral members with a different provenance. Strategies 5 and 6 reach three swept dimensions by translating a solid emissive cube or ball, where the published route reaches k = 3 by leaving three path scalars unsampled. Same estimator shape, same chord integral, different way of arriving.
next-event estimationnot a memberSee below. It is a transpose of the family, not a rung of it, and the earlier draft of this page had this wrong.
A correction, since this page is where it would have done the damage. An earlier draft of this section called next-event estimation "the degenerate member — sweep nothing" and said it "lives in the same dispatch as the rest". Both halves are wrong. NEE closes the same three-dimensional constraint by a connection: sample the light point, sample the scatter point on the camera ray, and the photon direction becomes deterministic, paid for with the 1/r² geometry factor. A sweep closes it by intersection: sample less, and let the camera ray decide the position, paid for with the co-area Jacobian. Sweeping and aiming are different operations and the dimension count does not apply to the second, because NEE does not generate the two subpaths independently. And in this renderer NEE is not in the swept dispatch at all — strategies 1 and 2 are a full multi-bounce camera walk with phase-MIS, and the single surface connection is gated to exactly those two strategies (PPIntegrator.cpp-4760). It shares the light samplers with the swept strategies and nothing else.

What is true, and is sharper than the thing it replaces: the photon sphere is NEE with the two segments' roles exchanged. NEE samples the camera distance and solves for the photon distance; strategy 7 samples the photon distance and solves for the camera distance. Same paths, same integrand, opposite assignment of which length is drawn and which is found. The renderer already carries the proof: strategy 7's density along the camera ray is σ_t·e^(−σ_t·t)·|ω·d| (ppDirPrimPdf, :1677) — the free-flight distance pdf, pushed forward to camera arclength by the tangency factor. That is a density in the same measure as NEE's own, so the two could be MIS-combined, and the ingredient for doing it already exists. Nobody has done it. No strategy in the roster MIS-combines a swept primitive with the camera walk. The renderer does compose the two — comp mode runs a swept primary alongside the path-tracing backup — but by a hard 0/1 partition of declared path classes, not by a weight; ppCompWeight returns a float only so that a weighted seam can replace its body later without touching anything else in the file.

Photon mapping is the correlated member — and only partly

Sampling a fresh primitive per camera sample is one choice; drawing a batch once per pass with no reference to any camera ray and letting every pixel sum the same batch is the other, and the second one is photon mapping's move. That mode is built and validated (pp.correlated, pp.primCount, pp.freezePool): twelve films byte-identical with the mode off, and correlated films reaching the uncorrelated energy across two presets and eight strategies. The normalisation is the whole risk, and it is the same rule the reference photon mapper follows — divide by primitives drawn, never by primitives that crossed the ray. The wrong version was built on purpose, measured at 2.1× to 6.8× too bright on frame energy and 6.6× to 14.2× per lit pixel, found to be flat to 1.5% over a 16× range in passes, and then deleted. It converges cleanly to the wrong answer and nothing in the image says so.

But the honest scope of that is narrower than "photon mapping". Three things are still missing, and two of them are missing on purpose:

  • No radius reduction, and for most members the idea does not apply. A progressive radius schedule exists to drive a kernel's bias to zero while keeping variance finite. Strategies 3–17, 20, 22–24, 26 and 27 have no kernel: nothing in them converges by shrinking a radius, and a progressive shrink would inject bias where there is none. Six slots carry one — 18 and 19, the tracer beam and the tracer point; since the one-dimensional group shipped, the ring at 28, the arc at 29 and their mixture with the beam at 30; and the hourglass pair at 21, whose beam member carries it at weight O(r) on the caustic and at one everywhere else, and whose energy moves with it (+0.9 % at r = 0.15) — and their radius is a single fixed slider, pp.beamRadius, rather than a schedule. Sharing one width is load-bearing rather than incidental in 30: with the same r across its three members the 2r cancels identically in the balance weights. So the family does not contain progressive photon mapping — it removes the problem PPM solves for the analytic members, and does not solve it for the five that need it. What it does instead is publish the ladder rather than shrink it: five radii from 0.30 down to 0.019 at 3,200 passes each, every rung carrying its own error bar, on Caustics without a kernel.
  • No surface gather. Photon mapping's headline is a density estimate on surfaces, and the family has none: no kd-tree, no kNN, no surface photons. Every strategy is single fog scatter, and the only surface term is one Lambertian connection with no continuation.
  • No multi-bounce photon storage. The frozen batch stores primitives, not paths. One traced light path can yield several beam segments, but no stored vertex is ever reused by a different estimator, and the light-side chain terminates at the first diffuse surface. And freezing the batch across passes converges to that batch's realisation, not to L — the UI labels it as not converging, because it is the artifact mode, not a render mode.

So the correct sentence is: correlation makes the family a superset of volumetric photon mapping's density-estimation core — the reuse structure, and the point and beam kernels — and not of progressive photon mapping, and not of surface photon mapping at all.

strategy 9 · cone
correlated/fig-cone-9.png
strategy 21 · MIS trio
correlated/fig-mis-21.png
Correlated mode, low sample countA frozen batch of primitives shared by every camera ray, before averaging hides them. Each visible sheet is one photon primitive weighted by its co-area Jacobian. The right pane is strategy 21, whose batch contains a beam, a point and a swept sheet at once — three rungs of the ladder in one frame, under one MIS weight.

What the construction does not reach, and which axis each one varies

Every item below fails to be contained for a structural reason, not for want of work. The test is the same each time: name the axis it varies, and check whether the sweep construction touches that axis at all.

  • Bidirectional path tracing varies the join index. BDPT's content is the (l,e) grid — which light-subpath vertex is connected to which eye-subpath vertex — and the megakernel here implements every cell of it, interior connections included. The sweep construction fixes that index and never moves it: light → manifold → camera ray, with a straight camera leg. The renderer states this as a machine-checkable declaration rather than a claim. Its strategy table gives every photon-primitive row q = 0 (no non-scatter vertex between the eye and the fog scatter) and nV ∈ [1,1] (exactly one fog scatter), and no row is ever allowed a diffuse vertex in the light-side chain (kPPClass[25], PPIntegrator.cpp-1981). A family with one join cannot contain a family indexed by every join.
  • Vertex connection and merging varies how the join is made — connection or merge — and then MISes the two across the whole pyramid. That is two moves the family does not have. A merge reuses a stored vertex from a different subpath under a kernel; a sweep integrates one fresh photon analytically. Correlation supplies storage, but not merging, because a kernel-free sheet has no radius to merge inside. And VCM's actual payoff is the MIS between merging and every connection technique — which needs a pyramid to MIS against.
  • Metropolis varies the sequence of samples, not the estimator. This is the one that would be embarrassing to get wrong. MLT is a sampler over path space: it mutates the random numbers some estimator consumes, and the estimator underneath is unchanged. In this renderer, modes 4 and 5 mutate the primary sample vector feeding the same CPU path tracer, and mode 8 does the same with a scaled-down mutation on the randoms that fed a specular bounce. You can run Metropolis on a photon-primitive estimator — nothing stops a chain from mutating the primitive's random vector — but you cannot write Metropolis as a strategy number, because it is not a way of forming a path. It is a way of choosing which path to form next.
  • Manifold exploration varies how a specular chain is solved. It runs a Newton solve on the half-vector constraint to walk from one valid specular path to a nearby one. The hourglass looks adjacent — both are about chains through glass — but it is the opposite move: it never solves for a path, it parameterises the entire family of them in closed form, because rotating the whole refraction chain about the light-to-centre axis is a rigid motion. Exact where it applies, and it applies only to a sphere with a point-sampled origin. Manifold exploration applies to arbitrary geometry. Neither contains the other. (This renderer's mode 8 is labelled Manifold and implements no Newton solve at all — see the ledger.)
  • Path guiding varies the proposal density. It learns a directional distribution and draws from it. That is orthogonal by construction: a swept strategy could draw its non-swept scalars from a learned density and not one line of the derivation would change. The family fixes what is swept; guiding fixes where the remaining draws come from. Nothing in this renderer composes them, and that is a gap rather than a boundary.

The paragraph, if you read only one

The photon-primitive family is a superset of photon density estimation — photon points, beams, planes and volumes are one estimator at zero, one, two and three swept dimensions, and here they are consecutive blocks of one function under one MIS ledger that prices them by their kernel's order in the radius. It is a sibling of the connection strategies — BDPT, VCM and light tracing vary which two vertices are joined, and every photon primitive joins the same two, so neither family contains the other and their product is larger than either. It is orthogonal to the samplers — Metropolis mutates the numbers an estimator consumes and guiding changes the density they come from; both compose with a swept primitive and neither can be written as one. And it is itself a special case: single scatter in a medium, a straight camera leg, and a light-side chain that may be specular but never diffuse — a restriction this renderer does not merely accept but declares, one row per strategy, in a table a second integrator is checked against.

That is less sweeping than "photon primitives generalise the other estimators", and it is the version that survives contact with the source. It is also the more interesting claim, because the boundary it draws is exactly where the next piece of work is: the megakernel's own comment already sketches photon primitives as a third axis on the (l,e) grid, and strategies 22, 23 and 24 have taken one step onto it.

07 — The third axis

The megakernel wrote down the meeting point before the estimator existed

Immediately under the BDPT strategy map, in the same comment block that, until 2026-09-04, mislabelled the interior connections, the shader sketches what a photon primitive would be in BDPT's own terms. The comment arrived with the shader on 2026-04-22; PPIntegrator.cpp was created on 2026-06-14, seven weeks later. It is the most useful sentence either family has about the other — with one loose line of its own, quoted below and corrected under it.

// - 0-bounce connection: shadow ray (standard BDPT, what we have now)
// - 1-bounce connection: photon hits a surface, connects to eye vertex
// (equivalent to VCM/UPS — vertex connection + merging)
// - 2-bounce connection (sweep dimension): photon primitive spans a
// region, connects via an intermediate surface. Requires sweepline
// integration over the photon primitive's extent.
// These extend the (l,e) grid into a 3rd "connection complexity" dimension.
shaders/pathTrace.comp:2488-2494, quoted whole. Its second line is the loose one — merging is not a connection at any bounce count, it is a kernel over a stored vertex, and calling it a 1-bounce connection is the same conflation this page spends a section avoiding. The last line is the part that holds, and it is exactly right: BDPT indexes techniques by where the two subpaths are joined, and the photon-primitive family indexes them by how much of the photon is left free at the join. Those are independent, which is why neither contains the other, and why the product of the two is a bigger space than either.

The renderer has already taken one step onto that third axis. Strategies 22, 23 and 24 — the vertex hourglass members — are seeded on the glass rather than at the light, and their light side is traced back to an emissive entity rather than sampled from scene.lights[]. The light endpoint of a photon primitive is therefore a subpath vertex, which is the beginning of a light-vertex index on a swept estimator. It is not a pyramid yet; it is one column of one.

The measurements say the step is sound. Energy ratio 22/20 = 1.007 and 1.020 on presets 47 and 52; 23/20 = 1.0001 on both at 48k passes; 24 against the stochastic beam reference restricted to the reflection branch = 0.9982, and 0.9996 with the top twenty pixels removed from each side. And the other half, stated with the same precision: none of the three has ever been looked at on screen. They pass headless numeric checks — the ratios above, one of them to four digits — and no human has seen a frame — built, validated, and never once looked at. In this project's grading that is a different status from "works", and the triage reference records it as such.

What the swept family has that none of the others do

One capability is genuinely absent from every other mode in this renderer, and it is the reason the family exists rather than being a curiosity. A smooth dielectric is a delta BSDF: fix the incoming direction and Snell fixes the outgoing one, so a connection through glass has zero density. NEE cannot do it. BDPT's interior connections cannot do it — the pyramid's own cell description says so, "Diffuse-only (implemented). Specular needs photon primitives or merge (VCM)". Merging can do it, blurred by a radius. Manifold exploration is the published answer and this renderer does not have one.

The photon hourglass does it analytically. Rotating the whole refraction chain about the light-to-centre axis is a rigid motion, so the swept manifold is exact, the crossing is closed-form, and the caustic has no kernel radius in it at all. Paired with the stochastic beam as strategy 21, at equal passes (200, preset 48) the median relative error on the isolated caustic is about 1.9× lower than the beam's alone, and the converged pair matches the converged beam at a median ratio of 1.0000.

medium a ψmax C glass ball φ waist ψ x1 x2 r ωout eye d a b x J = |((a×(x−y))×ωout)·d| a = ∂x/∂φ = a×(x−y) b = ∂x/∂r = ωout 20 · photon hourglass · sample ψ on the cap, sweep φ, r
Strategy 20Sample ψ on the cap of directions that hit the ball, trace one deterministic chain through two refractions, then sweep φ and r. The exit ray revolved about the axis is a quadric; the camera ray pierces it in up to two places. No radius, no kernel, no gather.

And the limits, stated as plainly as the capability. It needs a sphere — the derivation rests on rotational symmetry about the light-to-centre axis, and a glass box or plane is not handled. It needs the light outside the ball. It renders the caustic and nothing else: strategy 20 has no direct-airlight term, so a solo frame is mostly black with a bright cone, which looks like a bug and is the entire estimator. Solo, it is about 2.4% low because it cannot express internal-reflection chains, and strategy 21 closes that by carrying them on the beam.

08 — Grading

What is real, what is a stub, and what says something it should not

A survey that reports only capabilities is a brochure. This is the other column. Every row was read out of the source for this page; four of them were already recorded in an internal audit from June, and six were corrected or relabelled in the source on 2026-09-04 and are marked so.

ThingStatusAnchorWhat it means for a reader
Standalone light tracer class (the feature ships elsewhere)supersededEngineRender.cpp:2087Constructed and disabled every frame; trace() has no call site. Superseded by the megakernel's own light path.
photonGather.compdeadEnginePhysics.cpp:748357 lines. applyPhotonGather has no caller; V2 is the live kernel.
Photon-mapper volume mapstubPhotonMapper.cpp:56, 378-382Surface-only by flag. The beam branch is an empty block. The volume photon count is structurally zero.
Photon mapper "Final Gather"stubphotonGatherV2.comp:296Menu option 2 falls through to kNN; the mode does not exist. Since 2026-09-04 the entry reads "Final Gather (not built - renders kNN)".
UPBP beamsstubUPBPIntegrator.h:39Built, uploaded, drawn. enableBeamMerge is read nowhere. No beam appears in any radiance estimate. The header has said so, flag by flag, since 2026-09-04, and the panel toggle is greyed.
VCM MIS weightsapproximateVCMIntegrator.cpp:551-563, 675A kNN-fill-fraction heuristic clamped to [0.05, 0.95], plus a flat 0.5. dVCM/dVC/dVM are computed, stored and never read.
VCM balance/power selectordeadVCMIntegrator.cpp:230, 235Both helper functions have no call site; config.misMode is never read. Greyed and labelled [inert] in the panel since 2026-09-04.
BDPT MIS method Anever builtpt_bdpt.glsl:101-106"A balance" and "A power" both return flat 1/N; only B exists. Since 2026-09-04 both entries are labelled [inert] and the tooltip says the A-vs-B oracle cannot be run.
BDPT MIS method Breal, validatedpathTrace.comp:2498-2679All-sites pbrt-style path-pdf sum, user-validated as unbiased and faster-converging. Compiled out on volume scenes — it exceeds the instruction ceiling.
Interior-connection commentfixedpathTrace.comp:2478Said "NOT YET IMPLEMENTED" about a loop 1,440 lines below it. Corrected 2026-09-04: it now says IMPLEMENTED, points at the loop, and records what it used to claim.
Pyramid [S] tooltipstaleEngineRenderViz.cpp:2033Labels balance and power "(TODO)". True of method A, false of method B. The Render panel's tooltip was corrected on 2026-09-04; this one was not.
Pyramid e=0 row colouringmisleadingEngineRenderViz.cpp:2142Painted active with no aperture test while the shader correctly drops it (pt_bdpt.glsl:28). At the default Pinhole Size of 0 the cells look live and render black.
Pyramid grid over-rangeminorEngineRenderViz.cpp:1869Range shading uses maxBounces+2; the vertex arrays cap at 8. Above 8 bounces the panel marks cells in-range that no site can reach.
Pyramid "T=cycle mode" hintwrongEngineRenderViz.cpp:2261There is no GLFW_KEY_T handler in src/. T is the on-panel button, not a key.
Shrunken-mode cost notewrongdocs/CAPTURE-SHOOTING-LIST.md:564"Renders the scene once per cell." It does not; every pixel traces once and discards off-tile contributions. The cost is samples per technique, not frame time.
The pyramid on screennever captured—No screenshot of the BDPT pyramid exists anywhere in this repository. The two shots and the clip above are recipes, not records; the pass counts in them are estimates from the rig's character, not measurements.
Manifold explorationmislabelledManifoldIntegrator.cpp:234PSS-MLT with a 0.1× step on specular randoms. No Newton solve. newtonIterations is written and never read.
MLT kelemenMLT flagdeadMLTIntegrator.h:20Offers a path-space mode that does not exist; both modes are primary sample space.
PSSMLT "fix bias"misnamedMLTIntegrator.cpp:826-847A 3×3 box blur on border pixels, after tone mapping. Cosmetic, and honestly described in its own comment.
Path guiding Dir BinspinnedPathGuidingIntegrator.cpp:12-56Was a 4-32 slider over a hard-coded 16×16 histogram; any value but 16 decoded bins wrongly and normalised by the wrong solid angle. Pinned to 16 and greyed on 2026-09-04 (UIManagerRender.cpp:7396-7400); the code still holds two constants.
ReSTIR PTapproximationrestirPT.comp:1-7Screen-space radiance reuse, not path resampling. Labelled experimental in the UI and an approximation in its own header.
PP strategies 22 / 23 / 24numerically validated, never seendocs/EXPECTED-BEHAVIOUR.md §2Energy ratios within 2 % of two independent references, one of them to four digits. No human has looked at a frame from any of them.
Path-guiding enable togglefixedUIManagerRender.cpp:7386-7391Was bound to poisEnabled, so enabling the field viz also ran a gradient filter. Now bound to pgEnabled, with a comment naming the old bug. The capture notes still record it as open; they are stale.
GPU light subpath, mirror tintknown bugpathTrace.comp light pathA coloured mirror reflects white in LT and BDPT modes. Known, unfixed.

Rendered, and validated, are different words

It would be tidy to say the CPU integrators have never been rendered on purpose, and it would be false. This project keeps a capture inventory with explicit status codes — OK = verified working, UNSEEN = built and headless-checked but never viewed by a human — and modes 2 through 8 are all marked OK there, with per-mode recipes and viewport visualisers that someone clearly drove (docs/CAPTURE-SHOOTING-LIST.md §4). They have been looked at. What is marked UNSEEN is a shorter list: the MLT chain and acceptance visualiser, showcase presets 49-52, the split A/B compare, clean view itself — and, from this page, the BDPT pyramid, which has no capture anywhere in this repository, and photon-primitive strategies 22, 23 and 24.

What this page is not claiming

None of the seven CPU integrators has ever been validated against a reference. The photon-primitive family has, extensively, and the GPU path tracer has against it — median 1.0005 on the one transport class both express identically, with directly-seen emitter pixels masked because the two disagree there by a factor of 2.96 and it is unresolved which convention is intended. But there is no number anywhere in this repository for "is VCM right", and the reason is plain: nobody built the gate. Modes 2 through 8 are teaching instruments and research scaffolding that happen to render; they are not evidence, and this page does not lean on them as any.

Nor is a stub a criticism. A stub in a research renderer is a decision about where the time went, and this project's went into the swept estimators and the machinery to prove they are right. The point of listing them is that a reader can tell which is which without reading 20,000 lines, and that the four labels which promised more than they delivered — method A, final gather, manifold exploration, beam merging — are named here rather than discovered later. Three of the four were relabelled in the panel on 2026-09-04; manifold exploration still carries its name.

Read next. The megakernel and the scene it walks for how the GPU side is built and specialised; the photon hourglass for the analytic caustic in full; how I know the pictures are right for the validation ladder the numbers on this page come from. The triage reference behind the ledger is docs/EXPECTED-BEHAVIOUR.md; the June audit that independently found four of the same stubs is docs/2026-06-06-participating-media-estimator-audit.md.