💦 Stylized Water Shader

How to create a stylized water shader in Unity.

31 minute read

Jump to heading Introduction

In this tutorial I will explain step-by-step how to create a beautiful stylized water shader in Unity using Shader Graph. The goal is not to create a physically accurate result, but rather to achieve a real-time, controllable and good-looking stylized water shader.

Jump to heading 1. Initial set up

⚠️ This tutorial has been made with Unity 2022.2.6f1 and Universal RP 14.0.6.

Before starting this tutorial, there are a few things that need to be set up.

Jump to heading Render settings

Make sure to enable the depth and opaque textures in the URP pipeline asset.

URP renderer settings.
URP renderer settings.

Jump to heading Shader and material

Create a new Unlit Shader Graph shader. The shader material should be set the Unlit and the surface type to Opaque.

Shader settings.
Shader settings.

You can then create a material that uses this shader.

Stylized water material.
Stylized water material.

Select this material and in the Advanced Options, change the Render Queue to be Transparent.

Material settings.
Material settings.

Jump to heading Scene

For the scene, I am using The Illustrated Nature by Artkovski but you can use any scene you want.

In the scene, I created a plane and assigned it the material that we created before.

Start scene.
Start scene.

Now let's get started!

💡 During this tutorial, newly added nodes will be marked in green so you can easily follow along to create the shader yourself from the ground up.

Jump to heading 2. Figuring out the depth of the water

The single most important step in creating our water shader is figuring out the depth of the water. We will use the depth value it to drive many other effects of our water such as color, opacity, refraction and foam.

Jump to heading Camera-relative depth

Most water shader tutorials use some variation of the following node setup to calculate a depth fade value that goes from shallow (1, white) to deep (0, black).

Camera-relative depth fade.
Camera-relative depth fade.

In this node setup, the Scene Depth (Eye) node returns the distance (in world space units, meters) between the camera and the objects below the water. You can imagine it by tracing an imaginary ray from the camera towards the water that stops when it first hits an object under the water surface. The distance of this ray is what is returned by the node.

Scene depth and screen position.
Scene depth - screen position = water depth.

What we actually care about is not the distance between the camera and the objects under the water, but the distance between the surface of the water and the objects under the water. For this, we can use the alpha component of the Screen Position (Raw) node which gives us the distance between the camera and the water surface. We can then Subtract the 2 distances to get the desired distance which represents the Water Depth. This is visualized in the diagram above.

Depth subtraction.
Depth subtraction.

Finally we Divide by a depth range/distance control parameter, Saturate the output (clamp between 0 and 1) and then perform a One Minus operation to get a white value near the shore and a black value in the deep parts of the water.

Depth division.
Depth division.

It is very important to note that this calculated depth value is not the vertical depth of the water. If you were looking at the water and shooting an invisible ray from your eye towards a point of the water, the distance that the ray would travel between hitting the surface of the water and hitting the ground below the water surface is the distance that you get here from these nodes. This means that when looking at the same point on the water surface, the returned depth value depends on how shallow the angle is under which you are looking at the water surface.

This effect/artifact can be seen in the video below when moving around the camera. You can especially see it on the rocks where the same spots on the water get either a black or a white color based on on shallow the viewing angle is.

Jump to heading World-space depth

Personally I am not a fan of how the depth effect looks using the previous method. The perceived depth values change when moving around your camera and I would rather have a constant depth value that is independent of camera position.

💡 This is just a personal preference and you can keep using the camera-relative depth calculation if you like.

To 'fix' this camera-relative depth, I have figured out an alternative way to calculate the depth which happens in world space.

World-space depth fade.
World-space depth fade.

This node setup gives you the depth of the water as if you would put a measuring tape vertically into the water and measure the distance from the surface of the water to the seabed. When moving the camera around, the calculated depth values do not change in appearance.

Jump to heading Depth Fade subgraph

It is a good idea to put all of the nodes we just created into a Depth Fade subgraph. This will make our main graph more clean and organized.

Depth fade subgraph.
Depth fade subgraph.

Jump to heading 3. Colors and opacity

In this section we will add color to our water surface using the depth values we just calculated.

Jump to heading Shallow and deep

Now that we know the depth of our water, we can use it to drive the colors and transparency of our water. We can simply use the output of our Depth Fade subgraph (which is a value between 0 and 1) to Lerp between a shallow water color and a deep water color.

