Урок · Просунутий рівень · ~75 хв
SPH · Симуляція рідини · Canvas 2D

Симуляція рідини SPH з нуля у 200 рядках

Гідродинаміка згладжених частинок (SPH) представляє рідину як набір частинок, що взаємодіють через інтерполяцію зі зважуванням за ядром. Без сітки, без скінченних різниць — лише частинки, сили та три магічні ядрові функції. Цей урок будує повноцінний 2D-розв'язувач SPH менш ніж у 200 рядках JavaScript із рендерингом у контекст Canvas 2D.

1Ядра SPH — Poly6, Spiky, Viscosity

SPH інтерполює будь-яку величину A в точці r як зважену суму по сусідах у межах радіуса h:

// Радіус згладжування const H = 30; // px — усі сусіди мають бути в межах цієї відстані const H2 = H * H; // Ядро Poly6 — для оцінки щільності (гладке навіть при r=0) function Wpoly6(r2) { if (r2 >= H2) return 0; const x = H2 - r2; return 4 / (Math.PI * H2 * H2 * H2 * H2) * x * x * x; // точний коефіцієнт: 4/(pi * h^8), але ми вкладаємо його у вибір REST_DENSITY } // Градієнт ядра Spiky — для сил ТИСКУ (ненульовий при r=0, протидіє злипанню) function gradWspiky(rx, ry, r) { if (r <= 0 || r >= H) return { x: 0, y: 0 }; const coeff = -30 / (Math.PI * H2 * H2 * H2) * (H - r) * (H - r) / r; return { x: coeff * rx, y: coeff * ry }; } // Лапласіан ядра Viscosity — для сил В'ЯЗКОСТІ (додатний, розсіює швидкість) function lapWviscosity(r) { if (r >= H) return 0; return 40 / (Math.PI * H2 * H2 * H2) * (H - r); }
Навіщо три різні ядра? Ядро Poly6 гладке в початку координат — добре для щільності. Ядро Spiky має ненульовий градієнт навіть на дуже малих відстанях, тож сили тиску завжди розштовхують частинки, що перекриваються. Лапласіан в'язкості додатний усюди, що гарантує, що в'язкість завжди гасить відносну швидкість.

2Оцінка щільності

const REST_DENSITY = 300; // цільова щільність (приблизно кг/м², у одиницях частинок) const MASS = 1; // маса частинки function computeDensities(particles) { for (const pi of particles) { let density = 0; for (const pj of particles) { const dx = pi.x - pj.x, dy = pi.y - pj.y; const r2 = dx*dx + dy*dy; density += MASS * Wpoly6(r2); } pi.density = Math.max(density, REST_DENSITY * 0.01); // уникаємо нуля } }

3Сила тиску

Тиск виводиться зі щільності за рівнянням стану. Слабкостисливий SPH використовує константу жорсткості:

const STIFFNESS = 200; // тиск = жорсткість * (щільність - щільність_спокою) function computePressure(p) { p.pressure = STIFFNESS * (p.density - REST_DENSITY); } function pressureForce(pi, pj) { const dx = pi.x - pj.x, dy = pi.y - pj.y; const r = Math.sqrt(dx*dx + dy*dy); if (r === 0 || r >= H) return { x: 0, y: 0 }; // Симетрична сила тиску (3-й закон Ньютона) const pressure = (pi.pressure + pj.pressure) / 2; const grad = gradWspiky(dx, dy, r); return { x: -MASS * pressure / pj.density * grad.x, y: -MASS * pressure / pj.density * grad.y }; }

4Сила в'язкості

const VISCOSITY = 200; function viscosityForce(pi, pj) { const dx = pi.x - pj.x, dy = pi.y - pj.y; const r = Math.sqrt(dx*dx + dy*dy); if (r >= H) return { x: 0, y: 0 }; const lap = lapWviscosity(r); const dvx = pj.vx - pi.vx; const dvy = pj.vy - pi.vy; return { x: VISCOSITY * MASS / pj.density * dvx * lap, y: VISCOSITY * MASS / pj.density * dvy * lap }; } // Поєднуємо всі сили function computeForces(particles, gravity = 500) { for (const pi of particles) { pi.fx = 0; pi.fy = 0; computePressure(pi); } for (let i = 0; i < particles.length; i++) { const pi = particles[i]; for (let j = i + 1; j < particles.length; j++) { const pj = particles[j]; const pf = pressureForce(pi, pj); const vf = viscosityForce(pi, pj); pi.fx -= pf.x + vf.x; pi.fy -= pf.y + vf.y; pj.fx += pf.x + vf.x; pj.fy += pf.y + vf.y; } pi.fy += gravity * pi.density; // гравітація пропорційна щільності } }

5Інтегрування та обробка меж

const DT = 0.003, DAMPING = -0.5; const W = 800, HEIGHT = 600; // розмір полотна function integrate(particles) { for (const p of particles) { // Напівнеявний (симплектичний) метод Ейлера p.vx += DT * p.fx / p.density; p.vy += DT * p.fy / p.density; p.x += DT * p.vx; p.y += DT * p.vy; // Зіткнення з межами if (p.x < H) { p.x = H; p.vx *= DAMPING; } if (p.x > W - H) { p.x = W - H; p.vx *= DAMPING; } if (p.y < H) { p.y = H; p.vy *= DAMPING; } if (p.y > HEIGHT - H) { p.y = HEIGHT - H; p.vy *= DAMPING; } } } // Рендеринг function render(ctx, particles) { ctx.clearRect(0, 0, W, HEIGHT); ctx.fillStyle = 'rgba(0,10,30,1)'; ctx.fillRect(0, 0, W, HEIGHT); for (const p of particles) { const speed = Math.sqrt(p.vx*p.vx + p.vy*p.vy); const t = Math.min(speed / 300, 1); ctx.fillStyle = `hsl(${200 - t*140}, 80%, ${40 + t*30}%)`; ctx.beginPath(); ctx.arc(p.x, p.y, H * 0.3, 0, Math.PI*2); ctx.fill(); } }

6Просторове хешування для пошуку сусідів за O(N)

Наївний цикл пошуку сусідів за O(N²) стає непрактичним вже понад ~500 частинок. Просторове хешування створює сітку, де кожна комірка зберігає індекси частинок, які вона містить. Тоді пошук сусідів відвідує лише 9 найближчих комірок:

class SpatialHash { constructor(cellSize) { this.cs = cellSize; this.table = new Map(); } key(x, y) { return `${Math.floor(x/this.cs)},${Math.floor(y/this.cs)}`; } clear() { this.table.clear(); } insert(p) { const k = this.key(p.x, p.y); if (!this.table.has(k)) this.table.set(k, []); this.table.get(k).push(p); } neighbours(p, radius) { const result = []; const cells = Math.ceil(radius / this.cs); const cx = Math.floor(p.x / this.cs), cy = Math.floor(p.y / this.cs); for (let dx = -cells; dx <= cells; dx++) for (let dy = -cells; dy <= cells; dy++) { const bucket = this.table.get(`${cx+dx},${cy+dy}`); if (bucket) result.push(...bucket); } return result; } } // Використання у computeForces: замініть внутрішній цикл на // const neighbours = spatialHash.neighbours(pi, H); // for (const pj of neighbours) { ... } // Часова складність: O(N * k), де k = сер. кількість сусідів (~20–50)
З просторовим хешуванням 2 000 частинок симулюються на 60 FPS в однопотоковому браузері. Для 10 000+ частинок перенесіть розв'язувач у Web Worker або портуйте на обчислювальні шейдери WebGL за допомогою transform feedback чи підходу з текстурним ping-pong.