Відтворення рідини у грі — heightmap метод

🎨 Комп'ютерна графіка ⏱ ~10 хв читання Середній рівень
Хвильове рівняння Heightmap Fresnel Beer's Law WebGL GLSL

Heightmap-рідина — улюблений трюк геймдевелоперів: замість дорогої симуляції частинок або Navʼє-Стокса, висота поверхні описується двовимірним масивом і еволюціонує за хвильовим рівнянням. Результат — реалістичне інтерактивне водне mid-poly озеро, пул або калюжа за мінімальних витрат GPU.

1. Хвильове рівняння для heightmap

Поверхня рідини — тонкий шар + малі збурення амплітуди h(x,y,t), де h — відхилення від рівноваги. В лінійному наближенні h задовольняє хвильове рівняння:

∂²h/∂t² = c² · ∇²h

де c = √(g·d) — швидкість хвилі (shallow water)

g = 9.81 м/с², d — глибина

Для d = 1 м: c ≈ 3.13 м/с

Це лінійне хвильове рівняння — нескомпресована поверхнева хвиля у граничному випадку довгих хвиль (λ ≫ d). Воно дає "ripple" ефект: кружечки розходяться з постійною швидкістю, відбиваються від стінок, інтерферують, зменшуються через затухання.

Що воно НЕ описує

Нелінійні ефекти (breaking waves, спінінення, bore waves), вертикальний рух частинок (важливий для великих амплітуд), повна Navʼє-Стокс динаміка. Але для інтерактивних ігор — достатньо.

2. Явна скінченно-різнична схема

Дискретизуємо в просторі (кроки Δx, Δy) та часі (крок Δt). Позначення: h^n_{i,j} = h(i·Δx, j·Δy, n·Δt). Явна схема "leap-frog":

h^{n+1}_{i,j} = 2·h^n_{i,j} − h^{n-1}_{i,j}

+ (cΔt/Δx)² · (h^n_{i+1,j} + h^n_{i-1,j} − 2h^n_{i,j})

+ (cΔt/Δy)² · (h^n_{i,j+1} + h^n_{i,j-1} − 2h^n_{i,j})

Умова стійкості CFL: c·Δt / Δx ≤ 1/√2 (2D)

При порушенні умови Куранта-Фрідріхса-Леві схема розходиться — числові осциляції наростають. У грі зазвичай: Δt = 1/60 с, Δx = розмір тайлу. Звідси максимальна швидкість хвилі c_max ≤ Δx·60/√2.

Затухання (damping)

Без загасання хвилі відбиваються вічно — нереалістично. Множимо на коефіцієнт загасання d < 1 після кожного кроку:

h^{n+1} = (h^{n+1}_raw) · d — "energy dissipation"

Типовий діапазон: d ∈ [0.97, 0.999]

d = 0.99 → після 100 кроків залишається 0.99^100 ≈ 37% амплітуди

3. Апроксимація як клітинний автомат

Є ще більш спрощена, але ефективна версія — cellular automaton підхід, популяризований у блозі Lode Vandevenne. Зберігаємо лише дві текстури: поточна висота та попередня. Правило оновлення:

// Fragment shader — один крок хвильового клітинного автомату
uniform sampler2D uCurr;   // поточний стан h^n
uniform sampler2D uPrev;   // попередній стан h^{n-1}
uniform vec2 uTexelSize;   // 1/N для кожного напрямку
uniform float uSpeed;     // (c*dt/dx)^2
uniform float uDamping;   // 0.99
varying vec2 vUV;

void main() {
  float c = texture2D(uCurr, vUV).r;
  float p = texture2D(uPrev, vUV).r;
  // Сусіди
  float n = texture2D(uCurr, vUV + vec2(0, uTexelSize.y)).r;
  float s = texture2D(uCurr, vUV - vec2(0, uTexelSize.y)).r;
  float e = texture2D(uCurr, vUV + vec2(uTexelSize.x, 0)).r;
  float w = texture2D(uCurr, vUV - vec2(uTexelSize.x, 0)).r;

  float laplacian = n + s + e + w - 4.0 * c;
  float next = (2.0 * c - p + uSpeed * laplacian) * uDamping;
  gl_FragColor = vec4(next, 0., 0., 1.);
}

Цей шейдер виконується повноекранно, ping-pong між двома FBO — ідентично до симуляції реакції-дифузії. Кількість операцій: O(N²) за кадр на GPU — навіть 512×512 сітка зручна при 60 FPS.

