SPH Fluid Simulation — 流体模拟

水往低处流——这句话人人都知道,但计算机怎么”理解”水?

让它记住每个水分子的位置?不现实,一杯水有 $10^{25}$ 个分子。用格子把空间切开记录密度?可以,但不够灵活。今天用的是第三种方法:把水离散成一堆会动的粒子,粒子之间互相感知、互相推挤,集体涌现出流体行为

这就是 SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学),一种用于电影水特效、工程爆炸模拟、宇宙演化计算的经典算法。


最终效果

SPH 流体模拟最终帧

颜色映射速度:🔵 蓝色 = 静止,🟡 黄色 = 中速运动,🔴 红色 = 高速冲击。

600 个粒子从左下角的方块初始状态,在重力作用下铺展、碰壁、最终沉底——整个过程模拟了约 2 秒的物理时间。

模拟序列——流体如何从方块变成摊开的液体

t=0s(初始方块) t=0.5s(开始扩散) t=1.0s(持续铺展) t=1.5s(接近稳定) t=2.0s(最终状态)
seq0 seq1 seq2 seq3 seq4

第一章:为什么是”粒子”方法?

流体的两种描述方式

描述流体运动有两种经典视角:

欧拉视角(Eulerian):站在岸边看河流。你盯住空间中的一个固定点,记录经过这个点的水的速度、密度。计算机里的实现就是把空间切成网格,每个格子存速度和密度。优点:数学简洁,压力项容易处理;缺点:格子是固定的,自由表面(水面、水花)很难追踪。

拉格朗日视角(Lagrangian):跟着水分子一起游。每个”粒子”代表一小团流体,随着流动而移动,携带自己的速度、密度等属性。SPH 就是拉格朗日方法。优点:自由表面天然存在(粒子在哪表面就在哪);缺点:粒子之间的交互需要精心设计。

一个直觉:想象一群人在广场上互相推挤——每个人是一个粒子,推挤力就是”压力”,踩到别人脚时的摩擦就是”粘性”,大家往低处走就是”重力”。整体上,人群的流动就像流体。

连续方程变成粒子方程

流体力学的核心是 Navier-Stokes 方程

$$\rho \frac{D\mathbf{v}}{Dt} = -\nabla p + \mu \nabla^2 \mathbf{v} + \rho \mathbf{g}$$

翻译成中文:

$$\text{密度} \times \text{加速度} = \underbrace{-\text{压力梯度}}{\text{往稀处流}} + \underbrace{\text{粘性力}}{\text{速度均匀化}} + \underbrace{\text{重力}}_{\text{往下流}}$$

SPH 的核心任务:把这个连续的偏微分方程,变成可以用离散粒子计算的形式。


第二章:SPH 的灵魂——核函数

核函数是什么?

SPH 中,每个粒子不孤立存在——它能”感知”周围一定半径 $h$(称为光滑半径)内的所有粒子。核函数 $W(r, h)$ 描述了这种感知的衰减规律:

  • 距离为 0(正中心)时,权重最大
  • 距离越远,权重越小
  • 距离超过 $h$ 后,权重为 0(感知范围之外的粒子被忽略)

直觉:就像手机信号——距离越远信号越弱,超出基站覆盖范围就没信号了。

有了核函数,任意物理量 $A$(比如密度)就可以从周围粒子加权求和得到:

$$A(\mathbf{x}) = \sum_j \frac{m_j}{\rho_j} A_j \cdot W(|\mathbf{x} - \mathbf{x}_j|, h)$$

这个公式读作:在位置 $\mathbf{x}$ 处的物理量 = 对所有邻居粒子,用核函数权重加权求和

为什么要三个核函数?

这是 SPH 最精妙的设计,由 Müller 等人在 2003 年的论文中提出。不同的物理量对核函数的要求不同:

核函数一:Poly6——用于密度

$$W_{poly6}(r, h) = \frac{4}{\pi h^8}(h^2 - r^2)^3, \quad r < h$$