Shallow and deep water colors.
Shallow and deep water colors.

Lerping between a shallow and a deep water color already gives us a nice effect for our water.

Going the extra mile: HSV lerping

As explained by Alan Zucconi in this great article about colour interpolation, we can improve the appearance of the color of our water by lerping in HSV space instead of RGB space. To do this in Shader Graph, a custom function node can be used that converts our RGB colors to HSV, lerps in HSV space and then converts them back to RGB.

half3 RGBToHSV(half3 In)
{
half4 K = half4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
half4 P = lerp(half4(In.bg, K.wz), half4(In.gb, K.xy), step(In.b, In.g));
half4 Q = lerp(half4(P.xyw, In.r), half4(In.r, P.yzx), step(P.x, In.r));
half D = Q.x - min(Q.w, Q.y);
half E = 1e-10;
return half3(abs(Q.z + (Q.w - Q.y)/(6.0 * D + E)), D / (Q.x + E), Q.x);
}

half3 HSVToRGB(half3 In)
{
half4 K = half4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
half3 P = abs(frac(In.xxx + K.xyz) * 6.0 - K.www);
return In.z * lerp(K.xxx, saturate(P - K.xxx), In.y);
}

void HSVLerp_half(half4 A, half4 B, half T, out half4 Out)
{
A.xyz = RGBToHSV(A.xyz);
B.xyz = RGBToHSV(B.xyz);

half t = T; // used to lerp alpha, needs to remain unchanged

half hue;
half d = B.x - A.x; // hue difference

if(A.x > B.x)
{
half temp = B.x;
B.x = A.x;
A.x = temp;

d = -d;
T = 1-T;
}

if(d > 0.5)
{
A.x = A.x + 1;
hue = (A.x + T * (B.x - A.x)) % 1;
}

if(d <= 0.5) hue = A.x + T * d;

half sat = A.y + T * (B.y - A.y);
half val = A.z + T * (B.z - A.z);
half alpha = A.w + t * (B.w - A.w);

half3 rgb = HSVToRGB(half3(hue,sat,val));

Out = half4(rgb, alpha);
}

The custom function node then fits in like this with the rest of our nodes.

HSV color lerping.
HSV color lerping.

When looking at the water, the intermediate colors between shallow and deep will appear more vibrant.

Going the extra mile: Posterize

A cool and easy effect to add is posterization. We simply add the Posterize node and add a property to control the number of steps.

Color posterization.
Color posterization.

A good example of where such a posterization technique was used is in the game A Short Hike. In the image below you can see the different color bands which are a result of the posterization.

A Short Hike water.
A Short Hike water.
Bonus tip: Use a gradient

If you want even more control over the colors of your water, you can make use of a gradient texture to drive the color. In the example below I have a gradient that I turn into a 256x1 texture that I then sample using the [0,1] depth value. The step to convert from a gradient to a texture is needed because Shader Graph does not support gradient properties.

Gradient coloring.
Gradient coloring.

This setup allows you to do both hard and soft transitions of colors.

Below is the code I used to convert from a gradient to a texture.

using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
using System.IO;

public static class GradientTextureMaker
{
public static int width = 128;
public static int height = 4; // needs to be multiple of 4 for DXT1 format compression

public static Texture2D CreateGradientTexture(Material targetMaterial, Gradient gradient)
{
Texture2D gradientTexture = new Texture2D(width, height, TextureFormat.ARGB32, false, false)
{
name = "_gradient",
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp
};

for (int j = 0; j < height; j++)
{
for (int i = 0; i < width; i++) gradientTexture.SetPixel(i, j, gradient.Evaluate((float)i / (float)width));
}

gradientTexture.Apply(false);
gradientTexture = SaveAndGetTexture(targetMaterial, gradientTexture);
return gradientTexture;
}

private static Texture2D SaveAndGetTexture(Material targetMaterial, Texture2D sourceTexture)
{
string targetFolder = AssetDatabase.GetAssetPath(targetMaterial);
targetFolder = targetFolder.Replace(targetMaterial.name + ".mat", string.Empty);

targetFolder += "Gradient Textures/";

if (!Directory.Exists(targetFolder))
{
Directory.CreateDirectory(targetFolder);
AssetDatabase.Refresh();
}

string path = targetFolder + targetMaterial.name + sourceTexture.name + ".png";
File.WriteAllBytes(path, sourceTexture.EncodeToPNG());
AssetDatabase.Refresh();
AssetDatabase.ImportAsset(path, ImportAssetOptions.Default);
sourceTexture = (Texture2D)AssetDatabase.LoadAssetAtPath(path, typeof(Texture2D));
return sourceTexture;
}
}

