一次 WebGPU 流体之旅

如果你关注过 Web 图形圈子,可能知道 Hector Arellano(又名Hat)。这篇文章不仅是技术拆解,更是一段关于坚持、试错、与 Web 图形演进的故事。

注意:Demo 依赖WebGPU,并非所有浏览器都支持。请使用支持 WebGPU 的浏览器(例如最新版 Chrome / Edge,并确保 WebGPU 已启用)。

在继续阅读之前……先去拿杯喝的——这篇很长。

13 年前……

我正盯着电脑屏幕发呆(无聊得很),一个很要好的朋友Felix打电话给我,非常认真又兴奋地说:Gathering Party 刚发布了一个新 Demo。它有流体模拟、粒子动画、惊艳的着色方案——最重要的是,它真的很美。

那时候 WebGL 还算“新东西”,把硬件加速的 3D 图形带进浏览器,看起来会打开很多门。我天真地以为:WebGL 也许能做出 Felix 给我看的那种东西。

但我开始研究那个 Demo 的实现方式时,就撞上了残酷现实:里面用到了一堆我从没听过的 API/特性——“Atomics(原子操作)”“Indirect Draw Calls(间接绘制调用)”“Indirect Dispatch(间接派发)”“Storage Buffers(存储缓冲区)”“Compute Shaders(计算着色器)”“3D Textures(三维纹理)”。

它们属于现代图形 API 的能力,但在当时的 WebGL 里基本不存在。

更别提它还用了很多听起来就很复杂的算法/技术:用SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学)驱动粒子动画、用histopyramids做流压缩(我当时还想:我为什么需要这个?)、用 GPU 上的marching cubes(从粒子生成三角形???)等等。

我完全不知道从哪里开始。更糟的是,Felix 跟我打赌:这种流体效果不可能在浏览器里“可用于生产”。

10 年前……

又过了三年,Felix 说他还有一个更炸裂的 Demo一定要我看。除了流体模拟,它还用实时光线追踪渲染了几何体——材质很震撼、画面很惊人。

这下挑战更大了:我不仅想模拟流体,我还想用光追去渲染它,得到漂亮的反射与折射。

我花了大概 3 年才把这些东西理解到能在 WebGL 里“硬凿”出来:

我用 SPH 让粒子行为像流体;

我用 marching cubes 从粒子生成网格(见图 1 的描述)。

当时没有 atomics,我就用多次 draw call 把数据塞进纹理的 RGBA 通道来“分层”;没有 storage buffer 和 3D 纹理,我就用纹理存数据,并用二维层来“模拟” 3D 纹理;没有 indirect draw,我就干脆按预期数量发起 draw call;没有 compute shader,我就用顶点着色器做 GPGPU 的数据重排……虽然也做不出那种“在 buffer 里随意写多个内存位置”的事,但至少我能在 GPU 里生成一个加速结构。

实现是能跑,但离“美”差得很远(Felix 直接评价:丑。确实丑,你可以想象图 2)。我那时也不太懂距离场,也不知道怎么把 shading 做得更有趣,基本就是老派 phong。

性能也限制了很多高级效果:环境光遮蔽、更复杂的反射折射……但至少我能渲染出点东西。

7 年前……

再过三年,我又做了一些进展:实现了一个混合式光追。思路是:marching cubes 先生成三角形,然后用光追去算二次射线做反射/折射;同一个光追还能遍历加速结构去做焦散。这些基本都沿用了Matt Swoboda的想法(那些 Demo 的原作者)。我的工作大部分就是:把他的点子尽量在 WebGL 里跑起来(祝你好运)。

效果在视觉上还不错(类似图 3),但需要非常强的 GPU。当时我用的是 NVidia 1080GTX。也就是说:即使 WebGL 可行,也不可能拿去做“生产”。手机不行,普通笔记本也扛不住。

看得到“结果”,却用不到真实项目里,这种挫败感很强。我花了太多时间,最后也没有达到期望。至少,这套代码库还能继续帮我学习。

于是我停了。

Felix 赢了赌局。

