Water shader

By Sebastiaan Westheim

Introduction

Ever since working at an animation studio, I’ve dreamed of creating a water shader. Until a couple of weeks ago, I didn’t have any understanding of how shaders worked, much less how to write them. Through the following blogpost I want to take you, the reader, on a journey on how it all came together and resulted in the shader seen above.

Before getting into the nitty gritty of things I want to clarify a couple of things. Water comes in many shapes and forms. In interviews with the developers of Sea of Thieves, they mention that they seperate water into the following three groups. (Rare Ltd. (2016, July 6))

  • Ponds, which are still bodies of water and very reflective
  • Shores, which are slightly moving bodies of water and less reflective
  • Deep ocean, which are highly moving bodies of water and even less reflective

In this blog post I’m making one universal shader which handles all of the above, but for more specific cases, you might want to use seperate shaders instead.


Goals of the water shader

Every water shader is different, while older game titles might get away with a simple texture moving along the mesh, that is simply not going to cut it for a game revolved around its water.
So I’ve set the following main goals:

  • Fast – the shader shouldn’t get in the way of other game elements.
  • Interactive – the water should be able to interactable by other elements such as boats.

And besides that I’ve set the following sub-goals:

  • Transparent – When looking through the water, you should somewhat be able to see through it.
  • 3D – The water can’t be a flat plane, the vertices have to move.
  • Reflective – By looking at the water.

With that out of the way lets get started


Depth

A key component of a water shader is depth. Depth is crucial because various other features of water depend on it, such as water color, transparency, caustics and foam.
Camera depth can easily be obtained through the _CameraDepthTexture, and sampling that using Unity’s build-in shader functions.

struct Input 
{
    float4 screenPos;
    float eyeDepth;
};
 
sampler2D _CameraDepthTexture;
float _FadeStrength;
 
 
void vert (inout appdata_full v, out Input o)
{
     UNITY_INITIALIZE_OUTPUT(Input, o);
     COMPUTE_EYEDEPTH(o.eyeDepth);
}
 
void surf (Input IN, inout SurfaceOutputStandard o) 
{
    float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos)));
    float depth = 1.0 - saturate(_FadeStrength * (sceneZ - IN.eyeDepth));
    o.Albedo = depth;
    o.Alpha = 1;
}

This allows us to check depth based on the camera position, making it great for water transparency and color.
Creating and lerping two colors using depth will create a nice looking transition between deep and shallow water.

By creating a rendertexture and orthographic camera looking down on the terrain we can render a heightmap. Passing this heightmap back to the shader allows us to place foam on fixed places, regardless of main-camera position and angle.
By checking whether the red channel of the depthtexture is higher than a certain value, we render the foam otherwise we ignore it.

Combining these two effects will result in a stylized water.

Cameradepth and heightmap in action. Foam slowly transitioning between 2 predefined values.

Reflection

Water is a very light and colorless substance. This is why, when the surface of the water becomes smooth the water will begin to show reflective properties. Because of this, games that aim to simulate water use various techniques to create a reflection.

Screen Space Reflection (Post processing)

By default Unity provides SSR as a package. It is easy to use and shows a fairly accurate reflection.
Screen space reflection is a technique where the post processing effect reads the render image, and mirrors objects into objects with metallic surfaces, making it a quite efficient way to mirror objects.
The mayor downside of SSR is that when the camera doesn’t view the entire object, the reflection darkens resulting in a fairly buggy effect.

Reflection probes

Reflection probes are another component that provide reflections. By default Unity only provides sphere and box reflection probes, which need to be rendered each time the camera moves to create a realistic effect.

Unity HDRP provides further access to planar reflection probes, which don’t need to be rendered each time when the camera moves. The only downside however that it’s being limited to the HD render pipeline.

Planar reflection

Whereas reflection probes require a 360 degree render, planar reflection renders the part of the scene where the player is looking at, but from the opposite side of the camera position.
While it still needs to be rendered each time the camera moves it is still faster than the reflection probes.
The only mayor downside is when a object sticks through the reflection plane, causing the reflections to mess up. In the case of water this is a dealbreaker but for mirrors this wouldn’t be an issue.

Reflection in my water shader was implemented with normal reflection probes.
I’ve done this because I didn’t want to limit my shader to the HD renderpipeline. Using reflection probes on my water created the following results.

Water shader with extreme reflections to showcase the effect.

Waves

