Strategies 20 & 21 · the photon-primitive family
A caustic in fog is a transport path you rarely see drawn interactively: light leaves a lamp, refracts twice through a glass ball, and scatters off a fog particle on its way to the eye. The obstacle is not the budget — it is that every vertex on that path is either a delta or a point you cannot connect to, so there is nothing to sample toward. So we stopped sampling it and solved it: by giving up nearly all the freedom a medium path has, and taking a closed form in exchange.
A caustic is what happens when a smooth refracting surface folds a bundle of light rays onto itself. All the energy that entered a wide patch of the lens leaves through a narrow one, so the image is bright exactly where the map from light source to receiver becomes singular. That singularity is the whole difficulty: the paths that matter are the ones a random sampler is least likely to find, and their contribution is concentrated on a set of near-zero measure.
Put the receiver on a surface and there are answers — photon mapping, SDS handling, manifold walks. Put it in a medium and the receiver is every point in a volume. Now the caustic is not a curve on a floor; it is a two-dimensional sheet hanging in space, seen edge-on and face-on at once along a single camera ray, and the eye can be anywhere relative to it. This is the shaft of light under a wine glass, the bright knot in a smoky room beneath a crystal.
You do not often see it drawn interactively. That is an observation about what tends to get shipped, and it is worth flagging as exactly that: this repository holds no survey of what other renderers do, and nothing on this page should be read as one. What the repository does hold is the structural reason, which is sharper than any cost argument — and section 02 is that reason.
This renderer's own audit is blunt about the starting point: it had no working volumetric photon beam at all. VCM and the photon mapper were surface-only, the UPBP beams were stubs, and its volumetric photon points sit behind a flag that defaults to off. There was no caustic-in-fog reference to compare against, so the reference had to be built — which is also why every number further down is a ratio against something built here, rather than against a published baseline.
A smooth dielectric has a delta BSDF. Fix the incoming direction and Snell's law determines the outgoing one — there is no lobe to sample, no density to evaluate, no freedom to spin. That single fact removes every tool a path tracer normally reaches for.
Next-event estimation asks: from this fog particle, what is the direct connection to the light? Through a glass ball, that connection exists for a measure-zero set of positions and has zero probability density everywhere else. Bidirectional path tracing asks the mirror question and gets the mirror answer. The only way through is to already be on the path — which is to say, by luck. This is the classic specular-connection problem, and in a medium it applies at every scattering point along the camera ray, not just at the ones that happen to land on a surface.
The usual escape is to trace photons forward from the light, let them refract, and then blur them: a photon map with a kernel, or a photon beam with a radius. That works, and it is what strategy 18 in this renderer does. But the kernel is the price. A caustic is a structure whose whole content is its sharpness, and a radius large enough to gather reliably is a radius large enough to erase what you came for.
The photon is not the problem. The blur is the problem. So: keep the photon, and delete the kernel.
Here is the organising idea behind the whole family this estimator belongs to. A photon leaves a vertex with a direction and a distance: three coordinates, (θ, φ, t), in a frame at that vertex. Ordinarily you sample all three and get a point, which a camera ray will never hit, which is why you need a kernel to catch it.
Instead: fix some coordinates and sweep the rest. Sweep k of the three and the photon's endpoint is no longer a point but a k-manifold in space. The camera ray is a 1-manifold. A generic crossing of the two has dimension k + 1 − 3 — and the arithmetic decides everything:
| swept | the photon becomes | crossing | what it needs |
|---|---|---|---|
| k = 0 | a point | −3 | a 3-D ball kernel, 3/4πr³ |
| k = 1 | a curve — beam, ring, arc | −1 | a 1-D tube kernel, 1/2r |
| k = 2 | a surface | 0 | nothing. Isolated roots. |
| k = 3 | a solid | +1 | a segment, collapsed by co-area |
At k = 2 the crossing has dimension zero. A line meets a surface in points, generically, with no coincidence required and no radius involved. The estimator's weight is the integrand divided by a Jacobian, and there is no kernel anywhere in it — nothing to widen, nothing to tune, nothing to blur. In this renderer's roster of 31 strategies, exactly six carry a kernel; the hourglass is not one of them.
That is the trade, stated plainly. Sweeping two coordinates means giving up two thirds of the freedom a general photon path has. The estimator can no longer express arbitrary transport — it can only express the family of paths that lies on the swept manifold. What it gets back is that a camera ray lands on it exactly.
The bet is that for this one transport — light, glass, fog, eye — the freedom you gave up was freedom you did not need.
And be precise about what makes the bet pay, because it is easy to get wrong and this repository measured it the hard way: the operative condition is not “fewer dimensions”. It is “no connection to make”. Where a next-event connection does exist — ordinary single scatter from an area light — the same swept machinery loses to a light sample and a shadow ray by 2.2–3.1× per pass, at every emitter size tested, and the repository's own instruction is not to use it there. Collapsing degrees of freedom is the method. Having no density to sample is the reason. Section 08 has that ledger in full.
The insight is small and it is the whole thing. At a refraction, rotating the plane of incidence about the surface normal rotates the incoming direction and the Snell-refracted outgoing direction by the same azimuth. Hold the incidence angle fixed and sweep φ: ωin traces one cone, ωout traces a different cone on the far side, and the two are locked together at a shared azimuth. Two cones meeting at a waist. An hourglass.
The form that ships is stronger than that. For a light point y and a glass sphere of centre C, take the axis a = normalize(C − y). The configuration is now exactly rotationally symmetric about a, so rotating the entire refraction chain — entry point, internal chord, exit point, exit ray — about that axis is a rigid motion. The azimuth lock is not an approximation to be maintained; it is automatic, exact, and it survives arbitrarily many specular vertices.
So the per-sample recipe is: pick a ball; sample cos ψ on the cap of directions that actually hit it; trace one deterministic chain at φ = 0, with two exact refractions and their Fresnel transmittances carried as deterministic weights; then sweep {φ, r}. The swept manifold is the surface of revolution of the exit ray. It is a quadric.
J is the co-area Jacobian: the rate at which the swept surface sweeps area, measured against the camera direction d. It appears in the denominator of the estimator's weight, and that is not a defect — it is the caustic. Where the surface folds, J shrinks, and the same photon energy is credited over a smaller area. Brightness where the map is singular is precisely what a caustic is.
Two anchors confirm the formula is the right one. As ior → 1 the glass ball dissolves and J expands algebraically onto the plain direction-swept cone's factor — the same measure, conversion factor 1.000000000, zero outside the cap. And a finite-difference check of the analytic co-area agrees to 1.7×10−7, with a step-size sweep that shows the residual is roundoff in the probe and not error in the formula.
Everything above is geometry. This is the part that buys the speed, and it comes down to three facts that stack. Scope first: everything in this section is true of the one-ball sheet and of a chain that keeps the revolution symmetry. Break the symmetry — put the second ball off the axis — and the closed form is the exact thing you lose. Section 06 is about that, and about what it costs.
The swept sheet is a surface of revolution of a straight ray about an axis — a quadric. Substituting the camera ray into it gives an honest quadratic A s² + B s + C in the ray parameter. Two roots, closed form, no iteration, no Newton, no interval subdivision, no bisection. Per camera ray, finding where the entire caustic sheet lies costs one discriminant and one square root.
This is the part that surprises people. J is not a second computation. In its analytically cancelled, factored form it is the discriminant:
One difference of two squares replaces a seven-term cancellation, no wz sits in a denominator, and — the point — the quantity that decides whether a root exists and the quantity the estimator divides by are the same number. You do not test for an intersection and then evaluate a Jacobian. You solve once and read both off.
The hourglass is multiple-importance-sampled against the photon beam and the photon point, which is what makes it robust at the grazing angles where any one technique degenerates. Normally that means every technique must evaluate every other technique's density at a shared path — and for an analytic swept primitive that would mean inverting the sweep, i.e. a root-find inside the weight. It does not, here. At a shared path the fold dilation det3 is identical across techniques, so it cancels out of the balance heuristic entirely and each numerator is a product of quantities already in scope:
The density and the caustic are the same object seen from two sides: 1/|det3| is the estimator's pdf conversion and it is the fold brightening.
Add it up. Per camera ray, per photon sample, the cost of the entire volumetric caustic is: one deterministic two-refraction chain, one quadratic, one square root, a Jacobian that was already computed, and a closed-form weight. No kernel to size. No photon map to build, store or query. No neural anything. No temporal accumulation to hide the noise. That is why the picture is recognisable in the first few frames instead of the first few thousand.
One label, because this repository is strict about it and a new reader should inherit the strictness: strategy 21 — the hourglass MIS-ed with the beam and the point — is CONSISTENT, NOT UNBIASED, and the code says so at the site. At a finite beam radius the members deposit on manifolds a radius apart, so the balance weights do not sum to one pointwise and the pair's energy moves with r. Strategy 20 alone is kernel-free, but its grazing guard is a discard rather than a clamp, so its support is narrower than its declared class. Neither is a defect being hidden; both are printed next to the estimator that carries them.
The closed-form roots were checked against an independently derived arbiter and read exact 0.0 residual. On the shipped binary, replayed over 10.5 million crossing records across seven dump jobs, a float64 replay reproduces the binary's roots and its J bit for bit on 100 % of records; there were 0 invented and 0 missed crossings in every job, and 0 of 226 063 accepted crossings carry |J/Jexact − 1| > 10−6 (worst 5.9×10−8, which is the float32 cast of J itself). Rotation-equivariance of the two-refraction chain: 3.5×10−15. docs/hourglass/README.md
The rigid-motion argument does not care how many glass balls the photon passes through — only that the whole configuration is a surface of revolution about one axis. So a collinear chain, every centre on the line from the light through the first ball, is free: the same machinery, the same quadric, the same solver, with more refractions folded into the deterministic chain. Two balls in a row focus twice, and the waist gets tighter and stranger. A planar mirror behind the ball is free too, by reflecting the light point across it to a virtual origin: the configuration is again rotationally symmetric, and no new mathematics is needed.
Take the balls off the axis and the symmetry breaks. The exit ray no longer sweeps a quadric — it sweeps a ruled surface with no closed-form intersection, so the azimuth has to be found by search: scan for sign changes, bracket, then bisect. That is tier T3, it works, its energy at the shipped default reads 1.0006 ± 0.0063 on the caustic mask — 0.1 σ from one — and it costs 9.32 ± 0.15× a collinear two-ball chain and 11.79 ± 0.18× a single ball, both measured within one round rather than across sessions, for exactly the reason you would expect — the thing section 05 is about, the closed form, is the thing it gives up. The accounting is unambiguous about where the time goes: at the shipped 64 rulings, 83.8 % of the pass is the sign scan alone, and everything that is not the root-find is 8.7 %. An attempt to bound the search by screen extent was built, measured at 1.00–1.73× and filed as a no-go: interval arithmetic overestimates the relevant derivative by 3.3–48.5×, so the bound is too loose to pay. The live candidate is a closed-form rewrite of the azimuth function, verified to 6.2×10−15 over 4096 azimuths and not yet built.
This is a good place to notice what the estimator is really made of. Collinear chains are free because they preserve a symmetry; off-axis chains are expensive because they destroy it. The speed was never about the code. It was about the constraint.
The tempting headline is that the analytic caustic is cheaper than rendering the surfaces. On the scene where both are timed in one campaign, that is not true per pass, and it should be said before anything else is. Here is the whole column, one binary, preset 79:
| what is being rendered | 217×140 | 607×447 |
|---|---|---|
| Surface render, no medium at all | 1.862 | 14.982 |
| Surface render, full medium camera walk | 4.621 | 37.656 |
| The hourglass alone — the whole volumetric caustic | 5.719 | 41.703 |
| Both together, composited | 7.208 | 67.576 |
So the caustic costs about 3× a medium-free surface pass, and 1.24× a full medium camera walk at 217×140, 1.11× at 607×447. Dearer, on every row. No scene anywhere in this repository publishes an hourglass-alone cell that is cheaper per pass than a surface render of the same scene in the same campaign.
But that was the wrong comparison, and noticing why is the point of this section. A surface render does not draw this. Turn the fog on in a path tracer and you get airlight and multiple scatter and no caustic at all, because the specular connection has no density. The comparison that means something is against the estimator that draws the same transport — the kernel-based photon beam — on the same scene, at equal time, in one campaign. That comparison is not close.
| the same transport, drawn two ways | hourglass | photon beam | factor |
|---|---|---|---|
| Two-ball collinear chain (preset 68) | 6.798 | 241.1 / 329.4 | 35–48× |
| The post-ball mirror leg (preset 71) | 7.019 | 122.1 (r 0.12) | 17–21× |
| The two-ball caustic, as shipped (preset 68) | — | — | 30–54× |
| and where the beam wins — at the waist, kernel r = 0.01 | 11.3 | 9.27 | 0.82× |
The repository states its own verdict on that rig without a ratio to soften it: “the collinear chain is 35–48× better than the reference on equal time and agrees with it on energy inside the bars; the post-ball mirror is 17–21× on equal time and agrees to 0.2 % against its isometry.” A second, independent probe puts the cushion the beam leaves at 17–32×. The last row is there on purpose: shrink the beam’s kernel far enough and aim the camera at the waist itself and it wins by 0.82×, because a narrow kernel sitting exactly on the fold is a good estimator of the fold. Everywhere else on the sheet, it is not.
Not faster than drawing the surfaces. Between 17× and 48× faster than the only other thing that draws the caustic.
A ratio can hide a slow baseline, so here is the clock — and each figure with the film it was measured on, because film size moves these by a factor of twenty. On a 217×140 film of a single glass ball, the hourglass costs 1.17–1.49 ms per pass, of which roughly 0.76 ms is the photon-primitive estimation itself and the rest is per-pass overhead that every strategy pays. On a two-ball collinear chain, same film, 2.013 ± 0.042 ms.
At a viewport somebody would actually sit in front of — 605×397 — exactly one published cell supports the word interactive, and it is the double-chain showcase: 20.042 ± 0.675 ms per pass, 49.9 passes a second, CPU only. Read that cell for what it is: the hourglass alone, every surface flag forced off, so it is a caustic on black rather than a finished frame — and this project reserves the word real-time for its GPU path, which runs none of these strategies at all. The heavier scenes do not: the off-axis pair is 19.7 passes/s and the Atrium 15.1 at 607×447. What that buys is a caustic you can fly around, not one that is finished on arrival — the showcase sits at 15 % relative error on its caustic after 1600 passes and 7 % after 6400, which at 50 passes/s is half a minute and two minutes of holding still. The converged beauty shot, 4.5 % on the caustic itself rather than on the easy parts of the frame, is 5 minutes 25 s.
And on the composited showcase frame, measured against each cell’s own converged mean, the typical lit pixel arrives 57× and 227× sooner than under the stochastic partition it replaces:
| estimator | preset 79 | preset 78 |
|---|---|---|
| Analytic caustic + surfaces, composited | 145 | 84 |
| The stochastic partition it replaces | 8 282 | 19 042 |
| ratio | 57× | 227× |
At 217×140 that composited frame runs at about 139 passes per second on the CPU alone and reaches a clean typical pixel in 145 passes — roughly one second. The stochastic route needs 8 282 passes for the same frame at about the same cost per pass, so it needs a minute of the same machine. Read the two tables together and that is the shape of the result: the same picture, sooner, by between one and two orders of magnitude, for a per-pass cost in the same bracket as an ordinary render.
One more axis, because a caustic is judged on sharpness and not only on noise. At equal passes the hourglass pair’s caustic waist is 7.7 px against the beam’s 70 px at the beam’s own radius — and the correction travels with that number, because a 1-D kernel’s width scales with its radius, so at matched radius the pair is sharper by 2.0×, not by nine. On error rather than width: 2.1× lower at equal passes on the fold scene, median relative error 0.1324 against 0.2542 on the isolated caustic, and below the focus — where the beam’s geometry degenerates — 6× lower variance.
ms/pass on this machine is a machine reading. A cell that cannot have changed — a surface pass with no hourglass in it — read 13.860 in one campaign and 14.982 in the next, and a whole-run ladder moved 23 % across the same gap. Every ratio quoted above is taken inside a single measurement campaign, and none of them crosses two. The producers are docs/collage/harness/collage.py and docs/hero-scenes/harness/atrium.py, and the two use different protocols whose difference is one headless startup.
A page that only sells is worth nothing to whoever inherits the code, so here is the other column. This repository's own thesis, written after the measurements rather than before, is that a swept primitive is more machinery than a light sample plus a shadow ray — and applied to transport that was easy to sample, the machinery does not pay.
And the hourglass itself carries real restrictions, each of them a direct consequence of the symmetry it is built on. It needs a sphere dielectric — a box or a planar slab breaks the revolution and would need a different form. It needs a point-sampled source. It has no member on the k = 0 class at all, so it draws the caustic and not the lamp's ambient glow in the fog; that is why it is paired with the beam in strategy 21, and why the showcase mode uses strategy 20 alone when it wants a dark room. Its characteristic artefact is the tangency locus, where the camera ray goes parallel to the sheet, J → 0, and the weight blows up — which is the entire reason MIS is not optional here. There is a guard for the residual tail; measured, it discards 0.18 % of the film, touching 42 of 30 380 pixels, none of them in the caustic core.
And the win covers the caustic, not the frame. The probe puts it plainly: the gain is on the 9–13 % of the frame the caustic footprint actually covers, and every other pixel pays the same pass and gains nothing from it. On the off-axis chain that is starker still — 73.8 % of the pass is chain walks that find no root at all, because the azimuth scan runs at every pixel while the caustic is a tenth of the picture. An estimator that is 30× better on a tenth of the frame is a very good thing to own and it is not a 30× faster renderer.
One more, and it matters for the word real-time: every one of the 31 photon-primitive strategies runs on the CPU. Nothing here is a GPU megakernel. The frame rates quoted above are one CPU's, and the analysis in the handover docs is that the closed-form group is portable to the GPU while the hourglass specifically would be inefficient there. Read “fast” as “fast for what it draws, on a CPU, against alternatives that draw the same thing” — which is still the surprising claim.
This page is an orientation, not a replacement. Everything it summarises is written down somewhere in this repository in far more detail, with its producers and its gates attached. Start here: