vidar.games

Real-time water caustics

2026-11-02 · Discuss on X

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:

  1. 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.
  2. An environment map is rendered from the sun: for each texel, the world position and depth of the first surface the sun sees.
  3. 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

When to use it

Pools, shallow seas, fountains: places where the camera looks down at the bottom through calm or rippled water.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

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.