使用Godot游戏引擎解释Navier-Stokes流体模拟
摘要
一篇博客教程,解释如何在Godot游戏引擎中实现Navier-Stokes流体模拟,包含代码和数学解释,供学习使用。
暂无内容
查看缓存全文
缓存时间: 2026/05/30 16:27
# 用 Godot 游戏引擎解释纳维-斯托克斯流体模拟 ·
来源:https://myzopotamia.dev/navier-stokes-fluid-simulation-explained-with-godot
2026年5月19日当我第一次在游戏开发中偶然发现流体模拟时,我被效果之好惊呆了。我真的很想学习这是如何工作的,但关于这个主题的学习材料出奇的稀少——而且我找到的那些都相当难懂。尽管如此,我还是决定尝试一下;而且——既然要做——为什么不写一篇博客文章出来,希望能让下一个人更容易理解呢?
在开始之前,我想强调几点:
- 我不是数学家。如果你在我的解释中发现错误,请在 Bluesky 上给我发私信或发送电子邮件,我会进行修正。
- 这个实现仅供学习目的。因此,它的实现方式在性能上并不是最优的。所有计算都在 CPU 上完成。我们引入了太多的变量。这一切都是为了使其更易于阅读和学习,而不一定是为了挤出最高的帧率。
我使用的学习资料:
- Jos Stam 的《实时游戏流体动力学》(PDF)
- Mike Ash 的《流体模拟傻瓜书》(https://www.mikeash.com/pyblog/fluid-simulation-for-dummies.html)
你可以在以下仓库中找到所有代码:github.com/rskupnik/godot-fluid-simulation-demo (https://github.com/rskupnik/godot-fluid-simulation-demo)
我使用 git 提交来标记与这篇博客文章章节对应的代码检查点,所以如果你不想边读边写代码,可以利用提交视图 (https://github.com/rskupnik/godot-fluid-simulation-demo/commits/master/) 来跟随。为了方便起见,我在每章都包含了一个“项目快照”和一个“差异”链接,分别指向:讨论时的代码库和提交差异视图。
AI 声明:这篇博客文章的每一个字和这个代码库的每一行代码都是我写的。所有图和视频都是我自己创作的。AI 仅用于研究。
最后,如果你喜欢这样的工作,可以考虑给我买杯咖啡 :)
---
## 基础
我们将使用的算法基于流体流动的物理方程——纳维-斯托克斯方程。我们的用例是游戏开发,所以我们希望牺牲这些计算的精度以换取速度。效果需要足够好,但不要过于昂贵。我们通过三种方式实现这一点。首先,我们使用一个相对较小的网格,单元格较大。其次,我们以任意时间步长推进模拟。最后,我们使用近似方程(例如高斯-赛德尔松弛)来为某些方程得到足够好的解。
让我先从数学描述开始,描述我们将在本篇博文中做什么。这个描述可能听起来令人生畏,但别担心——我们会在过程中解释一切。开始:我们将通过在一个向量速度场中移动一个标量密度场来模拟流体流动。我们将模拟速度扩散和平流以及密度扩散和平流。然后我们将添加速度投影,目的是使流体遵守质量守恒定律——这将通过使用压力场平衡散度来实现。我们将在需要的地方使用双线性插值和高斯-赛德尔松弛来近似值。
好了,废话不多说,让我们开始吧!
---
## 旅程从网格开始
项目快照 (https://github.com/rskupnik/godot-fluid-simulation-demo/tree/0a755f8eb80a56cb990f68b95356f38445770bb3)
创建一个新的 Godot 项目并添加一个 Node2D。我把它叫做“FluidGrid”。为它附加一个脚本。所有代码都放进那个脚本里。
首先,让我们定义网格:
``
@export var N := 16
@export var cell_size := 32
var size := 0
``
这里,`N` 是每行和每列的单元格数量——基本上是网格的大小——而 `cell_size` 是单个单元格的像素大小。我们还定义了 `size`,我们很快就会初始化它。我们将其设置为 N+2,因为我们还需要边界。
接下来,让我们添加用于存储实际数据的数组。
``
# 密度表示“这个单元格包含多少物质”
var density: PackedFloat32Array
var density_prev: PackedFloat32Array
# “u”存储水平速度(x 方向)
var u: PackedFloat32Array
var u_prev: PackedFloat32Array
# “v”存储垂直速度(y 方向)
var v: PackedFloat32Array
var v_prev: PackedFloat32Array
``
目前,我们需要三个数组——`density` 将存储密度,`u` 将存储水平速度,`v` 将存储垂直速度。
密度告诉我们给定单元格包含多少物质——它的范围在 `0.0` 到 `1.0` 之间,0 表示空,1 表示满。技术上,它可以超过 1.0,但我们只显示到 1.0。我们显示密度的方法很简单——用颜色。充满密度的单元格将是全白的,而没有密度的单元格则是完全透明的。
速度数组也存储浮点数,它们描述给定单元格的速度。速度为 0 表示没有移动,然后它可以为正或负,分别对应向右或向左(水平方向)和向下或向上(垂直方向)。结合起来,它们告诉我们给定单元格的速度。我们可以将速度存储为一个 `Vector2f` 数组,但如果我们将它们分开为两个浮点数数组,计算起来会容易得多。在显示这些信息时——我们将为每个单元格绘制小的蓝色箭头。
你可能还会奇怪为什么每个数组都有一个 `_prev` 等价物——这是因为对于某些计算,我们将遍历真实数组并实时修改数据,在这些情况下,我们需要在开始迭代之前“快照”数据,这样我们就能知道在我们开始修改之前值是什么。这主要用于近似方程。我遵循 Stam 论文的命名约定使用 `_prev` 这个名称,尽管我认为 `_snapshot` 会是一个更具描述性的名称。
好了,我们继续。是时候初始化所有这些了!
``
func _ready():
# 正确调整所有数组的大小
# 我们使用一维数组来存储网格,这就是为什么我们需要乘以 N
# 添加 “+2” 是为了边界,因为每个维度(x 和 y)都有两个边界
# 对于 x 维度,左边和右边各有一个单元格边界,因此 “+2”。y 方向同理
size = (N + 2) * (N + 2)
density.resize(size)
density_prev.resize(size)
u.resize(size)
v.resize(size)
u_prev.resize(size)
v_prev.resize(size)
queue_redraw()
``
我们可以使用二维数组,但使用一维数组会更简单。我们只需要一个辅助函数来更容易地为这个数组建立索引。
``
# 这是一个辅助函数,使处理打包成一维数组的网格更容易
# 我们可以用单元格索引(i 和 j)调用它,它会将其转换成一维数组中的索引
func IX(i: int, j: int) -> int:
return i + (N + 2) * j
``
有了这些,我们现在可以实现 `_draw()` 函数来显示网格。
``
# 这是用于绘制的标准 Godot 函数
# 我们想要绘制一个简单的 (N+2)*(N+2) 的矩形网格,大小为 cell_size
func _draw():
for j in range(0, N + 2):
for i in range(0, N + 2):
var x := i * cell_size # 这将索引转换为屏幕上的像素位置
var y := j * cell_size
var rect := Rect2(x, y, cell_size, cell_size)
var is_boundary := i == 0 or j == 0 or i == N + 1 or j == N + 1
var fill := Color(0.16, 0.08, 0.08) if is_boundary else Color(0.08, 0.08, 0.08)
draw_rect(rect, fill, true)
draw_rect(rect, Color(0.35, 0.35, 0.35), false)
``
这很好理解。我们遍历网格并将每个单元格绘制为一个简单的 `Rect2`,稍微改变边界单元格的颜色。
你现在可以运行项目,你应该会看到:
---
## 将“流体”放入“流体模拟”
项目快照 (https://github.com/rskupnik/godot-fluid-simulation-demo/tree/12e1eaa8f03e3b8addba5c688478de65f717e9de)
差异 (https://github.com/rskupnik/godot-fluid-simulation-demo/commit/12e1eaa8f03e3b8addba5c688478de65f717e9de)
现在我们有了网格,让我们向其中添加一些流体。
我们将从非常简单开始——我们将能够通过用鼠标点击单元格来向其中添加密度。然后我们将让密度慢慢消失——这在以后会很有用,这样我们就可以进行实验,而无需让网格充满流体并需要重新启动。
``
# 这个辅助函数将我们点击鼠标的位置
# 转换为单元格坐标
# 所以如果我们点击网格中的某个位置,它将返回一个 Vector2i,其中
# 第一个元素是该网格中单元格在 x 维度上的索引
# 另一个元素是该单元格在 y 维度上的索引
func cell_from_mouse(pos: Vector2) -> Vector2i:
return Vector2i(floor(pos.x / cell_size), floor(pos.y / cell_size))
# 这是用于处理输入的标准 Godot 函数
# 我们想要检测鼠标点击并将密度注入到点击的单元格中
# 密度表示为一个浮点数,存储在 “density” 数组中
func _input(event):
if event is InputEventMouseButton and event.pressed:
# 找出被点击的单元格
var cell := cell_from_mouse(to_local(event.position))
var i := cell.x
var j := cell.y
if i >= 1 and i <= N and j >= 1 and j <= N:
density[IX(i, j)] += 1.0 # 向单元格注入密度
queue_redraw() # 告诉 Godot 重绘网格
``
现在我们需要绘制密度数组中的内容。转到 `_draw` 函数,将 `var fill := Color...` 行改为这样(如果困惑,请参阅上面的 diff 链接)。
``
var fill := Color(0.08, 0.08, 0.08)
if is_boundary:
fill = Color(0.16, 0.08, 0.08)
else:
# 即使密度可以超过 1.0,我们也需要将其限制在 0.0 到 1.0 之间以进行绘制
var d : float = clamp(density[IX(i, j)], 0.0, 1.0)
fill = Color(d, d, d)
``
我们使用 `IX()` 函数来索引密度数组,并用它来确定单元格中颜色的“强度”。目前,每次鼠标点击会向单元格注入 `1.0` 的密度,所以该单元格应该变成白色。
这是效果
---
## 消失吧,消失吧,消失吧
项目快照 (https://github.com/rskupnik/godot-fluid-simulation-demo/tree/3fef7f109084e026b0111aea47e83715c54302ba)
差异 (https://github.com/rskupnik/godot-fluid-simulation-demo/commit/3fef7f109084e026b0111aea47e83715c54302ba)
如前所述,我们想添加一个简单的淡化效果,使密度慢慢消失——以避免以后堵塞我们的网格。
让我们从一个控制此效果强度的简单变量开始:
``
@export var density_fade_rate := 0.1
``
现在,该实现 `fade_density()` 函数了
``
# 随着时间流逝淡化密度
func fade_density(delta: float) -> void:
for j in range(1, N + 1):
for i in range(1, N + 1):
var idx := IX(i, j)
# 我们需要将密度消退的速率乘以 delta
# 以使其不受帧率影响
density[idx] = max(0.0, density[idx] - density_fade_rate * delta)
``
在这个函数中,我们遍历每个单元格,并将该单元格中的密度减少量,由密度消退速率乘以时间增量决定。如果你来自游戏开发世界,你可能对 `delta` 很熟悉,但为了完整起见——这个变量由 Godot 引擎本身提供,它包含自上一帧绘制以来经过的时间。它旨在用于各种方程式中,以模拟时间的流逝或将某些效果绑定到用户的帧率。
最后,我们需要每帧调用这个 `fade_density()` 函数,我们将使用 Godot 的标准 `_process()` 函数来实现
``
# 这是每帧调用的标准 Godot 函数
# 它是我们模拟的核心
# “delta” 变量保存自上一帧以来经过的时间
# 目前我们用它来慢慢淡化密度
func _process(delta: float) -> void:
fade_density(delta)
queue_redraw()
``
由于我们现在每帧都修改密度数组(通过淡化它),我们还需要每帧重绘网格,这就是 `queue_redraw()` 的作用。再次强调——这是 Godot 的内置函数。
此时,你应该能够点击单元格添加密度,并看到它慢慢消失
---
## 是时候来些箭头了
项目快照 (https://github.com/rskupnik/godot-fluid-simulation-demo/tree/ac5b28b58f084a8a39b5751f033cb052f36f8209)
差异 (https://github.com/rskupnik/godot-fluid-simulation-demo/commit/ac5b28b58f084a8a39b5751f033cb052f36f8209)
好了,既然我们可以看到密度了,是时候也可视化速度了。
从一个控制箭头比例的变量开始
``
@export var velocity_draw_scale := 20.0
``
这可以用来让箭头更美观。它不影响速度本身,只影响我们绘制箭头的缩放因子。想要更大的箭头?增加参数。箭头太大?缩小它们!同样重要的是注意,这个参数在任何方面都不影响模拟——它纯粹是视觉上的。
我们的速度场目前在每个单元格中初始化为 `0.0`——所以让我们硬编码一些临时速度来确保绘制工作正常。你应该在此步骤结束时删除它们。
在 `_ready()` 中添加这些
``
# 临时
# 在这个单元格中,我们将水平速度设置为 1.0,垂直速度设置为 0.0
# 结果:指向右方的水平箭头
u[IX(8, 8)] = 1.0
v[IX(8, 8)] = 0.0
# 在这个单元格中,我们将水平速度设置为 0.0,垂直速度设置为 -1.0
# 结果:指向上方的垂直箭头
# 记住在 Godot 中,y 轴从上到下,因此 -1.0 指向上
u[IX(9, 8)] = 0.0
v[IX(9, 8)] = -1.0
# 在这个单元格中,我们将水平速度设置为 1.0,垂直速度设置为 -1.0
# 结果:指向右上方(对角线)的垂直箭头
# 记住在 Godot 中,y 轴从上到下,因此 -1.0 指向上
u[IX(10, 8)] = 1.0
v[IX(10, 8)] = -1.0
``
好的,现在我们需要一个函数来绘制箭头。在此之前——将当前的 `_draw()` 函数重命名为 `_draw_grid()`。然后在下面添加 `_draw_velocity_arrows()`。
``
# 在内部单元格中绘制速度箭头,箭头尖端为小圆圈
func _draw_velocity_arrows():
for j in range(0, N + 2):
for i in range(0, N + 2):
var is_boundary := i == 0 or j == 0 or i == N + 1 or j == N + 1
if not is_boundary:
var idx := IX(i, j)
var center := Vector2(
i * cell_size + cell_size * 0.5,
j * cell_size + cell_size * 0.5
)
var velocity := Vector2(u[idx], v[idx])
var end := center + velocity * velocity_draw_scale
draw_line(center, end, Color(0.2, 0.8, 1.0), 2.0)
draw_circle(end, 2.5, Color(0.2, 0.8, 1.0))
``
在这个函数中,我们定义了一个 `Vector2`,它由该单元格中的水平速度(`u` 数组)和垂直速度(`v` 数组)组成。然后我们定义了一个 `center` 变量,它表示单元格的中心,也是速度箭头的起点;以及一个 `end` 变量,它使用了我们的 `velocity_draw_scale` 参数。然后我们将 `center` 和 `end` 都传递给 `draw_line`(这是一个 Godot 函数),它告诉 Godot 绘制一条从 `center` 开始到 `end` 结束的线。由于我们使用 `velocity_draw_scale` 缩放 `end`,箭头会根据我们设置的 `velocity_draw_scale` 的大小而成比例地变长或变短。最后,我们在末端绘制一个小圆圈,作为某种“箭头点”。
好了,但我们仍然需要调用这个函数。让我们恢复 `_draw()`,定义如下
``
func _draw():
_draw_grid()
_draw_velocity_
相似文章
流体模拟入门指南
基于Jos Stam论文的实时3D流体模拟分步教程,面向程序员,注重实际编码而非繁重的物理和数学。
一种全GPU工作流:构建高超声速流动物理仿真器
本文介绍了一种全GPU工作流,通过可微分求解器(JAX-Fluids)和基于残差的精化方法加速高超声速流动神经仿真器的数据生成与训练,提高训练分布之外的物理一致性和可靠性。
godotengine/godot
Godot Engine 是一个免费、开源、跨平台的游戏引擎,用于创建2D和3D游戏,采用社区驱动的开发模式,基于MIT许可。
@techartist_: 交互式Alpha斯特林发动机模拟,具有动画运动学、热驱动运动和粒子气体动力学。Co…
一个使用ChatGPT和Three.js构建的交互式Alpha斯特林发动机模拟,具有动画运动学、热驱动运动和粒子气体动力学。
发现流体动力学百年难题的新解决方案
DeepMind 研究人员利用 AI 技术在基础流体动力学方程中发现了新的不稳定奇点族,有望推动对纳维-斯托克斯方程等百年数学难题的理解。该项工作与布朗大学、纽约大学和斯坦福大学合作,以前所未有的计算精度揭示了爆炸行为的规律。