Real-time water caustics
Sunlight through a moving water surface draws bright lines on the pool floor.
Left: the sun through flat water.
Right: the same sun, refracted through the waves and traced to the floor.
The problem
Under water, light isn't even. A wave works like a weak lens: it bends neighbouring rays together in one spot and spreads them apart in the next. A common shortcut is a looping caustics texture scrolled over the floor. It ignores the real waves and the rocks.
How it works
Three GPU passes:
- A height field holds the water. Each step pulls every height toward the average of its 4 neighbours, keeps the velocity, and damps it by 0.995. Normals come from the height differences.
- An environment map is rendered from the sun: for each texel, the world position and depth of the first surface the sun sees.
- A fine grid of the water surface is drawn from the sun. In the vertex shader each vertex refracts the sun ray through the water normal (eta 0.7504, air to water), then marches it through the environment map until it goes behind a stored surface. The vertex moves to that hit point.
The fragment shader compares each triangle's area on the water with its area where it landed, using dFdx and dFdy. Brightness = 0.15 × water area ÷ floor area. Rays that bunch up make small triangles, and small triangles make bright lines. Additive blending sums the overlaps. Then the floor shader looks the result up from the sun's view, like a shadow map.
What I hit
A fan of bright streaks ran out from the centre of the map. The sun saw through thin gaps between the sand and the wall blocks, where the environment map stayed empty at position 0, 0, 0. Every ray through a gap "landed" there. A base slab under the pool fixed it.
The repo projects the refracted direction as a point (w = 1), so the light camera's translation leaks into it. With the repo's sun straight down that barely shows. I tilted the sun about 9°, so I projected it as a direction (w = 0).
Triangles split between a rock top and the floor get very thin and flare into bright lines. I drop triangles stretched more than 6× or squeezed more than 16×.
My build
three.js 0.170. Pool 2 × 2 units, water 0.8 deep, sand and 77 rocks, all procedural. Height field 256 × 256, one step per video frame. Caustics grid 616 × 616 over 2.4 × 2.4 units, caustics map 1536 × 1536, ray march up to 96 steps of one texel.
The left half uses the same pipeline with a flat height field. Same sun, same total light, just no focusing.
Trade-offs
- Follows the actual waves and the actual rocks, no baked texture
- Two extra passes (sun-view map, caustics) and a shadow-map style lookup
- Each vertex reads the map up to 96 times
- Sharpness is capped by the grid and map size
- One refraction, first hit only: nothing under overhangs
When to use it
Pools, shallow seas, fountains: places where the camera looks down at the bottom through calm or rippled water.
Source and credits
Repo: github.com/martinRenou/threejs-caustics (BSD-3-Clause license), commit e24fa18
Code: I ported the repo's height-field simulation (shaders/simulation), the light-view environment map (shaders/environment_mapping), the caustics pass (shaders/caustics/water_vertex.glsl and water_fragment.glsl: refraction with eta 0.7504, ray march through the environment map, intensity 0.15 × area ratio, additive blending) and the caustics lookup with its 5-tap blur (shaders/environment) to three.js 0.170, plus the refraction part of its water surface shader. Changes: tilted sun, direction projected with w = 0, longer ray march, stretched-triangle filter, larger grid; details in the notes. The pool, sand, rocks, lighting and the flat-water comparison are my own. No assets or screenshots from the repo (no shark, rock, plant models or skybox).
Topic inspired by @dangreenheck's real-time caustics work.