Creating the side-view water’s surface

In this lesson, we’re going to code a simple 2D water shader, with a surface that slowly scrolls and deforms using an image texture.

Create a new sprite node as you did for Reflection2D and name it Water2D. Give it a ShaderMaterial and a new Shader resource.

Following our reflection shader, we want our water to take two variables into account: the scale of the sprite node and the aspect ratio of the texture. We also want to keep the zoom_y uniform to add reflections to our water at the end of the process.

Attach the Reflection2D.gd GDScript file to the Water2D and add the scale, zoom_y, and aspect_ratio uniforms in your shader:

shader_type canvas_item;

// Updated from GDScript
uniform vec2 scale;
uniform float zoom_y;
uniform float aspect_ratio;

void fragment() {
}

Sampling the water’s texture

For the water’s surface, you should use a tiling diffuse texture, that is to say:

  1. An image that can be repeated seamlessly.
  2. A texture that does not contain light information.

You can find one in the Godot project, water_diffuse.png.

If you want to draw and use a personal texture, after importing it into Godot, you need to set it to repeat. To do so, double-click on the image file in the FileSystem tab and head to the Import tab. In the Flags category, click on the drop-down menu next to Repeat and pick Enable.

Select your Water2D node and assign the texture to its Texture property.

Open your shader again. Add the following code to maintain the texture’s aspect ratio as we resize our water sprite.

vec2 uv_waves = UV * scale;
uv_waves.y *= aspect_ratio;

vec4 color_water = texture(TEXTURE, uv_waves);
COLOR = color_water;

Here, we sample the texture that is assigned to the sprite using the TEXTURE variable. This code should make it so when you resize your Water2D sprite, the texture gets repeated instead of being stretched.

To change the number of times to texture tiles into the bounding box, we should add a new uniform, tile_factor.

Let’s add another uniform to control the vertical scale of the UVs, scale_y_factor. It will help us give a sense of perspective to the water, by squashing it vertically.

uniform float scale_y_factor :hint_range(0.1, 4.0) = 2.0;
uniform float tile_factor :hint_range(0.1, 3.0) = 1.4;

