FluidSimulation 是一个 Unity 流体仿真实验。它最开始并不是为了做一段漂亮的水流预览,而是想把流体产生的浮力接进物理系统。后来沿着粒子法一路挖下去,项目最终同时做了基于 Unity DOTS + Burst 的 CPU 版本,和基于 Compute Shader 的 GPU 版本。

这类项目特别容易出现一种错觉:画面里粒子动起来了,好像就成功了。实际上粒子数量一多,邻居搜索、内存布局、边界处理和数值稳定性会一起跑出来敲门。所以这个项目更像一份“公式如何落到数据结构上”的实验记录,而不是单纯的动画 Demo。(:з」∠)
为什么选择 SPH
SPH(Smoothed Particle Hydrodynamics)从拉格朗日视角处理流体:每个粒子携带位置、速度、质量、密度等物理量,粒子移动时通过核函数估计周围的连续场。相比固定网格,粒子法不需要先把整个容器切成规则体素,处理液滴、复杂边界和流体与物体交互时更直观。
项目用的是 WCSPH。每一帧的核心流程粗略写成:
1 | 粒子位置 |
密度阶段用 Poly6 核,压强梯度用 Spiky 核,粘性用对应的 Laplacian 核。状态方程是 p = k(ρ - ρ₀),ρ₀ 是静止密度,k 是气体常数。压强通常限制为非负,免得低密度区域产生不符合当前实验设定的吸引效果。
选基于力的 SPH 还有个很现实的原因:项目的起点是浮力。粒子的压力、边界作用和合力可以继续进 Unity 的物理系统,给刚体提供反作用;如果只追求视觉稳定的 PBD / PBF,位置约束确实方便,但要把自然的力传给刚体就得额外建模了。
真正的瓶颈:邻居搜索
如果每个粒子都和其他所有粒子比较,邻居搜索就是 O(N²)。粒子数量从几百涨到几千时,公式本身可能还没来得及成为问题,遍历所有组合已经先把帧率拖到地板上了。
所以项目用了空间网格:先根据粒子位置算出它所在的网格坐标和 Hash,再只检查自身及周围 26 个格子。一个粒子只需要访问可能存在邻居的局部区域,不用扫描整个数组。
CPU 版本用 NativeMultiHashMap<uint, int> 把 Hash 映射到粒子索引;GPU 版本则实现了另一套连续数据通路,因为 GPU Compute Shader 用不了 Unity 的原生容器:
1 | 位置量化 |
空间哈希的重点不在 Hash 函数本身多神秘,而在于让后续邻居遍历变成连续内存区间。GPU 喜欢整齐、可预测的访问,MapTable 就是把“某个格子里有哪些粒子”从一个随机查找问题变成几个下标范围。
边界粒子:容器不是一堵贴图
只模拟流体粒子、没有边界约束的话,水会很快从容器里穿出去,或者在墙边表现得像一团失去方向的果冻。项目把流体粒子和边界粒子做成不同的 ECS Component。初始化系统从场景实体收集位置、速度、半径、质量、静止密度、粘度和气体常数;边界粒子不参与流体速度更新,但会参与密度与压力计算。
核半径按粒子半径的 4 倍计算,网格大小也由它决定。流体和边界粒子分别放进 NativeMultiHashMap<uint, int>,流体粒子找邻居时会同时看到容器边界,压力场因此能在靠墙区域形成排斥效果。
边界粒子的存在也让调试更具体:流体整体漂移?可能是重力或边界力方向错了。只在墙角爆炸?可能是邻居统计、核半径或局部密度出了问题。把边界当成真实参与者,比在画面外偷偷写一个“碰到墙就反弹”好解释多了。
CPU 版本:先把公式做成能调试的 Job 链
FluidSPHByCPUSystem 用 Burst 编译多个 Job。每帧先建空间网格,再通过 IJobParallelFor 并行处理流体粒子的密度和压强,之后计算压力、粘性和重力,最后由 ApplyForceJob 把合力写回实体的速度与位置。
Job 之间用 JobHandle 串接,临时的 HashMap、位置数组、压强数组在依赖完成后释放。这个顺序很重要:并行计算的性能不是把所有函数都加上 Schedule 就会自动出现的,读写依赖和容器生命周期没处理好,轻则结果不稳定,重则直接撞上安全检查或内存问题。
CPU 版本的意义还在于可观察性。公式、边界和力的方向都能逐粒子检查;调参数时可以先确认密度合不合理、压力有没有变负、粘性力有没有真的把速度差拉近。它未必是性能最好的版本,却是整个实验最可靠的参照物。
GPU 版本:把同一套物理放进 Compute Shader
GPU 版本把粒子和边界的位置、质量、速度、密度、压强与合力放进 ComputeBuffer。每帧依次执行 HashPosition、BitonicSort、ClearTable / MapTable、ComputeDensityAndPressure 和 ComputeForce 等 kernel。
HashPosition 根据位置量化网格并写入索引,Bitonic Sort 把相同 Hash 的粒子排到连续区域,MapTable 为每个格子建立起止下标。ComputeDensityAndPressure 遍历相邻格子,用 Poly6 计算密度并由 WCSPH 状态方程得到压强;ComputeForce 再算压力力、粘性力和重力。Unity 侧的 ECS 系统最后应用这些结果。
GPU 的难点不是把 C# 公式抄成 HLSL,而是重新设计数据布局和同步方式。CPU 可以靠容器和 Job 调度表达关系,GPU 则更在意 buffer 是否连续、kernel 之间的读写顺序和线程组规模。两套实现共享仿真参数和目标,但没法强行共享每一个数据结构。
数值稳定性:粒子爆炸通常不是“显卡不行”
粒子突然飞散、聚成尖刺或贴着边界抖动时,第一反应很容易怪 GPU。但 SPH 的结果同时受时间步长、平滑半径、粒子间距、静止密度、气体常数和粘性影响。压力太硬会放大微小的密度误差,时间步太大又会让一次更新跨过稳定范围。
所以我调试时一次只改一个参数,并记录密度范围、最大速度和边界粒子附近的压力,而不是只盯着画面看。先把粒子数量减小、固定边界和重力,再逐步打开交互力;最小场景都不稳定的话,换成 GPU 只会让错误发生得更快。
CPU 与 GPU 的结果也不该拿“每个粒子的位置完全一样”当唯一标准。并行归约顺序、浮点精度和线程调度都会带来细小差异,更合理的检查是密度统计、液面形状、能量趋势和长时间运行是否保持在可接受范围内。
内存布局决定了 GPU 版本能不能跑得动
GPU 版本每帧要维护粒子位置、质量、速度、密度、压强、合力和排序索引。每个 kernel 都重新分配 buffer 的话,计算还没开始,分配与同步就先吃掉大量时间;粒子数据在多个 buffer 之间来回复制,又会把并行计算的收益亏光。
所以 HashPosition、排序、MapTable、密度压力和合力计算之间要共享一套明确的 buffer 生命周期。排序后的索引让邻居访问集中,MapTable 只保存区间边界,后续 kernel 不需要重新建立“这个格子有哪些粒子”的关系。物理公式只是其中一部分,数据怎么流动同样决定性能。
为什么保留 CPU / GPU 两套实现
运行时会检查 SystemInfo.supportsComputeShaders 和 UseGPU 设置。硬件支持且用户打开 GPU 时走 Compute Shader,否则回退 CPU。这个回退让 Demo 在不同设备上都能跑,也给了两套实现互相校验的机会。
当然,CPU 与 GPU 的浮点执行顺序不完全相同,结果不会逐粒子、逐位一致。更靠谱的验证方式是比较整体密度范围、液面形状、边界稳定性和长时间运行趋势,而不是期待每一帧的像素都重合。数值模拟不是截图比对,容许误差本身就是问题的一部分。
交互与浮力:粒子不能只会自己播放
项目还实现了鼠标拾取和交互力。用户可以直接干扰粒子,观察压力场怎么变化,而不是只能看一段预录好的流体动画。交互力最好被当作外部力加进合力计算,和重力、压力、粘性走同一个物理入口,这样不同交互方式就不会各自偷偷修改位置。
浮力是项目的起点:当流体压力和边界作用能够被明确计算,才有机会把它们传给刚体。这里的目标不是做一套工业级流体求解器,而是让“粒子移动”“容器约束”“物体受力”之间存在可追踪的关系。
交互力也要注意施加位置和方向。鼠标拾取得到的是一个局部影响区域,力应该根据粒子与交互点的距离衰减,再加入统一的合力路径;直接改位置的话,画面可能看起来更“跟手”,但它绕开了压力、粘性和边界约束,之后很难解释系统为什么突然多出了能量。
这次实验留下的东西
FluidSimulation 最后让我记住的不是某个 Shader 名字,而是一个很朴素的顺序:先用容易验证的 CPU 版本把公式和边界做对,再为 GPU 重新设计索引与内存布局;先确认邻居搜索正确,再谈粒子数量和帧率;先理解浮力需要什么,再决定用哪种求解方法。
从浮力出发,绕到 SPH、DOTS、空间哈希、Bitonic Sort 和 Compute Shader,路径有点远,但每个拐弯都留下了可以继续实验的接口。粒子们还会继续乱跑,而我还会继续盯着它们问:你这次爆炸,到底是物理问题还是索引问题?(`・ω・´)
如果继续扩展,我会优先做三件事:给 CPU 和 GPU 共用一套小规模参考场景,记录每一步的密度和压力;为边界粒子增加更明确的可视化;再把邻居数量、最大速度和 kernel 耗时显示到调试面板。流体仿真很适合“看起来差不多”,但只有把中间量也显示出来,才知道它是真的在模拟,还是刚好动得像水。