In order to simulate water movement, games have a limited choice of which techniques that they can use. These techniques are as follows:

Normal maps

Creating waves using normal maps. This can be done by creating two normals maps, with different tilling sizes. Next each normal map is offset and slowly transitioning in a different direction.
By multiplying the normal maps back together a wave like effect is created.

Gerstner waves

Gerstner waves is a technique based on the formula discovered by Franz Josef Gerstner in 1802.
Even though the discovery is old, Gerstner waves are still considered a realistic model of the ocean, thus they are still used in games today like Dishonored (Sergeev, A. (2019, July 19)), and shores in War Thunder (Tcheblokov, T. (n.d.)).

FFT

Fast Fourier Transform Water is a more advanced technique for simulating water movement. While Gerstner waves are considered a realistic model of the ocean, in statistical models the wave height is considered a random variable of horizontal position and time. Hence why FFT exists.
Being a more realistic model and more advanced algorithm it is commonly used by bigger games such as Sea of Thieves (Kozin, V. & Rare Ltd. (n.d.)) and War Thunder (Tcheblokov, T. (n.d.).


Due to the complexity of FFT, I decided to use Gerstner waves to simulate waves. An interesting aspect of gerstner waves is a single vertex not only moves in the vertical axis, but also in the horizontal axis. As can be seen in the animation below. While this isn’t troublesome for the shader, it does introduce problems when calculating the height at a specific point, especially when adding multiple Gerstner waves.

Natural Philosophy. (n.d.)
Red vertex displacing over time

Unlike FFT water, gerstner waves are well documented, making it easy to implement.

// Source: https://catlikecoding.com/unity/tutorials/flow/waves/
float3 GerstnerWave (float4 wave, float3 p, float s, inout float3 tangent, inout float3 binormal) {
    float steepness = wave.z;
    float waveLength = wave.w;
    float k = PI2 / waveLength;
    float c = sqrt(GRAVITY / k);
    float2 d = normalize(wave.xy);
    float f = k * (dot(d, p.xz) - c * _Time.y * s); 
    float a = steepness / k;
 
    tangent += float3 (
        -d.x * d.x * (steepness * sin(f)),
        d.x * (steepness * cos(f)),
        -d.x * d.y * (steepness * sin(f))
    );
 
    binormal += float3(
        -d.x * d.y * (steepness * sin(f)),
        d.y * (steepness * cos(f)),
        -d.y * d.y * (steepness * sin(f))
    );
 
    return float3 (
        d.x * (a * cos(f)),
        a * sin(f),
        d.y * (a * cos(f))
    );
}

Applying multiple of these Gerstner waves to the shader creates the following water.

Gerstner waves applied to shader, smoothness reduced.

Collision

Because the shader displaces the vertices of the water, it is impossible to raycast against. In order to solve this I’ve tested various solutions for calculating waveheight at a specific point.

Subtracting offset
The initial solution executed the Gerstner wave method twice, once to calculate it from the specified point, and the second time calculating it from the specified point while substracting the value returned by the previous execution. While this did reduce the displacement, it still refused to give precise results

Lookup table
The second solution created a lookup table per gerstnerwave with a fixed horizontal step between the values. While this worked perfectly for individual gerstner waves, the solution wouldn’t work when multiple waves were displacing the vertices. As can be seen in the video, the cubes height wouldn’t stick to the water.

Which leaves us with my final solution, which is pre-calculating vertex displacement ahead of the next fixed update in the compute shader.
While retrieving data from the GPU usually requires the CPU to wait for the GPU, we can avoid that using callbacks provided to us by the fairly new AsyncGPUReadback.RequestIntoNativeArray method.
Having the compute shader calculate the displacement 50 times per second (FixedUpdate interval of 0.02) using the callback gives a miniscule amount of overhead.

RequestIntoNativeArray requires the use of a NativeArray hence why it doesn’t allow us to insert a 2 dimensional array. In order to solve this using a 1 dimensional array, there has been a slight modification in how triangles/quads are made.

// size is the amount of vertices in a direction. E.g. 16;
private void Awake()
{
    TotalQuads = (size - 1* (size - 1);
    TotalTriangles = TotalQuads * 2;
    triangles = new int[TotalTriangles * 3];
    int tIndex = 0;
    int vIndex = 0;
    int NextThreshold = size - 1;
 
    for (int i = 0; i < TotalQuads; i++)
    {
        if (vIndex == NextThreshold)
        {
            vIndex++;
            NextThreshold += size;
        }
 
        triangles[tIndex] = vIndex;
        triangles[tIndex + 1= triangles[tIndex + 4= vIndex + 1;
        triangles[tIndex + 2= triangles[tIndex + 3= vIndex + size;
        triangles[tIndex + 5= vIndex + size + 1;
 
        vIndex++;
        tIndex += 6;
    }
}
Lines drawn by looping over the triangles array and calling the Debug.DrawLine method.

Having the triangles drawn and positioned properly, we can now start looping over each triangle and checking whether a specific point is inside.
After we’ve verified that a specific point is inside the triangle, we can use the barycentric coordinate system to find the height at the specified point.

int index = 0;
var pos = target.position;
for (int i = 0; i < TotalTriangles; i++)
{
    Vector3 p1 = nativeArray[triangles[index]];
    Vector3 p2 = nativeArray[triangles[index + 1]];
    Vector3 p3 = nativeArray[triangles[index + 2]];
 
    var result = CollisionMath.PointInTriangle(pos, p1, p2, p3);
    if (result)
    {
        var height = CollisionMath.PointHeightInTriangle(p1, p2, p3, pos.x, pos.z);
        target.MovePosition(new Vector3(pos.x, height + Water.WaterBaseHeight, pos.z));
        break;
    }
    index += 3;
}
Buoy implemented and properly sticking to the water.

Conclusion

Result

In the video above can be seen how the shader looks now after these four weeks of research and development.
A stylized looking water shader using gerstner waves.
I’m fairly happy with the result as both of my main goals have been fulfilled.
Towards the end of the video can be seen how much performance I still have when rendering from the editor (~1800 fps).
During the development of the shader I’ve spend a lot of time on research and development of waveheights at a specific point, so I’m very happy this worked out in such a nice way.
I’ve also spend a fair amount of time on displacing the waves using a displacement texture and tesselation, which sadly didn’t make the cut.

There is still a lot that can be improved and left to be desired, granted infinite time I would like to add:

  • Dividing the ocean into chunks
  • Level of detail
  • Displacement texture
  • Tessellation
  • Refraction
  • Caustics
  • Normal maps
  • Splashes
  • Foam on waves
  • Foam texture
  • Planar reflection probes
  • Further polishing
  • Improve buoyancy by including rotation.

Sources

  • Sergeev, A. (2019, July 19). Dishonored: Animation, Pre-Production and Water Shader. 80 Level. https://80.lv/articles/dishonored-animation-pre-production-and-water-shader/
  • Unity. (n.d.). AsyncGPUReadback.RequestIntoNativeArray. Unity3d. Retrieved October 26, 2020, from https://docs.unity3d.com/2019.3/Documentation/ScriptReference/Rendering.AsyncGPUReadback.RequestIntoNativeArray.html
  • Tcheblokov, T. (n.d.). Ocean simulation and rendering in War Thunder. Nvidia.Com. Retrieved October 26, 2020, from http://developer.download.nvidia.com/assets/gameworks/downloads/regular/events/cgdc15/CGDC2015_ocean_simulation_en.pdf
  • Flick, J. (2018, July 25). Waves. Catlikecoding.com. https://catlikecoding.com/unity/tutorials/flow/waves/
  • Kozin, V. & Rare Ltd. (n.d.). Technical art of Sea of Thieves. Https://Onedrive.Live.Com. Retrieved October 26, 2020, from https://onedrive.live.com/view.aspx?resid=E44A07276535AAD9!516880&ithint=file%2cpptx&authkey=!AFC0nIs4NiJH9G8
  • Guidev. (2019, June 2). Planar reflection with Unity – Part 1/2 [Video]. Youtube. https://www.youtube.com/watch?v=tdIv9lJghVg&feature=youtu.be
  • Natural Philosophy. (n.d.). [GIF]. Tumblr. https://64.media.tumblr.com/50d0d974613d828b436ccb8d7c484411/tumblr_n75shrJiAU1tcjz2ao1_400.gif
  • Unity. (2020, July 7). Making a Water Shader in Unity with URP! (Tutorial) [Video]. YouTube. https://www.youtube.com/watch?v=gRq-IdShxpU
  • Rare Ltd. (2016, July 6). Sea of Thieves Inn-side Story #3: Engineering Great Water [Video]. Youtube. https://www.youtube.com/watch?v=9nxlmCq4220&feature=youtu.be

Related Posts