Фізика · Динаміка рідин · WebGL
📅 Березень 2026 ⏱ ≈ 13 хв читання 🎯 Просунутий рівень

Рівняння Нав'є-Стокса у WebGL — симуляція рідини в реальному часі

Стаття Джоса Стама «Stable Fluids» 1999 року показала, як розв'язувати рівняння нестисливого Нав'є-Стокса так, щоб розв'язок ніколи не «вибухав», незалежно від кроку за часом. Ключова ідея — розщеплення за операторами: розглядати адвекцію, дифузію, зовнішні сили та проєкцію тиску як чотири окремі підкроки кожного кроку за часом. На сучасному обладнанні весь розв'язувач працює у фрагментних шейдерах WebGL зі швидкістю 60 кадрів/с.

1. Рівняння нестисливого Нав'є-Стокса

Рівняння нестисливого Нав'є-Стокса описують рух ньютонівської рідини зі сталою густиною. У векторній формі це два зв'язані диференціальні рівняння з частинними похідними:

Імпульс: ∂u/∂t = −(u · ∇)u + ν ∇²u − (1/ρ)∇p + f
Нерозривність: ∇ · u = 0

u — поле швидкостей (2D або 3D векторне поле)
p — поле тиску (скаляр)
ν — кінематична в'язкість (ν = μ/ρ)
ρ — густина (стала для нестисливої течії)
f — зовнішні об'ємні сили (гравітація, перетягування мишею тощо)

Рівняння нерозривності ∇ · u = 0 — це обмеження нестисливості: рідина не може стискатися, тож дивергенція поля швидкостей має скрізь дорівнювати нулю. Це обмеження керує розв'язувачем тиску.

Рівняння імпульсу має чотири члени праворуч:

2. Розщеплення за операторами — підхід Stable Fluids

Метод Стама просуває розв'язок на один крок за часом Δt за допомогою чотирьох послідовних підкроків, кожен з яких застосовується до сітки поля, збереженої як текстура з плаваючою комою:

1 Додати сили — накопичити зовнішні імпульси (сплеск швидкості мишею, гравітація) безпосередньо в сітку швидкостей
2 Дифузія — розв'язати u = u + ν Δt ∇²u неявно через ітерацію Якобі; безумовно стійка навіть за великих ν чи Δt
3 Адвекція — простежити кожну точку сітки назад уздовж поля швидкостей і взяти значення з попереднього поля (напівлагранжева)
4 Проєкція — обчислити поле тиску, що робить u бездивергентним; відняти його градієнт від u
Чому вона «стійка»? Явна скінченнорізницева адвекція потребує умови CFL (Δt · |u| / Δx < 1). Напівлагранжева адвекція (крок 3) рухається назад уздовж ліній течії й використовує білінійну інтерполяцію, що безумовно стійка — ціною певної числової дифузії.

3. Напівлагранжева адвекція

Ідея: замість запитувати «куди прямує рідина в комірці сітки x?», запитуй «звідки рідина в x прийшла?» Простеж поле швидкостей назад на Δt і візьми там значення попереднього поля.

u_new(x) = u_old( x − Δt · u_old(x) )

x_src = x − Δt · u(x) ← «звідки воно прийшло»
білінійна інтерполяція у x_src в u_old

У GLSL (один канал текстури з плаваючою комою = компонента швидкості):

// Шейдер адвекції — gl_FragCoord.xy відображається в UV у [0,1]²
uniform sampler2D uVelocity;   // поточне поле швидкостей (vec2, закодований як RG)
uniform sampler2D uSource;     // поле для адвекції (швидкість або барвник)
uniform vec2      uTexelSize;  // 1/роздільна здатність
uniform float      uDt;