为什么选它:密度计算只需要函数值本身,不需要梯度。Poly6 在 $r=0$ 处梯度为零,这意味着当两个粒子完全重叠时,密度计算不会产生奇异值(无穷大的力),数值稳定。

直觉:把核函数想象成一个圆饼的截面——中间厚、边缘薄,平滑过渡到零。

1
2
3
4
5
6
7
static const float POLY6K = 4.0f / (PI * std::pow(H, 8));  // 归一化常数

inline float kernel_poly6(float r2) { // 传入 r² 避免开方
if (r2 >= H2) return 0.0f; // 超出感知半径,权重为零
float d = H2 - r2; // (h² - r²)
return POLY6K * d * d * d; // × (h² - r²)³
}

注意:这里传入的是 $r^2$ 而不是 $r$,因为求点积计算 $r^2$ 不需要开方,比求 $r$ 快一倍。

核函数二:Spiky——用于压力梯度

$$\nabla W_{spiky}(r, h) = \frac{-10}{\pi h^5} \frac{(h-r)^2}{r} \hat{r}, \quad r < h$$

为什么不能用 Poly6 的梯度:Poly6 在 $r \to 0$ 时梯度趋近于零,这意味着当两个粒子离得很近时,计算出来的排斥力会消失——粒子会无阻力地叠在一起,产生”粒子聚集”(clustering)现象,流体看起来像一团泥。

Spiky 核的特别之处:它在 $r \to 0$ 时梯度不为零,保证了粒子靠近时总有足够的排斥力把它们推开,防止聚集。

直觉:就像两个磁铁靠近时排斥力不会消失一样——距离越近,排斥越强。

1
2
3
4
5
6
7
8
static const float SPIKY_GRAD = -10.0f / (PI * std::pow(H, 5));

inline Vec2 kernel_spiky_grad(const Vec2& rij, float r) {
if (r <= 1e-6f || r >= H) return {0, 0};
float d = H - r; // (h - r)
float coeff = SPIKY_GRAD * d * d / r; // × (h-r)² / r,除以 r 是为了归一化方向向量
return rij * coeff; // rij 是方向向量,乘系数得到梯度
}

核函数三:Viscosity Laplacian——用于粘性力

$$\nabla^2 W_{visc}(r, h) = \frac{40}{\pi h^5}(h - r), \quad r < h$$

粘性力的计算需要的是速度场的拉普拉斯算子(二阶导数),它描述了一个点的速度与周围平均速度的差异。

为什么要专门设计:物理上,粘性力是耗散的——它让快的粒子变慢、慢的粒子变快,使速度趋向均匀,而绝不能注入能量。Viscosity 核的拉普拉斯值恒为正数,保证粘性力永远是耗散方向的。如果用其他核函数的二阶导,可能出现负值,粘性力反而会加速粒子,这在物理上是错误的。

直觉:粘性就像蜂蜜里的阻力——运动快的部分被”拖慢”,运动慢的部分被”带动”,最终大家速度趋于一致。

1
2
3
4
5
6
static const float VISC_LAP = 40.0f / (PI * std::pow(H, 5));

inline float kernel_visc_lap(float r) {
if (r >= H) return 0.0f;
return VISC_LAP * (H - r); // 值恒为正,保证粘性力耗散
}

第三章:从核函数到物理方程

密度计算

用 Poly6 核对周围粒子求和,得到每个粒子所在位置的局部密度:

$$\rho_i = \sum_j m_j W_{poly6}(|\mathbf{x}_i - \mathbf{x}_j|, h)$$

