Three glass spheres and two aimed mirrors casting caustics through fog

Light transport research · RadianceLab

Ten integrators,
one scene

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.

Transport modes
10
one GPU, eight CPU, one rasteriser
Megakernel
4,090
lines, one dispatch, specialised to the live feature mask
Where the two paths agree
1.0005
median CPU/GPU over 40,842 pixels
Where they do not
Le→/2.96
an emitter seen directly · unresolved

01 — Shape of the thing

The two renderers do not even share their intersection code

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.

#ModeWhere it runsStatus on this site
0RasteriserGPU, GLScene inspection and LTC area lights
1Path tracerGPU megakernelThe reference path. §02 onward is about this
2Photon mapperCPUSurface only; the KD-tree is real and used here
3VCMCPUSurface only — skipVolumes = true, hard-coded
4 / 5MLT / PSSMLTCPUSurface only; chain and acceptance visualisers built
6UPBPCPUBeams are built, uploaded and drawn — and never queried by the estimate
7Path guidingCPUSpatial radiance cache with a directional field overlay
8Manifold explorationCPUMislabelled. newtonIterations is written into its config and read nowhere; it is an MLT loop
9Photon primitivesCPUThe 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.

The mode number is a request, and the strategy number is not the one in the combo

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.

ControlWhat it actually doesCatalogue grade
Deferred, cooled-down mode switchA three-frame cooldown, then glFinish, then eight integrator invalidations, then the switch. The pending target is what the radio drawsseen
Auto-enable on switchDefaults 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 runseen
Medium-support legendTen 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 itseen
Strategy 2 — MIS (naive + NEE)The power heuristic over BSDF sampling and next-event estimation. Fourth in the picker, ID 2 internallyseen
Strategy 7 — Light Sample OnlyPure 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 reasonseen

What they share, and what they diverge on

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:

  • A rotated box is a different solid to each renderer. The GPU treats Box as an OBB and rotates the ray by the entity's quaternion; the CPU intersector has an AABB path only. Any scene with a tilted box is outside the comparison.
  • Every bounded plane is 0.04 wider than it is declared. Both intersectors clip with diff > halfExt + 0.02 per axis, deliberately mirrored so the two agree. An estimator that reads an emitter's area from an intersection therefore integrates over a quad of side 2h + 0.04 and reads 8–15% high on presets 47, 48 and 52. The pad was left global because removing it is not byte-neutral for any strategy in the roster.
f = ρ/π · cos_s · L_e · cos_y / r² diffuse wall · ρ emissive quad · L_e camera camera ray r n_y cos_y n_s cos_s no continuation y x_s surface NEE · one Lambertian connection · the L D E reference denominator
The agreementOne camera ray, one diffuse hit, one connection to the ceiling emitter, no continuation. With the GPU's point lights disabled and directly-seen emitter pixels masked, this is the only transport both paths express identically — and it is the reference denominator every other CPU/GPU ratio is built on.
CPU surface NEE / GPU, preset 0, diffuse pixelsmedian 1.0005
Interquartile range over n = 40,842 pixels0.963 … 1.039
Same emitter, seen directly, oblique view — CPU(20.0, 19.0, 17.0)
Same pixel — GPU(6.76, 6.43, 5.75)
Ratio2.96× · unresolved

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 path tracer recompiles itself when the render state changes shape

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.

mask = BDPT | SPECTRAL | VOLUMES | BDPT_MIS_B
Derived from lightStrategy, spectralMode, enableMedium && sceneHasVolume, and the BDPT MIS selection.
ComputePathTracer.cpp:81 · defines at :98 · recompile at :1703

The ladder, and the measurement that put a rung in 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:

1 — full requested maskcompile
2 — on failure, drop FEAT_BDPT_MIS_BBDPT survives, flat MIS
3 — on failure, mask 0minimal kernel, always fits
4 — on failure, keep the previously-compiled programno reset, no crash

Rung two exists because a combination was measured to fail. The source records it in the mask function rather than in a commit message:

// Measured: method B + FEAT_VOLUMES exceeds the GPU instruction
// ceiling, so on volume scenes method B is not requested.
// All other combos fit.
ComputePathTracer.cpp:88 — so the mask function refuses to request the combination in the first place, and the ladder's second rung is the safety net for hardware other than the one it was measured on.

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.

