The water is still a rectangle with a definite edge that sharply separates it from the background. In this lesson, we are going to create a smoothly animated shoreline that follows our waves.
There are a few ways we could go about designing it. For example, we could generate the waves procedurally using periodic functions like sine and cosine. We already have our height value, though, so we can use that instead.
To draw our shoreline, we need a black-and-white mask that only covers the top of the waves. We also want to make the top of our water transparent using a complementary mask.
At the top of your shader, add the uniforms we’ll use to control our shoreline:
// color of the shoreline uniform vec4 shore_color :hint_color = vec4(1.0); // Size of the shore in V coordinates. uniform float shore_size :hint_range(0.0, 0.2) = 0.01; // Controls how smoothly the shore blends with the water and background. uniform float shore_smoothness :hint_range(0.0, 0.1) = 0.02; // Controls the amplitude of the waves, and the number of fragments carved out of the water. uniform float shore_height_factor :hint_range(0.01, 1.0) = 0.1;
Let’s start by splitting the water into two areas:
We can use our UV coordinates as a base and shaping functions to create precise masks.
Starting with the step()
function, we can output two pixelated masks. We first take the UV.y
coordinate to produce a smooth vertical gradient and use our height value to output values of 0.0
or less for fragments around the top of the sprite.
To do so, subtract (1.0 - height) * factor
to the UV.y
coordinate in your fragment shader:
float waves_height_gradient = UV.y - ((1.0 - height) * shore_height_factor);
We need to use 1.0 - height
here to carve the waves where the parallax effect pushes our water down.
Using this value, we can calculate the upper part of the water:
float upper_part = 1.0 - step(0.0, waves_height_gradient);
The step function is an efficient alternative to using if and else statements. Its first argument is a threshold, and its second argument the value we want to compare against the threshold. If wave_height_gradient
is lower or equal to zero, the step function will output a value of 0.0
. If wave_height_gradient
is greater than zero, the function outputs one. Doing so gives us black at the top and white at the bottom of our image. That’s why we subtract the resulting value to one to get the upper part.
With that information, we can use smoothstep()
to calculate the shoreline’s mask. The smoothstep function is similar to step()
, except it interpolates smoothly between two values using a smooth range or factor.
A call to smoothstep()
is enough to calculate the shoreline’s mask:
float shoreline = smoothstep(0.0, waves_height_gradient, shore_size);
The first two arguments define the range in which the function smoothly interpolates from 0.0
to 1.0
. The third argument, shore_size
, is the value we want to interpolate. Here, if shore_size
is in the range [0.0, waves_height_gradient]
, the function outputs a value between 0.0
and 1.0
. If shore_size
is lower or equal to 0.0
, the function outputs 0.0
. If shore_size
is greater than waves_height_gradient
, we get 1.0
.
Another call to smoothstep gives us the waves’ mask.
float wave_area = smoothstep(waves_height_gradient, 0.0, shore_smoothness * (1.0 - UV.y));
This function call can be a bit mind-boggling at first. Our third argument produces a vertical gradient that’s bright at the top and gets darker as we move down along the sprite’s surface. Its values are scaled down by the shore_smoothness
, that should be a small number.
The waves_height_gradient
stores higher values as we move down along the sprite’s bounding box. As it does so, shore_smoothness * (1.0 - UV.y)
stores values closer and closer to 0.0
, making the smoothstep function output values close to 1.0
. There is a thin value range, controlled by shore_smoothness
, where shore_smoothness * (1.0 - UV.y)
moves in the range [waves_height_gradient, 0.0]
, causing smoothstep()
to produce a thin black-and-white gradient.
We can finally remove the upper_part
in the mask to compute our water’s alpha channel:
wave_area -= upper_part;
We’re going to add our shoreline’s color multiplied by our mask to the water’s color.
COLOR.rgb += shoreline * shore_color.rgb;
You need to do so after calculating the waves’ color. Adding a value like so is equivalent to using the add blending mode in digital painting applications. It brightens underlying colors.
Note that depending on the shoreline’s tone, if you are working with HDR and the glow effect, you might need to limit the output color, so the shore doesn’t glow.
COLOR.r = min(COLOR.r, 1.0); COLOR.g = min(COLOR.g, 1.0); COLOR.b = min(COLOR.b, 1.0);
We finally need to use our wave_area
mask to make the top of the waves transparent:
COLOR.a *= wave_area;
Using a multiplication here allows you to add transparency to other parts of your water if you so desire.
And we’re done! You now have a complete 2D water shader with plenty of parameters to tweak.
Here is the complete code for the shoreline.
uniform vec4 shore_color :hint_color = vec4(1.0); uniform float shore_size :hint_range(0.0, 0.2) = 0.01; uniform float shore_smoothness :hint_range(0.0, 0.1) = 0.02; uniform float shore_height_factor :hint_range(0.01, 1.0) = 0.1; void fragment() { // ... float waves_height_gradient = UV.y - ((1.0 - height) * shore_height_factor); float upper_part = 1.0 - step(0.0, waves_height_gradient); float shoreline = smoothstep(0.0, waves_height_gradient, shore_size); float wave_area = smoothstep(waves_height_gradient, 0.0, shore_smoothness * (1.0 - UV.y)); wave_area -= upper_part; // ... COLOR.rgb += shoreline * shore_color.rgb; COLOR.a *= wave_area;