Breakdown 1 of 3

Gerstner waves in HLSL

I implemented a custom HLSL Gerstner-wave shader with artist-friendly parameter tuning.

Overview

I built the waves up in stages. This let me iterate and test before creating the more complex setups:

  1. A Blueprint proof of concept - A proof of concept to create a single Gerstner wave to see it working.
  2. A single HLSL wave - The same equation rewritten and compacted into a custom node, which made it easy to write out rather than dealing with loads of nodes.
  3. Six waves via a function - Using the function to create six variations with different settings.
  4. A loop over N waves - The final node, where the wave count is just a parameter allowing for dynamic wave generation and a spread.

Going through these steps was highly useful, as it was a lot easier to debug when adding one aspect at a time. I used VS Code to write the HLSL and copied it into the custom node, which was much easier than trying to write it in the editor. Some of the funny bugs I encountered are shown below:

The equation

The equation is Tessendorf’s Gerstner wave from Simulating Ocean Water (Tessendorf, 2001). The one change is that the horizontal term is scaled by a steepness instead of the amplitude, so the sharpness of the crests can be tuned separately from their height.

This change and the Unreal implementation are heavily based on Ghislain Girardot’s breakdown (Girardot, 2022), which builds on Epic Games’ implementation in the experimental Water plugin (Epic Games, 2021), with some modifications of my own.

For a beginner’s introduction to wave simulation in general, I recommend Acerola’s How Games Fake Water (Acerola, 2023) as a starting point.

The wave loop

The custom node is one loop over every wave. Each pass works out that wave’s size, direction and phase, then adds its share to the world position offset, the normal and a mask, so displacement and lighting share the work instead of doing it twice.

Wave size

Each wave gets its parameters from its index divided by the total wave count, and that count stays fixed even when the loop stops early. The loop runs to count_limit, but the fraction always divides by count. Lowering the limit just drops waves from the end, and the waves that remain are exactly the same waves in the same places. That’s what makes the material LODs possible. If the fraction divided by the number of waves actually run, dropping from 16 waves to 8 would change every wave, and the switch between LODs would be visible. The waves that get dropped are the ones at the max end of the ranges, so the cheaper LODs lose that end rather than a random sample. The values used in the final scene are in the material’s parameter panel.

From that fraction, each wave interpolates its wavelength , steepness and amplitude between ranges the artist sets, biased by a distribution exponent so the spread of wave sizes can be tuned instead of always being linear:

float actual_waves = min(count_limit, count);

for (int i = 0; i < actual_waves; i++)
{
    const float i_fraction = (float)i / (float)count;
    const float alpha = pow(i_fraction, distribution);

    const float length = lerp(length_range.x, length_range.y, alpha);
    const float steepness = lerp(steepness_range.x, steepness_range.y, alpha);
    const float amplitude = lerp(amplitude_range.x, amplitude_range.y, alpha);
    // ...
}

Direction

Directions come from a small pseudo-random generator seeded with two primes, then get blended towards a wind direction the artist sets, using a spread parameter. At zero spread every wave runs with the wind and the water reads as one directional swell, and turning it up makes the surface choppier and less organised. The generator is deterministic on purpose, because the same seed has to give the same ocean every time the shader compiles.

rng_x = (rng_x * 1103515245) + 12345;
rng_y = (rng_y * 1103515245) + 12345;
float2 direction = float2(cos((float)rng_x / 801571.f), sin((float)rng_y / 10223.f));
direction = normalize(lerp(wind_direction, (direction * 2.f) - 1.f, spread));

Speed and phase

Wave speed comes from the deep water dispersion relation using real gravity, so long waves travel faster than short ones, as they do in real life, without anyone having to tune it. The wavelength gives the wavenumber , that gives the angular speed , and together they give the phase at a point on the flat mesh, for a wave travelling in direction :

float dispersion = tau / length;
float speed = sqrt(dispersion * 981.f);
float wave_time = speed * time;
float pos = dot(position, direction * dispersion) - wave_time;

Offset and normal

The offset sums every wave. The horizontal term pulls points towards each crest, which is what sharpens them, and the vertical term is the height:

The normal is summed in the same pass, with :

The normal’s vertical term is the one place mine differs from the textbook, which would use . It uses a clamped amplitude over the wavelength instead, so small waves don’t flatten the normal out.

o_xy += -strength * wave_sin * direction * amplitude;
o_z += wave_cos * amplitude;
n_xy += wave_sin * intensity * direction;
n_z += wave_cos * steepness * clamped_amplitude / length;

Full HLSL

The full custom node, with the maths mapped to its variables. (HLSL, 60 lines)
MathsIn the HLSL
alpha, from i_fraction
length, steepness, amplitude
dispersion
wave_time
pos
direction, blended toward wind_direction by spread
strength * amplitude, since strength = steepness / (amplitude * k)
o_xy, o_z, output as WPO
n_xy, n_z, output as Normal
// WPO XYZ
float2 o_xy = float2(0.0f, 0.0f);
float o_z = 0.0f;
// Normal XYZ
float2 n_xy = float2(0.0f, 0.0f);
float n_z = 0.0f;
// Mask Z
float m_z = 0.0f;

// Start primes
int rng_x = 1000211;
int rng_y = 11587;

float actual_waves = min(count_limit, count);

// Loop through waves
for (int i = 0; i < actual_waves; i++)
{
    // Using a normalised fraction of the wave count allows waves to be consistent even at distance
    const float i_fraction = (float)i / (float)count;
    const float alpha = pow(i_fraction, distribution);

    // Computed wave param based on alpha
    const float length = lerp(length_range.x, length_range.y, alpha);
    const float steepness = lerp(steepness_range.x, steepness_range.y, alpha);
    const float amplitude = lerp(amplitude_range.x, amplitude_range.y, alpha);
    const float clamped_amplitude = saturate(amplitude * 50.f);

    // Random Direction
    rng_x = (rng_x * 1103515245) + 12345;
    rng_y = (rng_y * 1103515245) + 12345;
    float2 direction = float2(cos((float)rng_x / 801571.f), sin((float)rng_y / 10223.f));
    // User-authored direction
    direction = normalize(lerp(wind_direction, (direction * 2.f) - 1.f, spread));

    // Gerstner wave
    float tau = 2.f * PI;
    float dispersion = tau / length;
    float speed = sqrt(dispersion * 981.f);
    float wave_time = speed * time;
    float pos = dot(position, direction * dispersion) - wave_time;
    float wave_sin = sin(pos);
    float wave_cos = cos(pos);
    float intensity = amplitude * dispersion;
    float strength = steepness / intensity;

    // Result WPO
    o_xy += -strength * wave_sin * direction * amplitude;
    o_z += wave_cos * amplitude;
    // Result Normal
    n_xy += wave_sin * intensity * direction;
    n_z += wave_cos * steepness * clamped_amplitude / length;
    // Result Mask
    m_z += wave_cos / count;
}
WPO = float3(o_xy.x, o_xy.y, o_z);
Normal = normalize(float3(n_xy.x, n_xy.y, 1.f - n_z));
Mask = m_z;
Height = o_z;
return 0;

Sources