Навіщо писати власний шейдер океану?
Вбудований Water з папки прикладів Three.js гарний і
добре працює. Але користуватися ним — це чорна скринька: крутиш
числа й сподіваєшся на краще. Написати його з нуля означає
зрозуміти, чому вода виглядає саме так.
Крім того: одного разу я бачив продакшн-шейдер океану, який падав на WebGL2 через залежність від розширення в коді Water з Three.js. Написати свій означає нуль несподіваних залежностей.
Хвилі Герстнера — фізика
Справжні океанські хвилі не синусоїдальні. Поверхня води набагато ближча до хвилі Герстнера (також відомої як трохоїдальна хвиля) — гребінь гостріший, а западина пласкіша, ніж у чистій синусоїді:
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: збираємо все разом
До кінця третього дня океан мав:
- 8 підсумованих хвиль Герстнера з різними напрямками, частотами й крутизною
- Відбиття неба, змішане через Френеля, та підводний колір, що згасає з глибиною
- Піну на гребенях хвиль, керовану висотою зміщення
- Карту нормалей для високочастотних деталей поверхні (брижі)
- Дзеркальний відблиск сонця за моделлю Блінна-Фонга
Загальний обсяг коду шейдера: ~180 рядків GLSL. Працює зі сталими 60 FPS навіть на вбудованій графіці.
Симуляцію океану можна побачити на /ocean/. Спробуйте змінити повзунок напрямку вітру й подивіться, як зміниться малюнок хвиль.