video · A kernel swapping itself out
Preset 51. Let the frame accumulate to visibly clean, ~300 passes. Open the Render panel and toggle Spectral on. Hold 4 s. Toggle it off. Hold 4 s. Keep the log pane visible in frame for the whole clip.
Watch for The accumulation counter drops to zero at the instant the log prints the mask change, and the image restarts from noise. That reset is the point: the two images were made by different estimators and must not be averaged.
Feature maskThe recompile is invisible except for the reset it forces, which is why it is worth showing in motion rather than describing.

03 — Geometry

Every ray tests every primitive, and that is the design

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.

PrimitiveGPUCPUNote
SphereexactexactThe one both paths agree on completely
Planeinfinite plane, per-axis clipsame+0.02 pad on every axis, mirrored deliberately
BoxOBB, quaternionAABB onlyThe two paths disagree on any rotated box
VolumeSphereexactexactMedium container, not a surface
VolumeBoxAABBAABBNo orientation on either side
Geometry and accelerationStateCatalogue grade
Per-primitive UVsSpherical 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 oneseen
Only preset 30 has a rotated boxglm::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 practiceseen
Triangle meshesMesh and Vertex exist, are built procedurally in code, and are consumed by the rasteriser alone. The path tracer never sees onesuperseded
Mesh file importNo OBJ, no glTF, no loader of any kindabsent
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 unitinert
KD-treeThe 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 structsseen

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:

Screenshot — Render panel · Acceleration section · check the BVH and Grid boxes so both red not-implemented borders are visible in one frame, with the KD-tree row showing normally alongside
An honest controlTwo options draw a red border reading "BVH traversal on GPU not yet implemented". The pattern is used in eight places across the interface. Notice which controls do not have it — that is the claim being made.

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

Fourteen BSDFs are implemented, and every one is reachable by loading a preset

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.

#TypeWhat is actually implementedReachable
0DiffuseCosine Lambertian; also the fallback branchpreset
1MetalGGX conductor, albedo-tinted Schlick; delta below r = 0.001; optional thin film on the Fresnelpreset
2GlassFresnel and TIR; GGX perturbation above r = 0.01, at which point it reports a pdf and joins MIS. Smooth glass is a delta vertexpreset
3CoatedDiffuse4%-at-normal achromatic coat over a coloured basepreset
4PaperLambertian sheet with 0.3·(1−cos) forward transmissionpreset
5IridescentThree-interface Airy reflectance per RGB wavelength over a GGX lobepreset
6GlintHashed world-position facet cells, four candidate normals. Density clamps at 50, so the top half of its 10–500 slider does nothingpreset
7SnowflakeReflected azimuth snapped to 60° incrementspreset
8DipoleSSSRough 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 indexpreset 56
9PassthroughRay continues, stays specularpreset 56
10MirrorDelta reflection tinted by albedo — but only while ior ≤ 1.01; above that Schlick washes the tint to whitepreset
11AnisotropicTangent frame built from the shading normal, so on a sphere it brushes along latitudepreset 56
12HairMarschner-like R / TT / TRT at 10 / 60 / 30 %preset 56
13DispersiveGlassCauchy index; see §05preset

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 systemWhat it isCatalogue grade
The 14-BSDF enumAn 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 writtenseen
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 verticesscene-dependent
Material auto-clone on editEditing 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 followseen

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 inspector has instruments of its own, and nobody has looked at them either

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:

  • A spectral albedo curve editor. Switch a material to spectral and the colour picker is replaced by up to eight draggable Gaussian lobes over 380–780 nm, drawn on a wavelength-to-RGB ramp — click to add a lobe, drag to move it, right-click to remove it, with a smoothing width in nanometres and a live RGB preview swatch, auto-seeded from the current RGB the first time you open it. never viewed
  • A BSDF lobe plot. A three-dimensional lobe you drag to change the incoming direction, so the shape of the branch you just selected is visible before you render anything. never viewed
  • An emission-profile visualiser. The five profiles from §07 drawn in 3-D, in emission colour or as a heatmap, with a power read-out beside them. It is the goniometric diagram of a light, in the panel that defines the light. never viewed

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.

Screenshot — Preset 3 (Material Showcase) · GPU path tracer · 600+ passes · clean view (G) · frame the full row of spheres so all ten of the showcase's BSDFs are legible; then preset 56 (RICH: The Apothecary) for the other four
Ten of fourteen, then the other fourLook at the iridescent sphere's hue shift toward the silhouette — that is three-interface Airy interference evaluated per wavelength, not a gradient texture. Subsurface, anisotropic, hair and passthrough are absent from this frame by construction; they live one preset over, in the Apothecary.

