当像素学会游泳:流体模拟的奇妙旅程

在计算机的虚拟世界里,让一滩水流动起来可比教猫咪游泳简单多了。今天我们就来揭开流体模拟的神秘面纱,看看那些在屏幕上奔腾的浪花、缭绕的烟雾,究竟是如何被数学公式驯服的。

流体模拟的数学基石: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 方程的数学之美,到离散化的编程技巧,流体模拟就像一场跨越数学和计算机科学的舞蹈。

当然,真实世界的流体模拟要比这复杂得多,还需要考虑更多的物理细节和优化技巧。但只要掌握了这些基本原理,你就已经迈出了让像素学会游泳的第一步。下次当你看到游戏里汹涌的河流、电影中缭绕的仙气时,或许就能会心一笑,因为你知道,那些美丽的流体背后,是数学和代码共舞的旋律。

相关推荐
吃杠碰小鸡4 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone9 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_090129 分钟前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农40 分钟前
Vue 2.3
前端·javascript·vue.js
夜郎king1 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳1 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_2 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝2 小时前
RBAC前端架构-01:项目初始化
前端·架构