Урок
⏱️ ~70 хвилин 🎓 Просунутий 🛠️ GLSL · Three.js · WebGL FBO

Симуляція рідини на GPU за допомогою WebGL

Нестислива рідина в реальному часі виконується повністю на GPU. Кожен прохід симуляції — це повноекранний GLSL-шейдер, що записує у текстуру з плаваючою комою, без жодного циклу фізики на CPU. Цей урок охоплює конвеєр Нав'є-Стокса: адвекцію, зовнішні сили, розв'язання рівняння Пуассона для тиску, корекцію дивергенції та візуалізацію барвника за допомогою ping-pong рендер-таргетів у Three.js.

Передумови

Огляд конвеєра Нав'є-Стокса

Рівняння імпульсу для нестисливої рідини Нав'є-Стокса має вигляд:

∂u/∂t = −(u·∇)u − (1/ρ)∇p + ν∇²u + f

де u — поле швидкостей, p — тиск, ν — в'язкість, f — зовнішні сили. З умовою нестисливості ∇·u = 0.

Кожного кадру GPU виконує ці проходи по порядку:

1. Адвекція: u_new = advect(u, u, dt) — самоадвекція швидкості
2. Сили: u += f * dt — додавання гравітації, сплеску мишею, плавучості
3. Дивергенція: div = ∇·u — обчислення, скільки рідини «створюється/знищується»
4. Тиск: ∇²p = div — ітеративне розв'язання методом Якобі (20–50 ітерацій)
5. Проєкція: u -= ∇p — віднімання градієнта для забезпечення ∇·u = 0
6. Адвекція барвника: dye_new = advect(dye, u, dt) — рух кольору разом із потоком

Ping-pong рендер-таргети

Шейдер не може одночасно читати й записувати ту саму текстуру. Ping-pong використовує два FBO — з одного читають, поки в інший пишуть, а потім вони міняються місцями:

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

function createFBO(width, height) {
  return new THREE.WebGLRenderTarget(width, height, {
    type: THREE.FloatType,           // 32-бітний float на канал
    format: THREE.RGBAFormat,
    minFilter: THREE.LinearFilter,
    magFilter: THREE.LinearFilter,
    wrapS: THREE.ClampToEdgeWrapping,
    wrapT: THREE.ClampToEdgeWrapping,
  });
}

class DoubleFBO {
  constructor(w, h) {
    this.read  = createFBO(w, h);
    this.write = createFBO(w, h);
  }
  swap() { [this.read, this.write] = [this.write, this.read]; }
}

const SIM_RES = 256;
const velocity = new DoubleFBO(SIM_RES, SIM_RES);
const pressure = new DoubleFBO(SIM_RES, SIM_RES);
const dye      = new DoubleFBO(SIM_RES, SIM_RES);

Рендер-таргети типу FloatType потребують розширення WebGL OES_texture_float (доступне в усіх сучасних браузерах). Three.js вмикає його автоматично, коли ви запитуєте THREE.FloatType.

Шейдер адвекції

Напівлагранжева адвекція: «звідки прийшла ця частинка один крок часу тому?» — рухаємося полем швидкостей назад і семплимо там:

// фрагментний шейдер адвекції
const advectFrag = /* glsl */`
  uniform sampler2D uVelocity; // поле швидкостей, ЯКИМ адвектуємо
  uniform sampler2D uSource;   // поле, яке адвектуємо (швидкість або барвник)
  uniform float uDt;
  uniform vec2  uTexelSize;    // 1/SIM_RES для кожної осі

  varying vec2 vUv;

  void main() {
    // Читаємо швидкість у поточній комірці
    vec2 velocity = texture2D(uVelocity, vUv).xy;

    // Трасуємо назад у часі: prev_pos = current - velocity * dt
    vec2 prevUv = vUv - velocity * uDt * uTexelSize;

    // Семплимо вихідне поле в попередній позиції
    gl_FragColor = texture2D(uSource, prevUv);
  }
`;

Щоб виконати прохід шейдера: рендеримо повноекранний квад із цим шейдером, записуючи в цільовий FBO:

function runPass(material, targetFBO) {
  quad.material = material;
  renderer.setRenderTarget(targetFBO);
  renderer.render(quadScene, quadCamera);
  renderer.setRenderTarget(null);
}

advectMat.uniforms.uVelocity.value = velocity.read.texture;
advectMat.uniforms.uSource.value   = velocity.read.texture;
advectMat.uniforms.uDt.value       = dt;
runPass(advectMat, velocity.write);
velocity.swap();

Дивергенція та рівняння Пуассона для тиску

Обчислюємо дивергенцію (наскільки розширюється поле швидкостей), а потім ітеративно розв'язуємо рівняння для тиску методом Якобі:

// Шейдер дивергенції — читає швидкість, записує скаляр div у канал .r
const divergenceFrag = /* glsl */`
  uniform sampler2D uVelocity;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float L = texture2D(uVelocity, vUv - vec2(ts.x, 0)).x;
    float R = texture2D(uVelocity, vUv + vec2(ts.x, 0)).x;
    float B = texture2D(uVelocity, vUv - vec2(0, ts.y)).y;
    float T = texture2D(uVelocity, vUv + vec2(0, ts.y)).y;
    float div = 0.5 * (R - L + T - B);
    gl_FragColor = vec4(div, 0, 0, 1);
  }
`;