这段铺垫对一篇“教程”来说太长了,但我想把背景交代清楚:有些 Demo 看起来像“几天搞定”,实际可能是多年积累;你要花时间学很多技术,也经常要借鉴别人的想法——最后也可能仍然失败。

WebGPU 登场

还记得那些“现代图形 API 的关键词”吗?WebGPU 基于现代 API 标准,这意味着我不必再靠 Hack:

我可以用 compute shader 直接操作 storage buffer;

我可以用 atomics 做邻域搜索、流压缩时的索引写入;

我可以用 dispatch indirect 来只生成必要数量的三角形,并用同样的方式绘制它们。

我想学习 WebGPU,于是决定把之前的流体工作迁移过来,顺便理解新范式:怎么组织 pipeline 和 binding、怎么管理 GPU 内存与资源……做一个小 Demo 很适合练手。

需要先讲清楚:本文的 Demo并不适合生产。在 M3 Max 这类比较强的 MacBook Pro 上它可能能跑到 120fps;M1 Pro 上大概 60fps;其它不错的机器也许 50fps……但如果你拿去跑在 MacBook Air 上,“浏览器流体梦”会很快破碎。

那它为什么仍然有价值?

因为它其实是一组可拆解的技术集合。你可能对其中某个部分感兴趣:粒子动画、从势场生成表面(避免 ray marching)、间接光、世界空间 AO……你可以把仓库里的代码拿出来,只取你需要的部分来构建自己的想法。

这个 Demo 大致可以拆成 4 个主要阶段:

流体模拟:用粒子模拟(基于 Position Based Dynamics 思路)驱动流体的运动。

几何生成:用 GPU 上的 marching cubes,从粒子生成渲染用三角形。

几何渲染:使用距离场估算几何厚度以做次表面散射(SSS),并用体素锥追踪(Voxel Cone Tracing)计算 AO。

合成:地面反射模糊、调色与 Bloom 等后期。

流体模拟

很多年前,如果你想在图形圈子里“显得很酷”,你得证明你能自己做流体模拟:做 2D 就很强,做 3D 就是“封神”(当然这是我脑内的中二设定)。为了“封神”(也为了赢赌局),我开始疯狂读 3D 模拟相关的资料。

做流体的方法很多,其中一种叫SPH。理性做法应该是先评估哪个方法更适合 Web,但我当时选它就因为名字听起来很酷。SPH 是粒子法,这一点长期来看很有好处,因为后来我把 SPH 换成了 position based 的方法。

如果你做过“群体行为(steering behaviors)”或 flocking,会更容易理解 SPH。

Three.js 有很多 flocking 示例,它基于吸引、对齐、排斥等 steering 行为。用不同的权重/函数,根据粒子之间的距离决定粒子受哪些行为影响。

SPH 的做法也有点类似:你先算每个粒子的密度,再用密度算压力;压力就像 flocking 里的吸引/排斥,使粒子靠近或远离。密度又是邻域粒子距离的函数,所以压力本质上也是“由距离间接决定的”。

SPH 的粘性项(viscosity)也类似 flocking 的对齐项(alignment):让粒子速度趋向邻域的平均速度场。

为了(过度)简化,你可以把 SPH 理解成:给 flocking 套上一组更“物理正确”的参数,让粒子更像流体。当然 SPH 还会涉及表面张力等更多步骤,且其核函数/权重远比这里描述复杂,但如果你能把 flocking 做好,理解 SPH 会更轻松。

SPH/群体行为都有一个共同难点:朴素实现是O(n2)O(n^2)O(n2),粒子多就会爆炸。你需要一个加速结构只查询附近粒子,让复杂度从O(n2)O(n^2)O(n2)降到O(k⋅n)O(k\cdot n)O(k⋅n)(kkk是每个粒子要检查的邻居数)。常见做法是体素网格:每个体素格子存最多 4 个粒子索引。

在这个示例里,算法会检查粒子周围 27 个体素,每个体素最多 4 个粒子,所以最多 108 次邻域检查。听起来也不少,但比检查 8 万个粒子要好太多。

omega-njs.watchjwd.cn