05 — Spectral

Dispersion is gated on the spread parameter, not on the material type

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.

n(λ) = ior + B/λ² − B/0.589²    B = (ior − 1)·0.01·spread
Cauchy, referenced to the sodium D line. Physical B for common glass is about (ior − 1)·0.01; the spread slider is an honest exaggeration factor, not a hidden fudge.

Two estimators for one physical effect

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.

Hero wavelength, per path380 + 400·ξ nm
Spectral reflectance≤ 8 Gaussian lobes / material
RGB dispersion estimator1 of 3 channels, ×3 weight
R / B anchors620 nm / 460 nm
Sky in spectral modeachromatic
A dispersive caustic separating into a spectrum
Preset 52, rendered by the CPU integratorThe same dispersion model, exercised through the photon-primitive path at strategy 21 rather than through the megakernel this section describes; the two read the same per-object spread. What to notice is not the fan itself but its ordering: red bends least and sits outermost, blue innermost, and the separation grows with distance from the sphere rather than being constant across the beam. A texture cannot do that; a per-wavelength index has to.
video · Dispersion is not a texture
Preset 52, spectral mode on, GPU path tracer, clean view. Orbit the camera slowly through about 60 degrees over 10 s, then hold.
Watch for The colour bands sweep across the receiving surface and change width as the geometry changes, while their order stays fixed. A painted rainbow would translate rigidly with the surface instead.

06 — Participating media

Seven ways to make fog reach the same switch; one of them is inert

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.

SourceWhat it gives youState
HomogeneousConstant density. What every caustic preset usesmeasured
Noise LabFBM Perlin / Worley / Ridged / Marble / Turbulence / Voronoi, 1–8 octaves with per-octave amplitude mutes; 15 one-click presets that also set σ and gseen
Legacy procedural16 styles, including Gyroid and Schwarz-P triply periodic minimal surfacesseen
SDFSphere, box, torus, cylinder, cone, octahedron, Mandelbulb, with an optional hollow-shell isosurfaceseen
Physics textureA 3D density texture written by the SPH / MPM / Euler solvers — real, end to endseen
Baked node graphUp to eight pairs of 3D textures — absorption, scattering and two channels of tint in one, the third and emission in the othernever viewed
Reaction–diffusionA 3D RD patternbranch 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 modelWhat it doesCatalogue 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 entitynever viewed
RGBA medium modeA 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 alphanever viewed

Where a medium is actually authored, and the two panels that cannot read each other

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.

Three phase functions, and a noise floor two of them cannot escape

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.

Phase 0 — Henyey–GreensteinNEE + phase sampling
Phase 1 — Lorenz–Mie, 3 lobesphase sampling only
Phase 2 — generalised Rayleighphase sampling only
g used by every caustic preset0.55

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.

Screenshot — Contact sheet, six frames, GPU path tracer, 400 passes each, identical camera and lighting: (1) Noise Lab "Cloud" preset, (2) legacy procedural Gyroid, (3) legacy procedural Schwarz-P, (4) SDF Mandelbulb with hollow shell on, (5) a physics-driven SPH volume mid-splash, (6) homogeneous g=0.55 with a caustic cone through it
One switch, six branchesCompare the edge quality between the analytic SDF frames and the noise-driven ones: the SDF isosurface has a defined boundary because the density is a signed distance, while the FBM fields fade. Both go through the same delta-tracking transport.

07 — Sampling and counting

A point light is never sampled at a volume scatter vertex — and that is why the estimator research exists

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.