// Шейдер ітерації Якобі для тиску — виконується 30–50 разів
const pressureFrag = /* glsl */`
  uniform sampler2D uPressure;
  uniform sampler2D uDivergence;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float L = texture2D(uPressure, vUv - vec2(ts.x, 0)).r;
    float R = texture2D(uPressure, vUv + vec2(ts.x, 0)).r;
    float B = texture2D(uPressure, vUv - vec2(0, ts.y)).r;
    float T = texture2D(uPressure, vUv + vec2(0, ts.y)).r;
    float div = texture2D(uDivergence, vUv).r;
    // Jacobi: p_new = (L+R+B+T - div) / 4
    float pNew = (L + R + B + T - div) * 0.25;
    gl_FragColor = vec4(pNew, 0, 0, 1);
  }
`;

// Виконуємо 40 ітерацій Якобі
for (let i = 0; i < 40; i++) {
  pressureMat.uniforms.uPressure.value = pressure.read.texture;
  runPass(pressureMat, pressure.write);
  pressure.swap();
}

Проєкція (віднімання градієнта тиску)

Віднімаємо градієнт тиску від швидкості, щоб зробити поле бездивергентним:

const projectFrag = /* glsl */`
  uniform sampler2D uVelocity;
  uniform sampler2D uPressure;
  uniform vec2 uTexelSize;
  varying vec2 vUv;
  void main() {
    vec2 ts = uTexelSize;
    float pL = texture2D(uPressure, vUv - vec2(ts.x, 0)).r;
    float pR = texture2D(uPressure, vUv + vec2(ts.x, 0)).r;
    float pB = texture2D(uPressure, vUv - vec2(0, ts.y)).r;
    float pT = texture2D(uPressure, vUv + vec2(0, ts.y)).r;
    vec2 gradP = vec2(pR - pL, pT - pB) * 0.5;
    vec2 vel = texture2D(uVelocity, vUv).xy;
    gl_FragColor = vec4(vel - gradP, 0, 1);
  }
`;

Додавання сил — взаємодія мишею

«Бризкаємо» силами та барвником на симуляцію, коли користувач рухає мишею:

const splatFrag = /* glsl */`
  uniform sampler2D uTarget;
  uniform vec2  uPoint;    // нормалізована позиція миші [0,1]
  uniform vec3  uColor;    // вектор сили (xy) або колір барвника (xyz)
  uniform float uRadius;
  varying vec2 vUv;
  void main() {
    float d = distance(vUv, uPoint);
    float splat = exp(-d * d / uRadius);
    vec4 current = texture2D(uTarget, vUv);
    gl_FragColor = current + vec4(uColor * splat, 0);
  }
`;

// Викликаємо на mousemove, щоб додати швидкість + барвник
function onMouseMove(e) {
  const x = e.clientX / innerWidth;
  const y = 1.0 - e.clientY / innerHeight; // інвертуємо y
  const dx = (e.clientX - prevMouseX) / innerWidth  * 15.0; // масштабуємо до швидкості симуляції
  const dy = (e.clientY - prevMouseY) / innerHeight * 15.0;

  splatMat.uniforms.uPoint.value.set(x, y);
  splatMat.uniforms.uRadius.value = 0.0015;

  // Сплеск швидкості
  splatMat.uniforms.uColor.value.set(dx, -dy, 0);
  splatMat.uniforms.uTarget.value = velocity.read.texture;
  runPass(splatMat, velocity.write); velocity.swap();

  // Сплеск барвника
  splatMat.uniforms.uColor.value.set(0.3, 0.7, 1.0); // блакитний барвник
  splatMat.uniforms.uTarget.value = dye.read.texture;
  runPass(splatMat, dye.write); dye.swap();
}

Перенесення та відображення барвника

Адвектуємо текстуру барвника бездивергентним полем швидкостей, а потім відображаємо її:

// Кожного кадру:
// 1. Адвекція швидкості
advectMat.uniforms.uSource.value = velocity.read.texture;
runPass(advectMat, velocity.write); velocity.swap();

// 2. Додавання сил (якщо натиснута миша)

// 3. Обчислення дивергенції
runPass(divergenceMat, divFBO);

// 4. Розв'язання тиску (40 ітерацій Якобі)
for (let k = 0; k < 40; k++) { /* ... */ }

// 5. Проєкція
projectMat.uniforms.uVelocity.value = velocity.read.texture;
runPass(projectMat, velocity.write); velocity.swap();

// 6. Адвекція барвника
advectMat.uniforms.uVelocity.value = velocity.read.texture;
advectMat.uniforms.uSource.value   = dye.read.texture;
runPass(advectMat, dye.write); dye.swap();

// 7. Відображення барвника на екран
displayMat.uniforms.uTexture.value = dye.read.texture;
renderer.setRenderTarget(null);
renderer.render(quadScene, quadCamera);

Повноцінну робочу реалізацію дивіться у симуляції рідини на цьому сайті — вона використовує саме цей конвеєр із роздільністю 512×512 та curl-шумовим примусом для автономного руху.

Продовжуйте навчання

🛠

Експериментуйте у Playground

Розширте цю симуляцію рідини — пишіть і запускайте код Three.js безпосередньо у браузері.

Відкрити Playground → Переглянути симуляцію ↗