从“网墙”到柔性曲面:一次 WebGPU 波浪的逆向分析与重建

一片令人着迷的波浪

最初,我只是想为我的工具站 Tool4Furry 的首页制作一片动态背景。

在 Motion 的一篇示例文章顶部,有一片缓缓起伏的蓝色网格。它的线条非常细,像是某种半透明的织物,几道银蓝色的波脊从暗处浮现,又逐渐消失在背景中。

最吸引我的并不是动画有多复杂,而是它对视觉层次的控制。

整片网格有着明确的空间深度,却几乎看不到传统三维模型那种生硬的边缘。远处的线条隐约可见,近处的曲面层层交叠,而最明亮的部分始终集中在少数几道波峰上。即便画面不断变化,也没有喧宾夺主。

这正是我希望带到工具站首页的感觉。

从表面上看,它似乎并不难实现:定义一组波浪函数,生成网格顶点,再按照某种透视关系将其投影到屏幕上。

但真正动手之后,我发现自己远远低估了这片波浪。

为什么画出来的总是一堵"网墙"?

最早的实现使用 Canvas 2D。

我从正弦函数开始,把一组曲线沿屏幕排列,让它们随着时间缓慢起伏。随后加入不同频率的波动、横向连接线,以及根据曲面坡度计算的明暗变化。

画面逐渐有了网格,也有了波浪,却始终缺少参考作品中的柔软感。

线条密集时,画面像一道竖立的纱窗;线条减少后,又只剩下一组单薄的平行曲线。增加横向连接线可以强化空间结构,却也让网格的机械感更加明显。

我曾将问题归因于投影方式。

于是,接下来的实验开始引入更完整的空间几何:连续曲面、法线、正交相机,甚至为膜面增加沿法线偏移的下表面与侧缘,希望通过真实的几何厚度制造层次。

图 1:Wavefield V0.2 的 Canvas 2D 实验。引入膜面几何与相机之后,波浪已经具有连续形态,但线条和空间层次仍然比较单薄。

这条路线并非完全没有价值。它使曲面的定义更加明确,动画的空间关系也更容易控制。

可问题依旧存在:我能够改变波浪的形状,却无法让它像参考画面那样,在亮处形成轻薄的弧面,在暗处自然融入背景。

更复杂的几何没有带来预期的视觉结果。

与此同时,密集网格还容易出现摩尔纹。提高绘制分辨率、增加曲线数量,未必能改善这一现象,有时反而让局部线条更难辨认。

到这一步,我开始怀疑,自己一直试图解决的,可能根本不是几何问题。

顺着浏览器,找到真正的 Shader

既然反复调整数学模型都无法取得满意的结果,不如直接研究参考画面在浏览器里是如何生成的。

最初的检查并没有得到太多有用的信息。

页面上确实存在一个 canvas 元素。我依次尝试获取 2d、webgl 和 webgl2 上下文,却没有找到预期的渲染入口。一开始,我甚至怀疑这个画布的绘制过程是否经过了某种特殊封装。

后来才意识到,自己的探测列表遗漏了一个选项:

js 复制代码
canvas.getContext("webgpu")

这一次,浏览器返回了 GPUCanvasContext。

也就是说,这片页头背景并不是普通的 Canvas 2D 绘图,也不是我最初怀疑的 WebGL,而是由 WebGPU 驱动。

这里还有一个容易混淆的地方:被调查的页面虽然是 Particle Orb 示例,但我真正感兴趣的是文章页头的蓝色背景,而不是文章内部的粒子球演示。两者不能因为出现在同一页面,就被视为同一套渲染实现。

沿着 DevTools 的 Sources 继续查找,我定位到了当时负责页头绘制的 ParticleFrame 模块。

在对应的函数处下断点后,运行时显示当前变体为:

js 复制代码
variant = "terrain"

随后,我找到了该变体使用的 WGSL 着色器字符串。它的长度为 5,961 个字符,包含顶点与片元着色器入口。

图 2:2026 年 10 月的浏览器运行时观察。通过实际调用参数与 WGSL 字符串确认页头使用的 WebGPU 地形渲染路径。

