How it works
Every frame, KausticsRenderer.encode(into:) runs five steps on the GPU.
#1. The light’s view
The casters are drawn from the point light into a shadow map. Glass solids also have their front and back faces drawn from the light, keeping each texel’s outward normal and distance. And over each receiver, the casters’ lowest and highest points are drawn into a small height map.
#2. The throw
One thread for each vertex of each caster works out how much light reaches it: the light’s intensity over the distance squared, times the cosine to the vertex normal, times a soft visibility from the shadow map. Then, by material:
- Metal reflects it, with Schlick’s Fresnel. The reflected ray is marched through the height map of the receiver it is heading for, and stopped if it would pass back through a caster on the way.
- Water refracts it once, with the exact Fresnel transmittance, and the light carries on inside the medium, absorbed as it goes.
- Glass refracts it in, finds the face the ray meets inside by marching it through the light’s view of the solid, and splits it there as Fresnel has it. The share that gets out is one layer of throws. The share reflected back inside (all of it, past the critical angle) goes on to the next face and is split there again, up to the material’s
internalReflections; each way out is another layer of throws.
Water and glass also throw the share of the light their surface reflects, weighed by Fresnel at each wavelength, as a faint caustic of its own.
With an Abbe number, water and glass are traced at eight wavelengths from 400 to 700 nm. Each is bent by its own index of refraction and carries its share of the CIE 1931 color matching functions in sRGB, scaled so the eight add up to exactly white. Each slice is also blurred by how far its own colors fan out, so eight slices run together into one continuous spectrum.
Each vertex also records how fast its light spreads. For a distance travelled s, where θ is the light’s angular radius seen from the vertex and r is the roughness:
σ = s × √((θ/2)² + (1.4 r²)²)The first term is the light’s own size, the second a GGX lobe’s width. Inside water and glass the spread is divided by the index of refraction.
#3. Triangles
Each receiver draws every triangle of every caster at the three points where its corners’ rays meet the receiver’s plane, once for each wavelength and path through glass, and once for the reflection. The triangle’s light is its corners’ power times the area of the triangle on the caster over the area it lands on.
Where a curved surface gathers light into a smaller patch it gets brighter, and where it spreads it, dimmer. That ratio is the whole caustic.
A triangle that lands thinner than a texel is drawn a texel thick with its light dimmed to match, so folds in the light do not sparkle, and one stretched past a limit fades out. Everything is added together in 32-bit floats, with 4x multisampling where the GPU can blend 32-bit floats so, and one sample a texel where it cannot (as on the Simulator). In half floats, the faint light of one triangle among hundreds over a bright fold rounded away: a glass ball lost 1.3% of its light that way, and 3.6% with its colors split eight ways.
#4. Blur by distance
Each fragment’s light goes into three bands by its spread: near (up to about 3 texels), mid (up to about 12) and far. The near band is drawn at the receiver’s resolution and the others at half, then reduced to a quarter and a sixteenth.
Each band is blurred with a separable Gaussian that gathers rather than scatters, so every texel’s light is spread by its own σ and not its neighbours’. The three are then laid back together with a cubic B-spline. The bands overlap, so a line whose spread grows along its length widens smoothly instead of in steps.
#5. The texture
What comes out is an rgba16Float irradiance texture for each receiver, covering its rectangle.
#Checked against exact answers
The tests check the result against cases with exact answers. Under flat water, the floor receives the surface’s irradiance times the Fresnel transmittance. Under a flat mirror, it receives the light of the light’s mirror image. Both come out within 3%. A glass ball’s caustic is compared with an exact ray trace of a million rays through the same sphere: the light it lands is within 2% of the trace’s, and its brightest point falls on the same texel. And a dense flint prism must split red from blue while keeping their totals close.
#How close it gets
A heavier suite, AccuracyTests, renders the demo’s scenes and compares each receiver’s light with a CPU photon trace of tens of millions of photons that shares none of the library’s shaders. The score is the relative mean squared error: the squared difference in luminance, less the trace’s own noise, over the trace’s mean square. Lower is better.
- Kaustics
- Image-space caustics*
- Caustic maps*
- Path tracing, one sample*
Gold ring
Rough gold ring
Glass ball
Flint glass ball, colors split
Dense flint prism
Pool
Relative mean squared error against a photon trace, lower is better.
* Sample figures, not yet measured.
#The view on top
KausticsView adds a simple renderer on top: the surfaces lit by the light, its soft shadow and the caustics; metal reflecting a small procedural studio; glass and water showing the surfaces behind them, bent and reflected inside as the light is. It is there to make the easy case easy, not to be a general renderer.