Jump to heading Horizon color

Next, we will add a color to the parts of the water at the horizon. For this, we will use a Fresnel Effect node with a horizon color and horizon distance parameter. We will use the the output of the Depth Color from the previous section and lerp between that and the Horizon Color.

Horizon color.
Horizon color.

When you look at the water at a very sharp angle, the horizon color will show up in the distance.

Jump to heading Underwater color

Right now we are directly setting the color of the water but because we do this, the colors of the objects beneath the water get kind lost. To fix this, we will take the underwater color into account when shading the surface of the water.

Underwater color.
Underwater color.

We sample the Scene Color node which returns the color of the geometry below the water surface. Then where we set the other colors to be transparent, we will use the underwater color instead. This is done using the One Minus node. Finally we add the colors we already had to the underwater color to get the final color.

By changing the alpha components of the shallow and deep water colors, we can control how much the underwater color is added.

Jump to heading 4. Refraction

Let's add a cool effect, refraction!

Jump to heading Refracted UVs

Many nodes that we have already used like the Scene Color and Scene Depth nodes have a UV input parameter that we left to the default up until now. We will start by adding a UV parameter to the Depth Fade subgraph and connect it up to the Scene Depth node inside of the subgraph.

Scene depth UVs.
Scene depth UVs.

Now that we can control the UVs which the Depth Fade subgraph uses, we can distort those UVs to add a refraction effect. Let's use the following node setup to create refracted UVs.

Refracted UVs.
Refracted UVs

In the node setup above, we tile and move some Gradient Noise that we then Remap to a [-1,1] range, Multiply with a refraction strength parameter and then add to the Screen Position (default) (which are the regular, undistorted UVs).

It is a good idea to put these nodes in their own Refracted UV subgraph to make the graph clean and organized. We can then use this refracted UVs and connect them to the UV input of the Depth Fade subgraph as well as the UV input of the Scene Color node that we used for the underwater color.

Now we get a nice refraction effect!

Going the extra mile: Fixing refraction artifacts

As you might have seen in the previous clip, the refraction we have right now is flawed. When an object sticks out of the water you'll see that the refraction effect is present in places where it should not be.

Refraction artifacts.
Refraction artifacts.

One way to solve this is to perform a depth check to see if we should use the distorted or the undistorted UVS. The Scene Position subgraph is a subgraph I created that contains the nodes to calculate the world space scene position that was shown in the first section about calculating the world space depth.

Fixing refraction artifacts.
Fixing refraction artifacts.

With this fix implemented, the effect looks much better and only shows up where you can actually see the object inside of the water.

Fixed refraction.
Fixed refraction.

Jump to heading 5. Foam

The next step is to add foam to the water.

Jump to heading Surface foam

We will start by adding foam that gets drawn on top of the water surface.

Panning UVs

We will add surface foam to the shader step by step. We will start by setting up the UVs that we will use to sample a surface foam texture. Let's create a subgraph called Panning UVs and add the following nodes.

Panning UVs.
Panning UVs.

This subgraph takes in UVs and then adds movement to them as well as some Tiling and an Offset.

The nodes on the left are used to convert the direction parameter which is a value between 0 and 1 into a direction vector for the UV movement. You could as well have just a vector input parameter and set the direction yourself, but this approach allows you to work with a single direction slider between 0 and 1 and control the full 360° range of movement. For performance reasons, you could leave this out and just directly set the direction.

Distorted UVs

Next, we will take the panning UVs and distort them using some sine functions. For this, we will use a Custom Function node because it would be a hassle to recreate the math in nodes.

Distorted UVs.
Distorted UVs.

We take the output of the Panning UV subgraph and plug it into a Custom Function node. For the custom function, we use the following code.

