//Graphics · Case study
Underwater Caustics & Lighting
A real-time caustics and volumetric lighting pipeline built into XeRender, my Vulkan renderer — plus the benchmark harness I wrote to prove the rendering changes were worth their cost.
- Date
- Q1 2026
- Role
- Solo — graphics programmer
- Tech
- C++ / Vulkan / GLSL
- Focus
- Volumetrics & compute

01The problem
Underwater lighting reads as fake the moment the caustics stop agreeing with the water above them. The usual shortcut — a looping pre-baked caustic texture — breaks as soon as the surface moves, the light angle changes, or the camera goes under.
I wanted caustics that are actually derived from the live water surface, running fast enough to be usable in a real frame budget, and I wanted to be able to prove the cost rather than guess at it.
02The pipeline
The surface is driven by a Gerstner wave simulation that runs entirely in a compute shader, producing a normal map for the current frame. Keeping it on the compute queue means the CPU is not doing per-vertex wave math every frame.
That normal map feeds the caustics pass, which projects light through the surface via a light matrix and samples it in the fragment shader on submerged geometry. Explicit memory barriers synchronise the compute output with the graphics passes that consume it.
// Colour attachment for the caustics resolve pass
VkAttachmentDescription colorAttachment{};
colorAttachment.format = swapChainImageFormat;
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;03Measuring it properly
Rather than eyeball the framerate, I built a testing harness into the renderer: selectable water modes, scripted test suites, and automatic CSV export. Each run reports mean and median FPS, 1% lows, mean frame time, standard deviation, and how many frames were rejected as outliers.
That made the quality-versus-cost trade-off an actual measurement. Two configurations, both over 95 valid frames with zero outliers:
| Mode | Suite | Mean FPS | 1% low | Frame time | Std dev |
|---|---|---|---|---|---|
| PB — Physically-Based | Performance | 151.79 | 106.61 | 6.690 ms | 0.868 ms |
| OPT — Optimised | Trade-Off Sweep | 91.91 | 80.15 | 10.930 ms | 0.751 ms |
The two runs use different test suites, so they describe two configurations rather than a single before/after. The point of the harness is that the numbers are repeatable and exportable — I can re-run a suite after a change and compare like for like.
04Debug tooling
Everything is live-tunable through an ImGui layer: sun, secondary and ambient lighting, rim light, water surface and underwater parameters, particles, effects, and a wireframe toggle. Being able to isolate one term while the scene runs is what made the lighting tractable to debug.

05Results



