想象你在后厨熬一锅浓汤:木勺搅动,汤面旋起涡流,胡萝卜丁互相推挤又弹开,蒸汽贴着锅沿袅袅上升。如果要把这锅汤的每个动作搬进游戏,让玩家舀起、搅动、甚至泼向怪物,你需要一套叫“流体模拟”的算法。流体没有固定形状,给它一点力就持续变形流动,这既是它迷人的地方,也是计算的噩梦。工程师们想出了两条路:一条把锅切成棋盘格子,记录每一格汤的平均速度,这叫欧拉网格法;另一条给每粒食材装上追踪器,看它如何移动碰撞,这叫拉格朗日粒子法(SPH)。从开放世界的湖面到战斗时的血花,从火山口的浓烟到下水道的污水,流体模拟就是那口看不见的锅。本文不堆公式吓人,而是从一锅汤讲起,带你从零看懂流体模拟的原理、算法与工程优化。
掌握流体模拟的原理与优化手段,你就能在游戏与影视项目中独立设计逼真的水、烟、火效果并解决性能瓶颈——从“会调参数”升级为“懂物理”的核心技术人才。
可视化演示
展示SPH粒子流体、Euler网格方法与性能对比,支持敏感度调节。
本节配有交互动效(进入视口后自动加载,约 3KB)。
SPH粒子流体模拟 青色粒子表示流体单元,受重力与压力影响 蓝色连线表示粒子间邻居关系与密度计算 ρ_i = Σ m_j W(r_i - r_j, h) SPH通过核函数计算粒子密度与压力
流体特性入门
熬汤的第一课,是认清食材的脾性。豆腐是固体,刀切成什么样,下锅还是什么样;汤汁是流体,倒进圆碗成圆,倒进方盒成方。流体没有“记性”,只要持续受力就会不断变形流动,这正是它在游戏中如此迷人又如此难算的原因。游戏里的水花、烟雾、岩浆、墨汁,全是“捏不成形”的东西,统统归流体模拟管。
从技术的眼光看,游戏开发者只关心流体的三个核心特征:一是不可压缩性,水怎么挤体积都不缩水;二是粘性,蜂蜜流动缓慢、清水飞溅四射,差别全在粘性系数;三是涡旋,搅动后的汤会保持旋转,这是视觉上“像不像流体”的关键。围绕这三个特征,我们可以用一个无量纲数给流体“定性”——雷诺数:
由此,游戏流体模拟的目标可以总结为三条:视觉可信(看起来像水)、交互实时(玩家一碰就有反馈)、成本可控(必须在几十帧内算完)。一碗汤是“稀”还是“稠”,在游戏里就是调雷诺数——数字大,水花四溅;数字小,蜂蜜挂壁。
- 分子·惯性力ρ 是密度,U 是特征流速,L 是特征长度(比如漩涡直径),三者相乘代表“汤有多想保持运动”。
- 分母·粘性力μ 是动力粘度,代表“汤有多想停下来”;稠汤 μ 大,稀汤 μ 小。
- 比值定性Re 远大于 1 时惯性主导、水花四溅;Re 远小于 1 时粘性主导、蜂蜜挂壁。
控制方程解析
认识了食材,就要写菜谱。纳维-斯托克斯方程(Navier-Stokes,简称 NS 方程)就是流体的“牛顿第二定律”,碗里每一小团汤都严格服从 F=ma。这个方程把“速度如何变化”拆成四股力量的博弈:惯性让汤自己带着自己跑,压力把汤从高压处推向低压处,粘性让相邻汤层互相拖拽,外力(重力、搅拌棒)从外部推波助澜。
写成数学形式,就是我们常说的 NS 方程:
再配上一条“铁律”——不可压缩约束:汤的体积既不膨胀也不收缩,数学上要求速度场散度恒为零:
菜谱写好了,可惜 NS 方程没有解析解,必须把锅切成小份(离散化)后用计算机数值求解。切法不同,就分出了两大门派:欧拉网格法和拉格朗日粒子法。下一节我们就分别走进这两间厨房。
- 左端时间导数速度场随时间的变化率,即“这一小团汤下一秒怎么动”。
- 平流项汤自己带着自己跑——上一刻的流动把速度“搬运”到新位置,漩涡因此能够自转。
- 压力项高压往低压推,让拥挤处疏散;不可压缩性主要依靠它来维持。
- 粘性项邻层速度差带来的拖拽,把突变磨平、让能量耗散,蜂蜜的“黏糊感”就来自它。
- 外力项重力、搅拌棒推力、风力等一切外部作用。
- 不可压缩约束速度场散度恒为零,等价于“汤的体积不胀不缩”。
欧拉网格方法
做法一:给汤锅盖上一张标好格子的坐标纸,格子固定不动,我们只记录每一格汤的平均速度、密度和压力——这是“守株待兔”式的观察:汤流过格子,我们把读数记下来。网格上的每个格子只存一个“平均值”,所以计算量只取决于格子数量,与汤的细节无关。这就是欧拉网格法(Eulerian Grid Method)。
1999 年 Jos Stam 提出的“稳定流体”(Stable Fluids)把每步计算拆成三个动作:平流(汤带着自己的速度、密度等属性沿流线漂移到新位置)、扩散(粘性让邻格速度互相传染)、投影(修正速度场,让散度归零以满足不可压缩约束)。其中压力投影需要求解一个压力泊松方程:
欧拉网格法的拿手菜是烟、雾、云这类“充满整个空间”的流体——因为格子天然覆盖所有区域,无需关心“表面在哪”。它的短板是数值耗散:细节像被煮烂一样,剧烈的漩涡会被网格的平均效应磨平。想要更锐利的水花,就得请出下一节的粒子法。
- 压力拉普拉斯左端是压力场的二阶空间导数,刻画压力在空间中的“峰谷分布”。
- 散度源项右端以当前速度场的散度为“罪状”,散度越大,需要修正的压力越强。
- 投影修正解出 p 后,从速度中减去压力梯度项,速度场散度归零,流体恢复“不可压”。
粒子方法SPH
做法二:把汤想象成一颗颗小汤圆,每颗汤圆随身携带追踪器,记录自己的位置、速度和压力。汤圆们在锅里互相推挤、彼此拖拽、四处飞溅——这就是拉格朗日粒子法。其中最著名的是光滑粒子流体动力学(Smoothed Particle Hydrodynamics,简称 SPH)。
在 SPH 里,一颗汤圆的密度无法直接称重,只能“目测”:看看自己周围多大范围内聚着多少邻居。这个“视线范围”叫核半径 h,邻居的贡献按距离加权,权重由核函数 W 给出:
密度大意味着被挤得太紧,压力随之升高;压力差把汤圆们推向更空旷的地方。将压力沿核函数梯度方向施加,就得到粒子间的压力合力:
SPH 的天赋在于自由表面和大变形——水花飞溅、浪花翻卷、液体从杯沿溢出来,这些欧拉网格要费很大力气处理的场景,对粒子法来说只是家常便饭。缺点是邻居查找昂贵,压力计算容易抖动,需要小心调参。
- 对称压力括号内是两粒子压力按各自密度平方归一化后的和,保证作用力对称。
- 核梯度方向∇W_ij 指向“权重衰减最快”的方向,把力导向正确的作用线。
- 整体取负负号让力从高压粒子指向低压粒子,拥挤的汤圆被推开,流体因此“松软”而有弹性。
稳定性与边界
游戏里做汤,火候必须拿捏。时间步长是火候的表盘:步长太大,速度信息一个时间步内“穿越”好几个格子,数值直接发散成 NaN——锅炸了。步长太小,计算量翻倍,帧率崩盘。数学家给出了安全线,就是著名的 CFL 条件(柯朗-弗里德里希斯-列维条件):
实际操作中,游戏渲染帧率固定为 60fps,物理往往要拆成 2~4 个更小的子步来满足 CFL;同时还要处理边界:粒子撞上锅壁要反弹并损失速度(法向反转 + 切向摩擦),或者用距离势场把粒子“顶”回容器内部。一句话:不想让汤溢出来,锅沿(边界)和火候(时间步长)都要盯紧。
- 分子·传播距离u_max 是全场最大流速,乘时间步长 Δt,即一个步内信息能跑多远。
- 分母·最小单元Δx 是网格宽度或粒子核半径,代表数值方法能分辨的最小尺度。
- 库朗数判定C ≤ 1 时信息一步最多跨一个单元,数值稳定;C > 1 时信息跳过多个单元,解发散。
性能优化策略
汤圆成千上万,如果每颗都去遍历全场找邻居,复杂度是 O(N²),再强的 CPU 也会卡成幻灯片。聪明的做法像厨房里按楼层分抽屉存放食材:空间哈希(Spatial Hashing)把空间切成固定大小的桶,找邻居时只搜查中心桶及其周围 9 个桶,复杂度从 O(N²) 降到 O(N·k)——k 是平均邻居数,通常只有几十。
第二板斧是 GPU 并行:每颗汤圆的受力只依赖邻居,粒子之间天然独立,适合在显卡上几千个线程同时结算。不过并行有收益上限,阿姆达尔定律给出了天花板:
第三板斧是多分辨率简化(LOD):远处的水面用粗糙网格糊弄,近处的浪花才上高精度粒子;玩法区域的流体全精度模拟,背景的烟雾用便宜的大网格冒个意思。优化本质是“把力气花在玩家看得见的地方”——汤的精华,在表面那层油花上。
- 串行比例1 - f 是程序中无法并行的部分,无论多少核它都照跑一遍。
- 并行摊薄f / N 表示可并行部分被 N 个处理器瓜分后摊薄的开销。
- 极限加速比N 趋于无穷时,S 最多接近 1/(1 - f),瓶颈永远是串行部分。
游戏实战应用
最后把汤端上桌。设计上,流体最适合做“搅动式”互动:抹布擦水、刀切西瓜迸汁、角色踩水坑溅起浪花——玩家要的就是即时反馈的快感。系统上,流体需要与刚体物理(木块漂在水面、角色趟水减速)联动,通常的做法是给刚体一个额外的浮力和阻力。
视觉上,单纯一堆粒子穿模飞溅并不好看,需要叠加泡沫、浪花贴花和屏幕湿痕来“化妆”。性能上,每帧留给流体的预算通常只有 2~5 毫秒,成本可以用一个简单的模型估算:
实战经验还有三条:优先缩小核半径而非猛减粒子数(密度保不住会更难看);用固定时间步长躲避随机抖动;让流体系统与刚体物理共享碰撞层。一锅好汤,火候、食材、调料三者平衡——流体模拟的“火候”是时间步长,“食材”是粒子或网格,“调料”是各种参数。调好了,玩家会忍不住伸手搅一搅。
- 粒子总数 N汤圆总颗数,决定底盘成本,是最直观的预算项。
- 平均邻居 k核半径内的平均邻居数,空间哈希让它从 N 降到常数 30~60。
- 单对成本t_pair 是计算一对粒子交互的耗时,在 GPU 上可进一步摊薄。
AI 应用场景
强化学习训练场中的“物理汤锅”
阶段:训练环境 / 模拟数据生成
解决的问题:机器人学习倒水、搅拌、注液等操作时,真实实验成本高、重复性差、难以自动复位。用流体模拟搭建虚拟厨房,可以批量生成海量交互数据供强化学习 agent 练习。
具体案例:研究团队在虚拟场景中训练机械臂“倒红酒不洒”,环境内置 SPH 流体模拟,agent 每回合自动重置酒瓶和酒杯,用数千 GPU 小时训练出稳健的倒水策略,再迁移到真实机械臂上。游戏引擎里的流体模拟,在这里成了 AI 的“练兵场”。
流体参数的 AI 自动调参
阶段:模型调优 / 参数搜索
解决的问题:SPH 的核半径、粘度、粒子数、时间步长组合空间巨大,人工试错费时费力,且视觉质量与帧耗时互相牵制,很难手工调出最优配方。
具体案例:技术美术用贝叶斯优化自动搜索参数,目标函数 = 视觉相似度评分 + 帧耗时惩罚。周末让 AI 跑几百组模拟,周一直接拿到兼顾“好看”和“流畅”的参数组合——相当于让 AI 替你反复尝汤、调整咸淡。
流体谜题的智能通关测试
阶段:测试与验证
解决的问题:含流体谜题(堵漏、引流、灭火、排水)的关卡,状态空间巨大,人工测试难以覆盖所有解法和边界状态,流体穿模、粒子飞散等 bug 极难复现。
具体案例:测试 agent 在流体模拟环境中随机探索操作序列,自动发现“水穿透地板”“流速过大导致粒子爆炸”等缺陷,并生成可一键回放的复现录像。AI 测试员像一位不知疲倦的食客,把所有奇怪的喝汤姿势都试了一遍。
Python 演示
import mathimport random class Particle: """一颗汤圆: 记录位置/速度/密度/压力""" def __init__(self, x, y): self.x = x self.y = y self.vx = 0.0 self.vy = 0.0 self.density = 1.0 self.pressure = 0.0 class SPHSimulation: def __init__(self, count=100): self.particles = [ Particle(random.uniform(0.3, 0.7), random.uniform(0.3, 0.7)) for _ in range(count) ] self.h = 0.06 # 核半径: 汤圆的视线范围 self.mass = 0.01 # 每颗汤圆的质量 self.rest_density = 1.0 # 静止密度 self.gas_constant = 3.0 # 压力系数 self.viscosity = 0.15 # 粘性系数 self.dt = 0.0008 # 时间步长 def _kernel(self, r): """Poly6 核函数: 伞内邻居的权重函数""" if r >= self.h: return 0.0 q = self.h * self.h - r * r return 315.0 / (64.0 * math.pi * self.h ** 9) * q ** 3 def _kernel_grad(self, dx, dy): """Spiky 核函数的梯度: 压力力的方向来源""" r = math.hypot(dx, dy) if r <= 0.0 or r >= self.h: return 0.0, 0.0 s = self.h - r factor = -45.0 / (math.pi * self.h ** 6) * s * s / r return factor * dx, factor * dy def compute_density(self): for p in self.particles: total = 0.0 for q in self.particles: dx = p.x - q.x dy = p.y - q.y total += self.mass * self._kernel(math.hypot(dx, dy)) p.density = total p.pressure = self.gas_constant * (total - self.rest_density) def step(self): self.compute_density() for p in self.particles: ax, ay = 0.0, -9.8 for q in self.particles: if p is q: continue dx = p.x - q.x dy = p.y - q.y r = math.hypot(dx, dy) if r >= self.h or r <= 0.0: continue gx, gy = self._kernel_grad(dx, dy) pf = -self.mass * ( p.pressure / p.density ** 2 + q.pressure / q.density ** 2 ) ax += pf * gx ay += pf * gy w = self._kernel(r) vf = self.viscosity * self.mass * w / q.density ax += vf * (q.vx - p.vx) ay += vf * (q.vy - p.vy) p.vx = max(-30.0, min(30.0, p.vx + ax * self.dt)) p.vy = max(-30.0, min(30.0, p.vy + ay * self.dt)) p.x += p.vx * self.dt p.y += p.vy * self.dt if p.x < 0.05: p.x = 0.05 p.vx = abs(p.vx) * 0.5 if p.x > 0.95: p.x = 0.95 p.vx = -abs(p.vx) * 0.5 if p.y < 0.05: p.y = 0.05 p.vy = abs(p.vy) * 0.5 if p.y > 0.95: p.y = 0.95 p.vy = -abs(p.vy) * 0.5 def run(self, frames=100): for i in range(frames): self.step() if i % 20 == 0: cx = sum(p.x for p in self.particles) / len(self.particles) cy = sum(p.y for p in self.particles) / len(self.particles) print("frame=%d center=(%.3f, %.3f) density=%.3f" % (i, cx, cy, self.particles[0].density)) if __name__ == "__main__": sim = SPHSimulation(count=100) sim.run(frames=100)
total += self.mass * self._kernel(math.hypot(dx, dy))密度是核半径内所有邻居质量的加权和,伞内才算数。p.pressure = self.gas_constant * (total - self.rest_density)密度超过静止密度越多,压力越大,汤圆被挤得越“不高兴”。if r >= self.h or r <= 0.0: continue只和伞内邻居交互,这是 SPH 计算的局部性来源。pf = -self.mass * (p.pressure / p.density ** 2 + q.pressure / q.density ** 2)对称压力项,保证你推我我也推你。ax += pf * gx; ay += pf * gy压力沿核梯度方向把粒子推向低压区。vf = self.viscosity * self.mass * w / q.density粘性系数决定邻居之间的“拖拽”强度,蜂蜜就靠它变稠。ax += vf * (q.vx - p.vx)速度差越大拖拽越强,让相邻汤圆速度趋向一致。p.x += p.vx * self.dt显式时间积分,步长受稳定性限制,这就是 CFL 条件在代码里的样子。p.x = 0.05; p.vx = abs(p.vx) * 0.5撞墙反弹:法向速度反转并衰减一半,模拟能量损耗。
常见误区
误区我直接把粒子数从一万一口气加到十万,画面肯定更精致,毕竟是“分辨率”嘛。
正解粒子数只是“食材量”,密度、核半径、粘性和时间步长共同决定“口感”。盲目加粒子而不缩小核半径或调小步长,汤会越煮越稠、压力抖动剧烈,甚至直接数值爆炸。正确的姿势是先用 CFL 条件锁定步长,再平衡粒子数与核半径,最后才谈加量。
为何常见:“分辨率越高越清晰”在图像领域深入人心,大家惯性类推到物理模拟,忽略了数值稳定性这道隐形门槛。
误区游戏里烟雾都用网格法、水花都用粒子法,所以欧拉网格法天生不能做水。
正解欧拉网格模拟水完全可行,业界著名的 FLIP、APIC 方法就是“网格 + 粒子”的混合体——网格负责压力求解,粒子负责输运细节。传统纯网格法的短板只是难以锐利跟踪自由表面,需要用水平集等辅助手段,并非“不能做水”。
为何常见:很多教程把“欧拉=烟雾、SPH=水”当作速记口诀,大家看漏了混合方法这片更广阔的中间地带。
误区老师总说步长太大会发散,那我干脆把时间步长设成 1e-8,稳如老狗。
正解步长变小确实让显式积分更安全,但游戏每帧预算有限,步长减半等于计算量翻倍。聪明的做法是“对症下药”:平流和力用显式积分、老老实实受 CFL 约束;压力投影用隐式求解、允许较大步长;再把步长与渲染帧解耦,用子步推进。
为何常见:教材为了强调显式方法的危险性,经常把“步长必须小”绝对化,忽略了隐式方法这条路。
误区论文里的流体渲染那么逼真,我只要把物理算得足够精确,玩家一定买账。
正解游戏追求的是“可信 + 好玩 + 流畅”。真实水花算得再准,若一帧要 20 毫秒、还难以控制,玩家体验反而糟糕。业界常做夸张、简化、风格化处理:加大粘性防穿模、增强涡旋补细节、碰撞后额外发射溅射粒子——一切都是为了“看起来对”。
为何常见:看到 Siggraph 论文的高保真渲染,误以为工程目标就是逼近真实,忘了游戏是“体验优先”的商品。
面试题
Q1. 游戏流体模拟的两大主流数值方法分别是什么?
- A.粒子追踪法与碰撞检测法
- B.欧拉网格法与拉格朗日粒子法← 正确答案
- C.有限状态机与行为树算法
- D.蒙特卡罗法与贝叶斯估计法
答案:B。欧拉网格法守着固定格子记录“流过格子的汤”(连续介质视角),拉格朗日粒子法(SPH)给每颗汤圆装追踪器。A 不是标准流派名,C 是 AI 行为逻辑,D 是统计方法,均与流体数值方法无关。
Q2. 蜂蜜比清水更难流动,反映的是流体的什么属性?
- A.密度
- B.压强
- C.粘性← 正确答案
- D.温度
答案:C。蜂蜜流动慢是因为层与层之间的“拖拽”强,即粘性大。密度只关乎轻重,压强是挤压效应,温度通过改变粘性间接影响流动,都不是根本属性。
Q3. 下列关于流体特性的说法,正确的有?
- A.水可近似视为不可压缩← 正确答案
- B.粘性越大流体越易流动
- C.涡旋是受搅动后的旋转← 正确答案
- D.雷诺数越大越趋近层流
答案:AC。水在常规受力下体积几乎不变,可近似不可压缩(A 对);搅动产生涡旋(C 对)。B 说反了——粘性大反而更难流动;D 也说反了——雷诺数大对应湍流而非层流。
Q4. 不可压缩流体的连续性约束在数学上等价于?
- A.速度场的散度等于零← 正确答案
- B.速度场的旋度等于零
- C.压力场的梯度等于零
- D.密度场的拉普拉斯为零
答案:A。不可压缩即“体积不缩水”,数学上要求速度场散度为零 ∇·u=0。旋度为零是“无旋”,压力梯度为零意味着无压强差,拉普拉斯为零是调和场,都与不可压缩不等价。
Q5. 关于欧拉网格法中的压力投影步骤,正确的说法是?
- A.通过解泊松方程求压力场← 正确答案
- B.用来给粒子累加碰撞推力
- C.负责把流体渲染成高光
- D.用来删除网格中的空单元
答案:A。压力投影先求解压力泊松方程,再用压力梯度修正速度场,让散度归零。B 是粒子受力累加,属于 SPH 流程;C 是渲染技巧;D 与投影无关。
Q6. CFL 条件要求在每个时间步内?
- A.信息传播距离不超过网格尺寸← 正确答案
- B.粒子数量保持恒定不变
- C.压力场必须完全收敛稳定
- D.渲染帧率至少达到六十帧
答案:A。CFL 条件的本质是“一步的传播距离不大于网格尺度”,像火候过大汤会炸锅。B 是粒子数守恒,C 是压力收敛,D 是渲染帧率,都不是数值稳定性判据。
Q7. SPH 中某粒子的密度是如何获得的?
- A.用核函数对邻居粒子质量加权求和← 正确答案
- B.直接读取网格节点上的插值压力
- C.对速度场做拉普拉斯算子运算
- D.用有限差分求解压力泊松方程
答案:A。SPH 密度 = 邻居质量 × 核函数的加权和,核函数像一把“伞”,伞内邻居算、伞外不算。B 是网格法思路,C 是粘性算子,D 是压力投影,都不是 SPH 求密度的方式。
Q8. SPH 相对于欧拉网格法的优点包括?
- A.天然支持自由表面与大变形← 正确答案
- B.质量守恒由粒子恒存自然保证← 正确答案
- C.不需要任何邻居遍历运算
- D.完全不受时间步长稳定限制
答案:AB。粒子恒存自然守恒质量,自由表面也无需额外追踪(A、B 对)。SPH 恰恰需要邻居遍历(C 错),显式时间积分同样受 CFL 稳定性限制(D 错)。
Q9. 空间哈希加速 SPH 的基本思想是?
- A.把空间切桶只查邻居桶中的粒子← 正确答案
- B.用四叉树存储每个粒子的速度值
- C.每帧重排粒子数组并做基数排序
- D.让粒子只与固定编号的粒子作用
答案:A。空间哈希把空间切成固定桶,找邻居只查中心桶及周围 9 个桶,把 O(N²) 降为 O(N·k),正如按抽屉找食材。B 是四叉树维护速度,C 是排序优化而非哈希核心,D 的“固定编号”不符合物理规律。
Q10. 关于流体稳定性与边界处理,正确的说法有?
- A.显式平流满足 CFL 才避免数值发散← 正确答案
- B.隐式压力投影可放宽时间步长限制← 正确答案
- C.边界碰撞反弹需反转法向速度分量← 正确答案
- D.核半径越大邻居数量必然越少
答案:ABC。显式平流受 CFL 约束(A 对);隐式压力投影无条件稳定、可放宽步长(B 对);边界碰撞需反转法向速度模拟反弹(C 对)。核半径越大覆盖的邻居越多而非越少,D 恰好说反。