Projecting the water in perspective

In this lesson and the following one, we will add depth and volume to our water using two techniques:

  1. Perspective projection. You will learn to project fragments in 3D.
  2. Parallax mapping, displacing our UV coordinates using a heightmap to give the illusion of 3D details.

How it works

We can use a three by three matrix to project a 2D rectangle in perspective, with two vanishing points. GIMP’s perspective tool works the same way.

If we use the same technique on a vertex shader, we move the node’s vertices and produce a different quadrilateral. But if we apply it to our UV coordinates in the fragment shader, we instead still get to fill the entire rectangle. We just alter the UV coordinates such that the water looks like it’s drawn in perspective.

The technique works like so:

  1. For each fragment, we multiply the UV value by a matrix to generate 3D coordinates from it.
  2. We use a division to project the coordinates back into the Sprite node’s bounding box.

We can use a three by three projection matrix here. It is a simplification over 3D projections that rely on four by four matrices. But three by three works well for UV manipulation in the context of 2D shaders.

This matrix is called an identity matrix. When we multiply any 3D point with it, the product is our point’s initial coordinates. In other words, this matrix won’t transform our coordinates. We use it as a starting point for transforms: any value change will shear or stretch our water in some way.

The values in the matrix represent a 3D coordinate system. Each row is a 3D vector that represents one unit on that axis. The top-row stores the vector for the X-axis, the second row the Y-axis, and the third row is for the Z-axis.

We won’t dive in-depth in matrices here as this is a topic that deserves its own lessons. But I want you to understand that we’re manipulating a coordinate system here and not just a bunch of random values. As we modify the axes stored in our matrix, we’re going to map our fragments to different 3D coordinates.

Let’s implement that in our shader. I will then show you the values I used to get the final result.

Coding the projection

Add a new uniform three by three matrix and assign it a default value corresponding to the identity matrix:

uniform mat3 transform = mat3(vec3(1,0,0), vec3(0,1,0), vec3(0,0,1));

At the top of the fragment function, we need to calculate 3D coordinates for each input UV value. As UV is a 2D vector, we can’t multiply it with our matrix directly: matrix multiplication is only defined with vectors that have the same number of rows, here, three.

We first need to add a third coordinate to our UV, which we set to 1.0, before multiplying it with our projection matrix:

vec3 projection = vec3(UV, 1.0) * transform;

This line produces 3D coordinates that we now need to project back onto the screen. To do so, we extract the X and Y position of our projected point and divide it by its Z component:

vec2 uv = projection.xy / projection.z;

When working in 3D, the graphics card handles that division for you. But as we’re manipulating UV coordinates in a fragment shader, we need to do it ourselves.

We can now use our new uv coordinates instead of the UV variables. Your code should look like so:

uniform mat3 transform = mat3(vec3(1,0,0), vec3(0,1,0), vec3(0,0,1));

void fragment() {
    vec3 projection = vec3(UV, 1.0) * transform;
    vec2 uv = projection.xy / projection.z;

    vec2 distortion = calculate_distortion(uv, TIME) * distortion_amplitude;
    // ...
    vec2 uv_waves = uv * tile_factor * scale;
    // ...
    vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv.y * uv_size_ratio_v * 2.0 * scale.y * zoom_y);

Notice how in the code block above, the variable uv is lowercase. Be careful not to use UV past the first line.

Testing the projection

To test the projection, head to the Inspector and expand the Shader Params section. We’re going to edit the Transform property.

Here is the value I used.

You can see I only modified the Y column:

  1. The value at the top skews the water horizontally, sliding the bottom of the texture towards the left.
  2. The Y value on the second row simulates the camera moving down towards the plane, giving the water more depth.
  3. The value on the 3rd row skews the texture towards the right, compensating for the value on the first row.

I played with those three until the result looked satisfying. You really have to eyeball it here.

And with that, we have water in perspective.

In the next lesson, we will give it a sense of volume using parallax mapping.