void DistortUV_float(float2 UV, float Amount, out float2 Out)
{
float time = _Time.y;

UV.y += Amount * 0.01 * (sin(UV.x * 3.5 + time * 0.35) + sin(UV.x * 4.8 + time * 1.05) + sin(UV.x * 7.3 + time * 0.45)) / 3.0;
UV.x += Amount * 0.12 * (sin(UV.y * 4.0 + time * 0.50) + sin(UV.y * 6.8 + time * 0.75) + sin(UV.y * 11.3 + time * 0.2)) / 3.0;
UV.y += Amount * 0.12 * (sin(UV.x * 4.2 + time * 0.64) + sin(UV.x * 6.3 + time * 1.65) + sin(UV.x * 8.2 + time * 0.45)) / 3.0;

Out = UV;
}

This code adds a distortion to the UVs using sine functions to make the foam a bit more interesting when moving around. It looks like this.

Sampling the foam texture

Now that we have moving and distorted UVs, we can use them to sample a foam texture and add it to the surface of our water.

Sampling the foam texture.
Sampling the foam texture.

We simply use a Sample Texture 2D node to sample the foam texture and then use a Step node to add a cutoff. Lastly we multiply by a foam color property.

💡 You could also use a Smoothstep node here to potentially get a smoother sampling of the foam texture.

We could simply add the surface foam to the water color using the Add node however I believe we can do a bit better by blending it using an Overlay subgraph that we can create. This subgraph contains the following nodes.

Adding the foam.
Adding the foam.

We can then use this Overlay subgraph to blend between the water color we already had (Base) and the output of the surface foam nodes (Overlay).

Blending the foam.
Blending the foam.

Using this, the surface foam can get blended more nicely with the water by taking into account the base color of the water underneath the foam. It is subtle, but quite nice.

Jump to heading Intersection foam

Next we will add foam that gets drawn at the edges of an object where it intersects the water surface.

Intersection foam mask

We will start by creating a mask that will define where the intersection foam should show up. For this, we will be using the Depth Fade subgraph we created before.

Intersection foam mask.
Intersection foam mask.

We use the Depth Fade subgraph to create a depth-based mask and use the Intersection Foam Fade parameter to control the hardness of the mask. When just showing this intersection mask, it looks like this.

Sampling the intersection foam

For the intersection foam, the setup is similar to the surface foam. Again we use the Panning UV subgraph and use those UVs to sample an Intersection Foam Texture. We use an Intersection Foam Cutoff parameter to control where we cut off the foam texture using a Step node. We multiply this Intersection Foam Cutoff parameter by the output of the Depth Fade subgraph that we used in the nodes for the depth-based mask. The reasoning is that we want the foam to be fully formed near the edges of the object, but disintegrate as it goes further away from the shore.

Intersection foam.
Intersection foam.

The other nodes are used to set the color of the intersection foam. We also multiply the alpha (transparency) of the intersection foam by the output of the nodes of the intersection foam mask that we created before. This makes sure that the intersection foam only shows up where we want it to (inside of the mask).

The sampled intersection foam texture could for example look like this.

Intersection foam texture.
Intersection foam texture.

Adding the intersection foam

Just like for the surface foam, we use the Overlay subgraph we created to nicely blend the intersection foam with the rest of the colors.

Intersection foam blending.
Intersection foam blending.

Now we have added a nice intersection foam effect to the water!

Going the extra mile: Use signed distance fields for intersection foam

One issue with the current implementation of the intersection foam is that it requires geometry underneath the water to be present before the intersection foam will show up. One more advanced technique to solve this is to have a top down orthographic camera that renders geometry to a mask and then generate an SDF texture from that mask. This is shown in this tweet by Harry Alisavakis. The SDF texture could then be sampled to generate a more uniform intersection foam mask.

Jump to heading 6. Lighting

An important feature of the water shader is how lighting interacts with it. We will go for a stylized look instead of a physically accurate one.

Jump to heading Surface normals

First, we will generate surface normals for the water by sampling a Normals Texture. We will use a common trick which is to sample the texture twice using slightly different sampling properties. We then combine these 2 samples by using the Normal Blend node.

Surface normals.
Surface normals.

Notice that we only use a single value for the Normals Scale and Normals Speed. We just slightly modify them before sampling the Normals Texture for a second time. Again, we use the Panning UV subgraph we created earlier to move the normals textures.

I have put the nodes from the screenshot above in a subgraph called Blended Normals. We can then use the output of these normals and apply a strength to them using the Normal Strength node. Finally we transform them to world space using the Transform node.

Blended normals.
Blended normals

We now have moving normals for our water that we can use where we can adjust the speed, scale and strength.

Jump to heading Lighting calculations