void main() {
  vec2 uv   = gl_FragCoord.xy * uTexelSize;
  vec2 vel  = texture(uVelocity, uv).rg;
  vec2 prev = uv - uDt * vel * uTexelSize; // зворотне трасування в просторі текстури
  gl_FragColor = texture(uSource, prev);  // білінійна вибірка (БЕЗКОШТОВНА на GPU)
}
Числова дисипація: білінійна інтерполяція — це фільтр нижніх частот. Кожен крок адвекції ледь помітно розмиває поле швидкостей. Для різкішої турбулентності використовуйте адвекцію Маккормака (адвекція вперед + назад + крок корекції) ціною двох додаткових зчитувань текстури.

4. Дифузія — ітерація Якобі

Неявне розв'язання (I − ν Δt ∇²) u_new = u_old відносно u_new дає розріджену лінійну систему. На регулярній сітці лапласіан дискретизується так:

∇²u ≈ (u_{i+1} + u_{i−1} + u_{j+1} + u_{j−1} − 4 u_{ij}) / Δx²

Неявна система u_{ij} = α·(сума 4 сусідів) + β·u_old
де α = Δx² / (ν Δt), β = 1 / (4 + α)

Ітерація Якобі: ітерувати uNew = β · (α·u_old + L(uPrev))
збігається за ~20–40 ітерацій для типових ν та Δt
// Шейдер Якобі (одна ітерація; ping-pong між двома FBO)
uniform sampler2D uX;        // поточне наближення x_k
uniform sampler2D uB;        // права частина b  (u_old)
uniform float      uAlpha;    // Δx² / (ν·Δt)
uniform float      uBeta;     // 1 / (4 + α)
uniform vec2      uTexelSize;

void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  vec4 xL = texture(uX, uv - vec2(uTexelSize.x, 0));
  vec4 xR = texture(uX, uv + vec2(uTexelSize.x, 0));
  vec4 xB = texture(uX, uv - vec2(0, uTexelSize.y));
  vec4 xT = texture(uX, uv + vec2(0, uTexelSize.y));
  vec4 b  = texture(uB, uv);
  gl_FragColor = (xL + xR + xB + xT + uAlpha * b) * uBeta;
}

Для рідин із низькою в'язкістю можна взагалі пропустити явну дифузію (ν = 0) — самої числової дифузії від адвекції достатньо для згладжування, і це заощаджує 20–40 проходів Якобі на кадр.

5. Проєкція тиску — розклад Гельмгольца

Після адвекції поле швидкостей u̅ зазвичай дивергентне (∇ · u̅ ≠ 0) — несумісне з реальною поведінкою рідини. Розклад Гельмгольца гарантує, що будь-яке векторне поле можна розкласти на бездивергентну частину та градієнт:

u̅ = u + ∇p / ρ
∇ · u = 0 ⟹ ∇²p = ρ ∇ · u̅ (рівняння Пуассона для тиску)

Крок 1: обчислити дивергенцію D = ∇ · u̅
Крок 2: розв'язати Пуассона ∇²p = D через Якобі (20–50 ітерацій)
Крок 3: відняти градієнт u = u̅ − ∇p / ρ
// Шейдер дивергенції
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float vL = texture(uVelocity, uv - vec2(uTexelSize.x,0)).x;
  float vR = texture(uVelocity, uv + vec2(uTexelSize.x,0)).x;
  float vB = texture(uVelocity, uv - vec2(0,uTexelSize.y)).y;
  float vT = texture(uVelocity, uv + vec2(0,uTexelSize.y)).y;
  gl_FragColor.r = 0.5 * (vR - vL + vT - vB);  // центральні різниці
}

// Шейдер віднімання градієнта
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float pL = texture(uPressure, uv - vec2(uTexelSize.x,0)).r;
  float pR = texture(uPressure, uv + vec2(uTexelSize.x,0)).r;
  float pB = texture(uPressure, uv - vec2(0,uTexelSize.y)).r;
  float pT = texture(uPressure, uv + vec2(0,uTexelSize.y)).r;
  vec2  vel = texture(uVelocity, uv).xy;
  vel -= 0.5 * vec2(pR - pL, pT - pB);
  gl_FragColor = vec4(vel, 0, 1);
}

