资料整理 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 循环,但把“水量”绑定到北京时间:午夜时粒子达到当天最大值,随后按剩余时间逐秒减少。被删除的是自由表面附近的粒子,因此水位缓慢下降,而不是整缸水均匀变稀。
浏览器边框是容器;时钟与备案号是固体;拖动图钉时,时钟框的速度被写入固体边界,于是水能被障碍真正推开。移动端的倾斜输入则改变重力方向。页面看到的像素水,只是同一组物理粒子的另一种渲染。