每日编程实践: SPH Fluid Simulation 流体模拟
SPH Fluid Simulation — 流体模拟
水往低处流——这句话人人都知道,但计算机怎么”理解”水?
让它记住每个水分子的位置?不现实,一杯水有 $10^{25}$ 个分子。用格子把空间切开记录密度?可以,但不够灵活。今天用的是第三种方法:把水离散成一堆会动的粒子,粒子之间互相感知、互相推挤,集体涌现出流体行为。
这就是 SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学),一种用于电影水特效、工程爆炸模拟、宇宙演化计算的经典算法。
最终效果

颜色映射速度:🔵 蓝色 = 静止,🟡 黄色 = 中速运动,🔴 红色 = 高速冲击。
600 个粒子从左下角的方块初始状态,在重力作用下铺展、碰壁、最终沉底——整个过程模拟了约 2 秒的物理时间。
模拟序列——流体如何从方块变成摊开的液体
| t=0s(初始方块) | t=0.5s(开始扩散) | t=1.0s(持续铺展) | t=1.5s(接近稳定) | t=2.0s(最终状态) |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
第一章:为什么是”粒子”方法?
流体的两种描述方式
描述流体运动有两种经典视角:
欧拉视角(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 | static const float POLY6K = 4.0f / (PI * std::pow(H, 8)); // 归一化常数 |
注意:这里传入的是 $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 | static const float SPIKY_GRAD = -10.0f / (PI * std::pow(H, 5)); |
核函数三:Viscosity Laplacian——用于粘性力
$$\nabla^2 W_{visc}(r, h) = \frac{40}{\pi h^5}(h - r), \quad r < h$$
粘性力的计算需要的是速度场的拉普拉斯算子(二阶导数),它描述了一个点的速度与周围平均速度的差异。
为什么要专门设计:物理上,粘性力是耗散的——它让快的粒子变慢、慢的粒子变快,使速度趋向均匀,而绝不能注入能量。Viscosity 核的拉普拉斯值恒为正数,保证粘性力永远是耗散方向的。如果用其他核函数的二阶导,可能出现负值,粘性力反而会加速粒子,这在物理上是错误的。
直觉:粘性就像蜂蜜里的阻力——运动快的部分被”拖慢”,运动慢的部分被”带动”,最终大家速度趋于一致。
1 | static const float VISC_LAP = 40.0f / (PI * std::pow(H, 5)); |
第三章:从核函数到物理方程
密度计算
用 Poly6 核对周围粒子求和,得到每个粒子所在位置的局部密度:
$$\rho_i = \sum_j m_j W_{poly6}(|\mathbf{x}_i - \mathbf{x}_j|, h)$$
意思是:粒子 $i$ 的密度 = 所有邻居粒子的质量,按照距离用核函数加权求和。
1 | void compute_density_pressure(std::vector<Particle>& ps) { |
状态方程的直觉: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 | void compute_forces(std::vector<Particle>& ps) { |
压力梯度的直觉:想象一个充满人的房间突然一侧人很多(高密度=高压),人群会自然往人少的地方(低压区)扩散——这就是流体的压力驱动流动。
粘性力的直觉:相邻粒子速度不同时,快的粒子被慢的粒子”拖慢”,慢的被”带动”——这就是粘性的效果。水的粘性低(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 | void integrate(std::vector<Particle>& ps) { |
数值稳定性: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 | // 模拟坐标 → 像素坐标 |
第六章:速度颜色映射——让物理可见
1 | Color speed_color(float t) { |
粒子渲染时用高斯衰减做软边缘,避免硬圆点看起来像马赛克:
1 | 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 | GAS_CONST(气体刚度)↑ → 流体更"硬",更不可压缩 → 压力振荡更剧烈 → 需要更小的 DT |
第八章:算法局限与进阶方向
本实现的简化
O(N²) 复杂度:每个粒子遍历所有其他粒子。600个粒子时 $600^2 = 360,000$ 次计算,勉强可用。如果粒子数达到 10,000,计算量是 $10^8$,明显太慢。
生产级解法:空间哈希(Grid Hash)或 KD-Tree,将每步计算降至 $O(N \log N)$ 甚至 $O(N)$。
简单盒子边界:碰壁时速度简单反弹,没有真实的边界粒子。
改进:Ghost Particles(在边界外放置镜像粒子)或 Boundary Force(边界产生排斥力)。
弱可压缩(WCSPH):理想气体状态方程会导致密度轻微振荡,出现压力波动。
改进:PCISPH(预测-修正 SPH)或 IISPH(隐式不可压缩 SPH),可以真正保证流体不可压缩。
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 | Out-of-bounds: 0 / 600 ✅ 所有粒子在边界内 |
小结
今天实现的 SPH 流体模拟,核心思路是:
- 把连续流体离散成粒子,每个粒子携带位置、速度、密度、压力
- 用核函数加权求和计算局部物理量——Poly6(密度)、Spiky(压力梯度)、Viscosity(粘性)三个核函数各有分工
- Navier-Stokes 方程告诉我们每个粒子的加速度 = 压力驱动 + 粘性耗散 + 重力
- 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
