Next, we will use the normals we just created to generate lighting effects. Again, we will two Custom Function nodes. Our custom function nodes take in a normal vector, position and view direction (all in world space). The nodes then output a Specular lighting term. For the normals, we use the output of our world-space transformed normals from earlier. For the position and view direction, we can use the Position and View Direction nodes respectively (both in world space). We have one custom function node for Main Lighting and another one for Additional Lights. This will make it possible for our light to react to the main light as well as additional point lights.

Lighting.
Lighting.

We can put all of the code for the lighting in one file. The code looks like this.

float LightingSpecular(float3 L, float3 N, float3 V, float smoothness)
{
float3 H = SafeNormalize(float3(L) + float3(V));
float NdotH = saturate(dot(N, H));
return pow(NdotH, smoothness);
}

void MainLighting_float(float3 normalWS, float3 positionWS, float3 viewWS, float smoothness, out float specular)
{
specular = 0.0;

#ifndef SHADERGRAPH_PREVIEW
smoothness = exp2(10 * smoothness + 1);

normalWS = normalize(normalWS);
viewWS = SafeNormalize(viewWS);

Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
specular = LightingSpecular(mainLight.direction, normalWS, viewWS, smoothness);
#endif
}

void AdditionalLighting_float(float3 normalWS, float3 positionWS, float3 viewWS, float smoothness, float hardness, out float3 specular)
{
specular = 0;

#ifndef SHADERGRAPH_PREVIEW
smoothness = exp2(10 * smoothness + 1);

normalWS = normalize(normalWS);
viewWS = SafeNormalize(viewWS);

// additional lights
int pixelLightCount = GetAdditionalLightsCount();
for (int i = 0; i < pixelLightCount; ++i)
{
Light light = GetAdditionalLight(i, positionWS);
float3 attenuatedLight = light.color * light.distanceAttenuation * light.shadowAttenuation;

float specular_soft = LightingSpecular(light.direction, normalWS, viewWS, smoothness);
float specular_hard = smoothstep(0.005,0.01,specular_soft);
float specular_term = lerp(specular_soft, specular_hard, hardness);

specular += specular_term * attenuatedLight;
}
#endif
}

We can then combine the Main Lighting and Additional Lighting by first running the main lighting through a Step node to get hard lighting and we then multiply by a Specular Color

Combined lighting.
Combined lighting.

Finally we can quite literally Add the lighting to the current output of the graph.

Adding lighting.
Adding lighting.

We now have pretty complex lighting effects for our water, allowing us to switch between smooth/rough water surfaces, hard/soft lighting, support for the main light and support for additional point lights.

Bonus tip: Using surface normals to influence refraction

Instead of using gradient noise to generate the refraction like we did before, it might be a better idea to use the surface normals to influence the strength of the refraction. This looks better visually. For this, you will have to transform the generated normals from Tangent space to View space, then Multiply by a Refraction Strength parameter and then add the result to the Screen Position to generate the refracted UVs.

Refraction using normals.
Refraction using normals.

Jump to heading 7. Waves

A big part of the look of our water shader is now complete so let's add some movement.

Jump to heading Vertex displacement

We will be adding wave movement by displacing the vertices of the water plane in the vertex shader. For this to work nicely, you need to make sure that your water plane has a high enough vertex density so that there are enough vertices to displace.

The nodes below show a very basic example of vertex displacement. We take the original world position of the vertex and add an offset (0,0,0 in this case). We then convert from World Space to Object space and link it up with the vertex position slot.

Vertex displacement.
Vertex displacement.

Jump to heading Gerstner waves

There are many levels of simulating wave displacement. You could start by adding simple sine waves or go all in and create a FFT wave simulation. We will use something between the two in terms of graphical fidelity: Gerstner Waves. There are many good tutorials about Gerstner Waves. I will just explain how I use them in my shader. If you want more information, I recommend this tutorial about waves by Catlike Coding.

Because the code for the waves is math-heavy, we again use a Custom Function node and add the output as an offset to the world position like we did in the previous section.

Gerstner waves.
Gerstner waves.

The following code generates 4 waves in total and then adds them together.

float3 GerstnerWave(float3 position, float steepness, float wavelength, float speed, float direction, inout float3 tangent, inout float3 binormal)
{
direction = direction * 2 - 1;
float2 d = normalize(float2(cos(3.14 * direction), sin(3.14 * direction)));
float k = 2 * 3.14 / wavelength;
float f = k * (dot(d, position.xz) - speed * _Time.y);
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))
);
}