omega-cds.watchjwd.cn

omega-fss.watchjwd.cn

omega-xas.watchjwd.cn

omega-cqs.watchjwd.cn

omega-fss.watchjwb.cn

omega-cqs.watchjwb.cn

omega-tss.watchjwb.cn

omega-njs.watchgw.com

omega-sys.watchgw.com

omega-fss.watchgw.com

omega-njs.watchae.com

omega-fss.watchae.com

omega-gys.watchae.com

omega-cqs.watchae.com

omega-tss.watchae.com

omega-njs.ulysseshwx.com

omega-dls.ulysseshwx.com

omega-njs.szwatchpg.com

omega-sys.szwatchpg.com

omega-fss.szwatchpg.com

omega-gys.szwatchpg.com

omega-dls.szwatchpg.com

omega-fss.swatchstar.top

omega-dls.swatchstar.top

omega-yts.swatchstar.top

omega-njs.swatchkb.top

omega-sys.swatchkb.top

omega-fss.swatchkb.top

omega-njs.shrolexwatch.com

omega-fss.shrolexwatch.com

omega-dgs.shrolexwatch.com

omega-dls.shrolexwatch.com

omega-yts.shrolexwatch.com

omega-njs.shjshd.cn

omega-ncs.shjshd.cn

omega-njs.rogerweixiu.com

omega-sys.rogerweixiu.com

omega-zzs.rogerweixiu.com

omega-dgs.rogerweixiu.com

omega-ncs.rogerweixiu.com

omega-hzs.vay.net.cn

omega-sz.vay.net.cn

omega-xms.vay.net.cn

omega-bjs.watchshouhou.cn

omega-shs.watchshouhou.cn

omega-hzs.watchshouhou.cn

omega-bjs.jshdwatch.com

omega-hzs.jshdwatch.com

omega-njs.jshdwatch.com

但邻域遍历仍然昂贵。SPH 还要求多次 pass:密度、压力/位移、粘性、表面张力……当你意识到 GPU 绝大部分算力都在“驱动粒子”时,性能就会变得非常重要。

而且 SPH 很难调参,你得理解很多工程/物理参数才能做得好看。

后来 NVidia 提出了一套粒子动力学方法:Position Based Dynamics(PBD),其中包含刚体、软体、流体、碰撞等。课程笔记在这里。

PBD 通过“约束(constraints)”直接修正粒子位置,结果稳定、调参相对容易。这让我从 SPH 转向PBF(Position Based Fluids)。核心差别在于:PBF 用约束来定义位移,而不是像 SPH 那样先算密度。

PBF 的参数更“无量纲”,更好理解。

但它也有代价:PBD 往往要迭代多次才能得到更好结果(计算约束、应用位移、计算粘性……反复执行),稳定但更慢。

而我不想只渲染粒子,我要渲染网格:GPU 还要算三角形、做渲染。我没有足够预算做多轮迭代,所以我必须“砍角”。

幸运的是,PBD 有一种很便宜的碰撞计算方式:在施加力(forces)后做一次 pass 即可。我选择:

用重力作为主力;

用 curl noise 作为辅助力,增加流体感;

用鼠标驱动一个很强的斥力(repulsion);

让碰撞负责避免粒子聚成奇怪的团。

curl + 重力提供“像流体”的整体趋势,碰撞避免粒子聚团。它不如 PBF 那么真实,但更快。

实现上只需要一次 pass 应用所有力,同时在 storage buffer 里生成网格加速结构;atomics 写索引只需要几行代码。你可以在仓库的PBF_applyForces.wgsl里读到力与网格构建的实现。

粒子位置更新在PBF_calculateDisplacements.wgsl:负责遍历邻域做碰撞,也负责和环境(不可见包围盒)碰撞。

pipeline 与绑定在PBF.js:模拟只用三个 shader——施力、位移更新、速度积分。位置更新后,速度通过“新位置 - 旧位置”的差值得到。

最后一个 shaderPBF_integrateVelocity.wgsl还会设置一个包含粒子信息的 3D 纹理,后续会用于 marching cubes 生成势场。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容