ZIC Blog

资料整理 01 · FLUID SIMULATION

如何写一个
FLIP 水体模拟器

把水放在粒子里运动,把不可压缩性放在网格里求解。二者往返一次,就是 FLIP 最迷人的地方。

本文是基于原教程的中文学习笔记与实现导读,并非逐字翻译。公式、结构与示例代码均为便于网页开发而重新表述;建议同时打开原文和演示阅读。

01 为什么要把粒子和网格放在一起

只用欧拉网格模拟流体,速度场很容易求解,却需要额外处理平流,细小的旋涡也会在反复插值中迅速消失。只用粒子,水花自然,但让密集粒子保持不可压缩又很困难。

FLIP(Fluid Implicit Particle)把职责拆开:粒子携带位置和速度,负责运动细节;交错网格暂时接收速度,负责压力投影与不可压缩约束。网格不是水本身,而是每一帧借给水的一张计算纸。

function step(dt: number) {
  integrateParticles(dt, gravity);
  pushParticlesOutOfObstacles();
  separateParticles();

  transferVelocities(true);   // 粒子 → 网格,并保存旧速度场
  updateParticleDensity();
  solveIncompressibility();
  transferVelocities(false);  // 网格变化量 → 粒子
}

02 空气不是速度为零的水

教程里最关键、也最容易被忽略的一点,是把空气当作“没有流体”,而不是速度为零的流体。水的密度约为 1000 kg/m³,空气约为 1 kg/m³;在这个二维演示中可以直接忽略空气,只处理含有粒子的非固体网格。

因此每帧要重新标记三类单元:固体、水与空气。空气单元之间的速度是未定义的,压力求解时既不能把它们当作零,也不应访问它们。这个边界决定了自由水面为什么能够自然出现。

for (const cell of grid) {
  cell.type = cell.solid ? SOLID : AIR;
}

for (const particle of particles) {
  const cell = grid.cellAt(particle.x, particle.y);
  if (!cell.solid) cell.type = FLUID;
}

03 粒子如何把速度交给网格

速度分量存放在交错网格上:水平速度 u 在单元左右边界,垂直速度 v 在上下边界。对每个粒子,找到围绕目标速度采样点的四个格点,再按双线性权重累加速度与权重,最后做归一化。

function splat(value: number, fx: number, fy: number) {
  const w00 = (1 - fx) * (1 - fy);
  const w10 = fx * (1 - fy);
  const w11 = fx * fy;
  const w01 = (1 - fx) * fy;

  add(0, 0, value * w00, w00);
  add(1, 0, value * w10, w10);
  add(1, 1, value * w11, w11);
  add(0, 1, value * w01, w01);
}

这里的偏移非常重要。采样 u 时网格在 y 方向偏移半格;采样 v 时在 x 方向偏移半格。偏移弄反,水仍然会动,却会出现难以解释的喷射、吸附和边角聚集。

04 让速度场不可压缩

一个水单元的散度,就是右侧流出减左侧流入,再加顶部流出减底部流入。正值表示水正在凭空变少,负值表示水被挤进同一个格子。压力求解的目标是把散度推回零。

for (let iteration = 0; iteration < pressureIters; iteration++) {
  for (const cell of fluidCells) {
    const div = uRight - uLeft + vTop - vBottom;
    const open = solidLeft + solidRight + solidBottom + solidTop;
    if (open === 0) continue;

    const correction = 1.9 * div / open; // 超松弛,加快收敛
    uLeft   += solidLeft   * correction;
    uRight  -= solidRight  * correction;
    vBottom += solidBottom * correction;
    vTop    -= solidTop    * correction;
  }
}

原演示使用 Gauss-Seidel 迭代,并用约 1.9 的超松弛系数加速收敛。固体相邻方向不参与分摊;浏览器边界、备案框以及我们时钟的拖拽框,本质上都可以写进同一张固体掩码。

05 PIC 与 FLIP 的差别只在回程

PIC 直接用网格插值得到的新速度覆盖粒子速度,非常稳定,却会快速抹去粒子之间的个体运动。FLIP 保存压力投影前的旧网格,把网格速度的变化量加回粒子,因此能保留水花、旋涡和惯性,也会带来更多噪声。

const pic = sample(grid.velocity, particle.position);
const before = sample(grid.previousVelocity, particle.position);
const flip = particle.velocity + (pic - before);

// 10% PIC 提供稳定性,90% FLIP 保留细节
particle.velocity = pic * 0.10 + flip * 0.90;

这也是 FLIP 的核心:粒子接收的不是“网格现在的速度”,而是“网格这一帧修正了多少”。改变 PIC/FLIP 混合比例,比另加突然静止、速度截断之类的补丁更符合模拟本身。

06 漂移、密度与粒子分离

速度场只能预测粒子是否将要碰撞,却看不到粒子已经重叠。长时间运行后,水会发生漂移:局部区域过密,另一些区域出现空洞。原教程采用两层修正。

第一层用邻域网格快速找到相近粒子,迭代地把它们推开;第二层把粒子密度写回单元中心,在压力散度中加入密度误差。初始水体的平均密度作为静止密度,过密区域会得到额外向外的压力。

const densityError = particleDensity[cell] - restDensity;
if (densityError > 0) {
  divergence -= stiffness * densityError;
}

粒子分离次数、物理半径、网格分辨率与粒子总量彼此耦合。半径变小不一定更细腻:邻域数量和压力迭代的工作量可能反而上升。网页端应先固定时间步,再按视口比例分配粒子预算。

07 把教程变成「逝者如斯」

本站时钟的底层遵循同一套 FLIP 循环,但把“水量”绑定到北京时间:午夜时粒子达到当天最大值,随后按剩余时间逐秒减少。被删除的是自由表面附近的粒子,因此水位缓慢下降,而不是整缸水均匀变稀。

浏览器边框是容器;时钟与备案号是固体;拖动图钉时,时钟框的速度被写入固体边界,于是水能被障碍真正推开。移动端的倾斜输入则改变重力方向。页面看到的像素水,只是同一组物理粒子的另一种渲染。