在 Shader 中,有三句注释格外引人注意:

wgsl 复制代码
// Broad, unequal hills form a landscape.
// Small waves add detail to its ridges.
// All grid edges sample the same continuous height field.

这是 Motion 当时交付给浏览器的页头 Shader 中的原始注释。

它们描述了三个非常朴素的原则:用宽缓且不相同的丘陵建立整体地形,用小波动丰富山脊,再让所有网格边从同一片连续高度场采样。

回头看自己之前的几轮实验,这三句话几乎直接指出了方向上的偏差。

我一直在思考如何把许多曲线组织成一张漂亮的网格;而参考实现首先考虑的,是如何生成一片有自然起伏的地形。

网格只是让这片地形被看见的方式。

四座丘陵,如何生成一片连续地形?

Motion 页头的 terrain Shader 并没有使用复杂的流体模拟,也没有在这份实现中采用传统的粒子系统来推动波浪。

它的基础是一片连续高度场。

对于平面上的任意位置 p=(x,z)p=(x,z) p=(x,z),高度 H(p,t)H(p,t) H(p,t) 由四组高斯丘陵和若干细节波动共同决定,可以概括为:
H(p,t)= ∑i=14 Aiexp⁡(− ∥wi⊙(p−ci(t))∥ 2 )+D(p,t)H(p,t)=\sum_{i=1}^{4} A_i\exp\left(-\left\|w_i\odot(p-c_i(t))\right\|^2\right)+D(p,t) H(p,t)=i=1∑4Aiexp(−∥wi⊙(p−ci(t))∥2)+D(p,t)

其中, Ai A_i Ai 控制丘陵的高度, ci(t) c_i(t) ci(t) 是随时间缓慢移动的中心位置, wi w_i wi 控制丘陵在不同方向上的宽度,而 D(p,t)D(p,t) D(p,t) 则由较小幅度的正弦波组成。

这几组丘陵并不完全对称,也不会以相同的速度移动。

有的丘陵宽而平缓,有的更高、更集中;它们彼此叠加,使画面中出现宽阔的波峰、倾斜的山脊,以及相对平静的低谷。细节波动只负责打破过于规则的形状,而不是主导整个画面。

与让每条线独立上下振动相比,这种方法有一个重要优势:无论网格如何细分,任意位置的高度都来自同一个函数。

相邻曲线不会各自拥有一套不相关的波动。横向与纵向的网格线也能在交点处自然连接。

地形的连续性,先于网格的连续性。

另一处值得注意的细节,是 Shader 在计算高度的同时,还计算高度沿两个方向的偏导数。

对于高斯丘陵而言,梯度能够直接通过解析公式求出,不必依靠相邻像素的有限差分进行估计。由此得到的坡度既能用于几何形态,也能参与后续的法线和光照计算。

这样,波峰在哪里、表面朝向哪里,以及哪一段线条应该被照亮,都由同一份地形数据决定。

但即使地形已经正确,视觉上的问题也只解决了一半。

如果只是把这片高度场投影到屏幕,再用统一的线宽和透明度绘制网格,结果仍然很容易像一张普通的三维线框模型。

真正让它显得柔软的,是接下来对每一根线条的处理。

柔软的秘密,藏在线条和光照里

在参考实现中,网格线并不是通过一次普通的 stroke() 绘制出来的。

Shader 将每条网格边扩展成具有屏幕空间宽度的四边形。每个四边形由两个三角形组成,因此一次实例化绘制就能够处理大量线段。

当时的 terrain 变体使用 192×112192\times112 192×112 个网格单元,每个单元对应两个方向的边,总共绘制 43,008 个线段实例。

不过,线段以什么形式交给 GPU,并不是最重要的视觉差异。

更关键的是:这些四边形并不会被绘制成均匀填充的带状区域。

一根线条如何变得柔软?

对于线条中心两侧的像素,片元着色器会根据横向距离计算覆盖率。

在 Wavefield 的实现中,可以将它表示为:
C(d)=exp⁡ (−12 (dσ) 2 ) C(d)=\exp\left(-\frac{1}{2}\left(\frac{d}{\sigma}\right)^2\right) C(d)=exp(−21(σd)2)