void fragment() {
    vec2 uv_waves = UV * scale * tile_factor;
    uv_waves.y *= aspect_ratio * scale_y_factor;
    // ...

Moving the water

To make the water move, we need to offset its UV coordinates over time. We can use the TIME built-in variable to do so.

We are going to use the product of TIME and a direction vector. I want my water to only move towards the left, so my direction has a value of 0.0 on the y-axis.

uv_waves += vec2(1.0, 0.0) * TIME;

An offset of 1.0 in the UV coordinates corresponds to scrolling over the entire sprite’s bounding box. We need a new value to slow down the scroll.

uniform float water_time_scale :hint_range(0.01, 2.0) = 0.1;

void fragment() {
    // ...
    uv_waves += vec2(water_time_scale, 0.0) * TIME;

The :hint_range() annotation above allows us to specify minimum and maximum values to set in the Shader Params. Adding that hint also adds a small slider button on the editable property in the Inspector.

You can already play with your shader’s properties to change the flow and scale of your water. Also, you can make the water’s direction configurable in the Inspector like so:

uniform vec2 water_direction = vec2(1.0, 0.0);

void fragment() {
    // ...
    uv_waves += normalize(water_direction) * water_time_scale * TIME;

Adding subtle distortion

With the moving water in-place, we can calculate the distortion. To distort the water, we will use a texture with some height information stored in its red and green channels. We included one in the Godot project: water_uv_offset.png. The red channel of the image represents a horizontal UV offset, and the green channel a vertical offset.

We use that data to offset our UVs per fragment when we sample the water’s texture and give it some turbulence.

Above your fragment function, add a new function named calculate_distortion. It’s going to calculate and return a UV offset for each fragment.

vec2 calculate_distortion(vec2 uv, float time) {
}

Functions do not have references to the uppercase variables that we get in the fragment shader. So we have to pass our UV coordinates and the time variable as arguments.

We want our distortion texture to move at different speeds and direction than the water so we get a lot of visual variation. To do so, we need to use two more uniforms: distortion_scale and distortion_time_scale. I’ve decided to make both variables 2D vectors so we have maximum control over our U and V axes:

uniform vec2 distortion_scale = vec2(0.5, 0.5);
uniform float distortion_time_scale :hint_range(0.01, 0.15) = 0.05;

With these values, we can calculate offsetted UV coordinates:

vec2 calculate_distortion(vec2 uv, float time) {
    vec2 base_uv_offset = uv * distortion_scale + time * distortion_time_scale;
}

We can then return the amount of distortion. To do so, we need to sample our distortion texture and extract its red and green channels to get a 2D vector. We then and multiply the result by two and subtract one to get both negative and positive values. Our function returns a 2D vector with X and Y components in the [-1.0, 1.0] range, giving us distortion in all directions.

vec2 calculate_distortion(vec2 uv, float time) {
vec2 base_uv_offset = uv * distortion_scale + time * distortion_time_scale;
return texture(distortion_map, base_uv_offset).rg * 2.0 - 1.0;
}

Then, we need to call calculate_distortion from the fragment shader and to add the returned value to the waves’ UVs. The values produced by the function are too large by default, so we’ll use a new uniform, distortion_amplitude, to scale it down.

uniform float distortion_amplitude :hint_range(0.005, 0.4) = 0.1;
// ...

void fragment() {
    vec2 distortion = calculate_distortion(UV, TIME);
    // ...
    uv_waves += distortion * distortion_amplitude;

Here is my code at this point:

shader_type canvas_item;

uniform sampler2D distortion_map : hint_black;

uniform vec2 distortion_scale = vec2(0.5, 0.5);
uniform float distortion_time_scale :hint_range(0.01, 0.15) = 0.05;
uniform float distortion_amplitude :hint_range(0.005, 0.4) = 0.1;

uniform float water_time_scale :hint_range(0.01, 2.0) = 0.1;
uniform float scale_y_factor :hint_range(0.1, 4.0) = 2.0;
uniform float tile_factor :hint_range(0.1, 3.0) = 1.4;

// Updated from GDScript
uniform vec2 scale;
uniform float zoom_y;
uniform float aspect_ratio;

vec2 calculate_distortion(vec2 uv, float time) {
    vec2 base_uv_offset = uv * distortion_scale + time * distortion_time_scale;
    return texture(distortion_map, base_uv_offset).rg * 2.0 - 1.0;
}

void fragment() {
    vec2 distortion = calculate_distortion(UV, TIME);

    vec2 uv_waves = UV * scale * tile_factor;
    uv_waves.y *= aspect_ratio * scale_y_factor;
    uv_waves += distortion * distortion_amplitude + vec2(water_time_scale, 0.0) * TIME;

    vec4 color_water = texture(TEXTURE, uv_waves) * water_tint;
    COLOR = color_water;
}

Mixing with the reflection

With the deformation working, let’s add reflections to the water plane. We are going to reuse the code from the previous lesson here. Reread it to learn how the reflections work in detail.

We need to add two uniforms to control our reflections: our transition gradient map and the reflection intensity.

uniform sampler2D transition_gradient :hint_black;

uniform float reflection_intensity :hint_range(0.01, 1.0) = 0.5;

In the fragment shader, we calculate our reflected UV coordinates, sample the screen texture, sample our transition gradient map, and mix the water’s color with the reflection.

We should also distort the reflection so it follows the water’s surface. Add the product of the distortion, its amplitude, and the uv_size_ratio_v to the reflected UV coordinates.

We need to use the uv_size_ratio_v to convert the distortion in local UV space to the screen’s UV coordinate system.

void fragment() {
    // ...
    // Calculate the reflection's UVs
    float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y;
    vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0 * scale.y * zoom_y);
    uv_reflected += vec2(0.0, uv_size_ratio_v) * distortion * distortion_amplitude;

    // Sample the reflection and water colors
    vec4 color_reflection = texture(SCREEN_TEXTURE, uv_reflected);
    vec4 color_water = texture(TEXTURE, uv_waves);

    // Sample the transition gradient map
    float transition = texture(transition_gradient, vec2(1.0 - UV.y, 1.0)).r;
    // Mix the reflection with the water color
    COLOR = mix(color_water, color_reflection, transition * reflection_intensity);

Tinting the water

Your water’s color may not fit the environment or work well with the reflections. Let’s add a variable to tint it.

We can use the multiply blending mode to darken and change the hue of a texture. Add a new uniform named water_tint and multiply the sampled water texture by it.

uniform vec4 water_tint :hint_color;
// ...

void fragment() {
    // ...
    vec4 color_water = texture(TEXTURE, uv_waves) * water_tint;
}

The :hint_color annotation tells Godot to give us a color picker for that property in the Inspector. We can now head to the Shader Params and play with the tint to make the water blend with its environment.

And with that, we have a simple 2D water shader. It should already work well for quite a few types of games, although it could use some more volume and a smoother transition between the top edge and the background. We will work on these things in the next lessons.

Here is the complete shader’s code:

shader_type canvas_item;

uniform sampler2D transition_gradient :hint_black;
uniform sampler2D distortion_map : hint_black;

uniform vec4 water_tint :hint_color;

uniform vec2 distortion_scale = vec2(0.5, 0.5);
uniform float distortion_time_scale :hint_range(0.01, 0.15) = 0.05;
uniform float distortion_amplitude :hint_range(0.005, 0.4) = 0.1;

uniform float water_time_scale :hint_range(0.01, 2.0) = 0.1;
uniform float scale_y_factor :hint_range(0.1, 4.0) = 2.0;
uniform float tile_factor :hint_range(0.1, 3.0) = 1.4;

uniform float reflection_intensity :hint_range(0.01, 1.0) = 0.5;

// Updated from GDScript
uniform vec2 scale;
uniform float zoom_y;
uniform float aspect_ratio;


vec2 calculate_distortion(vec2 uv, float time) {
    vec2 base_uv_offset = uv * distortion_scale + time * distortion_time_scale;
    return texture(distortion_map, base_uv_offset).rg * 2.0 - 1.0;
}

void fragment() {
    vec2 distortion = calculate_distortion(UV, TIME);

    vec2 uv_waves = UV * scale * tile_factor;
    uv_waves.y *= aspect_ratio * scale_y_factor;
    uv_waves += distortion * distortion_amplitude + vec2(water_time_scale, 0.0) * TIME;

    // Calculating UVs for screen reflection
    float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y;
    vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0 * scale.y * zoom_y);
    uv_reflected += vec2(0.0, uv_size_ratio_v) * distortion * distortion_amplitude;

    vec4 color_reflection = texture(SCREEN_TEXTURE, uv_reflected);
    vec4 color_water = texture(TEXTURE, uv_waves) * water_tint;
    float transition = texture(transition_gradient, vec2(1.0 - UV.y, 1.0)).r;
    COLOR = mix(color_water, color_reflection, transition * reflection_intensity);
}