Around The World, Part 30: Making better waves

This post is part of a series about Around The World, a game of exploration and discovery at sea. The game is currently in early development. If you're new to this project, you can read up on it here.

I previously blogged about how I’m modelling and rendering water waves in the game, but that was in the previous incarnation on a sphere. I’ve finally ported that over to the current version, and made some considerable improvements along the way. People told me it looks good, and I tend to agree. Let’s dive in!

Reflections

Even if a water surface is perfectly still, you can still tell that it’s water. This is because of how light interacts with it: part of the light passes through (transparency), and part of the light is reflected. Let’s tackle reflection first.

Since the water surface is mostly reflecting stuff that is also on screen (distant coasts), screen-space reflections seem like the way to go. Godot significantly improved its SSR algorithm in version 4.6, so we now get this without writing any shader code:

Test scene with reflection

That’s nice; it already looks pretty watery. However, real water is also transparent. I want some transparency in the game too, so the player can see and interact with nearby underwater objects like fish.

And here we hit a problem: Godot’s built-in material supports screen-space reflections, but not at the same time as transparency. So I’ll have to do the work myself and write a shader.

It’s just a fairly standard raymarch that inspects the depth buffer and returns the local colour when it hits something. However, because it’s meant for horizontal water surfaces, I can pull a few tricks: when hitting the side of the screen, rather than fading out like a normal SSR shader would because there’s no data, the raymarch just reverses direction. This means we still have reflections near the edges of the screen, even if they’re not quite correct.

Custom reflections

You can see that my implementation is not as great as Godot’s built-in one, most notably because it doesn’t use Hi-Z. But in practice, the water surface won’t usually be this smooth, and the artifacts are no longer noticeable.

It took me forever to figure out how to get the reflected colour to combine properly with albedo and shadows, but the answer turned out to be very simple: Godot shaders have a dedicated output variable just for this, called RADIANCE.

Though it may be hard to see in the above screenshot, there’s also a bit of transparency going on already.

Ripples

Before we start moving the water surface physically, let’s add some of that nice glittering that real water has. This is caused by small ripples (capillary waves) randomly disturbing the smooth surface.

In my implementation, they won’t be travelling a particular direction; it’s just a bit of seamless Perlin noise used as a normal map:

Normal map noise configuration

The trick to make this look good in motion is to have multiple layers of this noise (I use 4), each with a random offset, and add them together. The layers are faded in and out over half a second, and when a layer’s opacity reaches zero, its offset is assigned a new random value. By setting the phases of the layers 90 degrees apart (360° / 4), we make sure that the total amplitude of the sum of the layers remains constant.

To fit in with my pixelated-textures art style, I’m using nearest-neighbour sampling on the noise texture. The result speaks for itself:

See? I told you the reflection artifacts wouldn’t be a problem!

A wave

Now let’s add some bigger motion to the water surface. Actual water waves follow a trochoidal motion, where each point on the surface describes a circle:

Trochoidal wave
Source: Wikipedia by Kraaiennest, CC-BY-SA 4.0

In the previous version of the game, I decided not to do this, but to only move the vertices of the water mesh up and down. I thought I had a good reason for this:

It looks good, but it has a significant drawback: the water surface is no longer a simple height field. In other words, given a coordinate in the world, it’s very hard to compute what the water height at that point is. And we’ll need that computation later, when we want the ship to float on the surface and move in response to it.

This is nonsense! If the ship is floating on the water, and the water is moving sideways, the ship should move sideways along with it!

So let’s implement proper trochoidal motion, for a single sine wave at first (that is, a sine for the vertical motion and a cosine for the horizontal motion).

The “texture” that the water surface gets from the normal map really helps sell the horizontal motion.

Waves and wind

The game will have a dynamic weather system, so the waves will have to react to the wind. On a stormy day, we’ll have big waves, and on a calm day the waves will be small.

And they will never be simple sine waves; there’s always more going on. The real ocean surface is, essentially, a sum of sine waves, each with its own amplitude and wavelength, travelling at its own speed in its own direction.

