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:
- A Blueprint proof of concept - A proof of concept to create a single Gerstner wave to see it working.
- 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.
- Six waves via a function - Using the function to create six variations with different settings.
- 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)
| Maths | In 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
Acerola (2023). How Games Fake Water. [online] YouTube. Available at: https://www.youtube.com/watch?v=PH9q0HNBjT4 [Accessed 1 Oct. 2026].
An easy-to-follow introduction to how games fake water by summing waves.
Ang, N., Catling, A., Cifariello Ciardi, F. and Kozin, V. (2018). The Technical Art of Sea of Thieves. [online] SIGGRAPH 2018 Talks, Vancouver. Reuploaded to YouTube by iamarugin. Available at: https://www.youtube.com/watch?v=y9BOz2dFZzs [Accessed 1 Oct. 2026].
How a AAA studio handled a real-time stylised ocean with FFT waves. Video is reupload, SIGGRAPH History Archive (https://history.siggraph.org/learning/the-technical-art-of-sea-of-thieves-by-ang-catling-ciardi-and-kozin/).
Epic Games (2021). GerstnerWaveFunctions.ush, Unreal Engine Water plugin source code. [online] GitHub. Available at: https://github.com/EpicGames/UnrealEngine/blob/46544fa5e0aa9e6740c19b44b0628b72e7bbd5ce/Engine/Plugins/Experimental/Water/Shaders/Private/GerstnerWaveFunctions.ush [Accessed 1 Oct. 2026].
Epic's HLSL implementation of Gerstner waves from the experimental Water plugin.
Girardot, G. (2022). A deep dive into my process of creating this animated stylized ocean in UE. [online] YouTube. Available at: https://www.youtube.com/watch?v=UWGwq-_w08c [Accessed 1 Oct. 2026].
My main reference for building Gerstner waves.
Tessendorf, J. (2001). Simulating Ocean Water. [online] Available at: https://people.computing.clemson.edu/~jtessen/reports/papers_files/coursenotes2004.pdf [Accessed 21 Jan. 2025].
The Gerstner wave formula.