流体模拟(Stable Fluids)

由 Rust + WebAssembly 实时求解不可压缩流体方程

🖱️ 拖拽 搅动流体并注入染料  |  提高粘度,流动会变得像蜂蜜一样黏稠

📖 原理说明

用 Jos Stam 提出的「稳定流体」(Stable Fluids)算法实时求解不可压缩流体的 Navier-Stokes 方程。整个画面是一个 N×N 的网格,每个格子保存速度和染料浓度;每一帧先更新速度场,再让染料随速度场流动。这个算法最大的特点是无论时间步长多大都不会数值爆炸,因此常被用在游戏和交互特效里。

🧮算法原理

∂u/∂t = −(u·∇)u + ν∇²u − ∇p + f动量方程:平流 + 粘性扩散 − 压力梯度 + 外力
∇·u = 0不可压缩:速度场无散度
扩散(Diffusion)
粘度让速度在相邻格子之间逐渐均匀化,染料也会慢慢晕开。这里用隐式方法求解:解线性方程组 (1 + 4a)·x − a·Σ邻居 = x₀,采用 20 次 Gauss-Seidel 迭代。隐式方法对任意步长都稳定。
平流(Advection)
每个格子的新值,是把这个格子「沿速度场倒着追溯 dt 时间」后,在出发点上双线性插值得到的旧值。这叫半拉格朗日法:只做插值、不做外推,数值永远不会超出原来的范围,所以不会爆炸。
投影(Projection)
扩散和平流之后,速度场一般会有「源」和「汇」(散度不为零),这不符合不可压缩流体的性质。算法先解压力的泊松方程 ∇²p = ∇·u,再从速度中减去压力梯度 u ← u − ∇p,得到无散度的速度场。这一步正是产生漩涡的关键。
边界条件
网格外面有一圈边界格子。在垂直的墙上,水平速度取相反数;在水平的墙上,垂直速度取相反数,流体因此会被墙壁反弹,而不是穿墙而过。

🔄Rust 与 JavaScript 的分工

  1. JSJS 记录鼠标拖拽的方向和速度,调用 fluid_add_velocity() 和 fluid_add_density() 在拖拽位置注入力和染料;开启「自动喷射」时,底部两个喷口会持续注入。
  2. Rust每一帧调用 fluid_step(dt, 粘度, 扩散, 消散):先处理速度(扩散 → 投影 → 平流 → 投影),再处理染料(扩散 → 平流),最后按消散系数衰减染料。
  3. Rustfluid_render() 把 N×N 的染料浓度映射成颜色(深色 → 蓝 → 橙 → 浅黄),返回 RGBA 字节数组。
  4. JSJS 把字节数组写入 N×N 的小 ImageData,再开启平滑缩放,绘制到 512×512 的画布上。

⚡性能要点

  • 每一步要做 3 次线性求解(速度扩散 2 次,染料扩散 1 次),投影里还有 2 次,每次 20 轮迭代,128×128 网格一帧大约是 5 × 20 × 16,384 ≈ 164 万次格子更新。
  • 计算量与网格格子数成正比:把分辨率从 128 调到 192,格子数是原来的 2.25 倍,底部显示的求解耗时也会相应上升。
  • 所有场都用 f32 的一维数组存储,按行连续排列,内层循环顺序访问内存,对缓存友好。

源码crates/simulation/fluid/src/lib.rswww/fluid/index.js