Взаємодія: кидання каменя

function splashAt(gl, fbo, x, y, radius, amplitude) {
  // Малюємо гаусіановий "горб" у точці контакту
  // Потім перемальовуємо в поточний FBO з адитивним блендингом
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, gl.ONE);
  gl.useProgram(splatProg);
  gl.uniform2f(loc('uCenter'), x, y);
  gl.uniform1f(loc('uRadius'), radius);
  gl.uniform1f(loc('uAmp'), amplitude);
  drawFullscreenQuad(gl);
  gl.disable(gl.BLEND);
}

4. Foam map через накопичення швидкості

Піна утворюється де вода рухається швидко або де гребні хвиль перекидаються. У heightmap-моделі нема реального кипіння, але можна апроксимувати:

velocity_h ≈ |h^{n+1} − h^{n-1}| / (2·Δt) — "швидкість" висоти

foam_new = max(0, |Δh| - threshold) · foamGain

foam = lerp(foam_old, foam_new, foamDecay) ← накопичення з загасанням

// У шейдері оновлення foam:
float vel = abs(next - p);               // локальна зміна висоти
float newFoam = clamp((vel - 0.005) * 20.0, 0.0, 1.0);
float oldFoam = texture2D(uFoam, vUV).r;
float foam = mix(oldFoam, newFoam, 0.1) * 0.96; // decay
gl_FragColor = vec4(next, foam, 0.0, 1.0); // r=height, g=foam

Пакуємо висоту у R-канал, foam у G-канал — одна текстура замість двох. У фінальному рендерному шейдері foam використовуємо як маску для білої піни поверх water color.

5. Нормалі з heightmap

Нормаль до поверхні z = h(x,y) у точці (x,y) визначається градієнтом: n = normalize(−∂h/∂x, −∂h/∂y, 1).

// У vertex або fragment shader:
float ts = uTexelSize.x;
float hR = texture2D(uHeight, uv + vec2( ts, 0.)).r;
float hL = texture2D(uHeight, uv + vec2(-ts, 0.)).r;
float hU = texture2D(uHeight, uv + vec2(0.,  ts)).r;
float hD = texture2D(uHeight, uv + vec2(0., -ts)).r;
vec3 normal = normalize(vec3(hL - hR, hD - hU, 2.0 * ts * uHeightScale));

Масштаб uHeightScale важливий: занадто велике значення дає "металічний" вигляд з різкими відблисками, замале — плоску воду. Типово 0.5–2.0 залежно від розміру сітки та амплітуд хвиль.

6. Fresnel: дзеркало vs прозорість

Реальна вода при малому куті огляду — майже дзеркало, при великому — прозора. Це ефект Френеля (вже знайомий з каустик):

F(θ) ≈ F₀ + (1 − F₀)(1 − max(0, dot(N, V)))⁵

F₀ = ((n₁ − n₂)/(n₁ + n₂))²

Для вода/повітря: F₀ ≈ 0.02

