Hydrodynamics · GLSL
📅 March 2026 ⏱ ≈ 11 min read 🎯 Intermediate

Gerstner Waves: ocean mathematics and GLSL

Franz von Gerstner описав трохоїдні хвилі ще у 1804 році — і їхня формула досі лежить в основі більшості ігрових рендерів океану. Дізнайтесь, як сума синусоїд перетворюється на реалістичні гребені, а vertex shader обчислює поверхню для 1 000 000 вершин за кадр.

Simplified model: sinusoidal waves

Найпростіша хвильова поверхня — сума вертикальних синусоїд. Для однієї хвилі з напрямком d = (dₓ, d_z), амплітудою A та хвильовим числом k:

y(x, z, t) = A · sin(k · (dₓ·x + d_z·z) − ω·t + φ₀)

ω = √(g · k), where g ≈ 9.81 m/s² — free-fall acceleration
k = 2π / λ, λ — wavelength

Це дисперсійне співвідношення ω² = g·k означає, що хвилі з більшою довжиною хвилі рухаються швидше. Це і є причина характерної «перегонки» хвиль різних розмірів на океані.

Проблема синусоїд: чисті вертикальні хвилі виглядають пластиково. Реальні хвилі мають гострі гребені — вода там стягується вперед. Саме це описують хвилі Герстнера.

Gerstner trochoidal waves

Ключова відмінність: у хвилях Герстнера частинки не просто рухаються вертикально — вони описують кола у вертикальній площині (трохоїдна орбіта). Точки поверхні зміщуються і по горизонталі, і по вертикалі.

Для хвилі з вектором напряму D̂ (нормалізований), хвильовим числом k, амплітудою A:

P(x, z, t) = (x, 0, z) + Σᵢ Gerstner(Dᵢ, kᵢ, Aᵢ, ωᵢ, t)

Gerstner:
Δx = +(Aᵢ / kᵢ) · Dₓᵢ · sin(kᵢ·(Dᵢ·P₀) − ωᵢ·t + φᵢ)
Δy = +Aᵢ · cos(kᵢ·(Dᵢ·P₀) − ωᵢ·t + φᵢ)
Δz = +(Aᵢ / kᵢ) · Dzᵢ · sin(kᵢ·(Dᵢ·P₀) − ωᵢ·t + φᵢ)

Горизонтальне зміщення (sin) формує гострий гребінь: вершина хвилі тонша, западина — ширша. Саме так виглядає справжня морська хвиля. Чим більше A·k (крутизна хвилі), тим гостріший гребінь.

Steepness

Q = A · k ≡ steepness parameter

Constraint: Q ≤ 1.0 for all waves simultaneously
At Q > 1 the wave "folds back" — non-physical
Параметри крутизни: тихий океан — Q ≈ 0.2–0.4, буряне море — Q ≈ 0.6–0.9. При Q близько до 1.0 гребені стають дуже гострими і ефектними, але геометрія починає «протикатись» для сусідніх вершин сітки. Рекомендований діапазон: 0.4–0.7 для красивого ефекту без артефактів.

Wave parameters and realness

Parameter Symbol Typical values Effect
Amplitude A 0.05–2.0 m Wave height
Wavelength λ 2–200 m Crest frequency
Wave number k = 2π/λ 0.03–3.0 Steepness together with A
Angular frequency ω = √(gk) 0.5–5.0 rad/s Movement speed
Steepness Q = A·k 0.1–0.9 Crest sharpness
Direction D = (dₓ, dz) normalised Where "the wind blows"
Phase φ₀ 0–2π Start delay

Для реалістичного вигляду ≥ 4 хвилі накладаються одночасно з різними напрямками (±30°), довжинами хвиль і фазами. Великі хвилі задають загальний рух, дрібніші — «рябь».

Wave superposition

Фінальне зміщення вершини — сума з N хвиль. Всі хвилі обчислюються відносно недеформованих координат (x₀, z₀) вершини, тобто вхідні P₀ в усіх формулах — початкові, а не поточні координати.

P_final = P₀ + Σᵢ Gerstner(Dᵢ, kᵢ, Aᵢ, ωᵢ, φᵢ, t)

Total steepness constraint: Σᵢ Qᵢ ≤ 1.0

Типовий набір для «океану з вітром 8 м/с» — 4–8 хвиль. Більше — красивіше, але дорожче: кожна хвиля = N тригонометричних операцій (approx 3 sin/cos per vertex). На сучасному GPU з 1M вершин 8 хвиль = 8M тригонометричних операцій за кадр — цілком реально.

Normal computation

Нормаль поверхні потрібна для освітлення та відблисків. Для хвиль Герстнера нормаль обчислюється аналітично (без cross-product сусідніх вершин!):

WA = Aᵢ · kᵢ = Qᵢ for this wave
φ = k·(D·P₀) − ω·t + φ₀

Nₓ = −Σᵢ Dₓᵢ · WA · cos(φᵢ)
Ny = 1 − Σᵢ WA · sin(φᵢ)
Nz = −Σᵢ Dzᵢ · WA · cos(φᵢ)

N = normalize(Nₓ, Ny, Nz)

Це набагато ефективніше за чисельний підрахунок через сусідні вершини — і дає точнішу нормаль. Аналітична форма пряма наслідок часткових похідних рівняння Герстнера.

Vertex shader GLSL

