Light transport research · RadianceLab
A real-time GPU megakernel that recompiles itself to the render state, and a CPU integrator bank that runs interactively beside it. The two share a scene and a film format and almost nothing else — which is exactly what makes each usable as evidence about the other.
Above: preset 47, the mirror-relay room — rendered by the CPU photon-primitive integrator at strategy 21, not by the megakernel this page is about. Its black walls are the reason: that integrator reads no surface albedo, so a frame from it is fog, caustic and directly-seen emitters only. Nothing in this scene is a mesh. Every surface in it is a sphere, a bounded plane or an oriented box, intersected analytically.
01 — Shape of the thing
A scene is a flat list of entities with analytic primitive types. It is uploaded to the GPU as four hand-packed SSBOs, and it is walked directly on the CPU by a 169-line intersector. Those are two separate implementations of the same five shapes, written at different times, and that independence is what makes a number produced by one of them count as evidence about the other.
Ten transport modes sit on top of that one scene — the GPU megakernel in real time, eight CPU integrators interactively, and a rasteriser. Number keys switch between them; the viewport keeps its camera and its film. Nine of the ten write the same linear-HDR film, and the headless harness dumps that film in one format — a four-byte magic, three ints, then w·h·3 floats — so a GPU frame and a CPU frame are directly subtractable.
| # | Mode | Where it runs | Status on this site |
|---|---|---|---|
| 0 | Rasteriser | GPU, GL | Scene inspection and LTC area lights |
| 1 | Path tracer | GPU megakernel | The reference path. §02 onward is about this |
| 2 | Photon mapper | CPU | Surface only; the KD-tree is real and used here |
| 3 | VCM | CPU | Surface only — skipVolumes = true, hard-coded |
| 4 / 5 | MLT / PSSMLT | CPU | Surface only; chain and acceptance visualisers built |
| 6 | UPBP | CPU | Beams are built, uploaded and drawn — and never queried by the estimate |
| 7 | Path guiding | CPU | Spatial radiance cache with a directional field overlay |
| 8 | Manifold exploration | CPU | Mislabelled. newtonIterations is written into its config and read nowhere; it is an MLT loop |
| 9 | Photon primitives | CPU | The research. 31 strategy slots, 30 of them estimators and one a rasterised gather; 21 analytic swept manifolds, six kernel estimators, three baselines |
Mode 0 is not only a viewport. The raster panel carries a tracer-guided rasterizer — a toggle and a two-way mode, screen-space blend or vertex-baked, which reprojects the tracer’s own colour onto rasterised geometry and blends it over the raster shading so direct lights still contribute; the second mode is there for painterly and data-mosh passes. Beside it sits an experimental LTC area light, linearly transformed cosines after Heitz et al., which stands down to a legacy fallback light the moment the scene has a real quad light of its own. Both have readers in the raster renderer, both are one click from the panel, and both are never viewed: no capture of either exists anywhere in this repository. They are on this page because the honest thing to do with a working feature nobody has photographed is to name it, not to quietly leave it out of the mode table’s one-line summary.
Neither selector on that panel behaves the way a radio button usually does. A mode change is queued rather than applied: the click sets a pending target and a three-frame cooldown, the radio shows the target instead of the state, the viewport prints "switching to…", and only when the counter reaches zero does the engine call glFinish and invalidate all eight CPU integrator instances — including the split view's second photon-primitive integrator — before the mode actually changes. Three frames of latency, bought to avoid freeing a resource the GPU is still reading.
The light-strategy combo is stranger: its display order is not its enumeration. Seven entries map through comboToStrategy[] = {0, 1, 7, 2, 3, 4, 5}, because internal ID 6 used to be the photon mapper and is now an overlay with a tab of its own — and a saved configuration still carrying strategy 6 is silently rewritten to Naive on load.
| Control | What it actually does | Catalogue grade |
|---|---|---|
| Deferred, cooled-down mode switch | A three-frame cooldown, then glFinish, then eight integrator invalidations, then the switch. The pending target is what the radio draws | seen |
| Auto-enable on switch | Defaults on, and it is what makes a mode key work: completing the switch ticks that mode's own Enable flag. Every one of those flags is declared false, so with auto-enable turned off a mode key selects an integrator that does not run | seen |
| Medium-support legend | Ten bullets under the radios, green [Vol] on the three modes with a volume path — GPU tracer, UPBP, photon primitives — grey on the other seven. The claim §07 of this page makes, printed in the panel that makes it | seen |
| Strategy 2 — MIS (naive + NEE) | The power heuristic over BSDF sampling and next-event estimation. Fourth in the picker, ID 2 internally | seen |
| Strategy 7 — Light Sample Only | Pure s = 1: shadow rays, emission on a direct hit disabled, so no caustics, no specular chains, no sky — a deliberately biased probe of what light sampling alone recovers. It also kills volumes, and the panel says so physically: the Volume Medium toggle is greyed and struck through with a drawn line naming the strategy as the reason | seen |
The shared surface is narrow on purpose, and it has been audited. Two places where the two implementations of the same primitive do not describe the same object, both known and both bounded:
This is the design working, not failing. The GPU applies a view cosine |d·n| to a camera-seen emitter; the CPU emitter-hit term returns Le with no cosine. Both NEE paths agree, so only the L E class differs, and which convention is intended is still open. The acceptance gate that had run for months never caught it because its centre pixel views the emitter head-on, where cos = 1. Every CPU-vs-GPU ratio on this site masks directly-seen emitter pixels until it is settled.
02 — The kernel
The whole real-time path is one compute shader, 4,090 lines and 229 KB, and one glDispatchCompute per frame — bidirectional path tracing, fourteen materials, spectral transport and participating media in a single dispatch. A megakernel that large is slow to compile and, on some feature combinations, does not compile at all, so the engine specialises it rather than compiling all of it: it derives a four-bit feature mask from the live render state, builds a #define list from the mask, and rebuilds the kernel only when the mask changes.
The consequential detail is the last line of the recompile block: when a new variant loads, the accumulation buffer is reset, with the comment saying why. The image the user was accumulating was computed by a different estimator, and averaging the two would be quietly wrong. Turning spectral mode on therefore costs you your converged frame, visibly, rather than silently corrupting it.
A compile failure at this size is a real operating condition, not a theoretical one, so the recompile is a measured ladder rather than a single call, and every rung is a state the renderer keeps running in:
Rung two exists because a combination was measured to fail. The source records it in the mask function rather than in a commit message:
Startup deliberately compiles mask 0 rather than the mask the scene will want, because the minimal variant always fits and always starts fast; the first frame pays for the specialised one. Shaders are re-read from the build output at launch, so a shader change is a 6–14 second cycle with no rebuild of the C++.
The failure mode is louder headless than interactively. If even mask 0 fails, the application keeps running with no path tracer and logs one line. Headless that is fatal, because no film is written and the harness notices. On screen it just looks wrong.
03 — Geometry
There is no acceleration structure in the path tracer and no triangle in the scene, and both are decisions. traceRay is a linear scan over all primitives, and the volume interaction runs a second full scan per bounce. tinyobjloader is vendored and linked and appears in zero source files; not one triangle reaches the path tracer. It is worth the italics: the tracer's upload loop skips every entity whose primitive type is None, while the rasteriser uploads a GPU mesh for every entity that owns one, so an entity can carry both a triangle mesh and an analytic primitive and be a different solid to each renderer. One preset exercises exactly that, and the note below the acceleration table is what happens when it does.
The reason is not performance. It is that the analytic estimators this engine exists to serve need exact intersections, and one of them needs more than that. The photon hourglass sweeps a refracted photon about the axis running from the light to the sphere's centre, and its whole derivation rests on that revolution being an exact symmetry of the object. A tessellated sphere is not rotationally symmetric. It would not merely make the estimator slower; it would make it wrong, in a way that would show up as a bias nobody could localise.
So spheres, bounded planes, oriented boxes, volume spheres and volume boxes are the entire palette; every intersection in every frame is exact, and forty-nine scene presets are composed procedurally from it.
| Primitive | GPU | CPU | Note |
|---|---|---|---|
| Sphere | exact | exact | The one both paths agree on completely |
| Plane | infinite plane, per-axis clip | same | +0.02 pad on every axis, mirrored deliberately |
| Box | OBB, quaternion | AABB only | The two paths disagree on any rotated box |
| VolumeSphere | exact | exact | Medium container, not a surface |
| VolumeBox | AABB | AABB | No orientation on either side |
| Geometry and acceleration | State | Catalogue grade |
|---|---|---|
| Per-primitive UVs | Spherical for spheres, a plane-local tangent frame for planes, dominant-face projection for boxes — computed in the intersector, so every surface has a UV without anyone assigning one | seen |
| Only preset 30 has a rotated box | glm::rotate occurs exactly once in the whole scene layer: 45° about Y, on the dispersive prism. Everything else in forty-nine presets is axis-aligned, which is why the OBB/AABB split above has never bitten in practice | seen |
| Triangle meshes | Mesh and Vertex exist, are built procedurally in code, and are consumed by the rasteriser alone. The path tracer never sees one | superseded |
| Mesh file import | No OBJ, no glTF, no loader of any kind | absent |
| BVH (SAH, bucketed) | It builds, over per-entity AABBs, and the overlay draws its internal nodes as orange dashed boxes. intersect and intersectAny have no caller outside their own translation unit | inert |
| KD-tree | The one structure that is load-bearing: built for both photon maps, traversed by the gather shader on the GPU and by UPBP's kNN on the CPU, and carrying its own visualisation structs | seen |
The prism is two different solids, and the second one is 41 % too wide. Preset 30 builds its prism as six box faces with half-extents (0.5, 1.0, 0.5), then hands the tracer a pre-fattened half-extent of (0.7071, 1.0, 0.7071) — the axis-aligned bounding box of that cube rotated 45°, with the source comment saying exactly that. It was the right number when the intersector was AABB-only. The GPU box test now also rotates the ray by the entity's quaternion, so the fattening is applied on top of the rotation it was compensating for: the prism the path tracer intersects is a 45° box √2 wider in X and Z than the prism the rasteriser draws from the same entity, twice its volume. Read from the source, not measured — and the reason the primitive palette above lists a disagreement between the two intersectors and this section lists a second one inside the GPU's own path. The catalogue records the pre-fattening; what it does not record is that the OBB path double-counts it.
The tree code is not absent — BVH, Grid and KDTree all exist, are visualised in the viewport, and the KD-trees are genuinely used by the photon mapper. What does not exist is GPU traversal, and the interface says so itself rather than leaving it to a backlog:
What the decision costs. There is no image-texture route to surface albedo either — the tracer declares no sampler2D for it. Patterned surfaces come from a node graph baked to a per-entity texture array, or from a projector mode that welds a photograph into UV space. No preset builds a node graph, so every textured frame in this project was hand-authored in the editor and cannot currently be shipped as a preset.
04 — Materials
The material model is an explicit enum, not a heuristic read off metallic and IOR, and every one of its fourteen branches — Lambertian through thin-film metal, three-interface iridescence, dipole subsurface and Marschner-style hair — lives in the one megakernel and renders in real time. Ten of them are on the material showcase. The other four had no preset at all until the Apothecary (preset 56, a GPU-only room) was written to carry them: dipole subsurface on its jars, anisotropic brushed metal, hair, and passthrough on every volume prop. An earlier version of this page counted ten reachable and four unreachable; the catalogue caught the page predating the preset.
| # | Type | What is actually implemented | Reachable |
|---|---|---|---|
| 0 | Diffuse | Cosine Lambertian; also the fallback branch | preset |
| 1 | Metal | GGX conductor, albedo-tinted Schlick; delta below r = 0.001; optional thin film on the Fresnel | preset |
| 2 | Glass | Fresnel and TIR; GGX perturbation above r = 0.01, at which point it reports a pdf and joins MIS. Smooth glass is a delta vertex | preset |
| 3 | CoatedDiffuse | 4%-at-normal achromatic coat over a coloured base | preset |
| 4 | Paper | Lambertian sheet with 0.3·(1−cos) forward transmission | preset |
| 5 | Iridescent | Three-interface Airy reflectance per RGB wavelength over a GGX lobe | preset |
| 6 | Glint | Hashed world-position facet cells, four candidate normals. Density clamps at 50, so the top half of its 10–500 slider does nothing | preset |
| 7 | Snowflake | Reflected azimuth snapped to 60° increments | preset |
| 8 | DipoleSSS | Rough dielectric shell, classic dipole exit distance, Rodrigues walk. Silently inert on anything but a sphere — the branch searches for a sphere with a matching material index | preset 56 |
| 9 | Passthrough | Ray continues, stays specular | preset 56 |
| 10 | Mirror | Delta reflection tinted by albedo — but only while ior ≤ 1.01; above that Schlick washes the tint to white | preset |
| 11 | Anisotropic | Tangent frame built from the shading normal, so on a sphere it brushes along latitude | preset 56 |
| 12 | Hair | Marschner-like R / TT / TRT at 10 / 60 / 30 % | preset 56 |
| 13 | DispersiveGlass | Cauchy index; see §05 | preset |
Two cross-cutting behaviours are worth more than most of the individual lobes. Stochastic opacity is a passthrough Russian roulette that deliberately does not consume a bounce, so a half-transparent object does not eat the bounce budget behind it. Dielectric nesting depth is a counter incremented only on a transmitting refraction, and it is what stops fog being applied to the segment of a camera ray that is inside a glass ball — the fix for an interior that used to render black.
| Material system | What it is | Catalogue grade |
|---|---|---|
| The 14-BSDF enum | An explicit type on the material, not a heuristic read off metallic and IOR, and every one of the fourteen is reachable by loading a preset — a claim that only became true when the Apothecary was written | seen |
| Emissive material versus scene.lights[] | Two light systems that barely overlap. Every emitter the path tracer integrates comes from material.emissionStrength; the scene.lights[] array contributes only delta point lights, only when the point-light switch is on, only the first eight that are enabled, and — see §07 — only at surface vertices | scene-dependent |
| Material auto-clone on edit | Editing a material shared with an entity outside your selection group clones it first, renames the clone, and repoints only your group. It is why the six faces of a box edit as one object and the six faces of the next box do not follow | seen |
Four branches that went unlooked-at for most of the project's life. DipoleSSS, Passthrough, Anisotropic and Hair had no preset until the Apothecary assigned all four, and this page said "no preset" beside each of them for longer than it should have. The table's last column is what a reader can verify by pressing a key today; the correction is one of the rows on the how-I-know page.
The panel that edits those fourteen branches is not a stack of sliders. Its Surface tab runs a thousand lines and contains three plotters that no figure on this site has ever shown, each of which would be a feature in most renderers:
Three smaller pieces of the same panel are worth the sentence they take. Anisotropy angle is a separate slider from anisotropy amount, so a brushed metal's grain can be rotated independently of its strength — and it is the one field of the anisotropic branch that is never viewed. Negative emission unlocks a signed strength slider per material and per volume, and one preset ships a sphere at −10 — but the subtractive light does not arrive, and finding out took one grep. The Experimental tab's Allow Negative Emission switch has four references in the whole program: its declaration, the checkbox, the checkbox's own status line, and the preset that sets it. No uniform, no reader outside src/ui/. Its tooltip promises that “sampling uses abs(intensity) so both positive and negative emitters contribute proportionally” and nothing implements that: every emissive gate in the megakernel — the hit path, the surface light list, the volume light list — is a signed > 0.01, so a negative emitter is skipped by all three. The switch is inert, the signed slider is real, and this project's own catalogue still records the tooltip's promise as the behaviour. And per-entity emitter mute clones the material before it mutes, so silencing one quad does not silence its siblings, and it remembers the strength it took away.
05 — Spectral
The surprising fact first, because it catches people: plain Glass disperses. The branch that computes a wavelength-dependent index tests dispersionSpread > 0.001 and nothing else, so a material typed as ordinary glass with a non-zero spread refracts chromatically, and a material typed DispersiveGlass with zero spread does not.
Underneath is a full spectral pipeline. The engine carries a hero wavelength per path — one λ drawn uniformly from [380, 780] nm at the top of the eye path, stored so a bidirectional connection can reuse it. Spectral reflectance is up to eight Gaussian lobes per material, packed into the material SSBO and edited in the interface on a rainbow-backed canvas that replaces the albedo picker. Conversion back to RGB goes through the CIE 1931 curves with an explicit normalisation so an equal-energy white matches the luminance the RGB path would have produced.
Spectral mode feeds the hero wavelength straight into the Cauchy expression. RGB mode cannot — there is no single wavelength — so it runs a channel-level Monte Carlo instead: pick one of R, G or B uniformly, use that channel's wavelength, weight the throughput by three and zero the other two. The G wavelength is not fixed at 540 nm but slides between the R and B anchors under a slider, which is a control with no physical meaning that exists because it makes the fan easier to read on screen.
06 — Participating media
A medium is not attached to an entity — the medium is an entity, a volume sphere or volume box carrying its own scattering and absorption coefficients. Transport through it is delta tracking with null collisions, written inline in the eye loop rather than through the volume module's own routine, which is dead code. Density comes from one of seven sources, all dispatched by a single integer on the volume's config.
| Source | What it gives you | State |
|---|---|---|
| Homogeneous | Constant density. What every caustic preset uses | measured |
| Noise Lab | FBM Perlin / Worley / Ridged / Marble / Turbulence / Voronoi, 1–8 octaves with per-octave amplitude mutes; 15 one-click presets that also set σ and g | seen |
| Legacy procedural | 16 styles, including Gyroid and Schwarz-P triply periodic minimal surfaces | seen |
| SDF | Sphere, box, torus, cylinder, cone, octahedron, Mandelbulb, with an optional hollow-shell isosurface | seen |
| Physics texture | A 3D density texture written by the SPH / MPM / Euler solvers — real, end to end | seen |
| Baked node graph | Up to eight pairs of 3D textures — absorption, scattering and two channels of tint in one, the third and emission in the other | never viewed |
| Reaction–diffusion | A 3D RD pattern | branch runs, result discarded |
A grade corrected, on the sixth row. This table read seen for the baked node graph until 2026-09-06, and the project's own catalogue contradicted itself about it — one row calling the density source sighted, another calling the bake that is its only route unrendered. The stricter reading is the true one: nobody has pressed Bake and looked at the result. The route is also narrower than the row suggests, and The instrument panel now carries the anatomy — three routes from a medium graph to a voxel, of which one folds an arbitrary math chain to a single number at the volume's centre and one has exactly one procedural payload slot per primitive, so two generators wired into absorption and scattering do not both survive.
| Two edges of the medium model | What it does | Catalogue grade |
|---|---|---|
| Decoupled media [Experimental] | Three extra procedural slots on one volume — scatter, absorption, emission — each with its own style, scale, speed and parameter. They do not replace the density source; they modulate it, each slot multiplying the volume's ordinary density by its own field, with style 0 returning exactly 1. The vocabulary is the fifteen legacy procedural styles only, so a Noise Lab, SDF or reaction–diffusion field cannot drive a slot. Absorption that ripples where scattering is a gyroid, from one entity | never viewed |
| RGBA medium mode | A global switch that turns the medium colour picker from three channels into four and makes the alpha a per-volume density multiplier, scaling both σs and σa so the medium gets denser without changing its albedo. It is applied where the scene is packed for the GPU, which fixes its scope exactly: the megakernel and the rasteriser honour it, and every CPU integrator — photon mapper through photon primitives — reads the raw coefficients and ignores the alpha | never viewed |
All seven of those sources are reached through one panel — Properties, four tabs, the place an entity stops being a shape and becomes something with physics. Its Medium tab holds the seven density sources behind that single dispatch integer, the three phase functions with log and linear slider modes, decoupled media and volume emission. And it holds fifteen named presets that are the most under-described control in the program, because they are not noise presets at all: Fire, Smoke, Cloud, Lava, Nebula and the rest each write the scattering and absorption coefficients, the phase g and the volume emission alongside the ten noise parameters. “Fire” is scattering 6, absorption 0.1, isotropic, emission strength 5 at a red-orange. One button is an entire medium, not a texture.
The finding underneath that is the more interesting one. The Noise Lab in the node editor and the Noise Lab in this panel author the same kind of object into two disjoint halves of one per-primitive GPU record. The panel fills one block of it from the entity's own noise configuration; the node fills a different block from the node's parameter array. Neither can read the other's half, the node editor's version never touches the entity's configuration at all, and only the node's block carries a target mask and a blend mask. Two authoring surfaces for one field, agreeing only because they never meet — the same shape of finding as the two camera models on The camera is not a viewpoint, and found the same way.
How the reaction–diffusion row was established. Two defects, found separately. The scene's RD preset index was never assigned, which was fixed and verified at the upload boundary. The image still did not change, so the branch was forced to density = 0.0 and the probe re-run: byte-identical to the untouched build, centre pixel 0.04631 either way. So the branch executes and something downstream throws its result away. Unfixed, and the practical consequence is stated where it matters — shoot one preset per RD group, because presets 5–8 and 10–13 render identically.
Henyey–Greenstein short-circuits to isotropic below |g| = 0.01. Beside it sit an approximate three-lobe Lorenz–Mie — a size-dependent forward peak plus wavelength-split primary and secondary rainbow lobes near 138° and 130° and a backscatter glory, returning an RGB value so the scattering is genuinely chromatic — and a generalised Rayleigh with an optional 1/λ⁴ weighting.
Volume next-event estimation is skipped for the Mie and Rayleigh branches, because neither has a closed-form evaluation available at that call site. They render by phase sampling only. That makes them noisier by construction, which is a different statement from noisier by defect, and it is why a Mie fog box needs several times the passes of an HG one to read as clean.
That last row is doing more work than any other single number in the showcase scenes. At g = 0.55 the forward lobe is tight enough that a caustic cone reads as a beam with an edge; at g = 0 the same transport reads as haze.
Two limits found by running into them. Shadow-ray transmittance is Beer–Lambert per segment, homogeneous within each segment, so it does not integrate a heterogeneous density along the ray — a correctness gap that only bites on structured media, which is where the interesting media are. And a fog box larger than 22×14×22 makes the GPU path tracer render pure black. That is scale-dependent, unresolved, and currently avoided rather than fixed.
07 — Sampling and counting
The delta point-light connection appears at exactly one site in the megakernel, inside the surface shading block. There is no counterpart in the volume scatter branch. A scene lit by a point light and filled with fog therefore renders near-black on the GPU: the only way a camera ray in the medium can find that light is to hit it by chance, and it has zero measure.
That is not a bug in the tracer. It is the shape of the problem the photon-primitive family was built to solve, and it is the reason this engine's most-used reference is a stochastic photon beam rather than next-event estimation.
Five emission profiles sit on the emissive material itself, each with a polar tilt and an azimuth: diffuse cosine, laser at cos500, cone with a hard cutoff, uniform hemisphere, and a grazing-only ring. Cone and ring are used by no preset at all. They are not invisible, though — the Properties panel draws whichever profile is selected as a three-dimensional lobe you can right-drag to rotate, with a power read-out beside it, which makes it the one place in the program where a light's goniometric diagram and the light itself are the same object. Like the profiles it draws, it has never been viewed.
Volume emitters get their own next-event path, switched on automatically by two of the three config-only fog boxes — presets 39 and 43, the cube-light and ball-light rigs — so an emissive volume is sampled rather than found. It samples a point uniformly inside the shell (r·u1/3 in a sphere, uniform in a box), evaluates the density there, and converts the volume density to solid angle by pvol·r² with no cosine at the light, because there is no surface to have one. And it carries a consequence the profile list does not advertise: the sampler sets the emitter's material index to −1 on purpose, so an emissive volume has no emission profile. All five belong to surfaces.
The tidy version of this is that the profiles are evaluated identically wherever a light is used. The source is one shared function and four hand-transcribed copies: evalEmissionProfile in a module, called from light tracing and from two connection sites, and the same five-branch ladder written out again inline in four more places — a volume next-event branch, a surface next-event branch, a light-to-aperture connection, and the site where an eye path hits an emitter directly.
Four of the five agree. The fifth — the emitter-hit copy, the one that runs when you simply look at a light — writes the diffuse profile as abs(dot(rd, n)) where every other copy writes max(dot(−ω, n), 0), and writes the ring as 1 − abs(·) where the others clamp. So a diffuse emitter is two-sided when it is looked at and one-sided when it is connected to. That absolute value is the same view cosine §01 of this page reports as the unresolved 2.96× disagreement between the two renderers; naming it here says what it is structurally, which is a fifth transcription that drifted, not a convention anyone chose.
And the plot beside them is a sketch, not an evaluation. The goniometric visualiser draws each profile from its own drawing code rather than from the function the renderer evaluates, and for the ring the two do not match: the panel draws a lobe of length sin³θ, while the shader evaluates 3(1 − cosθ)². Both peak at grazing and they fall off differently. The read-out labelled Power is mean(emission RGB) × emissionStrength — no area, no solid-angle integral — so it is a radiance, and two emitters of wildly different size read the same number. Arrow lengths are normalised per profile as well, which hides that the ring's peak weight is 3 where the diffuse profile's is 1.
| Light sampling | What is implemented | Catalogue grade |
|---|---|---|
| Emission profiles (5) | Diffuse, laser, cone, hemisphere, ring, each with a polar tilt and azimuth — one shared function and four inline transcriptions, one of which is two-sided | seen |
| — cone and ring | Implemented on both the GPU and the CPU photon-primitive walk, and assigned by no preset. Presets use only diffuse, laser (30, 37) and hemisphere (37) | never viewed |
| — live goniometric plot | The selected profile as a rotatable 3-D lobe, in emission colour or as a heatmap, with the power read-out beside it, in the panel that defines the light | never viewed |
| Cone sphere sampling | Uniform over the solid-angle cone the sphere subtends, so the directional density is the constant 1 / 2π(1 − cosθmax) — the light-selection pdf multiplies it — and no sample is wasted near the silhouette. Switch it off and the legacy hemisphere-area sampler runs instead — two estimators of one integral, differing only in noise, for the price of one checkbox. It is not a pure A/B: inside the sphere's own radius the cone is undefined and the legacy path runs whatever the flag says | seen |
| Power-weighted light selection | Uniform, area-weighted, or area × luminance, each with a matching selectPdf so the choice stays unbiased. The Veach rig selects the third automatically | seen |
| Volume emitter sampling | Emissive volumes enter next-event estimation: uniform point in the shell, density evaluated at the sample, volume pdf converted by pvol·r², no emission profile | seen |
| Point lights from scene.lights[] | The only entry type the tracer uploads, capped at the first eight enabled, gated on the point-light switch — and connected at surface vertices only, which is the paragraph above | scene-dependent |
The newer of the two bounce models does not count ray segments. It counts scatter events, separately by type, with a clean reading:
Being able to set surfaces to zero and mediums to one, and see exactly the single-scatter term and nothing else, is what makes a light-transport bug findable. The legacy single-counter model is still selectable, and the two disagree about what "two bounces" means — which is worth knowing before comparing anyone's numbers to anyone else's.
| Counting and the film | What is implemented | Catalogue grade |
|---|---|---|
| Bounce cap ladder (default) | Max Surface and Max Medium as independent scatter-event counts, plus an optional total budget. The loop bound is the sum of the two caps, or the total when the budget is on; the per-type caps still apply inside it, so the budget limits the sum without relaxing either type | seen |
| Legacy bounce mode | Restores the old single cumulative counter, in which the camera ray is bounce 0 and volume single-scatter appears one step early. Kept selectable precisely so the two definitions can be compared rather than confused | seen |
| NaN/Inf splat guard | Eight guards in the megakernel. Six discard a contribution before it can be added — the splat writer itself, the light-to-camera connection from a surface vertex and the one from a medium vertex, the light-to-aperture connection, the interior-connection loop, and next-event estimation. The other two zero a whole eye-path result instead, once where the path returns and once before it is accumulated. The comment calls it preventing pixel poisoning, which is exactly right: one non-finite splat into an accumulation buffer is permanent | seen |
| Progressive accumulation | An RGBA32F accumulation image driven by a frame counter, with every image-changing control setting a reset flag. The reset clears the texture rather than restarting the average, and the source says why: light-tracer splats add to the accumulator instead of replacing it, so a stale splat would otherwise survive a parameter change it contradicts | measured |
08 — Named, not cropped
Everything on this page above the line was built; this is the part that is not finished, and the single most consequential item comes first because it is a bias source and the rest are not.
The eye path has no Russian roulette. The one roulette block in the megakernel is inside the light subpath. The eye path terminates on the bounce ladder alone, and the estimator is bounded instead by a hard clamp: radiance = min(radiance, vec3(1000.0)), with further clamping inside the light-tracer splat. That clamp is a bias with no counterpart in any of the CPU integrators, which declare their bias and measure it. Its effect on any image here has never been measured — so a GPU frame used as a reference is, strictly, a reference with an unquantified ceiling on it.
| Gap | What it costs | How it is known |
|---|---|---|
| No eye-path RR | A firefly clamp at 1000 stands in for it. Biased, and the bias is unmeasured | One roulette block, in the light path only; clamp at pathTrace.comp:2372 |
| Tonemap combo | None / Reinhard / ACES is offered; the shader hardcodes Reinhard and gamma 2.2. ACES is unreachable | tonemapMode has exactly two references: its declaration and the combo |
| Transmittance combo | Four methods offered, one implemented; the other three draw a red border but the control still moves | The variable is a function-local static, never persisted or read |
| Light subpath has no albedo | In light-tracing and BDPT modes, a coloured mirror reflects white | Known, unfixed, in the triage document |
| Volume NEE for Mie / Rayleigh | Those two phase functions are noisier by construction | Explicit gate in the volume NEE condition, with the reason in-comment |
| Heterogeneous shadow transmittance | Beer–Lambert per segment does not integrate a varying density along a shadow ray | Read from the transmittance routine |
| Write-only controls | A dolly debug overlay, a debug-overlays menu item and a furnace-test log toggle are set and never read | Two references each: the declaration and the widget |
| Fog box over 22×14×22 | The GPU path tracer renders pure black | Scale-dependent, unresolved; the limit is documented and respected |
| Preset 9 and preset 45 | Menu entries that dispatch nothing | The dispatch skips 9 between its two ranges; 45 exists nowhere |
| No denoiser, no TAA | Blur is temporal EMA plus a spatial Gaussian; that is all | No SVGF, OIDN or à-trous anywhere. The panel itself says "[No backend]" |
Absent by choice, in the same list. A denoiser would undermine the argument this whole project makes. Every comparison here is a statement about where noise comes from and which estimator has less of it, and a denoiser makes two estimators with very different tails look identical. The temporal EMA and spatial Gaussian are labelled blur, not denoise, for the same reason.
A single preset sets a focal length, and until it was fixed that value leaked: load that scene, then any other, and the rest of the session rendered at a 30% wider field with nothing on screen saying so. The byte-identity regression harness — eight films, two resolutions, a feature off must change nothing — is structurally blind to that entire class of bug, because every headless render launches a fresh process and so never loads two presets in sequence.
That is the most useful thing in this section. A validation apparatus has a shape, and the shape is a claim about what it cannot catch. Anything a preset writes and no preset resets is invisible to this one.