It turns out there’s a deep rabbit hole of theory and measurements about this, but eventually I found the Pierson-Moskowitz spectrum to be perfect for my needs. It allows me to calculate the wave frequencies that occur at a given wind speed; from that I can compute their propagation speeds; and from those two follow their wavelengths. For the amplitude, I’m using another model, which gives me the significant wave height at a particular wind speed. The significant wave height is defined as “four times the standard deviation of the surface elevation”, and with some statistical gymnastics I’m able to make the sum of an array of sines match that standard deviation.

I thought I’d need to resort to manual inputs to get some artistic control over all this, but no, the outcome of the model is pretty convincing already.

Here’s what that looks like at a moderate gale of 15 m/s, which is around 29 knots or 7 Bft:

Changing wind speed

During gameplay, the wind speed can change at any time, though the change will always be gradual. How do we deal with this? We cannot simply update the wavelength of our existing sine waves, because the water surface would appear to contract or expand horizontally.

In the previous version, I solved this by not changing the sines at all, but rather fade them in and out over time. Whenever a sine would expire and a new one would take its place, it would check the wind speed and set up its parameters to match. It worked, but it was a bit difficult to experiment with, because every change would take 30-60 seconds to propagate. Fade the waves any faster, and the changes would be too visible.

So in the new version, I came up with another algorithm. It’s still based on fading sine waves in and out, but now, the set of sines is completely fixed. Each of them has a fixed wavelength and a direction, which we can plot in polar coordinates:

Wave inspector

Each dot represents a single sine; the farther from the center the dot is, the larger its wavelength. The blue dots indicate active sines, that is, those that align with the current wind speed. The size of the dot is relative to that sine’s amplitude. So in the above image, the wind is blowing west to east, activating the waves that travel to the right. Because the wind speed is 15 m/s and I’ve accounted for wind speeds up to 35 m/s (hurricane speeds, 12 Bft), the biggest waves are barely active at all.

This lets me change the wind speed quickly in the Godot editor or the game’s debug panel, and the water surface is updated instantly to match. While tuning the various parameters, this saved me a lot of time.

Breaking waves

This is all fine for deep water, but in shallow water, waves behave differently. As they approach the coast, they slow down and grow taller, until they break and topple into a flatter shape.

This is difficult to model accurately, but I’ve made a rough approximation of the process:

Essentially, what I’m doing is flattening the circular motion into an ellipse, making it taller and narrower the shallower the water is relative to the wavelength. This effect needs to remain somewhat subtle, otherwise the sum-of-sines tends to go haywire at higher wind speeds, causing the water surface to turn inside out and intersect itself.

Foam

At higher wind speeds, the wind causes foam to form on the water surface. Let’s add that! I happened to be in the Dutch coast town of Scheveningen during strong winds, so I used the opportunity to take some reference photos from the famous pier:

Photo of sea foam

The foam is far from uniform; it tends to form “tendrils” that slowly fade away. To mimic that, cellular noise works best:

Foam noise texture setup

Another observation is that there’s no such thing as “a little foam”; it’s either there (white) or not there (transparent). We can do that by thresholding the noise texture. Of course we’ll add a little bit of smoothing to the threshold to make the transition between foam and not-foam less jarring.

And where should we put the foam? It tends to form at the crest of waves, and then fades out gradually until the next wave comes along. However, since we’re doing a sum of many sines, what exactly is the “crest”? I compute that by summing up all the phase vectors of the waves, multiplied by their respective amplitudes. This gives me a phase vector that more or less follows the biggest sines, but doesn’t discount the small ones entirely either. And the angle of that vector tells me where in its cycle that combined wave currently is: at the crest, falling, at the trough, or rising. With some simple arithmetic we can make foam density increases quickly as a wave crest approaches, then make it gradually fall off towards the trough.

And in the end, how does it look?

I think that looks perfectly adequate for a one-person indie game. On to the next thing!