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

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

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

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

相关推荐
G等你下课27 分钟前
告别刷新就丢数据!localStorage 全面指南
前端·javascript
该用户已不存在28 分钟前
不知道这些工具,难怪的你的Python开发那么慢丨Python 开发必备的6大工具
前端·后端·python
爱编程的喵31 分钟前
JavaScript闭包实战:从类封装到防抖函数的深度解析
前端·javascript
LovelyAqaurius31 分钟前
Unity URP管线着色器库攻略part1
前端
Xy91034 分钟前
开发者视角:App Trace 一键拉起(Deep Linking)技术详解
java·前端·后端
lalalalalalalala36 分钟前
开箱即用的 Vue3 无限平滑滚动组件
前端·vue.js
前端Hardy36 分钟前
8个你必须掌握的「Vue」实用技巧
前端·javascript·vue.js
snakeshe101039 分钟前
深入理解 React 中 useEffect 的 cleanUp 机制
前端
星月日40 分钟前
深拷贝还在用lodash吗?来试试原装的structuredClone()吧!
前端·javascript
爱学习的茄子41 分钟前
JavaScript闭包实战:解析节流函数的精妙实现 🚀
前端·javascript·面试