Light transport research · RadianceLab
Not a renderer with a GUI bolted on. An instrument you drive — and the reason the research moved as fast as it did.
Every other page on this site shows a picture and then argues about it. This one is about the apparatus that decided which picture to take.
01 — The claim
A photon-primitive estimator fails in ways a single number cannot describe. It is unbiased and still wrong at one arc of one surface; it converges and still to the wrong constant; it looks perfect and is four and a half times too bright. Debugging that requires looking at the sampling geometry, not at the image.
So the instruments came first, or at least alongside. The primitive explorer that draws a swept manifold in world space, the correlated batch that freezes the sample set so a weight change is the only thing that moves, the split wipe that puts two estimators in one frame — those three are argued in full on Where the fireflies come from, and this page does not repeat them.
What this page covers is the rest of the panel: the parts that are not about photon primitives at all, and which a reader would have no way to guess exist. A node graph with fifty-seven node types whose output the GPU tracer evaluates per entity. A comparison-report generator that renders each configuration and writes a browsable HTML page. A ray tree you can fire backwards out of a light. An alarm that notices the renderer has been left in a diagnostic state and offers a way out.
Most of it has never been deliberately rendered, and that is said with some pride. It is the most interesting category on the site: the tools were built to answer a question, the question got answered, and nobody went back to photograph the tool. Instruments get used, not photographed. Where that is the case, the row says so.
The first four rows are the four marks used across this site; the catalogue keeps two more, and the sibling pages fold them into never viewed and inert / orphan rather than drawing new glyphs. They are printed here so the column adds up: an earlier version of this read-out stopped after four rows and left a reader 34 short of the total it claimed beside them.
The counts are the whole renderer, not this page: 555 catalogued features, of which 500 work — 253 seen, 116 never viewed, 97 measured, 18 superseded, 16 scene-dependent — and 116 have never been deliberately rendered. The remaining 55 are the 31 inert and the 24 absent, which closes. (Those figures are counted by docs/portfolio/coverage.py on every run. Until 2026-09-06 the catalogue's headline said 544 / 115 / 89 / 32 against its own rows, and this page copied it; the inert difference was a withdrawal the catalogue recorded in an addendum and never carried up.) (Earlier counts are superseded in order: 457, which closed against nothing; 515, what docs/FEATURE-CATALOGUE.md said before the features added on 2026-09-04 and 05 were catalogued; 521 and 523 during the 05th; then 526 with the passthrough surfaces, the exterior preset and the headless camera override; 528 with the surface caustic map and its acceptance harness; then, through 2026-09-06, 529 as passthrough support reached the whole integrator roster, 534 with the four gate-family presets and the aperture vocabulary they share, 536 with BSDF-general surface shading and its self-consistency read-out, 537 with the volume-light rig, 542 with the refraction chain, the post-ball mirror and their four presets, and 544 with photon ring-alt and the five-member gloss series; then, on 2026-09-07, 546 with the scene refusal printed for every strategy and 548 with the one-dimensional group and its frozen-batch swarm; 551 before the hourglass-medium mode and its walk-medium flag were catalogued on 2026-09-10; 553 before preset 79, “The Atrium” — the big room built for that mode — was catalogued the same day; and 554 before THE COLLAGE, the two-render mode, was catalogued that afternoon.) A disproportionate share of that 116 is on this page, which is exactly what a working instrument looks like.
| Instrument | What it is | Route | Grade |
|---|---|---|---|
| Node editor | 57 node types, 7 categories, live previews, detached editing with a Commit; output evaluated per entity by the GPU tracer | View ▸ Node Editor | never viewed |
| Comparison report | Renders up to 8 integrators on a time budget, writes PNGs, a browsable HTML report and replayable JSON. The only working image export | Advanced ▸ Compare | never viewed |
| Split A/B | Two independent estimator passes over one scene, composited as a draggable wipe | Compare menu · pp.split | never viewed |
| PP vs GPU window | Live CPU estimator beside a live GPU path trace at an aligned bounce depth | PP Estimator ▸ Compare | seen |
| Ray tree | A branching tree from a picked point, or in reverse from a random emitter | Visual ▸ Ray Tree | never viewed |
| Gizmo + panel mirror | MMB transform gizmo; the panel draws an animated copy that is itself draggable | MMB · Visual ▸ Visualization | seen / mirror never viewed |
| Clean view | Hides every viewport indicator so only the renderer's output remains | G | seen |
| Dolly zoom | Pins a target and solves focal length from distance as you move | Q in FPS capture | seen |
| Non-standard-state alarm | Detects five diagnostic configurations and offers one-click escape | automatic, Technique tab | seen |
| PrismShapeTest | The only unit tests in the repo. 61 assertions, glm-only link, non-zero exit on failure | build/bin/<cfg>/PrismShapeTest | measured |
| Scene preset menu | 63 entries in one submenu, in labelled bands — and the only way to change what is in the scene | File ▸ Scene Presets | seen |
| Film dumps · PGR1 | Three hooks writing linear-HDR float films; the format every number on this site was measured from | pp.dump*At | measured |
| Scene authoring | Adding or deleting an entity or a light from the UI | — | absent |
| Inert controls | Reachable widgets that reach nothing, three of which visibly rebuild the photon map | §08 | inert ×32 |
What was checked, and how. Every count on this page was read out of the source in this repository rather than taken from a summary — the node-type enum at core/NodeGraph.h:272-346, the preset menu items at ui/UIManager.cpp:275-372, the assertion call sites in tests/, the tab tables at ui/UIManagerRender.cpp:29-120. Three of those readings disagree with the project's own feature catalogue, and §06 and §07 say where and by how much.
02 — The node editor
Every page on this site closes with the same scope note, and buried in it is one clause: the renderer "also carries a node-graph material and projection editor with texture-space baking". That clause has been carrying a two-and-a-half-thousand-line editor, a thousand-line preview engine and fifty-seven node types on its back. This section is that clause spelled out.
It is a parameter graph, not a shader compiler. Nodes are evaluated on the CPU into per-entity material overrides and a GPU-uploaded noise configuration; nothing is codegen'd and no pipeline is rebuilt. That is the design constraint that makes everything else in this section cheap — a live preview is just the same evaluator on a small domain.
| Category | n | What is in it |
|---|---|---|
| Sources | 5 | Constant Float, Constant Colour, Time, WorldPos, UV |
| Texture | 11 | Texture (a GL handle from a loaded image), two checkers, Noise2D, FBM2D, Noise Lab 2D, reaction–diffusion 2D, legacy noise 2D, SDF 2D, Eulerian fluid 2D, physics 2D |
| Vol | 10 | The 3-D forms — Checker3D, Noise3D, Noise Lab 3D, reaction–diffusion 3D, legacy noise 3D, SDF 3D, Eulerian fluid 3D, volume incremental, physics 3D — plus Medium Container |
| Math | 21 | Add, Subtract, Multiply, Divide, Power, Sqrt, Abs, Sin, Cos, Clamp, Lerp, Remap, Scale, Step, SmoothStep, OneMinus, Fract, Floor, Min, Max, Negate |
| Vector | 5 | SeparateXYZ, CombineXYZ, Normalize, Length, Dot |
| Output | 3 | BSDF, Medium, Emission — the three terminals the tracer reads |
| Scene | 2 | Camera (a captured view-projection pose) and DataMosh Projector |
| Total | 57 | Seven categories, read straight off the cats[] table at NodeEditor.cpp:2485. The 58th enumerator is the COUNT sentinel, which is where the 56 / 57 / 58 disagreement in the catalogue's own footnote F1 came from |
Wiring a BSDF, Medium or Emission output node bakes the graph into three RGBA8 layers of a 2-D texture array, one slice per entity, at a resolution the user sets between 64 and 1024 px (default 256). The packing is fixed and worth stating plainly, because it is the contract between the editor and the tracer:
A correction to this page, found by reading the write instead of the comment. That last line said anisotropy angle until now, because the vector's declaration comment says AnisoAngle.A. Four hundred lines below it the byte that is actually stored is clamp01(dispersionSpread / 10), and the megakernel decodes it by multiplying by ten again. Anisotropy angle is baked nowhere: the channel mask has ten bits — albedo, roughness, metallic, emission colour, emission strength, IOR, opacity, anisotropy, dispersion spread and the dispersive gate — while the auto-seeded BSDF node offers eleven inputs. Three of them, anisotropy angle, thin-film thickness and glint density, can be wired to anything you like and will never leave the editor. The angle still reaches the shader, as a per-material uniform; it just cannot be driven by a graph.
Two details in that path are more interesting than the bake itself. A directly-wired 3-D procedural is deliberately excluded from the bake mask and evaluated in the shader instead, so a socket-level Multiply keeps the real material's base value rather than flattening it into a texture. And box faces, which normally deduplicate to a single primitive, are kept unmerged when baking to UV, so a photograph projected onto a box puts a different part of itself on each of the six faces.
Selecting an entity auto-creates and pre-seeds a graph from its current material — eleven BSDF inputs including anisotropy angle, thin-film thickness, glint density and dispersion spread — with an automatic unique-material split so an override on one box does not leak to its siblings.
Everything above is the surface half. Two node types are the medium — a MediumOutput terminal carrying absorption, scattering, phase g and colour, and a bakeable MediumContainer — and eleven upstream types can drive a volume live. It is tempting to describe that as the same machinery pointed at fog. It is not. There are exactly three routes from a medium graph to a rendered voxel, and they behave differently enough that a user would be surprised by two of them:
And none of it reaches the CPU. The photon-primitive estimator contains no reference to the node graph at all; it reads a volume's scattering and absorption coefficients straight off the entity. The CPU integrators that do consult the graph — every one of them except the estimator, through the photon mapper's header — share a single helper whose output struct has twelve fields: albedo, roughness, metallic, IOR, opacity, anisotropy, anisotropy angle, thin-film thickness, glint density, dispersion spread, emission colour and emission strength. None for absorption, scattering or phase. There is no medium equivalent on the CPU to leave unimplemented; the structure has no slot for one. A node-authored fog is a GPU-only object, and the baked-node-graph row of the density-source table on Ten integrators, one scene now says so.
The editor keeps a shadow copy of the graph. With Detached Editing on, the scene points at the committed copy and keeps rendering it while you rewire; the Commit button is disabled until there is something to commit, and pressing it swaps the graphs. A renderer that resets a 4000-pass accumulation every time you drag a node is not usable for material work, and this is the mechanism that avoids it.
Per-node inline 64² thumbnails, lazily-created 256² detail views, and — for Medium outputs — a ray-marched 3-D volume preview with its own orbit camera, drawn by a dedicated preview shader. Three preview modes (GPU, CPU Fast, CPU Full) trade fidelity for frame time. Double-clicking a node detaches it into its own manipulator window with its own orbit preview, and a checkbox decides whether you may have several open at once.
The create menu is type-filtered: dragging from a pin and releasing on empty canvas offers only nodes whose primary output type is compatible with that pin, and creates the node already connected.
The editor is a floating window over a viewport in which you have just selected the thing you are editing, so it has a placement solver: it measures the selected entity's screen rectangle and tries to fit itself left, right, above or below it. When it cannot, it says which of six reasons applied — the screen is really tiny, the item takes most of the screen, the item is larger than half the screen, there is not enough free space around it, the item is clipped by the viewport edge, or none of those and it covers the selection anyway. Each reason exists in two forms, overlaps and fully covers. Placement debug is where you watch it think: a scaled map of the work area with four rectangles drawn on it — the viewport, the selection's screen bounds, where the window actually is, and where the solver would put it — under the sentence it chose.
A window that tells you why it is in your way is a small thing, and it is the same instinct as the alarm in §06: the program would rather explain a bad state than present it.
| Toolbar control | What it does | Grade |
|---|---|---|
| Debug ▸ Show Layout Map | Placement debug. The 220-pixel map above, plus the solver's twelve-way reason string | never viewed |
| Bake / Clear Bake | Medium bake. On a Medium Container node: freeze the live procedural into a cached grid, after which the node reads “Baked N³ static field” instead of “Live preview only until baked”, and a Clear Bake button appears. The node's own Bake Res slider stops at 96; the dispatcher clamps at 128 and, at zero, falls back to the graph's master volume reference resolution — so the slider cannot reach the resolutions the baker will accept | never viewed |
Dropping an image file over the open editor makes it a Texture node at the cursor. Eleven extensions are accepted — png jpg jpeg bmp tga gif psd hdr pic ppm pgm — and the drop is silently ignored unless the node editor panel is open — a wart, named here because it should be: there is no feedback for a drop that goes nowhere.
The other direction is stranger and better. Any Texture node can request a snapshot of the clean frame — after the effects chain, before data moshing — as its own image. So you can photograph the scene from where you are standing and then project that photograph back into the scene through a captured camera pose, where it lands in the tracer's albedo array and receives correct global illumination. It is the strangest and best thing in the editor. Nothing on this site has shown it happening yet; the recording below is the shooting instruction.
The limitation, in the same breath. nodeGraph appears nowhere in src/scene/. No scene preset builds a node graph. Every textured or procedurally-shaded frame this project has ever produced was hand-authored in the editor and cannot be shipped as a preset, replayed from a config, or captured headlessly. That is also why nothing here is photographed yet: the tool has no persistent output the rest of the pipeline can pick up, and the shot list above is how it gets photographed anyway.
03 — The comparison report
Pick two or more of eight integrators, give each a time budget, press one button. The renderer switches modes on a timer, captures each one, and writes a folder you can open in a browser: one PNG per integrator, an index.html that wipes between them under your cursor, and a config.json that replays the run.
It is the most on-message tool in the program, and this section is the first place on the site it gets more than a ledger row. That row grades it measured, which is the wrong axis: it emits PNGs, HTML and JSON, not a validation number, and no generated report exists in this repository. This page grades it never viewed and stands by the stricter mark.
| Stage | What happens | Detail |
|---|---|---|
| Select | Eight checkboxes: GPU Path Tracer, Photon Mapper, VCM, MLT, PSSMLT, UPBP, Path Guiding, Manifold | The Generate button is disabled below two |
| Budget | Seconds per integrator, 1–120, with the estimated total printed live | Equal time, not equal sample |
| Run | Saves your current mode and every per-integrator enable flag, then walks the list, invalidating and re-arming each integrator as it goes | Progress bar, per-integrator status line, and a tooltip asking you not to turn the camera |
| Capture | Reads back the display texture and writes <Integrator>.png | CPU integrators render at half the scaled viewport |
| Report | Writes index.html with the images, names and layout inlined as JS | Three layouts, below |
| Round-trip | Writes config.json — 81 leaf fields across viewport, camera, render, compare, eight integrator blocks and the scene | Droppable back onto the panel to restore the run |
| Restore | Puts your integrator mode and all seven enable flags back the way it found them | Output folder test_YYYYMMDD_HHMMSS/ |
The generated page is a single canvas that redraws on every mouse move, clipping each integrator's image into a region defined by the cursor. There are three:
A Shuffle button rotates which integrator lands in which region, which matters more than it sounds: a wipe comparison has an ordering bias, and rotating the assignment is how you check that what you are seeing is the algorithm and not the position.
The export is 8-bit. Capture reads the display texture back as float, clamps each channel to [0,1], quantises to a byte and writes PNG. So the "only working image export in the program" is a post-tonemap, low-dynamic-range screenshot. It is fine for a side-by-side and useless for a ratio — which is exactly why not one number anywhere on this site came from it. Every measured result was taken from a PGR1 float film dump instead (§04).
Equal time is a choice, and it is the harder one to read. Eight integrators with wildly different per-sample costs, each given the same wall clock, produces the comparison a user cares about and not the comparison a paper reports. Nothing in the panel records sample counts, so a report cannot be converted to an equal-sample comparison after the fact.
The JSON round-trip has a hole, and it is the whole reason the Comparison JSON import is graded scene-dependent — one of the two grades this site draws no glyph for — rather than seen: what it restores is restored correctly, and whether the restored run means anything depends on which room happens to be loaded. The exporter writes scene.preset. The importer never reads it. Everything else comes back — camera pose, focal length, f-stop, focus distance, film size, aspect, max bounces, exposure, light strategy, spectral mode, resolution scale, the budget, the layout, the eight selection flags and every parameter of all eight integrators — but the scene does not. Load a saved comparison while a different preset is up and you will replay the settings against the wrong room, silently.
And one latent trap worth naming. The importer restores the photon mapper's v2Mode flag from JSON. That flag gates the entire photon-gather pass. It has exactly three references in the source — its declaration, one read in the render loop, one read in a status string — and no widget anywhere. A config.json carrying "v2Mode": false therefore turns photon gathering off with no control in the program that can turn it back on.
04 — Two images in one frame
The comparison report is the offline one. Two more run live, and the distinction between them matters: one compares two configurations of the same estimator, the other compares two different estimators of the same scene.
Two independent estimator passes over the same scene and camera, composited as a wipe with a draggable divider, and a radio deciding which side advances so you can freeze one and let the other climb — the frozen side is labelled as such. Side B overrides strategy, bounce counts, beam radius, guard, MIS point, visualisation, and the comp-mode backup and seam, so "A composited against A alone" is one field apart.
Its mechanics — why both panes bypass the stylisation chain, and why identical settings give bit-identical panes because the two sides share an RNG stream — are argued on Where the fireflies come from, and this page defers to it.
A grade disagreement, resolved in the strict direction. The feature catalogue marks both Split A/B and clean view seen; the visualisation page marks both never viewed. The two are answering different questions — "is this code path exercised" versus "has a person driven it and looked at the result". This page uses the second, stricter reading for Split A/B, because the page that owns that feature says no capture of it exists anywhere in the repository; and it keeps seen for clean view, because clean view is exercised deliberately in nearly every capture instruction on this site even though nobody has photographed the act of pressing G. Where two of this project's own documents disagree, the disagreement is worth printing rather than picking a winner quietly.
A separate window: the CPU photon-primitive estimator live on the left, a live GPU path trace on the right, at an aligned bounce depth, with a draggable divider and a bounce-offset slider for the GPU side. Two switches make it usable rather than decorative — a GPU medium-only mode that restricts the right-hand pane to medium transport through the path-type filter with no shader change, and a full-resolution toggle for a true pixel-for-pixel compare rather than a scaled one.
Neither. Every ratio, median and byte-identity claim on this site came out of the linear-HDR PGR1 film dumps, whose format and validation role are set out on How I know the pictures are right. What belongs here is only the driving surface: three hooks, one analysis script, and enough config keys to run the whole estimator with no window at all.
One detail of the GPU hook is an instrument in its own right: as it writes, it prints four probe luminances — centre, two points toward the light, and a corner — so a dump alone tells you the GPU's scatter-order semantics before you have compared it with anything. That is the kind of thing that only gets built after it has cost somebody a day.
The division of labour. The visualisation page puts it best and it applies to everything in this section: these tools decide where to look. They never decide whether the answer is right. A wipe is an argument; a float film and a median ratio are evidence.
05 — Driving the viewport
Clicking in the viewport does three things at once: it selects, it can focus, and it can set the autofocus distance from the clicked point. All three run through the same 169-line CPU intersector that also serves the gizmo, the ray tree and every CPU integrator — a flat scan over visible entities, no acceleration structure, because there are at most a few dozen analytic primitives in any scene.
Middle-mouse on a selection raises a transform gizmo: translate arrows, rotate rings, per-axis scale squares, and a right-button uniform-scale circle. Point-light proxies get the same gizmo restricted to movement, which is the correct restriction and a nice touch — a light has no scale to drag.
The part nobody has photographed is in the Visualization panel. It draws an animated mirror of the gizmo — three breathing axis arrows and their handles, hand-drawn into the panel's draw list — and that diagram is itself draggable and drives the real transform, staying in sync with the viewport gizmo in both directions. The legend adapts when the target is a light. It is a legend that works.
Fire a branching tree of rays and look at where light actually goes. Forward from a point you click, or in reverse from a random emitter, which is the mode that answers "why is nothing reaching this corner".
Segments are coloured by weight, interaction type and whether they are inside a medium, sharing the same glyph vocabulary as every other overlay in the program — cyan for the camera side, warm yellow for the photon side, a red dot at a swept or specular vertex. Six bounces at twelve splits is 3 million leaves if you took it literally; the render modes exist because you cannot look at that, and the occlusion-sample slider exists because deciding which of those segments are actually blocked is the expensive part.
G hides viewport indicators so that what is left is the renderer's output — nine guard sites across the render and overlay paths honour it, including a blanket early-return in the debug-viz frame and a suppression of all HUD text. It is why almost every capture instruction on this site says "press G". The visualisation page's inspection also found what it misses: photon dots, path overlays, the KD-tree, physics particles, the hover material badge and the dolly-zoom HUD are drawn outside those nine guards and survive it. Nine guards is the coverage, six overlays is the gap, and both are counted.
A correction to the sentence above. It called the last of those six a “read-out” until 2026-09-06. It is a full cinematic HUD — letterbox bars, pulsing accent lines, a crosshair, four corner brackets, a DOLLY ZOOM [Q] title and a live focal-length pair — and it is drawn inside the performance overlay's own function, after that function's early return. The performance overlay defaults to off, so by default pressing Q puts nothing on screen at all; turn View ▸ Performance on and clean view then hides none of it. Worse, the greyed Debug Overlay checkbox beside the dolly controls carries a tooltip reading “no dolly overlay was ever written” — and some fifty lines of one sit about a hundred and fifty lines below it in the same file. The checkbox really is inert; the sentence next to it is simply false. Argued in full on The camera is not a viewpoint.
Q, in FPS capture, pins a target at the current focus distance and then solves focal length from distance on every frame:
| Key | Does | Note |
|---|---|---|
| 1–9, 0 | Select integrator mode 0–9 and jump to that mode's settings tab | Suppressed while Ctrl is held |
| Tab | Cycle all ten techniques | The switch is deferred three frames so the GPU can drain |
| Ctrl+1/2/3 | Pinhole / thin lens / tilt-shift camera | The third is inert and honestly marked (WIP) |
| F1–F8 | Shaded, Wireframe, Normals, UV, Depth, Tangent, Material ID, Checker | |
| G | Clean view | Chosen over Q because Q was taken twice already |
| B / Home | Selection outline off / all selection and hover visuals off | B exists because standing inside a selected fog volume tints the whole screen |
| Enter / Esc | Capture and release the mouse | Right-button also releases |
| V | Walk ⇄ noclip | WASD, Space to jump with grounding, scroll sets speed, Shift/Ctrl/Alt multiply it |
| E / Q | Projector on-off / dolly zoom, in FPS capture — and ascend / descend, in noclip and fly | Double-bound. In captured noclip both fire from one press |
| R | Camera-driven wind force on the physics sim | |
| F12 | Take Snapshot | absent — logs "Snapshot requested" and returns |
The double binding is real and the source half-knows it. The comment next to the clean-view key explains that G was chosen "because Q is already the FPS descend key AND the dolly-zoom toggle" — the collision is documented as a reason to avoid a third use, and never fixed. Both handlers are gated on viewport capture; the movement handlers run in noclip and fly. So in captured noclip, E ascends and toggles the projector, and Q descends and toggles the dolly zoom. Found by reading both handlers, not by driving it.
What follows is the tail: overlays, chrome and shell controls that work, that this site has been photographed through for twelve pages, and that have nothing to argue. Under the rule published on Everything else a feature earns prose only where the page can say something its own name does not — so these get a row, a clause and their grade, and no paragraph.
| Control | What it is, in one clause | Grade |
|---|---|---|
| Pick-ray debug | The last picking ray in yellow with a red dot at the hit, a green image-plane quad one metre out built from the live field of view and aspect, and a text line giving the origin, direction and hit distance. Its own legend reads “Yellow=pick ray, Cyan=aperture, Green=image plane” — and the cyan ring is a fixed 0.05-unit marker of where the aperture is, not a drawing of how big it is | seen |
| Better selection / Selection occlusion | Projected glow silhouettes instead of the coarse screen-space box, and a second toggle that filters the silhouette against the depth buffer so hidden parts fade. Both are read in the render path and both honour clean view | seen |
| Interactive gizmo mirror | The panel-drawn gizmo described at the head of this section: animated, draggable, two-way with the viewport gizmo, legend adapting for lights | never viewed |
| Accel structure overlay | Orange dashed BVH internal nodes, or yellow non-empty grid cells with an option to show the empty ones, plus green and cyan per-primitive AABBs. It is the only consumer either structure has: the panel prints its own not-implemented border beside both, so the overlay draws boxes no ray traverses | seen |
| Window title telemetry | RadianceLab | FPS | renderer | camera | frame, rebuilt every frame. The fourth field is the camera class's own name, not the integrator mode as the catalogue's copy of the format string has it — which makes the title bar one of exactly two places in the program that read typeName(), the other being the estimator's refusal message on the camera page | seen |
| Renderer radios | Rasterizer or Path Tracer. activeRenderer has 21 references outside src/ui/ and every one of them is a read | seen |
| Menu-bar right cluster | A Node Editor button under a three-layer pulsing glow, a Top-Down projector-setup button with the same treatment, an FPG Select / Pick-Off toggle, and the live frame rate | seen |
| Custom font stack | One face, not four: SourceCodePro-Regular.ttf is loaded four times at 16, 21, 13 and 17 px with rasteriser multipliers 1.15, 1.25, 1.10 and 1.30, so the four “weights” are synthesised from one file. Three search paths are tried and the default ImGui font is used silently if none hits | seen |
| Entity context menu | Right-click a Scene Hierarchy row: one item, Hide or Show, which flips visible and marks the scene dirty | seen |
| PP quick settings on the Overall tab | The estimator's 31-entry strategy combo, the arbitrary-plane angle when strategy 16 is up, and medium bounces — without leaving the tab. Its own comment explains the length is taken with IM_ARRAYSIZE rather than a literal, because a hardcoded count is what hid strategies 17, 18 and 19 from this dropdown until one commit fixed it | seen |
06 — Things that argue back
A path tracer has a failure mode that costs more hours than any bug: you turn on a filter to isolate something, you get distracted, and three days later you are debugging a black image caused by a checkbox you set on purpose. So the Technique tab watches for it.
The restricted-render warning banner pulses orange at the top of the panel whenever any of five conditions holds, and it names each one it found rather than saying something is wrong:
Underneath is a single button, Reset to full render, that clears all five and puts max bounces back to 8. That last part is the design decision worth naming: the alarm does not restore the value you had, it restores a value that renders. An escape hatch that returns you to an unknown state is not an escape hatch.
The banner is the loudest of three. The other two fire on the light-strategy combo, and they are worth separating because they warn about different things: one warns that the estimator you picked will be noisy on this scene, the other that it cannot fire at all with the camera you have.
The variance warning is the interesting one, because it does not say “high variance” and stop. Selecting naive sampling prints (!) Naive fog is high-variance, and hovering it gives the mechanism and then the two cases in this program's own preset library where it bites: a scene's tiny ceiling lights, and the pow(cos,500) laser profile, which random scattered rays almost never hit. Beside it each strategy prints its own path-type algebra as a coloured line — + L(D|S|V)*E for what it gains, − LS(D|V)*E (no caustics) in red for what pure next-event estimation loses. A warning that names the preset it will fail on is a different object from a warning that names a risk.
The pinhole-size warnings have three severities and two thresholds, both readable off the source: red Needs pinhole size > 0! below 0.01 mm on a pinhole camera; red on a thin-lens camera at any size, because its aperture is too small for a naive light tracer whatever you do; and amber Naive LT converges slowly. Try pinhole 500+ mm. up to 200 mm, above which it stops being drawn. What that estimator needs, and why, is argued on The camera is not a viewpoint.
| Warning | Fires when | Grade |
|---|---|---|
| Restricted-render banner | Any of five narrowing filters is on, or max bounces is 0. Names each, and offers one button that clears all five | seen |
| Variance warning per strategy | Naive sampling is selected; names tiny ceiling lights and the pow(cos,500) laser as the cases it fails on | seen |
| Pinhole-size warnings, three severities | A light-tracer strategy is selected with an aperture that cannot receive it: < 0.01 mm, any thin lens, or < 200 mm | seen |
PrismShapeTest is a real compiled binary, linked against glm and nothing else — no GL, no window — so it builds in seconds and runs headless. It is produced by every ordinary build, and main returns 1 on any failure, which makes it a CI gate that already exists rather than one somebody should add.
It covers the two pieces of the photon-primitive machinery that are pure functions: the analytical shape classifier (which swept dimensions produce which shape) and the descriptor and evaluation helpers (Jacobians against finite differences, sphere-shell two-root cases, domain rejection, the legacy-versus-canonical warp, an inverse-square check, a multi-light furnace fold, and a Henyey–Greenstein sampler checked against a 200,000-point quadrature).
Sixty-one, not forty-four — and the visualisation page was right all along. This project's feature catalogue says 44 in a footnote; the visualisation page says 61. The catalogue counted only expectTrue and expectApprox, missing the classifier's two other assertion helpers entirely, and it adds that "several are inside loops" — there are three loops in the test files and not one of them contains an assertion.
The count that matters is the number of times the counter increments. Each of the four helpers — expectTrue, expectApprox, expectType, expectPath — increments once per call, expectApprox delegates rather than double-counting, and none is called from a loop. That is 14 + 10 + 7 = 31 in test_ppshape_classify.cpp and 18 + 12 = 30 in test_pp_eval.cpp: 61.
A correction to this section, which was itself a correction. It first published 60 and called 61 wrong, by counting six expectPath calls where there are seven — the missed one is at test_ppshape_classify.cpp:150. And it said the binary prints a total: it does not. Each translation unit declares its own static int g_total (test_pp_eval.cpp:12, test_ppshape_classify.cpp:16), so main at test_pp_eval.cpp:322 runs two independently-counting suites and prints two subtotals, never a sum. A section written to correct two other documents got the arithmetic wrong in the direction that made its own case, which is the only direction that matters. A claim about a test suite that nobody re-derives is not a gate, it is a rumour — including this one.
Five options in the estimator ship with an explicit guarantee in their own tooltip — off is byte-identical. Correlated batching off, surface NEE off, specular continuation off in both surface-NEE states, Lambertian emitter off, camera-leg far cap off. The claim is testable in the strongest way available: render, hash, flip the switch off, render, hash again, compare. A feature that promises to be free when disabled and is checked by md5 is a different kind of object from a feature that promises it in a comment.
Beside them sits the comp-mode class counter, which is the same instinct pointed at a harder target: per-class hit and energy counters on both estimator layers, printed at a fixed pass count, that raise <<< BOTH LAYERS -- DOUBLE COUNT when a bucket is non-empty on both. Its own comment explains why it has to exist — a mean averages a double count away, a median ignores it, and a seam shift cancels it out of both films. measured
07 — The preset menu
One submenu, sixty-three entries, labelled bands, one click each. It reads like a convenience. It is the scene editor.
The menu spans the whole history of the project: the Cornell Box that every CPU-versus-GPU ratio was measured in, the noise and reaction–diffusion volumes, four NEE test rigs, the Veach MIS plate, two analytic gate scenes that exist only to be checked against closed-form answers, the caustic labs, six showcase rooms with names like The Reliquary and Verdigris Cellar, and — added through 2026-09-06 — the four gate apertures, the two chain pairs and the five-member gloss series that the newest estimators were validated on.
| Count | What | Note |
|---|---|---|
| 63 | Menu entries | Verified by counting the writes to scenePresetRequested in UIManager.cpp at the current commit — 62 of them, with 62 distinct values, plus the one greyed entry that writes nothing. It was 44 until 2026-09-05, then 50, then 63 as the gate, chain, mirror and gloss families landed on 2026-09-06. It is one BeginMenu, split into visual bands by TextDisabled headers — “eight submenus” was the wrong noun for a right number |
| 62 | Menu entries that build something | Not quite 62 distinct scenes: 70 and 71 are one scene from two poses, so the menu reaches 61 rooms. Preset 9, "Outdoor", is the entry that builds nothing — it has no dispatch branch. Until 2026-09-04 it fell through and built a plain Cornell Box under the wrong name; since then the entry is greyed out, disabled and labelled "(not built)", with a tooltip naming the two open scenes to use instead — still an inert entry, now an honest one |
| 3 | Builders with no menu entry | Presets 38, 39 and 43 — the sphere-, cube- and ball-light foggy boxes — reachable only through pp.scenePreset |
| 65 | Scene builders in total | Counted as scenes built, not functions: presets 58–62 are one function, createLightSizeRig, called at five emitter sizes; 72–76 are one createGlossRig at five roughnesses; 70 and 71 are one scene from two poses, the second of which is a reference view rather than a picture |
| 2 | Numbers that do not exist | 45 and 53 have neither a menu entry nor a branch |
| 13 | Call sites of one factory | vclBuildShowcase assembles a room, a ball, an occluder and a prop from typed structs, and it is now called thirteen times in ScenePresetVolCaustic.cpp — the four showcase scenes, the three rich rooms, The Gate and three of its four apertures, the light-size rig, and the gloss rig, which calls it with no glass ball at all. This row has been wrong twice: it first said seven and omitted The Apothecary, then said eight and stopped counting when the gate and gloss families arrived |
| 4 | Shortcut buttons in the PP panel | The gate scenes, one click from the estimator that uses them |
The whole menu is a list of names, and a list of names is not worth a page. These are the entries the rest of this site keeps referring to without ever saying what is in them — plus the two source-only routines that assemble most of the recent ones, and the two numbers that are not there at all.
| Entry | What is in the room | Grade |
|---|---|---|
| Preset 4 — Glowing Spheres | Five tinted emissive volume spheres on the legacy noise bands | seen |
| Presets 10-13 — RD Box | The four reaction–diffusion patterns as one large volume box | seen |
| Preset 16 — Cornell + Glowing Volume | One fire-band volume with a positive volume-emission strength: a volume that is the light | seen |
| Preset 30 — Prism (Rainbow) | A dispersive glass prism and a laser emitter. It auto-enables spectral mode, and it holds the repository's only rotated box | seen |
| Preset 34 — Foggy Cornell, black walls | The apples-to-apples reference room for the estimator's gate C | measured |
| Preset 35 — Simple Foggy Box | One ceiling quad emitter, pure-scattering fog, black walls — the simplest scene in which a volume estimator can be wrong | measured |
| Preset 37 — Multi-Light Foggy Box | Four lights of four kinds — quad, line, laser, point — each with its own colour and emission profile. It is the scene the variance warning in §06 is describing | measured |
| Preset 42 — Cam-Ray Hit Ref | One area light dead ahead in vacuum, so the centre pixel must read exactly the emitted radiance. A scene built to have a known answer | measured |
| Preset 47 — Vol Caustic Lab | Three glass balls and two auto-aimed reflectors, the second red-tinted. A site hero image, and the room most of the capture recipes on this page name | seen |
| Preset 49 — Lens Array | Five identical glass balls, five analytic caustic cones. Wants a minimum bounce of 2, or the direct light drowns what it is showing | seen |
| Preset 50 — IOR Ladder | Equal radii at index 1.3, 1.5, 1.7 and 2.0 — a staircase of foci. Same minimum-bounce caveat | seen |
| Presets 45 and 53 | Do not exist. No menu entry, no dispatch branch, no builder — two gaps in the numbering rather than two missing scenes | absent |
| Parametrised showcase factory | vclBuildShowcase composes a room, a ball, an occluder and a prop from typed structs, so a new showcase is a struct literal rather than a new function. It is the mechanism behind the count in the table above | seen |
| vclSlottedPlane — gobo generator | Tiles a rectangle with a rectangular hole out of four coplanar quads. Any band thinner than 0.15 in half-extent is dropped rather than emitted — the comment beside the test reads “too thin to survive the pads”, and the pads are the +0.01 the quad builder adds and the +0.02 the plane intersector adds on top of it, which between them would grow a thin band across the hole it borders | seen |
Several presets configure the renderer as well as the scene, which is the difference between a demo and a rig: the prism scene auto-enables spectral mode, the Veach test auto-selects power-weighted light selection because its lights span a factor of five hundred, and two of the config-only fog boxes force volume-emitter sampling on. Fourteen of them now have an opinion about lensing, across ten dispatch branches — 24 to 34 mm, each with its framing reason written beside it in source. That is also the origin of a bug this site reports fixed on the camera page: a focal length set by one preset used to leak into every preset loaded after it, and it is fixed by a global reset in the prologue that the dolly-zoom solver overwrites one frame later.
Now the part that makes the menu load-bearing. entities.push_back and lights.push_back occur only inside preset builders — nowhere in src/ui/. There is no way to add or delete an entity or a light from the interface. You can move, scale, retexture, relight and rewire what a preset built, and you cannot change the cast. absent
File ▸ Save Scene, File ▸ Open Scene and File ▸ Export Image are menu stubs that draw a "not implemented" border; F12 logs a line and returns. So the scene graph has no serialisation in either direction, which is the same fact from the other side: the sixty-five builders are the file format.
This is worth stating plainly rather than burying, because it reframes the node editor in §02. That editor is not a scene authoring tool that happens to lack a save button. It is a material authoring tool for a fixed cast, and the reason no preset ships a node graph is that presets are C++ and graphs are runtime state, with nothing in between.
08 — Inert and mislabelled
Publishing this list is a claim about standards, so it belongs on the page about the instrument rather than in a footnote somewhere. A knob that reaches nothing is a bug in the interface even when the algorithm underneath is perfect, because the whole premise of this site is that a screenshot means what it appears to mean.
The test applied to every one of them: the variable this widget writes has zero reads outside src/ui/, and there is no alias or derived write carrying the behaviour elsewhere. That second clause is not decoration — two candidates failed it during the audit and are graded live instead. The projector's "Size Matching" block looks UI-local and in fact computes an angle that three render-side files read; the fixed-tracer-seed toggle greps as four UI-only references and reaches the GPU through a reference alias one line long.
| Control | Where | Status |
|---|---|---|
| Photon ▸ Jacobian | Photon tab | Two references: its declaration and its own toggle. Sets the rebuild flag |
| Photon ▸ Base Primitive | Photon tab | Three references, all in src/ui/. Sets the rebuild flag |
| Photon ▸ Final Primitive | Photon tab | Same. Sets the rebuild flag |
| Russian Roulette toggle | Tracer ▸ Basics | The algorithm runs unconditionally regardless — kill below 0.1 throughput after bounce 3 |
| Light tracer ▸ Paths/Frame | Light Strategy | Sits directly above the working Paths/Pixel slider |
| Reconstruction Kernel | Tracer ▸ Basics | Box / Tent / Gaussian, none of them read |
| MSAA / Antialiasing knob | Raster | Greyed, labelled [inert] and tooltipped: the window is created at one sample per pixel and no multisample framebuffer is ever built. Its neighbour, backface culling, is live |
| Tonemapping combo | Effects ▸ HDR | All three options render Reinhard. ACES exists in a shader that is never used |
| Dolly ▸ Debug Overlay | View ▸ POV | Declaration and checkbox |
| Physics ▸ Show Debug Viz | Advanced ▸ Volume | The Fluid Viz combo beside it is the real gate |
| Manifold ▸ Newton iterations / step size | Manifold tab | Sliders on a solver whose Newton step count is a hard zero |
| Active Integrators ▸ Photon Mapper thumbnail | always visible | bgTexPM is declared and never assigned, so mode 2 shows the path tracer's image — under a caption, added later, that says so. Mode 9 falls back the same way and has no caption |
| prism.json decorative keys | config file | Backend, integrator, spp, russianRoulette, spectralMode, tileSize, and the whole camera, debug and physics blocks are read by nothing |
Thirteen of thirty-two are above; the full list is in docs/FEATURE-CATALOGUE.md §8, and nine of them are honest — they say "(WIP)", or draw a not-implemented border, or in one case are literally labelled [inert] in the widget text. Those nine are a completeness gap, not a communication failure, and they are not the problem. The list also grows in the right direction: the catalogue's own addendum, §8a, written while acting on §8, withdrew one row (the UPBP beam overlay draws after all) and found five more inert controls by chasing the ones it had — a "Shoot Ray" button with an empty handler, two placeholder overlays, a Regen button that regenerates nothing, and a beam radius no estimator integrates over. The true figure is therefore at least thirty-six. The headline stays at thirty-two because that is what the four-sweep count produced; the delta is recorded rather than a new total invented.
These are the ones that matter, and they are all in the same panel. The Photon Mapper's Jacobian toggle and its Base Primitive and Final Primitive combos each set the photon-map rebuild flag and the accumulation reset when you touch them. Nothing else reads them. So the map visibly rebuilds, the image visibly restarts from zero, several seconds pass, and a new image resolves that is identical to the old one.
That is worse than doing nothing quietly. A dead control teaches you it is dead the first time you try it. A control that performs the ritual of working teaches you that the parameter has no effect on this scene — a conclusion about the renderer drawn from a defect in the panel. If any single item on this page is a bug rather than a gap, it is these three.
Eighteen controls promise something other than what they do. The worst are not the inert ones — they are the ones that are busily doing something else:
Two entries on that list of eighteen are rows on this site's own landing-page ledger, wrong for the same reason as everything else here: a claim written once and never re-derived. One of them said four material models are unreachable from any preset; a preset added later reaches all four.
The four inert ones below are the honest kind: each is greyed, bordered or captioned in its own widget text, so nobody is misled by them. They are listed because a completeness gap you can see is still a gap, and because three of the four were made honest after the audit that found them, which is the useful half of publishing a list like this.
| Control | What is really there | Grade |
|---|---|---|
| Volume Tracking ▸ Ratio Tracking / Null Scattering / Ray Marching | Three of a four-entry combo whose selection is a function-local static int. Picking one draws a not-implemented border and the words “Not yet implemented”; picking the fourth, delta tracking, prints a green line claiming the active, unbiased method — which is the one the shader really runs | inert |
| Menu bar ▸ Physics (Start / Pause / Reset) | Three disabled items under two lines of their own text saying that the physics is implemented and that its start, pause and reset controls live in Render Settings ▸ Volume. A dead menu that points at the live one | inert |
| Advanced VFX ▸ stAdvIterations | An iteration count on a transfer that is a fixed two-pass structure-tensor plus statistics transfer. Greyed and labelled [inert], with a tooltip naming the three sliders beside it that are live | inert |
| Advanced VFX ▸ ddAdvChannels | An 8-or-16 channel combo read only to set its own index — no uniform, no dispatch size. Greyed and labelled the same way | inert |
| Expr ▸ Negative Emission | The switch from the list above, and the one row here whose grade this page disputes. The catalogue grades it never viewed — reachable, never deliberately rendered — and records its tooltip's promise that sampling takes the absolute intensity so both signs contribute. The source disagrees: the flag has no reader outside src/ui/, and every emissive gate in the megakernel is a signed comparison against 0.01. Graded here as the catalogue grades it, and contradicted in the same row | never viewed |
Why publish it at all. Because the alternative is a portfolio in which every control looks equally trustworthy, and this one contains three that rebuild a photon map to convince you they did something. The list is the standard: a feature is not shipped when it compiles, and a number is not true because it was written down.