What the GPU does sample well

  • Sphere lights by solid angle. Sampling is uniform over the cone the sphere subtends, giving a constant pdf and no samples wasted near the silhouette. The older hemisphere-area sampler is retained behind a flag, which makes it a clean variance-only A/B: same image, different noise.
  • Power-weighted light selection. Three modes — uniform, area-weighted, and area × luminance. The Veach MIS scene selects power weighting automatically, because its lights span roughly 500× in emission and 500× in area and uniform selection there is a disaster.
  • Quad lights by the right edges. Each in-plane edge is paired with the half-extent of the world axis it points along, not by array position — so a non-square light is sampled correctly.
  • Multiple importance sampling at three sites — BSDF-hits-emitter, surface NEE, and volume NEE — all with the power heuristic at β = 2.

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 five-branch profile is written out five times, and one copy disagrees

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 samplingWhat is implementedCatalogue 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-sidedseen
— cone and ringImplemented 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 plotThe 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 lightnever viewed
Cone sphere samplingUniform 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 saysseen
Power-weighted light selectionUniform, area-weighted, or area × luminance, each with a matching selectPdf so the choice stays unbiased. The Veach rig selects the third automaticallyseen
Volume emitter samplingEmissive volumes enter next-event estimation: uniform point in the shell, density evaluated at the sample, volume pdf converted by pvol·r², no emission profileseen
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 abovescene-dependent
Screenshot — Preset 37 (Multi-Light Foggy Box), the one preset that assigns a non-default emission profile - select its laser emitter in the Scene Hierarchy - Properties, Material tab: TWO blocks are headed "Emission Profile", the upper one carrying Emission Color / Strength / the five-entry Profile combo and the lower one a collapsing header holding the 3-D visualiser; open both so they are in the same frame - set Profile = Cone, Theta (tilt) = 40 deg, Spread = 25 deg - in the visualiser set Color = Heatmap and right-drag inside the widget until the cone axis points across the frame rather than at you - shoot the two blocks and the "Power: ... | Profile: Cone" status line together - then change ONLY the Profile combo to Ring and shoot again without touching the widget; deliver as a left/right pair
A light's goniometric diagram, in the panel that defines the lightTwo profiles no preset assigns, drawn by the one control on this page that has never been switched on. What to notice in the right-hand frame: the ring lobe is drawn as a smooth grazing skirt, and the function the renderer actually evaluates for that profile is a different one — see the note above. The status line is the second thing to read, because the number beside Power does not change when you change the profile.

The bounce control is a teaching instrument

The newer of the two bounce models does not count ray segments. It counts scatter events, separately by type, with a clean reading:

Surface = 0emitters only
Surface = 1direct illumination
Medium = 1single scatter
Medium = 0ray passes through the fog
Optional total budgetcaps the sum, not each type

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 filmWhat is implementedCatalogue 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 typeseen
Legacy bounce modeRestores 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 confusedseen
NaN/Inf splat guardEight 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 permanentseen
Progressive accumulationAn 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 contradictsmeasured
video · Counting scatter events
Preset 48. New bounce model. Set Medium = 0, Surface = 0 and hold 2 s — emitters only. Step Surface 0 → 1 → 2 → 3, holding 2 s each. Return Surface to 1, then step Medium 0 → 1 → 2 → 3, holding 2 s each. Keep the two counters on screen.
Watch for The surface ladder adds a bounce of room light per step and saturates quickly; the medium ladder adds airlight and keeps changing the image far longer. Single-scatter — Surface 1, Medium 1 — is the frame every caustic comparison on this site is made in.

08 — Named, not cropped

What is wrong with the engine, in the same table as what is right

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.

GapWhat it costsHow it is known
No eye-path RRA firefly clamp at 1000 stands in for it. Biased, and the bias is unmeasuredOne roulette block, in the light path only; clamp at pathTrace.comp:2372
Tonemap comboNone / Reinhard / ACES is offered; the shader hardcodes Reinhard and gamma 2.2. ACES is unreachabletonemapMode has exactly two references: its declaration and the combo
Transmittance comboFour methods offered, one implemented; the other three draw a red border but the control still movesThe variable is a function-local static, never persisted or read
Light subpath has no albedoIn light-tracing and BDPT modes, a coloured mirror reflects whiteKnown, unfixed, in the triage document
Volume NEE for Mie / RayleighThose two phase functions are noisier by constructionExplicit gate in the volume NEE condition, with the reason in-comment
Heterogeneous shadow transmittanceBeer–Lambert per segment does not integrate a varying density along a shadow rayRead from the transmittance routine
Write-only controlsA dolly debug overlay, a debug-overlays menu item and a furnace-test log toggle are set and never readTwo references each: the declaration and the widget
Fog box over 22×14×22The GPU path tracer renders pure blackScale-dependent, unresolved; the limit is documented and respected
Preset 9 and preset 45Menu entries that dispatch nothingThe dispatch skips 9 between its two ranges; 45 exists nowhere
No denoiser, no TAABlur is temporal EMA plus a spatial Gaussian; that is allNo 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.

One more thing the harness cannot see

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.