这里的 dd d 是像素到线条中心的横向距离, σ\sigma σ 控制软边的宽度。

距离中心越近,覆盖率越高;越靠近边缘,覆盖率便平滑地衰减。

这与普通的硬边线条有着本质区别。

硬边线条的亮度在边界处迅速截止,而高斯覆盖的线条拥有一个明亮的中心,以及向两侧逐渐消失的柔和边缘。

单独看一根线,这种差异可能并不惊人。但当成千上万根线条沿着曲面排列时,连续的软边和透明叠加便开始产生类似纤维、薄纱的观感。

让一部分网格清晰,其余部分退入背景

仅仅使用高斯软边,还不足以形成参考画面中的空间层次。

另一个关键机制,是按照网格的纵深位置调整线条的清晰程度。

在焦点区域附近,线条更细、更清楚;离开焦点之后,线条逐渐变宽,同时降低峰值透明度。

这里的"景深"并不是完整的相机散焦后处理,而是一种针对线段宽度与覆盖率的风格化控制。

这一步非常重要。

如果只增加宽度而不降低透明度,远处的网格就会聚集成大片发亮的区域。参考实现因此引入了与线条宽度相关的能量补偿,让失焦线条在变宽的同时变淡。

与此同时,网格的两个方向也没有被同等对待。

参考 Shader 为主要线条与交叉线设置了明显不同的基础透明度,分别约为 0.72 和 0.14。主线形成可辨认的波浪走向,交叉线则退到背景中,只留下若隐若现的空间结构。

这解释了为什么它明明是一张完整网格,看起来却不像棋盘或纱窗。

图 3:基于 Wavefield v1.0.0 相同高度场和投影制作的软件参考渲染。三种状态共享几何,主要区别在于网格的绘制方式、高斯覆盖率、焦点衰减与局部光照;并非浏览器 WebGPU 的原生截图。

这张图把差异呈现得更直接。

左侧是连续的地形基础,右上是统一线宽、交叉线同样明显的硬边网格,右下则恢复了柔性材质。

三者的地形与视角没有变化。

但到了第三种状态,波浪才开始拥有明显的前后层次。大量远离焦点的线条几乎消失,少数清晰的弧线沿波脊延伸,原本生硬的网格逐渐形成柔和的曲面。

高光为什么只出现在少数波脊上?

还有最后一块拼图:光照。

参考 Shader 使用由地形坡度得到的法线,结合观察方向计算一种风格化的掠射高光。

当法线与视线接近特定角度时,高光会增强;但这种增强还受到焦点权重的约束。

换句话说,并不是所有朝向合适的网格都能同样明亮。远离焦点的区域,即便满足掠射角条件,高光也会受到抑制。

因此,画面中的亮部往往集中在几道局部波脊上,而不是整片网格同时发光。

最终,线条覆盖率、焦点、主次方向、深度衰减和高光权重共同决定透明度,再通过预乘 Alpha 与页面背景混合。

这套机制并没有为网格建立真实的实体厚度,却成功制造出了近似薄膜的视觉体积。

回到最初的失败尝试,我才意识到:自己一直想用更复杂的几何去解决的问题,其实有很大一部分属于材质与光学表现。

从理解原理,到重建 Wavefield

弄清楚参考画面的结构后,我重新整理了实验方向,并将它独立为 Wavefield 项目。

早期的 Canvas 2D 版本保留了探索过程中的几何与投影实验;新的实现则直接使用 WebGPU 和 WGSL,不再依靠 Canvas 2D 逐条绘制曲线。

不过,Wavefield v1.0.0 并不是把 Motion 页头的 Shader 原样搬过来。

两者在工程组织上有一个明确区别。

根据当时观察到的 Motion Shader,其网格顶点路径会计算所需的地形数据。而 Wavefield 将这一部分拆分出来:先通过 Compute Pass 对共享网格节点采样,再由 Render Pass 读取这些节点,将各条网格边绘制成软线段。

在完整网格质量下,Wavefield 使用 192×112192\times112 192×112 个单元,共有 21,809 个共享节点 ,对应 43,008 个线段实例。