void GerstnerWaves_float(float3 position, float steepness, float wavelength, float speed, float4 directions, out float3 Offset, out float3 normal)
{
Offset = 0;
float3 tangent = float3(1, 0, 0);
float3 binormal = float3(0, 0, 1);

Offset += GerstnerWave(position, steepness, wavelength, speed, directions.x, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.y, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.z, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.w, tangent, binormal);

normal = normalize(cross(binormal, tangent));
//TBN = transpose(float3x3(tangent, binormal, normal));
}

The custom function takes in values for the Steepness, Wavelength and Speed of the waves as well as 4 Direction values between [0,1] which can each control an individual wave. The normal vector is calculated as well.

💡 Feel free to add even more waves, but I think 4 waves is already a nice starting point.

Our waves look simple, but it is already quite nice.

Bonus tip: Using wave height to drive color

An additional thing you can do is use the Y component of the Offset output of the Gerstner Waves custom function node to influence the colors of the water. This way you can give the tops of the waves a slightly different color.

Jump to heading 8. Buoyancy

Buoyancy is a big and complicated topic if you want to achieve a realistic simulation. However, I wanted to share how you can go about adding basic buoyancy to your water.

Jump to heading Simulation on the CPU

Currently we are simulating the waves on the GPU through the vertex shader of our water. To create a buoyancy simulation, we will recreate the wave movement on the CPU in a C# script. This way we can use this CPU simulation to create buoyancy effects for floating objects. The setup looks pretty similar to what we already did in our shader. We create a function GetWaveDisplacement which takes in a position and some wave parameters, It then returns an offset which is generated by adding 4 waves together. I added the script below. The goal is to have the exact same thing as we did in the vertex shader, but then running on the CPU.

Script: GerstnerWaveDisplacement.cs
using UnityEngine;

public static class GerstnerWaveDisplacement
{
private static Vector3 GerstnerWave(Vector3 position, float steepness, float wavelength, float speed, float direction)
{
direction = direction * 2 - 1;
Vector2 d = new Vector2(Mathf.Cos(Mathf.PI * direction), Mathf.Sin(Mathf.PI * direction)).normalized;
float k = 2 * Mathf.PI / wavelength;
float a = steepness / k;
float f = k * (Vector2.Dot(d, new Vector2(position.x, position.z)) - speed * Time.time);

return new Vector3(d.x * a * Mathf.Cos(f), a * Mathf.Sin(f), d.y * a * Mathf.Cos(f));
}

public static Vector3 GetWaveDisplacement(Vector3 position, float steepness, float wavelength, float speed, float[] directions)
{
Vector3 offset = Vector3.zero;

offset += GerstnerWave(position, steepness, wavelength, speed, directions[0]);
offset += GerstnerWave(position, steepness, wavelength, speed, directions[1]);
offset += GerstnerWave(position, steepness, wavelength, speed, directions[2]);
offset += GerstnerWave(position, steepness, wavelength, speed, directions[3]);

return offset;
}
}

Jump to heading Buoyancy script

Next, we want to use the CPU simulation of the waves we did to actually make an object float on the water surface. For this I will use an approach that works using buoyancy effectors. These are points on an object where the wave offset is sampled and an appropriate force is added to the object. An object can have multiple effectors positioned around the surface of the object.

In my scene, I just have these effectors as empty gameobjects as children of the object that I want to simulate buoyancy for.

Effectors as children.
Effectors as children.

We can then create a script called BuoyantObject which will take in a reference to these effectors and apply force to them.