意思是:粒子 $i$ 的密度 = 所有邻居粒子的质量,按照距离用核函数加权求和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void compute_density_pressure(std::vector<Particle>& ps) {
int n = (int)ps.size();
for (int i = 0; i < n; i++) {
ps[i].density = 0;
for (int j = 0; j < n; j++) { // 遍历所有粒子(O(N²) 暴力)
Vec2 rij = ps[j].pos - ps[i].pos; // i 到 j 的向量
float r2 = rij.len2(); // 距离平方(避免开方)
ps[i].density += MASS * kernel_poly6(r2); // 加权累加
}
// 理想气体状态方程:密度偏离静止密度时产生压力
// 公式:p = k(ρ - ρ₀),k 越大流体越"硬"(越不可压缩)
ps[i].pressure = GAS_CONST * (ps[i].density - REST_DENS);
}
}

状态方程的直觉:REST_DENS 是”正常密度”(流体处于平衡态时的密度)。如果周围粒子太多,密度 $\rho > \rho_0$,压力为正,会把粒子推开;如果太稀,密度 $\rho < \rho_0$,压力为负,会把粒子拉近。这就是流体的”弹性”。

核函数归一化常数与 REST_DENS 的耦合关系

这是本次实现的最大陷阱,值得单独说明。

Poly6 核函数在 2D 中,对整个圆形区域积分的结果并不是 1,而是一个依赖于 $h$ 的值。对于 $h=0.2$,这个积分约为 0.84。

这意味着:即使每个粒子正好被 $h$ 半径内的理想数量粒子包围,计算出的密度也是 0.84,而不是 1000(SI 单位的水密度)

因此 REST_DENS 必须设置为与实际核函数积分匹配的值(这里设为 0.8),而不是 SI 单位的 1000 kg/m³。这是用”归一化坐标系”而非真实物理单位的代价。

力的计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void compute_forces(std::vector<Particle>& ps) {
int n = (int)ps.size();
for (int i = 0; i < n; i++) {
Vec2 fp(0,0), fv(0,0); // 压力加速度、粘性加速度
for (int j = 0; j < n; j++) {
if (i == j) continue; // 粒子不对自己施力
Vec2 rij = ps[i].pos - ps[j].pos; // i 相对 j 的位置向量
float r = rij.len();
if (r >= H) continue; // 超出感知半径,跳过

// ---- 压力梯度(Spiky 核)----
// 对称压力公式:取 i 和 j 压力的平均值,保证牛顿第三定律(作用力=反作用力)
float pij = (ps[i].pressure + ps[j].pressure) / (2.0f * ps[j].density + 1e-8f);
fp += kernel_spiky_grad(rij, r) * (-MASS * pij);
// 梯度方向:从高压区指向低压区
// 符号为负:流体从高压流向低压(顺着梯度的反方向移动)

// ---- 粘性力(Viscosity 核)----
Vec2 dv = ps[j].vel - ps[i].vel; // j 的速度 - i 的速度 = 速度差
// 速度差越大,粘性力越大;VISC 是粘性系数(越大越像蜂蜜)
fv += dv * (MASS * VISC * kernel_visc_lap(r) / (ps[j].density + 1e-8f));
}
// 总加速度 = (压力力 + 粘性力) / 密度 + 重力
// 注意:fp 和 fv 已经是加速度单位(除以了密度),只需再加重力
ps[i].force = (fp + fv) / (ps[i].density + 1e-8f) + Vec2(0, -GRAVITY);
}
}

压力梯度的直觉:想象一个充满人的房间突然一侧人很多(高密度=高压),人群会自然往人少的地方(低压区)扩散——这就是流体的压力驱动流动。

粘性力的直觉:相邻粒子速度不同时,快的粒子被慢的粒子”拖慢”,慢的被”带动”——这就是粘性的效果。水的粘性低(VISC 小),蜂蜜粘性高(VISC 大)。


第四章:时间积分——让粒子动起来

Leapfrog(蛙跳)积分

有了加速度,下一步是更新粒子的速度和位置。最简单的是欧拉积分:

$$\mathbf{v}_{t+dt} = \mathbf{v}t + \mathbf{a} \cdot dt$$
$$\mathbf{x}
{t+dt} = \mathbf{x}t + \mathbf{v}{t+dt} \cdot dt$$