// Single wave structure
struct Wave {
  vec2  direction;  // normalised direction xy
  float amplitude;
  float wavelength;
  float steepness;  // Q ∈ [0, 1]
  float phase;      // φ₀
};

uniform float uTime;
uniform Wave  uWaves[8];
uniform int   uNumWaves;

vec3 gerstner(vec3 P0, Wave w) {
  float k   = 6.2831853 / w.wavelength;
  float c   = sqrt(9.81 / k);            // phase speed
  float phi = k * dot(w.direction, P0.xz) - c * k * uTime + w.phase;
  float QA  = w.steepness * w.amplitude;
  return vec3(
    w.direction.x * QA * cos(phi),   // Δx
    w.amplitude         * sin(phi),   // Δy
    w.direction.y * QA * cos(phi)    // Δz
  );
}

vec3 gerstnerNormal(vec3 P0, Wave w) {
  float k   = 6.2831853 / w.wavelength;
  float c   = sqrt(9.81 / k);
  float phi = k * dot(w.direction, P0.xz) - c * k * uTime + w.phase;
  float WA  = w.amplitude * k;
  return vec3(
    -w.direction.x * WA * cos(phi),
    1.0 - w.steepness * WA * sin(phi),
    -w.direction.y * WA * cos(phi)
  );
}

void main() {
  vec3 P = position;
  vec3 N = vec3(0.0, 1.0, 0.0);

  for (int i = 0; i < uNumWaves; i++) {
    P += gerstner(position, uWaves[i]);
    N += gerstnerNormal(position, uWaves[i]);
  }
  N = normalize(N);

  vNormal   = normalMatrix * N;
  vWorldPos = (modelMatrix * vec4(P, 1.0)).xyz;
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(P, 1.0);
}
Оптимізація шейдера: переконайтесь, що цикл `for (int i …)` ітерується по константній межі або dynamically indexable uniform. В GLSL ES 3.0 (WebGL2) динамічний індекс uniform arrays підтримується, але для WebGL1 потрібно розгортання циклу через `#define`.

Fresnel effect

Реальна вода дзеркальна під гострими кутами і прозора при погляді зверху вниз. Це описується рівняннями Френеля, але у грі достатньо апроксимації Schlick:

F(θ) = F₀ + (1 − F₀) · (1 − cos θ)⁵

F₀ ≈ 0.02 for water surface (n₁=1.0, n₂=1.333)
θ = кут між нормаллю і вектором до камери (viewDir)
// Fragment shader — Fresnel effect (Schlick approximation)
vec3  viewDir  = normalize(cameraPos - vWorldPos);
float cosTheta = clamp(dot(vNormal, viewDir), 0.0, 1.0);
float F0       = 0.02;
float fresnel  = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);

// reflectColor — cubemap або skybox кольori
// waterColor — depth colour (dark blue)
vec3 color = mix(waterColor, reflectColor, fresnel);
gl_FragColor = vec4(color, 1.0);

При куті зору ~15° від горизонту fresnel ≈ 0.8–0.9 — вода майже повністю дзеркальна. При погляді зверху (0°) fresnel ≈ 0.02 — вода прозора, видно дно або глибинний колір.

Foam on crests

Піна — де хвилі найгостріші. Хороша апроксимація: піна там, де якобіан деформації наближається до нуля (поверхня "складається").

Foam ≈ saturate(−Jacobian + threshold)

Jacobian ≈ 1 − Nʸ where Nʸ is the vertical normal component
// Fragment: foam where the normal is tilted beyond threshold
float foam   = 1.0 - clamp(vNormal.y, 0.0, 1.0);
foam          = smoothstep(0.55, 0.8, foam);
vec3  color  = mix(oceanColor, vec3(1.0), foam * 0.6);

Реалістичніше рішення — зберігати Jacobian з попереднього кадру в пінній текстурі (accumulation foam) та повільно затухати. Але навіть статична версія значно покращує вигляд при Q ≥ 0.5.

Performance optimisation

Ocean mesh LOD

Висока деталізація потрібна лише поблизу камери. Класичний підхід: геоміпмеппінг або clipmap grid — концентричні кільця сітки зі зростаючим кроком вершин з відстанню. Близька зона: 512×512 вершин, далека: 64×64. Mesh залишається постійним, GPU тільки переміщує вершини.

Distributing waves across shaders

Великі хвилі (λ = 50–200 м) — у vertex shader: потрібна точність per-vertex. Дрібна рябь (λ = 0.1–2 м) — у fragment shader через normal map анімацію: дешевше і виглядає нітрохи не гірше.

Pre-computed FFT ocean

Промислові рушії (Unreal, Unity, GTA) використовують iFFT (inverse FFT) для генерації хвиль: задають спектр Піллсворта-Московіца у просторі частот, потім iFFT перетворює у висоти та нормалі. Це дозволяє симулювати справжній статистичний розподіл океанських хвиль. WebGPU Compute Shaders відкривають цей шлях і у браузері.

Рекомендація: для 60 FPS на мобільних — 4 хвилі Герстнера у vertex shader + normal map у fragment. Для десктопу — 8 хвиль + normal map + foam accumulation. FFT ocean краще залишити для WebGPU (2.0+).
▶ Live Demo

🌊 Run Ocean simulation

Gerstner waves with Fresnel reflection and procedural foam in WebGL

Open simulation →

🔗 Related Simulations

🌊Ocean Wave 🌧️Rain 🌈Rainbow