F = 1 → відбиваємо environment map (небо, хмари). F = 0 → дивимось крізь воду (дно, підводні об'єкти).

float cosTheta = max(0.0, dot(normal, viewDir));
float F0 = 0.02;
float fresnel = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
// Фінальний колір = суміш відбитого і заломленого
vec3 color = mix(refractionColor, reflectionColor, fresnel);

7. Beer's Law та UV-зміщення рефракції

Beer's Law (поглинання кольору)

Колір, що проходить крізь воду, поглинається по-різному для R, G, B компонент. Закон Бера-Ламберта:

I(λ) = I₀(λ) · exp(−κ(λ) · d)

Вода поглинає: κ_R ≫ κ_G > κ_B (червоне поглинається першим)

Типові коефіцієнти (нормований): κ = (0.45, 0.07, 0.03)

float depth = max(0.0, uWaterDepth - refractionZ);
vec3 absorption = exp(-vec3(0.45, 0.07, 0.03) * depth);
vec3 refractionColor = bottomColor * absorption;
// + deep water tint (дно невидиме на великих глибинах)
refractionColor = mix(deepWaterColor, refractionColor, exp(-depth * 0.5));

UV-зміщення рефракції (screen-space)

Замість справжнього трасування заломленого променя: зміщуємо UV look-up у screen-space текстурі дна на вектор, пропорційний проекції нормалі на площину XY:

float refractStrength = 0.02;  // сила ефекту заломлення
vec2 refractOffset = normal.xy * refractStrength;
// Зміщені UV для читання "підводної" текстури
vec2 refractUV = screenUV + refractOffset;
vec3 bottomColor = texture2D(uBottom, refractUV).rgb;

Це не фізично точно, але виглядає переконливо і дешево — один додатковий texture lookup на піксель.

8. Повний GLSL шейдер рендерингу води

// Water surface fragment shader
precision highp float;
uniform sampler2D uHeightFoam;  // R=height, G=foam
uniform sampler2D uEnvMap;      // кубмап або сферична текстура неба
uniform sampler2D uBottom;      // текстура дна
uniform vec3  uSunDir;          // напрямок до сонця
uniform vec3  uViewPos;
uniform float uWaterDepth;
uniform float uHeightScale;
uniform vec2  uTexelSize;
varying vec2  vUV;
varying vec3  vWorldPos;

void main() {
  float ts = uTexelSize.x;
  float hR = texture2D(uHeightFoam, vUV+vec2( ts,0)).r;
  float hL = texture2D(uHeightFoam, vUV-vec2( ts,0)).r;
  float hU = texture2D(uHeightFoam, vUV+vec2(0, ts)).r;
  float hD = texture2D(uHeightFoam, vUV-vec2(0, ts)).r;
  float foam = texture2D(uHeightFoam, vUV).g;
  vec3 normal = normalize(vec3(hL-hR, hD-hU, 2.0*ts*uHeightScale));

  vec3 viewDir = normalize(uViewPos - vWorldPos);
  float cosV = max(0.0, dot(normal, viewDir));

  // Fresnel
  float fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);

  // Відбиття (environment map)
  vec3 reflDir = reflect(-viewDir, normal);
  vec3 reflection = texture2D(uEnvMap, reflDir.xy * 0.5 + 0.5).rgb;

  // Заломлення (screen-space approximation)
  vec2 refractUV = vUV + normal.xy * 0.025;
  vec3 bottomCol = texture2D(uBottom, clamp(refractUV, 0.001, 0.999)).rgb;

  // Beer's Law поглинання
  float depth = uWaterDepth;
  vec3 absorbed = bottomCol * exp(-vec3(0.45, 0.07, 0.03) * depth);
  vec3 deepTint  = vec3(0.02, 0.08, 0.15); // глибоке море
  vec3 refraction = mix(deepTint, absorbed, exp(-depth * 0.3));

  // Specular (Blinn-Phong для spec highlight сонця)
  vec3 halfDir = normalize(viewDir + uSunDir);
  float spec = pow(max(0.0, dot(normal, halfDir)), 256.0) * 2.0;

  // Фінальна комбінація
  vec3 color = mix(refraction, reflection, fresnel) + spec;
  // Піна — завжди зверху
  color = mix(color, vec3(1.0), foam * 0.8);

  gl_FragColor = vec4(pow(color, vec3(1.0/2.2)), 1.0); // gamma
}

Порівняння підходів для ігрової рідини

Heightmap (цей метод)

O(N²) GPU, 60 FPS на 512×512. Інтерактивна, проста реалізація. Обмеження: не описує великих хвиль, перекидання.

Gerstner Waves

Аналітична формула, нульова симуляційна вартість. Ідеальна для Ocean/море. Не інтерактивна — хвилі не реагують на гравець.

FFT Ocean

Спектр Піллар-Герст → IFFT → heightmap. Дуже реалістичний, але не інтерактивний. Assassin's Creed Black Flag, Sea of Thieves.

SPH / Position-based

Частинкова симуляція. Фізично коректна для сплескування, але O(N²) CPU або складний GPU. Для маленьких об'ємів рідини.

Висновок: Heightmap + wave equation — золотий стандарт для ігрових калюж, ставків, невеликих озер де потрібна інтерактивність. Для відкритого моря — Gerstner + FFT Ocean. Для реалістичної рідини з розплеском — SPH або position-based fluid.
Висновок: Heightmap + wave equation — золотий стандарт для ігрових калюж, ставків, невеликих озер де потрібна інтерактивність. Для відкритого моря — Gerstner + FFT Ocean. Для реалістичної рідини з розплеском — SPH або position-based fluid.

🌊 Відкрити симуляцію Ocean →

🔗 Related Simulations

🌊Ocean 💧Rain 🌊SPH Fluid 🫧Bubbles