☰
WebGPU 计算着色器驱动流体仿真:基于 Navier-Stokes 方程手写网格烟雾效果
2026/10/5 5:52:53 网站建设 项目流程

WebGPU 计算着色器驱动流体仿真:基于 Navier-Stokes 方程手写网格烟雾效果

在 Web 交互设计中,流体动力学(Fluid Simulation)——如随鼠标轨迹翻滚的数字烟雾、流光水波与液体水墨——向来是打造顶级视觉沉浸感的终极圣杯。然而在过去的 WebGL 时代,要在浏览器端跑通一套稳定的流体模拟,前端开发者必须编写极其扭曲的代码:建立五六个独立的浮点数帧缓冲(Float FBO),在顶点着色器和片元着色器之间反复切换纹理附件,还要在 CPU 侧处理复杂的 Ping-Pong 状态机,性能稍有不慎就会导致页面瞬间掉帧卡死。

WebGPU 的横空出世彻底重写了物理可视化的底层逻辑。通过 WGSL(WebGPU Shading Language)的计算着色器(Compute Shader)与存储纹理(Storage Texture),我们能够直接把流体力学中最核心的偏微分方程——不可压缩纳维-斯托克斯方程(Incompressible Navier-Stokes Equations),以并行的网格计算流水线完整搬进 GPU 片上。让用户每一次轻微的鼠标滑过,都能在屏幕上掀起如丝绸般细腻翻滚的物理级烟雾漩涡。


一、不可压缩流体力学的四大离散求解步骤

流体的宏观运动遵循著名的纳维-斯托克斯动量守恒方程:

$$\frac{\partial \vec{u}}{\partial t} = -(\vec{u} \cdot \nabla)\vec{u} - \frac{1}{\rho}\nabla p + \nu \nabla^2 \vec{u} + \vec{f}$$

配合不可压缩性约束(速度场散度为零):

$$\nabla \cdot \vec{u} = 0$$

在计算机欧拉网格(Eulerian Grid)上实时求解该方程,图形学大师 Jos Stam 提出的“稳定流体算法(Stable Fluids)”将每一帧的推演严格拆分为四个阶段:

  1. 外力注入(Add Forces, $\vec{f}$):将用户的鼠标拖拽位移向量注入当前网格单元作为外力速度,同时注入对应颜色的烟雾染料浓度。
  2. 平流对流步(Advection):流体自身的速度场推动染料和速度场自身前进。为了防止数值发散,采用半拉格朗日法(Semi-Lagrangian Method):从当前网格点沿速度反方向回溯(Backtrace)时间 $\Delta t$ 找到粒子出发点,通过双线性插值获得新值。
  3. 粘性扩散步(Diffusion, $\nu \nabla^2 \vec{u}$):流体分子间的粘滞摩擦力。通过雅可比迭代(Jacobi Iteration)求解扩散泊松方程。对于烟雾这类低粘度流体,该步可适当简化。
  4. 压力投影步(Projection / Divergence-free):整个流体模拟最关键的核心!经过对流后的速度场会产生散度(Divergence),即某些网格流进的水比流出的多,破坏了“不可压缩”法则。我们必须先求解速度散度场,通过 20 次以上的雅可比迭代解出压力标量场 $p$,最后从速度场中减去压力梯度 $\nabla p$,强行将速度场投影到完全无散度($\nabla \cdot \vec{u} = 0$)的物理稳定状态。

二、手写 WGSL 压力泊松求解与对流计算着色器

在 WebGPU 中,速度场用 RG 16 位浮点存储纹理表征,染料密度用 RGBA 纹理表征。

看核心的压力泊松方程雅可比迭代 WGSL 计算着色器实现:

// WGSL 压力泊松求解器:jacobi_pressure.wgsl @group(0) @binding(0) var pressureRead : texture_2d<f32>; @group(0) @binding(1) var divergenceRead : texture_2d<f32>; @group(0) @binding(2) var pressureWrite : texture_storage_2d<r16float, write>; @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) id : vec3<u32>) { let coords = vec2<i32>(id.xy); let dims = vec2<i32>(textureDimensions(pressureRead)); if (coords.x >= dims.x || coords.y >= dims.y) { return; } // 1. 采样上下左右四个邻域网格的压力标量 (边界夹取) let pL = textureLoad(pressureRead, clamp(coords + vec2<i32>(-1, 0), vec2<i32>(0), dims - vec2<i32>(1)), 0).r; let pR = textureLoad(pressureRead, clamp(coords + vec2<i32>(1, 0), vec2<i32>(0), dims - vec2<i32>(1)), 0).r; let pB = textureLoad(pressureRead, clamp(coords + vec2<i32>(0, -1), vec2<i32>(0), dims - vec2<i32>(1)), 0).r; let pT = textureLoad(pressureRead, clamp(coords + vec2<i32>(0, 1), vec2<i32>(0), dims - vec2<i32>(1)), 0).r; // 2. 采样当前网格散度 (Divergence) let div = textureLoad(divergenceRead, coords, 0).r; // 3. 雅可比迭代离散公式:中心压力等于周围邻域压力之和减去散度后求均值 let newPressure = (pL + pR + pB + pT - div) * 0.25; // 4. 就地写入下一轮的压力存储纹理 textureStore(pressureWrite, coords, vec4<f32>(newPressure, 0.0, 0.0, 0.0)); }

