在计算机的虚拟世界里,让一滩水流动起来可比教猫咪游泳简单多了。今天我们就来揭开流体模拟的神秘面纱,看看那些在屏幕上奔腾的浪花、缭绕的烟雾,究竟是如何被数学公式驯服的。
流体模拟的数学基石:Navier - Stokes 方程
想象一下,你往平静的湖面扔了一颗石子,水波会一圈圈扩散开去。这看似简单的现象,背后却藏着一套复杂的数学法则,这就是 Navier - Stokes 方程。它就像流体世界的宪法,规定了每一个流体粒子的行为准则。
这套方程主要由几个部分组成。首先是连续性方程 ,它保证了流体不会凭空出现或消失,就像我们的银行账户余额,支出和收入总得平衡。然后是动量方程,它描述了流体如何在力的作用下改变运动状态,你可以把它理解成流体的 "牛顿第二定律"。
动量方程里藏着几个关键角色。第一个是惯性项 ,它体现了流体 "想保持原来运动状态" 的顽固脾气,就像坐在匀速行驶的汽车里,突然刹车时你会往前倾一样。第二个是压力项 ,流体内部的压力差会推动流体流动,比如挤牙膏时,牙膏会从压力大的地方跑到压力小的地方。第三个是粘性项 ,它反映了流体内部的摩擦力,蜂蜜之所以流动得慢,就是因为粘性大。最后还有外力项,最常见的就是重力,它让水总是往低处流。
用计算机驯服流体:离散化的魔法
计算机可不懂什么连续的方程,它只认识一个个离散的数字。所以我们要把连续的流体分割成无数个小格子,就像切蛋糕一样,每个格子里的流体状态都可以用几个数字来表示,比如速度、密度等。这个过程就叫做离散化。
我们可以把流体所在的区域想象成一个二维网格,每个网格点都有两个速度分量:水平方向和垂直方向。密度则代表了流体在这个格子里的 "浓度",比如烟雾越浓,密度值就越大。
JS 实现流体模拟:让像素动起来
下面我们用 JavaScript 来实现一个简单的 2D 烟雾模拟。这个模拟会用到前面说的 Navier - Stokes 方程的简化版本。
首先,我们需要创建一个网格来存储流体的状态:
arduino
class FluidSimulation {
constructor(width, height) {
this.width = width;
this.height = height;
// 密度数组,记录每个格子的烟雾浓度
this.density = new Array(width * height).fill(0);
// 速度数组,u是水平方向速度,v是垂直方向速度
this.u = new Array(width * height).fill(0);
this.v = new Array(width * height).fill(0);
// 临时数组,用于计算过程中存储中间结果
this.tempDensity = new Array(width * height).fill(0);
this.tempU = new Array(width * height).fill(0);
this.tempV = new Array(width * height).fill(0);
}
}
接下来,我们需要实现添加烟雾的功能,就像在平静的水面上滴一滴墨汁:
kotlin
// 在指定位置添加烟雾
addDensity(x, y, amount) {
const index = y * this.width + x;
if (index >= 0 && index < this.density.length) {
this.density[index] += amount;
}
}
然后是模拟流体流动的核心部分,我们需要依次处理方程中的各个项。首先处理扩散,让烟雾慢慢散开,这就像一滴墨在水里逐渐变淡的过程:
ini
// 扩散过程
diffuse(d, temp, diffusionRate, dt) {
const a = dt * diffusionRate * (this.width - 2) * (this.height - 2);
for (let i = 1; i < this.width - 1; i++) {
for (let j = 1; j < this.height - 1; j++) {
const index = j * this.width + i;
temp[index] = d[index] + a * (
d[index - 1] + d[index + 1] +
d[index - this.width] + d[index + this.width]
) / 4;
}
}
this.swap(d, temp);
}
// 交换两个数组
swap(a, b) {
for (let i = 0; i < a.length; i++) {
const temp = a[i];
a[i] = b[i];
b[i] = temp;
}
}
接着处理平流,也就是流体带着烟雾一起运动,就像河水带着落叶漂流:
ini
// 平流过程
advect(d, temp, u, v, dt) {
const dt0 = dt * this.width;
for (let i = 1; i < this.width - 1; i++) {
for (let j = 1; j < this.height - 1; j++) {
const index = j * this.width + i;
// 计算当前位置的流体粒子来自哪里
let x = i - dt0 * u[index];
let y = j - dt0 * v[index];
// 防止粒子跑到网格外面
x = Math.max(0.5, Math.min(this.width - 1.5, x));
y = Math.max(0.5, Math.min(this.height - 1.5, y));
// 找到周围的网格点
const i0 = Math.floor(x);
const i1 = i0 + 1;
const j0 = Math.floor(y);
const j1 = j0 + 1;
// 计算插值权重
const s1 = x - i0;
const s0 = 1 - s1;
const t1 = y - j0;
const t0 = 1 - t1;
// 插值得到当前位置的密度
temp[index] = s0 * (t0 * d[j0 * this.width + i0] + t1 * d[j1 * this.width + i0]) +
s1 * (t0 * d[j0 * this.width + i1] + t1 * d[j1 * this.width + i1]);
}
}
this.swap(d, temp);
}
最后,我们还需要处理压力,让流体在流动过程中保持体积不变,就像给气球充气,内部压力会阻止气球无限制膨胀:
ini
// 计算压力
project(u, v, p, div, iterations) {
const h = 1.0 / this.width;
for (let i = 1; i < this.width - 1; i++) {
for (let j = 1; j < this.height - 1; j++) {
const index = j * this.width + i;
div[index] = -0.5 * h * (
u[index + 1] - u[index - 1] +
v[index + this.width] - v[index - this.width]
);
p[index] = 0;
}
}
// 迭代求解压力
for (let k = 0; k < iterations; k++) {
for (let i = 1; i < this.width - 1; i++) {
for (let j = 1; j < this.height - 1; j++) {
const index = j * this.width + i;
p[index] = (div[index] + p[index - 1] + p[index + 1] + p[index - this.width] + p[index + this.width]) / 4;
}
}
}
// 应用压力修正速度
for (let i = 1; i < this.width - 1; i++) {
for (let j = 1; j < this.height - 1; j++) {
const index = j * this.width + i;
u[index] -= 0.5 * (p[index + 1] - p[index - 1]) / h;
v[index] -= 0.5 * (p[index + this.width] - p[index - this.width]) / h;
}
}
}
让模拟跑起来:帧循环的舞蹈
有了这些核心函数,我们就可以构建整个模拟的帧循环了。在每一次循环中,我们会依次执行扩散、平流、压力计算等步骤,让流体在时间的流逝中自然演化:
kotlin
// 每帧更新
update(dt) {
// 处理速度的扩散和平流
this.diffuse(this.u, this.tempU, 0.0001, dt);
this.diffuse(this.v, this.tempV, 0.0001, dt);
this.project(this.u, this.v, this.tempU, this.tempV, 40);
this.advect(this.u, this.tempU, this.u, this.v, dt);
this.advect(this.v, this.tempV, this.u, this.v, dt);
this.project(this.u, this.v, this.tempU, this.tempV, 40);
// 处理密度的扩散和平流
this.diffuse(this.density, this.tempDensity, 0.0001, dt);
this.advect(this.density, this.tempDensity, this.u, this.v, dt);
}
从代码到视觉:渲染流体的美丽
最后一步是把我们计算出的密度数据渲染到屏幕上。我们可以创建一个 Canvas 元素,然后根据每个格子的密度值,用不同的颜色来绘制:
ini
// 渲染到Canvas
render(canvas) {
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(this.width, this.height);
const data = imageData.data;
for (let i = 0; i < this.width; i++) {
for (let j = 0; j < this.height; j++) {
const index = j * this.width + i;
const d = Math.min(1, this.density[index]);
const r = Math.floor(d * 100);
const g = Math.floor(d * 100);
const b = Math.floor(d * 255);
const pixelIndex = (j * this.width + i) * 4;
data[pixelIndex] = r;
data[pixelIndex + 1] = g;
data[pixelIndex + 2] = b;
data[pixelIndex + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
结语:像素的海洋永不停歇
通过这些代码,我们让冰冷的像素拥有了流体的生命力。从 Navier - Stokes 方程的数学之美,到离散化的编程技巧,流体模拟就像一场跨越数学和计算机科学的舞蹈。
当然,真实世界的流体模拟要比这复杂得多,还需要考虑更多的物理细节和优化技巧。但只要掌握了这些基本原理,你就已经迈出了让像素学会游泳的第一步。下次当你看到游戏里汹涌的河流、电影中缭绕的仙气时,或许就能会心一笑,因为你知道,那些美丽的流体背后,是数学和代码共舞的旋律。