🌈 Rendering realtime caustics

8-minute read


I will go over my method of rendering real-time caustics. The goal is not to generate physically accurate result, but rather to achieve real-time, controllable, good-looking water caustics effects.

A good way of working with caustics is by creating caustics volumes. Essentially we create a volume, and position it where the caustics should show up in our scene.

World-space UVs

The caustics volume needs to render caustics onto the scene. For this, we will use a caustics texture. Since the caustics should be mapped to the geometry of the scene in world-space, we need to use world-space UVs. To get the world position, we will perform a reconstruction using the depth buffer. This is explained in the following sections.

1. Reading the depth buffer

First we sample the depth buffer using screen-space coordinates.

struct Attributes
float4 positionOS : POSITION;

struct Varyings
float4 positionCS : SV_POSITION;

Varyings vert(Attributes IN)
Varyings OUT;
OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
return OUT;

half4 frag(Varyings IN) : SV_Target
// calculate position in screen-space coordinates
float2 positionNDC = IN.positionCS.xy / _ScaledScreenParams.xy;

// sample scene depth using screen-space coordinates
real depth = SampleSceneDepth(positionNDC);
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(UV));

In the code above we simply sample the scene depth texture using normalized screen-space coordinates.

💡 The UNITY_REVERSED_Z block is used to handle platform-specific differences related to the depth buffer.

2. Reconstructing world position from depth

Now that we have access to the value of the depth buffer given a screen-space coordinate, we can use it to compute a position in world-space coordinates using the following code in the fragment shader.

// calculate position in world-space coordinates
float3 positionWS = ComputeWorldSpacePosition(positionNDC, depth, UNITY_MATRIX_I_VP);

If we want, we can visualize this in the scene using the following code in the fragment shader, where we take the fractional part of the world-space position (you can leave it out in the final shader, it is just for debugging).

half4 color = half4(frac(positionWS), 1.0);
if(depth < 0.0001) return half4(0,0,0,1);
if(depth > 0.9999) return half4(0,0,0,1);
return color;

As you can see, this gives us world-space positions to map our caustics texture to.

3. Caustics volume

We want the caustics volume to act like some sort of decal, where caustics show up wherever the volume intersects with the scene geometry. In order to achieve this, we calculate a bounding box mask in object space to limit the output of our shader.

// calculate position in object-space coordinates
float3 positionOS = TransformWorldToObject(positionWS);

// create bounding box mask
float boundingBoxMask = all(step(positionOS, 0.5) * (1 - step(positionOS, -0.5)));

The way this bounding box mask works is by relying on the fact that the positions of the vertices of the box volume in object space, have a min/max of -0.5 and 0.5 respectively. We use a step function to mask out any pixels that are out of these bounds. The all function is used to make sure this happens in all of the x/y/z axes.

By multiplying our output with this bounding box mask, we can limit the caustics to only be rendered where needed.

Bounding box mask.
Bounding box mask.


We now have everything required to start displaying caustics. We will do this in multiple steps, each step improving on the effect.

1. Mapping

We can map the caustics to the scene geometry by using the world-space coordinates as UVs. If you use the x and z components of the world-space position as UVs, the caustics will appear to be projected top-down onto the scene geometry. However, nstead of using a fixed direction, it would be better to let the direction of the light play a role in how the caustics are oriented.

half4x4 _MainLightDirection;


// calculate caustics texture UV coordinates (influenced by light direction)
half2 uv = mul(positionWS, _MainLightDirection).xy;
half4 caustics = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv);

In the code above, we use the main light direction to influence the UVs that we use to sample the caustics texture. By doing this, the caustics follow the direction of light and appear to be projected onto the scene.

Mapping caustics to the scene geometry.
Mapping caustics to the scene geometry.

To be able to access the light direction in our shader, a C# script is used with the following code.

var sunMatrix = RenderSettings.sun.transform.localToWorldMatrix;
causticsMaterial.SetMatrix("_MainLightDirection", sunMatrix);

This code will simply write the direction of the light to the appropriate shader property so that it can be used in the shader.

2. Scaling and movement

Now that we have the caustics mapped to the scene geometry, let's get them moving. We can use a simple panner function to move the caustics texture. There is a parameter for controlling the speed, as well as the scale of the texture.

