Гідродинаміка · GLSL
📅 Березень 2026 ⏱ ≈ 11 хв читання 🎯 Середнє

Хвилі Герстнера: математика океану та GLSL

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

Спрощена модель: синусоїдальні хвилі

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

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

ω = √(g · k), де g ≈ 9.81 м/с² — прискорення вільного падіння
k = 2π / λ, λ — довжина хвилі

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

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

Трохоїдні хвилі Герстнера

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

Для хвилі з вектором напряму 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 параметр

Обмеження: Q ≤ 1.0 для всіх хвиль одночасно
При Q > 1 хвиля «завертається» назад — нефізично
Параметри крутизни: тихий океан — Q ≈ 0.2–0.4, буряне море — Q ≈ 0.6–0.9. При Q близько до 1.0 гребені стають дуже гострими і ефектними, але геометрія починає «протикатись» для сусідніх вершин сітки. Рекомендований діапазон: 0.4–0.7 для красивого ефекту без артефактів.

Параметри хвилі та realness

Параметр Символ Типові значення Вплив
Амплітуда A 0.05–2.0 м Висота хвилі
Довжина хвилі λ 2–200 м Частота гребенів
Хвильове число k = 2π/λ 0.03–3.0 Крутизна разом з A
Кутова частота ω = √(gk) 0.5–5.0 рад/с Швидкість руху
Крутизна Q = A·k 0.1–0.9 Гостреність гребеня
Напрямок D = (dₓ, dz) нормалізований Де «дме вітер»
Фаза φ₀ 0–2π Затримка старту

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

Суперпозиція хвиль

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

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

Обмеження сумарної крутизни: Σᵢ Qᵢ ≤ 1.0

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

Обчислення нормалей

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

WA = Aᵢ · kᵢ = Qᵢ для даної хвилі
φ = k·(D·P₀) − ω·t + φ₀

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

N = normalize(Nₓ, Ny, Nz)

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

Vertex shader GLSL

// Структура однієї хвилі
struct Wave {
  vec2  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);            // фазова швидкість
  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`.

Ефект Френеля

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

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

F₀ ≈ 0.02 для водної поверхні (n₁=1.0, n₂=1.333)
θ = кут між нормаллю і вектором до камери (viewDir)
// Fragment shader — ефект Френеля (Schlick апроксимація)
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 — глибинний колір (темно-синій)
vec3 color = mix(waterColor, reflectColor, fresnel);
gl_FragColor = vec4(color, 1.0);

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

Піна на гребенях

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

Foam ≈ saturate(−Jacobian + threshold)

Jacobian ≈ 1 − Nʸ де Nʸ — вертикальна компонента нормалі
// Fragment: піна там, де нормаль нахилена більше 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.

Оптимізація продуктивності

LOD сітки океану

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

Розподіл хвиль між шейдерами

Великі хвилі (λ = 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+).

🌊 Запустити Ocean симуляцію

Хвилі Герстнера з Fresnel-відбиттям та процедурною піною у WebGL

Відкрити симуляцію →