一片令人着迷的波浪
最初,我只是想为我的工具站 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),高度 H(p,t) 由四组高斯丘陵和若干细节波动共同决定,可以概括为:
H(p,t)=i=1∑4Aiexp(−∥wi⊙(p−ci(t))∥2)+D(p,t)
其中, Ai 控制丘陵的高度, ci(t) 是随时间缓慢移动的中心位置, wi 控制丘陵在不同方向上的宽度,而 D(p,t) 则由较小幅度的正弦波组成。
这几组丘陵并不完全对称,也不会以相同的速度移动。
有的丘陵宽而平缓,有的更高、更集中;它们彼此叠加,使画面中出现宽阔的波峰、倾斜的山脊,以及相对平静的低谷。细节波动只负责打破过于规则的形状,而不是主导整个画面。
与让每条线独立上下振动相比,这种方法有一个重要优势:无论网格如何细分,任意位置的高度都来自同一个函数。
相邻曲线不会各自拥有一套不相关的波动。横向与纵向的网格线也能在交点处自然连接。
地形的连续性,先于网格的连续性。
另一处值得注意的细节,是 Shader 在计算高度的同时,还计算高度沿两个方向的偏导数。
对于高斯丘陵而言,梯度能够直接通过解析公式求出,不必依靠相邻像素的有限差分进行估计。由此得到的坡度既能用于几何形态,也能参与后续的法线和光照计算。
这样,波峰在哪里、表面朝向哪里,以及哪一段线条应该被照亮,都由同一份地形数据决定。
但即使地形已经正确,视觉上的问题也只解决了一半。
如果只是把这片高度场投影到屏幕,再用统一的线宽和透明度绘制网格,结果仍然很容易像一张普通的三维线框模型。
真正让它显得柔软的,是接下来对每一根线条的处理。
柔软的秘密,藏在线条和光照里
在参考实现中,网格线并不是通过一次普通的 stroke() 绘制出来的。
Shader 将每条网格边扩展成具有屏幕空间宽度的四边形。每个四边形由两个三角形组成,因此一次实例化绘制就能够处理大量线段。
当时的 terrain 变体使用 192×112 个网格单元,每个单元对应两个方向的边,总共绘制 43,008 个线段实例。
不过,线段以什么形式交给 GPU,并不是最重要的视觉差异。
更关键的是:这些四边形并不会被绘制成均匀填充的带状区域。
一根线条如何变得柔软?
对于线条中心两侧的像素,片元着色器会根据横向距离计算覆盖率。
在 Wavefield 的实现中,可以将它表示为:
C(d)=exp(−21(σd)2)
这里的 d 是像素到线条中心的横向距离, σ 控制软边的宽度。
距离中心越近,覆盖率越高;越靠近边缘,覆盖率便平滑地衰减。
这与普通的硬边线条有着本质区别。
硬边线条的亮度在边界处迅速截止,而高斯覆盖的线条拥有一个明亮的中心,以及向两侧逐渐消失的柔和边缘。
单独看一根线,这种差异可能并不惊人。但当成千上万根线条沿着曲面排列时,连续的软边和透明叠加便开始产生类似纤维、薄纱的观感。
让一部分网格清晰,其余部分退入背景
仅仅使用高斯软边,还不足以形成参考画面中的空间层次。
另一个关键机制,是按照网格的纵深位置调整线条的清晰程度。
在焦点区域附近,线条更细、更清楚;离开焦点之后,线条逐渐变宽,同时降低峰值透明度。
这里的"景深"并不是完整的相机散焦后处理,而是一种针对线段宽度与覆盖率的风格化控制。
这一步非常重要。
如果只增加宽度而不降低透明度,远处的网格就会聚集成大片发亮的区域。参考实现因此引入了与线条宽度相关的能量补偿,让失焦线条在变宽的同时变淡。
与此同时,网格的两个方向也没有被同等对待。
参考 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×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 只是让这种实现方式更加直接。
回顾整个过程,最初困扰我的一直是同一个问题:为什么明明已经画出了连续的波浪,却始终画不出那种轻薄、柔软的感觉?
答案并不在更多的三角形、更复杂的相机,甚至也不完全在波浪函数里。
它藏在那些不那么显眼的细节中:哪些线条应该清晰,哪些应该模糊;哪里需要一点高光,哪里应该保持黑暗;又有哪些网格边,应该在被看清之前就渐渐消失。
有时候,决定一幅程序化图形质感的,不是我们画出了多少东西,而是我们如何控制它们被看见。