这种组织方式让相邻边共享相同的高度和坡度数据,避免为每条线段的端点重复进行整套地形计算。对我而言,它也让代码结构更容易理解和调试。

图 4:Wavefield 的独立实验工作台。可调整丘陵位置、焦点、线条柔软度、网格透明度、局部高光及绘制参数,并固定时间以比较不同状态。

为了避免再次陷入单纯凭感觉调参的循环,我还为项目增加了一组可复现的控制能力。

固定时间与场景参数之后,可以比较同一帧在不同线宽、聚焦位置和透明度设置下的结果。这样的对照比让动画不断运行、凭肉眼猜测参数是否有效要可靠得多。

在运行时,Wavefield 默认将 GPU 提交频率上限控制为每秒 24 次,并提供更低的选项。画布的绘制倍率最高为 1.5;较窄的视口会使用更小的网格规模。

当页面不可见、波浪离开视口或用户启用减少动态效果的系统设置时,连续渲染可以暂停。WebGPU 不可用或设备丢失时,页面则会显示静态 CSS 降级效果,并保留重试入口。

这些措施并不意味着它已经在所有设备上具有稳定的性能表现。

项目目前展示的渲染耗时,主要是 CPU 侧的命令编码与提交成本,不能当作 GPU 实际执行时间。要判断移动设备上的功耗、显卡负载和不同浏览器的兼容性,还需要进一步的真实设备测试。

有必要交代的是,Wavefield 的默认场景参数、部分投影与光学系数参考了 Motion 页头的实际运行数据。这是一项有明确参考来源的重建实验,而不是完全独立构思的视觉设计。项目使用自行编写的 WebGPU 渲染代码,并在 Third-party notices 中记录了这些来源与授权边界。

这次实验并没有让我得出"WebGPU 一定比 Canvas 2D 更好"的结论。

Canvas 2D 依然适合大量二维图形与动画场景,WebGL 也完全能够实现相似的实例化线段效果。真正影响技术选择的,是图形任务本身:这里需要大量共享地形数据的网格边,以及在片元阶段独立控制的柔和覆盖率与光照。

WebGPU 只是让这种实现方式更加直接。

回顾整个过程,最初困扰我的一直是同一个问题:为什么明明已经画出了连续的波浪,却始终画不出那种轻薄、柔软的感觉?

答案并不在更多的三角形、更复杂的相机,甚至也不完全在波浪函数里。

它藏在那些不那么显眼的细节中:哪些线条应该清晰,哪些应该模糊;哪里需要一点高光,哪里应该保持黑暗;又有哪些网格边,应该在被看清之前就渐渐消失。

有时候,决定一幅程序化图形质感的,不是我们画出了多少东西,而是我们如何控制它们被看见。

相关推荐
宝哥聊编程4 小时前
主流 Agent 平台对比:Coze、Dify、飞书、扣子、OpenClaw 落地实操手册
前端·人工智能·飞书
时间的情敌4 小时前
Vue3 + Cesium 实战:本地上传 GLB 模型、模型上叠加 GLB、点击控制透明度
前端·vue.js·cesium
拖孩4 小时前
流量主门槛从 500 降到 100,我的小程序上线 18 小时够到了线
前端·后端·微信小程序
深蓝AI4 小时前
编码 Agent 的界面味靠提示词压不住:Impeccable 用无模型规则拦同一套模板
前端·程序员
kingx35 小时前
Bugku Reverse 与 Android 题解总集:APK / native / PE / IPA
android·逆向·ctf·bugku
January12076 小时前
【AI方法笔记】2026-11-02:模型流式吐 JSON 时,前端会遇到四种崩法
前端·人工智能·笔记·json
asdzx676 小时前
C# 操作 PDF 元数据:从属性写入到字段提取的完整流程
前端·pdf·c#
吹什么轩6 小时前
constexpr 完全指南:从 const 的两种含义,到编译期计算的四次演进
java·前端·javascript
Bruce_Liuxiaowei6 小时前
前端校验登录态,等于没校验——一次应急广播终端高危漏洞的发现与报送
运维·前端·安全