← Projects

//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
XeRender running the physically-based water mode with the debug UI open
XeRender 2.0 — physically-based water mode, with the ImGui debug and benchmark panels open.

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.

render_pass_setup.cppC++
// 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:

ModeSuiteMean FPS1% lowFrame timeStd dev
PB — Physically-BasedPerformance151.79106.616.690 ms0.868 ms
OPT — OptimisedTrade-Off Sweep91.9180.1510.930 ms0.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.

XeRender debug UI showing lighting, water and benchmark panels
The optimised water mode running a trade-off sweep, with results exporting to CSV.

05Results

Night-time ocean surface with specular sun glints
Surface specular response under a low-angle light
Underwater lighting render
Underwater scattering and depth falloff
Underwater lighting render
Caustics projected onto submerged geometry
Underwater lighting render
Volumetric light shafts through the water surface
Ask me about thisMore projects