Adding depth with Parallax Mapping

Even with the perspective projection, the water is still a bit flat. To give it more volume, we need to calculate the height of our waves. In 3D, we could use a dense mesh with displacement mapping to do so, like when drawing terrains. Instead, we’re going to fake it with a technique called parallax mapping.

How parallax mapping works

Parallax mapping consists of offsetting UV coordinates based on a heightmap to simulate volume.

In 2D, we are looking at a flat surface placed right in front of us. Even with our perspective projection, our water plane is still a flat surface. But if our water is supposed to have height and we see it from above, at an angle, the highest parts of the waves should appear closer to us than the lower parts.

Imagine that we want to simulate, looking at the water at an angle. The purple line represents the lowest point of the water plane. Looking at a 45-degree angle down, if the water has the height represented by the blue line, we would want to see point A. Instead, as we are looking at a flat plane, we see point B.

Parallax mapping tries to sample the texel at point A using the height data of the fragment we are looking at, point B in that case:

  1. We first sample the height on the heightmap for our fragment
  2. Then, we take the vector pointing towards the camera and scale it down, so its length corresponds to our height value.
  3. Finally, we project that scaled vector onto our surface, turning it into a UV offset.

As you can see, parallax mapping does not give us point A precisely, but we come closer to it. And for small amounts of parallax, especially for smooth surfaces, the technique works well. The more abrupt the changes in height, for example, on a brick wall, the more artifacts the method will cause.

There are more advanced techniques, mainly parallax occlusion mapping, that improve upon the one we are going to use here. Parallax occlusion mapping offers a better approximation of volume by using multiple samples for each fragment and trying to simulate parts of the surface that occlude others behind them.

Coding parallax mapping

Let’s get coding.

First, we need some height information for the water. I’ve decided to reuse the distortion texture, sampling it at a different scale. You could instead use a dedicated heightmap tailored to your water’s surface.

float height =  texture(distortion_map, uv_waves * 0.05 + TIME * 0.004).r;

I’m reusing the waves’ UV coordinates, scaling the texture up, and using a different time scale to move them. We only need one channel of information for the height map as the height variable is a floating-point value. That’s why we need to extract the red channel on the line above.

Now we can work on the parallax itself. The effect depends on our viewing angle. In 3d, the view direction is given to us by the engine and corresponds to your camera’s orientation.

In 2D, this is not the case, so we have to define our view direction. We can define it as a 3D vector.

Above the fragment shader, add a new constant named VIEW_DIRECTION.

const vec3 VIEW_DIRECTION = vec3(0.0, -0.707, 0.707);

It should be a unit vector, that is, a vector that’s one unit long. You can use the normalize() function for that. Here, the Y component makes the vector point down, and the Z component forward, making us look down at the water at a 45-degree angle.

Back in the fragment() function, we can project our view direction onto the water plane. We do that by dividing the X and Y components of the vector by its Z coordinate, just like in the previous lesson. We then multiply the result by our water’s height and a uniform that allows us to control the depth we want.

uniform float parallax_factor :hint_range(0.0, 1.0) = 0.2;
// ...

void fragment() {
    // ...
    vec2 parallax_offset = VIEW_DIRECTION.xy / VIEW_DIRECTION.z * height * parallax_factor;

Finally, we subtract the parallax value to the waves’ UV coordinates before sampling the water’s color. We also subtract the value to the reflected UV coordinates to have reflections follow the waves. For the reflected UVs, notice how we still need to apply our UV size ratio.

uv_waves -= parallax_offset;
// ...
uv_reflected -= parallax_offset * vec2(0.0, uv_size_ratio_v);

With that, your water should have a lot more volume. I invite you to play with the parallax_offset variable. In the final code, I’m adding 0.2 to raise the entire water:

vec2 parallax_offset = VIEW_DIRECTION.xy / VIEW_DIRECTION.z * height * parallax_factor + 0.2;

The full parallax code should look like that:

uniform float parallax_factor :hint_range(0.0, 1.0) = 0.2;
// ...
const vec3 VIEW_DIRECTION = vec3(0.0, -0.707, 0.707);

void fragment() {
    // ...
    float height =  texture(distortion_map, uv_waves * 0.05 + TIME * 0.004).r;
    vec2 parallax_offset = VIEW_DIRECTION.xy / VIEW_DIRECTION.z * height * parallax_factor + 0.2;
    uv_waves -= parallax_offset;
    
    // ...
    uv_reflected -= parallax_offset * vec2(0.0, uv_size_ratio_v);

Adding a shadow color

We can use our height information to shade our waves a little bit. Here, we are not going to simulate lighting. We are not even going to calculate the correct shadows. We will use the height information to darken parts of the water and increase the render’s contrast.

Add a new shadow color as a uniform and multiply it with the color_water. We want to mix that shadow tone with the water’s color. Our mask here could be the height. Instead, I’ve opted for parallax_factor - height, that produced more appealing results in my case:

uniform vec4 shadow_color :hint_color;
// ...

void fragment() {
// ...
    color_water.rgb = mix(color_water.rgb, color_water.rgb * shadow_color.rgb, parallax_factor - height);

It also takes into account the amount of parallax we apply. Again, this code is not calculating accurate shadow areas. But it can still make the water look pretty good.

In the next lesson, we will finish the series by adding a transition between the top of our water plane and the background.