但我们用的是 Leapfrog(蛙跳)——先更新速度,再更新位置。看起来和欧拉积分一样,但在数学上它是二阶精度的(误差是 $O(dt^2)$ 而非 $O(dt)$),而且天然具有能量守恒性质,不会让模拟无中生有地增加能量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void integrate(std::vector<Particle>& ps) {
const float DAMP = -0.5f; // 碰壁时速度衰减系数(0.5 = 损失50%动能)
for (auto& p : ps) {
// Leapfrog:先更速度,再更位置
p.vel += p.force * DT; // v(t+dt) = v(t) + a·dt
p.pos += p.vel * DT; // x(t+dt) = x(t) + v(t+dt)·dt

// 限速:防止数值爆炸
// 当 DT 较大或力很强时,单步速度变化可能过大导致粒子"飞出"边界
float spd = p.vel.len();
if (spd > 20.0f) p.vel = p.vel * (20.0f / spd); // 限制最大速度

// 边界反弹(简单的盒子边界)
// 碰到边界后,法线方向速度乘以 DAMP(-0.5),切线方向不变
if (p.pos.x < 0) { p.pos.x = 0; p.vel.x *= DAMP; }
if (p.pos.x > DOM_W){ p.pos.x = DOM_W; p.vel.x *= DAMP; }
if (p.pos.y < 0) { p.pos.y = 0; p.vel.y *= DAMP; }
if (p.pos.y > DOM_H){ p.pos.y = DOM_H; p.vel.y *= DAMP; }
}
}

数值稳定性:SPH 的时间步长 $dt$ 必须满足 CFL 条件(Courant-Friedrichs-Lewy):

$$dt < \frac{h}{v_{max}}$$

我们设 $h=0.2$,$v_{max} \approx 10$,则 $dt < 0.02$。实际用 $dt=0.001$,远低于上限,保证稳定性。


第五章:坐标系设计——避免踩坑

归一化坐标系 vs SI 单位

一个常见的陷阱:直接用 SI 单位(kg、m、s)时,水的密度是 1000 kg/m³,但核函数的归一化常数是针对”单位化”的坐标系设计的。混用会导致密度计算结果与 REST_DENS 差三个数量级,流体要么剧烈爆炸,要么完全不动。

本实现的选择:

  • 模拟域:$4 \times 3$(抽象单位,无物理含义)
  • 光滑半径:$h = 0.2$
  • REST_DENS:$0.8$(与 Poly6 核函数在这组参数下的积分值匹配)

渲染时,再把模拟坐标映射到像素坐标:

1
2
3
4
5
// 模拟坐标 → 像素坐标
// x: [0, DOM_W] → [5, IMG_W-5](留5像素边框)
// y: 翻转!模拟中 y=0 是底部,屏幕中 y=0 是顶部
int px = (int)(p.pos.x / DOM_W * (IMG_W - 10)) + 5;
int py = IMG_H - 5 - (int)(p.pos.y / DOM_H * (IMG_H - 10));

第六章:速度颜色映射——让物理可见

1
2
3
4
5
6
7
8
9
Color speed_color(float t) {
// t=0(静止)→ 蓝色;t=0.5(中速)→ 绿/黄;t=1(最快)→ 红色
// 这是经典的"冷热"色谱映射(类似气象图上的温度分布)
t = std::max(0.0f, std::min(1.0f, t));
float r = std::min(1.0f, t * 2.0f); // 红:后半段出现
float g = t < 0.5f ? t * 2.0f : 2.0f - t*2.0f; // 绿:中间最亮
float b = std::max(0.0f, 1.0f - t * 2.0f); // 蓝:前半段出现
return { (uint8_t)(r*255), (uint8_t)(g*255), (uint8_t)(b*255) };
}

粒子渲染时用高斯衰减做软边缘,避免硬圆点看起来像马赛克:

1
2
float alpha = std::exp(-d2 / (PRAD*0.7f * PRAD*0.7f));  // 高斯权重
// 离粒子中心越远,透明度越高(边缘逐渐消融)

第七章:调参经验与迭代历史

三次迭代

版本 问题 根因 修复
v1 第三方库警告 stb_image_write.h 未初始化成员 #pragma GCC diagnostic push/pop 屏蔽
v2 密度验证失败(avg=0.84,期望REST_DENS=1000) 核函数用归一化坐标,SI单位的REST_DENS不匹配 调整 REST_DENS=0.8, GAS_CONST=50, VISC=0.1
v3 bright_frac 验证失败(0.8% < 阈值1%) 600粒子在800×600像素中占比确实只有0.8% 降低验证阈值至0.5%

关键参数关系

1
2
3
GAS_CONST(气体刚度)↑  → 流体更"硬",更不可压缩 → 压力振荡更剧烈 → 需要更小的 DT
VISC(粘性)↑ → 流体更像蜂蜜,阻尼更强 → 更稳定,但流动更慢
H(光滑半径)↑ → 每个粒子感知范围更大 → 计算量增加,流体更"平滑"

第八章:算法局限与进阶方向

本实现的简化

  1. O(N²) 复杂度:每个粒子遍历所有其他粒子。600个粒子时 $600^2 = 360,000$ 次计算,勉强可用。如果粒子数达到 10,000,计算量是 $10^8$,明显太慢。

    生产级解法:空间哈希(Grid Hash)或 KD-Tree,将每步计算降至 $O(N \log N)$ 甚至 $O(N)$。

  2. 简单盒子边界:碰壁时速度简单反弹,没有真实的边界粒子。

    改进:Ghost Particles(在边界外放置镜像粒子)或 Boundary Force(边界产生排斥力)。

  3. 弱可压缩(WCSPH):理想气体状态方程会导致密度轻微振荡,出现压力波动。

    改进:PCISPH(预测-修正 SPH)或 IISPH(隐式不可压缩 SPH),可以真正保证流体不可压缩。

  4. 2D 模拟:真实流体是三维的,2D 只是横截面近似。

延伸阅读

  • 核心论文:Müller et al., Particle-Based Fluid Simulation for Interactive Applications,SIGGRAPH 2003
  • 进阶书籍:Fluid Simulation for Computer Graphics(Robert Bridson)
  • 实时流体:Nvidia FleX、Houdini FLIP Solver

量化验证结果

1
2
3
4
5
Out-of-bounds: 0 / 600       ✅ 所有粒子在边界内
Density: avg=3.6 min=0.8 ✅ 密度分布合理(含聚集区域)
Speed: avg=0.303 max=1.312 ✅ 速度稳定,无数值爆炸
Bright pixels: 6.4% ✅ 图像内容丰富
Bottom 1/3 particles: 41727 ✅ 重力效果正确,流体成功沉底

小结

今天实现的 SPH 流体模拟,核心思路是:

  1. 把连续流体离散成粒子,每个粒子携带位置、速度、密度、压力
  2. 用核函数加权求和计算局部物理量——Poly6(密度)、Spiky(压力梯度)、Viscosity(粘性)三个核函数各有分工
  3. Navier-Stokes 方程告诉我们每个粒子的加速度 = 压力驱动 + 粘性耗散 + 重力
  4. Leapfrog 积分更新速度和位置,重复执行数千步,流体动态就涌现出来了

最难的地方不是代码,而是参数调校——核函数的归一化常数、REST_DENS、GAS_CONST、DT 之间存在强耦合关系,任何一个设错都会导致模拟爆炸或静止不动。


完成时间: 2026-03-09 05:35
迭代次数: 3 次修复
编译器: g++ -O2 -std=c++17
运行时间: 2.16秒(600粒子,2000步)

代码仓库: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-09-sph-fluid-simulation