Effectors.
Effectors.
Script: BuoyantObject.cs
[RequireComponent(typeof(Rigidbody))]
public class BuoyantObject : MonoBehaviour
{
private readonly Color red = new(0.92f, 0.25f, 0.2f);
private readonly Color green = new(0.2f, 0.92f, 0.51f);
private readonly Color blue = new(0.2f, 0.67f, 0.92f);
private readonly Color orange = new(0.97f, 0.79f, 0.26f);

[Header("Water")]
[SerializeField] private float waterHeight = 0.0f;

[Header("Waves")]
[SerializeField] float steepness;
[SerializeField] float wavelength;
[SerializeField] float speed;
[SerializeField] float[] directions = new float[4];

[Header("Buoyancy")]
[Range(1, 5)] public float strength = 1f;
[Range(0.2f, 5)] public float objectDepth = 1f;

public float velocityDrag = 0.99f;
public float angularDrag = 0.5f;

[Header("Effectors")]
public Transform[] effectors;

private Rigidbody rb;
private Vector3[] effectorProjections;

private void Awake()
{
// Get rigidbody
rb = GetComponent<Rigidbody>();
rb.useGravity = false;

effectorProjections = new Vector3[effectors.Length];
for (var i = 0; i < effectors.Length; i++) effectorProjections[i] = effectors[i].position;
}

private void OnDisable()
{
rb.useGravity = true;
}

private void FixedUpdate()
{
var effectorAmount = effectors.Length;

for (var i = 0; i < effectorAmount; i++)
{
var effectorPosition = effectors[i].position;

effectorProjections[i] = effectorPosition;
effectorProjections[i].y = waterHeight + GerstnerWaveDisplacement.GetWaveDisplacement(effectorPosition, steepness, wavelength, speed, directions).y;

// gravity
rb.AddForceAtPosition(Physics.gravity / effectorAmount, effectorPosition, ForceMode.Acceleration);

var waveHeight = effectorProjections[i].y;
var effectorHeight = effectorPosition.y;

if (!(effectorHeight < waveHeight)) continue; // submerged

var submersion = Mathf.Clamp01(waveHeight - effectorHeight) / objectDepth;
var buoyancy = Mathf.Abs(Physics.gravity.y) * submersion * strength;

// buoyancy
rb.AddForceAtPosition(Vector3.up * buoyancy, effectorPosition, ForceMode.Acceleration);

// drag
rb.AddForce(-rb.velocity * (velocityDrag * Time.fixedDeltaTime), ForceMode.VelocityChange);

// torque
rb.AddTorque(-rb.angularVelocity * (angularDrag * Time.fixedDeltaTime), ForceMode.Impulse);
}
}

private void OnDrawGizmos()
{
if (effectors == null) return;

for (var i = 0; i < effectors.Length; i++)
{
if (!Application.isPlaying && effectors[i] != null)
{
Gizmos.color = green;
Gizmos.DrawSphere(effectors[i].position, 0.06f);
}

else
{
if (effectors[i] == null) return;

Gizmos.color = effectors[i].position.y < effectorProjections[i].y ? red : green; // submerged

Gizmos.DrawSphere(effectors[i].position, 0.06f);

Gizmos.color = orange;
Gizmos.DrawSphere(effectorProjections[i], 0.06f);

Gizmos.color = blue;
Gizmos.DrawLine(effectors[i].position, effectorProjections[i]);
}
}
}
}

What's important for the BuoyantObject script is that the wave parameters should match the ones that you use in your water shader. In my own projects I usually make it so the BuoyantObject script holds a reference to the water and then just reads out those properties instead having to set them manually.

Wave properties.
Wave properties.

This gives us some simple but nice buoyancy!

Jump to heading Caustics

Caustics are a really nice effect that you can add to your water. If you have enjoyed this tutorial so far, you can check out my caustics asset which can be added to any water shader and has several nice features. Here is a video showing the effect.

Support would be greatly appreciated! ❤️

Bonus tip: Reflections

Planar reflections can be added by using the following script (use at your own risk, has not been tested in a while 😅). Essentially it renders everything upside down to a texture called _PlanarReflectionTexture. You can then sample this texture in your shader using the following nodes.

Planar reflections.
Planar reflections.
Bonus tip: World space UVs

Instead of using regular UVs for things like sampling your textures, you can make use of world space UVs. This will enable you to use water tiles that you place next to each other, and then all of the effects will line up nicely!

World space UVs.
World space UVs.
Bonus tip: Fog

Support for fog can be easily added like this.

Fog.
Fog.
Bonus tip: Water trails

Water trails can be created using particle effects. Here is a great tutorial by Minions Art.

Jump to heading Conclusion

I hope you liked this tutorial. Please let me know what you think over on Twitter!

You can get the shader files here.

stylized-water.unitypackage

Jump to heading Additional resources

https://www.alanzucconi.com/2019/09/13/believable-caustics-reflections/

https://www.patreon.com/posts/making-water-24192529

https://catlikecoding.com/unity/tutorials/flow/waves/

Published