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:
ω = √(g · k), where g ≈ 9.81 m/s² — free-fall acceleration
k = 2π / λ, λ — wavelength
Це дисперсійне співвідношення ω² = g·k означає, що хвилі з більшою довжиною хвилі рухаються швидше. Це і є причина характерної «перегонки» хвиль різних розмірів на океані.
Gerstner trochoidal waves
Ключова відмінність: у хвилях Герстнера частинки не просто рухаються вертикально — вони описують кола у вертикальній площині (трохоїдна орбіта). Точки поверхні зміщуються і по горизонталі, і по вертикалі.
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
Constraint: Q ≤ 1.0 for all waves simultaneously
At Q > 1 the wave "folds back" — non-physical
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₀ в усіх формулах — початкові, а не поточні координати.
Total steepness constraint: Σᵢ Qᵢ ≤ 1.0
Типовий набір для «океану з вітром 8 м/с» — 4–8 хвиль. Більше — красивіше, але дорожче: кожна хвиля = N тригонометричних операцій (approx 3 sin/cos per vertex). На сучасному GPU з 1M вершин 8 хвиль = 8M тригонометричних операцій за кадр — цілком реально.
Normal computation
Нормаль поверхні потрібна для освітлення та відблисків. Для хвиль Герстнера нормаль обчислюється аналітично (без cross-product сусідніх вершин!):
φ = 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);
}
Fresnel effect
Реальна вода дзеркальна під гострими кутами і прозора при погляді зверху вниз. Це описується рівняннями Френеля, але у грі достатньо апроксимації Schlick:
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
Піна — де хвилі найгостріші. Хороша апроксимація: піна там, де якобіан деформації наближається до нуля (поверхня "складається").
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 відкривають цей шлях і у браузері.
🌊 Run Ocean simulation
Gerstner waves with Fresnel reflection and procedural foam in WebGL