Shader oceanu — GLSL, fale Gerstnera i Fresnel w 3 dni

Realistyczna woda oceaniczna z animowanym przemieszczeniem wierzchołków, pianą na grzbietach fal i efektem odbicia Fresnela — wszystko napisane od zera w GLSL w jeden długi weekend.

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:

x(u, t) = u − (Q · A · k.x) · sin(dot(k, u) − ω · t)
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ł:

Łą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.