Розв'язання Пуассона для тиску використовує той самий шейдер Якобі, що й дифузія — параметри α = −Δx², β = 1/4. 20–50 ітерацій зазвичай достатньо для плавної течії; розв'язувач Гаусса–Зейделя чи багатосітковий збігався б швидше.

6. Реалізація у WebGL — текстури ping-pong

Розв'язувач рідини у WebGL підтримує кілька цілей рендерингу з плаваючою комою (пари FBO для ping-pong). Мінімальне налаштування потребує:

// Мінімальний помічник FBO для WebGL2
function createDoubleFBO(gl, w, h, format) {
  const make = () => {
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, format.internal,
                   w, h, 0, format.format, gl.FLOAT, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    const fbo = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
    return { tex, fbo };
  };
  let read = make(), write = make();
  return {
    get read()  { return read;  },
    get write() { return write; },
    swap() { [read, write] = [write, read]; }
  };
}

// Покадровий цикл оновлення
function update(dt) {
  addForces(velocity, mouseForce);          // крок 1
  for (let i = 0; i < 20; i++)
    jacobi(velocity, velocity, alpha, beta); // крок 2: дифузія
  advect(velocity, velocity, dt);           // крок 3: адвекція швидкості
  computeDivergence(velocity, divergence);  // крок 4a
  pressure.read.clearToZero();               // крок 4b: скинути p
  for (let i = 0; i < 40; i++)
    jacobi(pressure, divergence, -dx*dx, 0.25); // 4c: розв'язання тиску
  subtractGradient(velocity, pressure);     // крок 4d: проєкція
  advect(dye, velocity, dt);                // адвекція барвника
  renderDye(dye);
}
Вимога WebGL2: рендеринг у текстуру з плаваючою комою потребує або EXT_color_buffer_float (WebGL 1), або WebGL 2 з відповідним внутрішнім форматом (gl.RG32F чи gl.R32F). Перевірте gl.getExtension('EXT_color_buffer_float') і м'яко відступіть до half-float текстур (gl.RGBA16F) для підтримки на мобільних пристроях.

7. Адвекція барвника та візуалізація

Саме поле швидкостей невидиме. Дві популярні техніки візуалізації:

// Шейдер ротора — кольоровий прохід утримання завихреності
void main() {
  vec2 uv = gl_FragCoord.xy * uTexelSize;
  float vL = texture(uVelocity, uv - vec2(uTexelSize.x, 0)).y;
  float vR = texture(uVelocity, uv + vec2(uTexelSize.x, 0)).y;
  float uB = texture(uVelocity, uv - vec2(0, uTexelSize.y)).x;
  float uT = texture(uVelocity, uv + vec2(0, uTexelSize.y)).x;
  float curl = 0.5 * ((vR - vL) - (uT - uB));  // ∂v/∂x − ∂u/∂y
  gl_FragColor = vec4(curl, 0.0, 0.0, 1.0);
}

// Сила утримання завихреності — повертає вихорам енергію, протидіючи дисипації
void main() {
  vec2  uv  = gl_FragCoord.xy * uTexelSize;
  float wL  = abs(texture(uCurl, uv - vec2(uTexelSize.x, 0)).r);
  float wR  = abs(texture(uCurl, uv + vec2(uTexelSize.x, 0)).r);
  float wB  = abs(texture(uCurl, uv - vec2(0, uTexelSize.y)).r);
  float wT  = abs(texture(uCurl, uv + vec2(0, uTexelSize.y)).r);
  float w   = texture(uCurl, uv).r;
  vec2  eta = normalize(vec2(wT - wB, wR - wL) + 1e-5);
  vec2  force = eta * w * uConfinement * uTexelSize;
  vec2  vel = texture(uVelocity, uv).xy + force * uDt;
  gl_FragColor = vec4(vel, 0, 1);
}

8. Розширення та подальше читання

💧 Симуляція SPH-рідини

Підхід до динаміки рідини на основі частинок — без потреби розв'язувати рівняння Пуассона для тиску.

Відкрити SPH-рідину →