half2 Panner(half2 uv, half speed, half tiling)
return (half2(1, 0) * _Time.y * speed) + (uv * tiling);

half2 moving_uv = Panner(uv, _CausticsSpeed, 1 / _CausticsScale);

By using these modified UVs to sample the texture, we get moving caustics.

3. Multiple textures

Having just a single caustics texture moving around looks kind of weird. To fix this, we will put a second caustics texture on top of the first one. The trick here is to move the textures with a different strength and scale.

// create panning UVs for the caustics
half2 uv1 = Panner(uv, 0.75 * _CausticsSpeed, 1 / _CausticsScale);
half2 uv2 = Panner(uv, 1 * _CausticsSpeed, -1 / _CausticsScale);

half4 tex1 = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv1);
half4 tex2 = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv2);

half3 caustics = min(tex1, tex2) * _CausticsStrength;

We combine the 2 moving textures using a min operation which will return the minimum of the inputs. We multiply the result with a strength parameter to control how bright the caustics appear.

In the code above, I use 1 single speed/scale parameter and use it for both textures by multiplying by some magic numbers, but you can of course expose the parameters so you have full control over it. The idea is just that the textures should have a different scale/speed and then the min operation will blend them.

4. Chromatic aberration

In real life, the light rays passing through the water surface get refracted because a change of medium occurs from air to water. During this process, different wavelength components of the light get refracted under different angles, causing the light ray to fall out into a rainbow pattern. This looks really beautiful and we will try to mimic it in our caustics effect.

The idea is to create function that will sample the caustics texture 3 times, each time adding an offset to the UVs. We then use these 3 samples as our r/g/b components of the final result.

half3 SampleCaustics(half2 uv, half split)
half2 uv1 = uv + half2(split, split);
half2 uv2 = uv + half2(split, -split);
half2 uv3 = uv + half2(-split, -split);

half r = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv1).r;
half g = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv2).r;
half b = SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, uv3).r;

return half3(r, g, b);

// sample the caustics
half3 tex1 = SampleCaustics(uv1, _CausticsSplit);
half3 tex2 = SampleCaustics(uv2, _CausticsSplit);

I got the idea to generate chromatic aberration like this from this article by Alan Zucconi.

5. Luminance mask

Next, we will improve our caustics effect by masking the caustics based on the luminance in the scene. This will make it so that the caustics appear less prominent in shadowed areas.

// luminance mask
half3 sceneColor = SampleSceneColor(positionNDC);
half sceneLuminance = Luminance(sceneColor);
half luminanceMask = lerp(1, sceneLuminance, _CausticsLuminanceMaskStrength);

The steps are simple. We sample the scene color, calculate the luminance, and then create a mask using a masking strength parameter.

Since the luminance value will almost never be zero, the caustics will still show up in shadowed areas, just less bright. If you really do not want caustics to show up there, you could work with a threshold value where caustics only show up if the luminance is over a certain value.

half luminanceMask = smoothstep(_CausticsLuminanceMaskStrength, _CausticsLuminanceMaskStrength + 0.1, sceneLuminance);

Another option is to sample the shadow map of the scene, and mask the caustics that way. This will result in hard cutoffs where the shadows are.

6. Edge fade

Currently the caustics have a hard cutoff at the edge of the caustics volume. To make the transition a bit softer, we introduce a soft edge fade mask.

half edgeFadeMask = 1 - saturate((distance(positionOS, 0) - _CausticsFadeRadius) / (1 - _CausticsFadeStrength));

We can control the radius and the strength of the mask. Using this, the edges of the caustics volume look a bit less harsh.

7. Underwater camera

To allow the camera to go inside of the caustics volume, we apply a cool trick where we use Cull Front to not render the front-facing polygons of the volume mesh, but only the back-facing (inside) polygons. We also use ZTest Always to always render those back-faces, even if other scene geometry is blocking it.

Cull Front
ZTest Always

This simple change allows the camera to enter the caustics volume without any problem.

This approach was kindlyexplained to me by A Beginner's Dev Blog, Harry Heathh, Mike V and Anton Kudin on Twitter, thanks!


And that's it! A nice looking caustics effect, as a result of many little techniques coming together.

You can get this shader including an additional volume system for easy placement on the Unity asset store. Any support would be greatly appreciated!

Additional resources