Light transport research · RadianceLab
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.
01 — What is actually in the box
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.
| # | Mode | What it actually implements | Volume | State |
|---|---|---|---|---|
| 0 | Rasteriser | GL forward pass; LTC area lights (Heitz 2016), 64×64 tables generated at init | — | works |
| 1 | GPU path tracer | Megakernel with eight light strategies, from naive BSDF sampling to BDPT with interior connections | yes | the reference |
| 2 | Photon mapper | CPU emission + KD-tree build, GPU kNN gather. Global and caustic maps. Surface only | no | partial |
| 3 | VCM | Connections + merging, but the weights are a photon-density heuristic, not the VCM MIS | no | approximate |
| 4 | MLT | Primary-sample-space Metropolis over a fixed-length random vector | no | named wrong |
| 5 | PSSMLT | Kelemen-style PSS-MLT with lazy per-coordinate mutation timestamps | no | the real one |
| 6 | UPBP | Connections + point merging in volume and on surfaces; delta tracking; heterogeneous media. Beams built and never queried | yes | beams stubbed |
| 7 | Path guiding | Uniform 3D grid of 16×16 directional histograms, one-sample MIS against the cosine lobe | no | bin count now pinned |
| 8 | Manifold exploration | PSS-MLT with a 0.1× perturbation on randoms that fed a specular bounce. No Newton solve exists | no | mislabelled |
| 9 | Photon primitives | 31 strategy slots — 30 estimators and a rasterised gather; 21 analytic swept manifolds, six kernel estimators, three baselines | medium only | the research |
| — | ReSTIR DI | Reservoir RIS + temporal + spatial reuse over the tracer's G-buffer; replaces the direct term | no | post-pass |
| — | ReSTIR PT | Screen-space reservoir reuse over accumulated radiance. Its own header calls it an approximation | no | experimental |
| PT · strategies 3–4 | Light tracer | Works, 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:
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
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.
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.
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.
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.
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 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:
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.
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.
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.
| Control | What it does | Catalogue grade |
|---|---|---|
| Pyramid Grid slider | Caps 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 × 5 | never viewed |
| MIS method B — statically unavailable on a volume scene | Not 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/N | scene-dependent |
| Feature-mask degrade ladder | The 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 rung | seen |
03 — The density-estimation branch
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.
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:
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.
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 panel | What it does | Catalogue grade |
|---|---|---|
| Progressive PPM, two shrink formulas | The 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-tree | seen |
| Max Iter behind a Confirm button | Pending 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 subject | seen |
| PM path-expression filter | Free text over L S D *, compiled to five integers for the gather and to a real std::regex for the overlay | seen |
| PM storage: photons vs paths | Not 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 above | seen |
| Accumulate when static | Image-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 habit | seen |
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 filter | What it does | Catalogue grade |
|---|---|---|
| Path-type regex filter | Four 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 renderer | never viewed |
| — the language is weaker than the box implies | Four integers per pattern; interior vertices and the wildcard's position both discarded; characters outside the vocabulary dropped in silence | seen |
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:
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.
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:
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.
04 — The Markov-chain branch
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.
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:
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.
05 — Learned and resampled
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.
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:
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.
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.
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 surface | What it does | Catalogue grade |
|---|---|---|
| Seven-way visualisation combo | Off, 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 it | never viewed |
| BRDF-override debug switches | All 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 on | never viewed |
| Nine multi-scatter damping controls | Max 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 luminance | never viewed |
| "Reset Legacy Profile" button | Writes nine values identical to the nine defaults | never viewed |
| ReSTIR CUDA / OptiX radios | A 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 live | inert |
06 — The claim, scoped
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.
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.
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.
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.
This is where an overclaim would hide, so each one gets a verdict rather than a family resemblance.
| Classical estimator | In the family | In this renderer |
|---|---|---|
| photon point | the k = 0 rung | A 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 beam | the k = 1 rung | A 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 plane | k = 2, two swept dimensions | A 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 volume | the k = 3 rung | Literal 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 estimation | not a member | See below. It is a transpose of the family, not a rung of it, and the earlier draft of this page had this wrong. |
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.
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:
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.
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.
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
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.
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.
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.
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
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.
| Thing | Status | Anchor | What it means for a reader |
|---|---|---|---|
| Standalone light tracer class (the feature ships elsewhere) | superseded | EngineRender.cpp:2087 | Constructed and disabled every frame; trace() has no call site. Superseded by the megakernel's own light path. |
| photonGather.comp | dead | EnginePhysics.cpp:748 | 357 lines. applyPhotonGather has no caller; V2 is the live kernel. |
| Photon-mapper volume map | stub | PhotonMapper.cpp:56, 378-382 | Surface-only by flag. The beam branch is an empty block. The volume photon count is structurally zero. |
| Photon mapper "Final Gather" | stub | photonGatherV2.comp:296 | Menu 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 beams | stub | UPBPIntegrator.h:39 | Built, 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 weights | approximate | VCMIntegrator.cpp:551-563, 675 | A 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 selector | dead | VCMIntegrator.cpp:230, 235 | Both 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 A | never built | pt_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 B | real, validated | pathTrace.comp:2498-2679 | All-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 comment | fixed | pathTrace.comp:2478 | Said "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] tooltip | stale | EngineRenderViz.cpp:2033 | Labels 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 colouring | misleading | EngineRenderViz.cpp:2142 | Painted 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-range | minor | EngineRenderViz.cpp:1869 | Range 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" hint | wrong | EngineRenderViz.cpp:2261 | There is no GLFW_KEY_T handler in src/. T is the on-panel button, not a key. |
| Shrunken-mode cost note | wrong | docs/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 screen | never 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 exploration | mislabelled | ManifoldIntegrator.cpp:234 | PSS-MLT with a 0.1× step on specular randoms. No Newton solve. newtonIterations is written and never read. |
| MLT kelemenMLT flag | dead | MLTIntegrator.h:20 | Offers a path-space mode that does not exist; both modes are primary sample space. |
| PSSMLT "fix bias" | misnamed | MLTIntegrator.cpp:826-847 | A 3×3 box blur on border pixels, after tone mapping. Cosmetic, and honestly described in its own comment. |
| Path guiding Dir Bins | pinned | PathGuidingIntegrator.cpp:12-56 | Was 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 PT | approximation | restirPT.comp:1-7 | Screen-space radiance reuse, not path resampling. Labelled experimental in the UI and an approximation in its own header. |
| PP strategies 22 / 23 / 24 | numerically validated, never seen | docs/EXPECTED-BEHAVIOUR.md §2 | Energy 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 toggle | fixed | UIManagerRender.cpp:7386-7391 | Was 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 tint | known bug | pathTrace.comp light path | A coloured mirror reflects white in LT and BDPT modes. Known, unfixed. |
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.
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.