Шейдер океану — GLSL, хвилі Герстнера та Френель за 3 дні

Реалістична океанська вода з анімованим зміщенням вершин, піною на гребенях хвиль та ефектом відбиття Френеля — усе написано з нуля на GLSL за довгі вихідні.

Навіщо писати власний шейдер океану?

Вбудований Water з папки прикладів Three.js гарний і добре працює. Але користуватися ним — це чорна скринька: крутиш числа й сподіваєшся на краще. Написати його з нуля означає зрозуміти, чому вода виглядає саме так.

Крім того: одного разу я бачив продакшн-шейдер океану, який падав на WebGL2 через залежність від розширення в коді Water з Three.js. Написати свій означає нуль несподіваних залежностей.

Хвилі Герстнера — фізика

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

x(u, t) = u − (Q · A · k.x) · sin(dot(k, u) − ω · t)
y(u, t) = A · cos(dot(k, u) − ω · t)

Де A — амплітуда, k — хвильовий вектор (напрямок + частота), ω = sqrt(g · |k|) — кутова частота (з дисперсійного співвідношення для глибокої води), а Q — параметр крутизни (0 = синусоїда, 1 = гострий гребінь).

У вершинному шейдері підсумовування 4–8 хвиль Герстнера з різними напрямками, амплітудами та частотами дає переконливу поверхню океану:

// GLSL, вершинний шейдер — функція хвилі Герстнера
vec3 gerstner(vec2 uv, float A, vec2 dir, float steepness, float speed, float t) {
  vec2 k = normalize(dir);
  float w = sqrt(9.8 * length(dir));
  float phase = dot(k, uv) * length(dir) - w * speed * t;
  float s = steepness * A;
  return vec3(
    s * k.x * cos(phase),
    A * sin(phase),
    s * k.y * cos(phase)
  );
}

// Підсумовуємо кілька хвиль
vec3 total = vec3(0.0);
total += gerstner(vUv * 20.0, 0.15, vec2(1.0, 0.8),  0.4, 0.8, uTime);
total += gerstner(vUv * 15.0, 0.10, vec2(-0.7, 1.0), 0.3, 1.1, uTime);
total += gerstner(vUv * 30.0, 0.05, vec2(0.5, -0.9), 0.6, 1.4, uTime);
vec3 displaced = position + total;

Френель — чому вода виглядає як дзеркало під ковзними кутами

Подивіться на спокійне озеро згори — і побачите крізь нього. Подивіться на нього під низьким кутом (майже горизонтально) — і побачите самі відбиття. Це ефект Френеля — відбивна здатність поверхні зростає в міру того, як кут огляду стає більш ковзним.

Наближення Шліка дає швидку, зручну для GPU формулу:

// Фрагментний шейдер — Френель за Шліком
float fresnel(vec3 viewDir, vec3 normal, float ior) {
  float r0 = pow((1.0 - ior) / (1.0 + ior), 2.0);
  float cosTheta = clamp(1.0 - dot(viewDir, normal), 0.0, 1.0);
  return r0 + (1.0 - r0) * pow(cosTheta, 5.0);
}

float f = fresnel(normalize(vViewDir), vNormal, 1.33); // IOR води ≈ 1.33
vec4 color = mix(refractColor, reflectColor, f);

Піна на гребенях хвиль

Справжні хвилі піняться на гребені через турбулентне захоплення повітря. У шейдері я визначаю гребені, перевіряючи, чи перевищує зміщена висота вершини поріг, а потім домішую білу текстуру піни, масштабовану надлишковою висотою:

float foamMask = smoothstep(0.6, 1.0, (waveHeight - 0.5) / 0.5);
vec3 foamColor = vec3(1.0);
color.rgb = mix(color.rgb, foamColor, foamMask * 0.85);

День 3: збираємо все разом

До кінця третього дня океан мав:

Загальний обсяг коду шейдера: ~180 рядків GLSL. Працює зі сталими 60 FPS навіть на вбудованій графіці.

Симуляцію океану можна побачити на /ocean/. Спробуйте змінити повзунок напрямку вітру й подивіться, як зміниться малюнок хвиль.