紧接着是半拉格朗日对流通道(Advection Pass):

// WGSL 半拉格朗日对流通道:advect.wgsl @group(0) @binding(0) var velocityTex : texture_2d<f32>; @group(0) @binding(1) var sourceQuantity : texture_2d<f32>; // 染料或速度场 @group(0) @binding(2) var texSampler : sampler; @group(0) @binding(3) var outputTex : texture_storage_2d<rgba16float, write>; struct Uniforms { deltaTime : f32, gridResolution : vec2<f32>, decayRate : f32, }; @group(0) @binding(4) var<uniform> u : Uniforms; @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) id : vec3<u32>) { let coords = vec2<i32>(id.xy); if (coords.x >= i32(u.gridResolution.x) || coords.y >= i32(u.gridResolution.y)) { return; } // 1. 提取当前单元速度 let vel = textureLoad(velocityTex, coords, 0).xy; // 2. 反向追溯粒子出发位置 let pos = vec2<f32>(coords) + vec2<f32>(0.5); let backtrackedPos = pos - vel * u.deltaTime; // 3. 归一化至 UV 坐标 [0, 1] 并利用硬件双线性采样器获得高精度平滑色彩 let uv = backtrackedPos / u.gridResolution; let advectedValue = textureSampleLevel(sourceQuantity, texSampler, uv, 0.0); // 4. 叠加轻微时间耗散,写入输出 textureStore(outputTex, coords, advectedValue * u.decayRate); }

三、WebGPU 管线编排与鼠标力场交互集成

在 TypeScript 宿主代码中,我们监听用户的鼠标移动,并在后台连续调度 Compute Pass 执行物理步进:

export class FluidSimulationEngine { private device: GPUDevice; private jacobiPipeline!: GPUComputePipeline; private advectPipeline!: GPUComputePipeline; private gridWidth = 256; // 256x256 的物理网格足以呈现极其惊艳的涡流细节 private gridHeight = 256; constructor(device: GPUDevice) { this.device = device; } public step(commandEncoder: GPUCommandEncoder, dt: number): void { // 1. 调度平流通道 (Advect Velocity & Density) // 2. 调度散度计算通道 (Compute Divergence) // 3. 循环 20 次调度雅可比压力迭代通道,强制压力收敛 for (let i = 0; i < 20; i++) { const jacobiPass = commandEncoder.beginComputePass({ label: `Jacobi Iteration ${i}` }); jacobiPass.setPipeline(this.jacobiPipeline); // 切换绑定组 (Ping-Pong) jacobiPass.dispatchWorkgroups(this.gridWidth / 16, this.gridHeight / 16); jacobiPass.end(); } // 4. 调度投影减速梯度通道 (Subtract Gradient) } }

四、生产避坑与性能压榨铁律

  1. 浮点纹理线性滤波支持(Float32 Filterable Limit):在对流通道中,我们使用了textureSampleLevel对浮点纹理进行双线性插值。WebGPU 规范中,r32float或rgba32float默认并不保证支持硬件线性滤波!必须在请求适配器时显式检查并请求特性:requiredFeatures: ['float32-filterable'];或者在生产中安全选用全平台标配的半精度浮点格式rgba16float。
  2. 数值耗散(Numerical Dissipation)与网格分辨率平衡:半拉格朗日方法天生带有数值耗散,网格分辨率如果太低(如低于 64×64),漩涡在转动半圈后就会迅速抹平成一团浑浊。在移动端,将流体模拟网格锁定在128×128 到 256×256之间,而在最终渲染呈现时配合双线性放大到全屏,是在算力与流体涡流锐度之间的黄金平衡点。

把偏微分方程交给着色器,在数字屏幕上复现大自然的流体律动,这正是 Web 极客探索视觉极限的最高浪漫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询