Dlaczego pisać własny shader oceanu?
Wbudowany Water z folderu przykładów Three.js jest
piękny i dobrze działa. Ale korzystanie z niego to czarna skrzynka
— kręcisz liczbami i masz nadzieję. Napisanie go od zera oznacza
zrozumienie, dlaczego woda wygląda tak, jak wygląda.
Poza tym: widziałem kiedyś produkcyjny shader oceanu, który padał na WebGL2 z powodu zależności od rozszerzenia w kodzie Water z Three.js. Napisanie własnego oznacza zero niespodziewanych zależności.
Fale Gerstnera — fizyka
Prawdziwe fale oceaniczne nie są sinusoidalne. Powierzchnia wody jest bliższa fali Gerstnera (nazywanej też falą trochoidalną) — grzbiet jest ostrzejszy, a dolina bardziej płaska niż w czystym sinusie:
y(u, t) = A · cos(dot(k, u) − ω · t)
Gdzie A to amplituda, k to wektor falowy (kierunek + częstotliwość), ω = sqrt(g · |k|) to częstotliwość kątowa (z relacji dyspersji dla wód głębokich), a Q to parametr stromości (0 = sinus, 1 = ostry grzbiet).
W shaderze wierzchołków sumowanie 4–8 fal Gerstnera o różnych kierunkach, amplitudach i częstotliwościach daje przekonującą powierzchnię oceanu:
// Shader wierzchołków GLSL — funkcja fali Gerstnera
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)
);
}
// Sumowanie wielu fal
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;
Fresnel — dlaczego woda wygląda jak lustro pod małym kątem
Spójrz na spokojne jezioro z góry, a zobaczysz przez nie na wylot. Spójrz z niskiego kąta (niemal poziomego), a zobaczysz same odbicia. To efekt Fresnela — odbiciowość powierzchni rośnie w miarę jak kąt patrzenia staje się bardziej styczny.
Aproksymacja Schlicka daje szybki wzór przyjazny dla GPU:
// Shader fragmentów — Fresnel wg Schlicka
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 wody ≈ 1,33
vec4 color = mix(refractColor, reflectColor, f);
Piana na grzbietach fal
Prawdziwe fale pienią się na grzbiecie z powodu turbulentnego wciągania powietrza. W shaderze wykrywam grzbiety, sprawdzając, czy wysokość przemieszczonego wierzchołka przekracza próg, a następnie wmieszuję białą teksturę piany skalowaną nadwyżką wysokości:
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);
Dzień 3: łączenie wszystkiego
Pod koniec dnia 3 ocean miał:
- 8 zsumowanych fal Gerstnera o zróżnicowanym kierunku, częstotliwości i stromości
- Odbicie nieba mieszane przez Fresnela i kolor podwodny tłumiony wraz z głębokością
- Pianę na grzbietach fal napędzaną wysokością przemieszczenia
- Mapę normalnych dla wysokoczęstotliwościowych detali powierzchni (zmarszczek)
- Specularne odbicie słońca metodą Blinna-Phonga
Łączny kod shadera: ~180 linii GLSL. Działa ze stałymi 60 FPS nawet na zintegrowanej karcie graficznej.
Symulacja oceanu jest dostępna pod adresem /ocean/. Spróbuj zmienić suwak kierunku wiatru i obserwuj, jak zmienia się wzór fal.