Skip to the content
How it works

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*
0.089
0.240
0.310
0.550

Gold ring

0.163
0.330
0.380
0.470

Rough gold ring

0.010
0.090
0.120
0.410

Glass ball

0.017
0.140
0.170
0.480

Flint glass ball, colors split

0.257
0.460
0.520
0.740

Dense flint prism

0.052
0.170
0.210
0.620

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.