无序中的有序,噪声的优雅

图片来源:unsplash/Ernest Karchmit

如果让你想象一片深邃的青绿色水面,光线从下方某处幽幽透出,成千上万缕淡青色的丝状物在水中缓缓漂浮、旋转,你会怎样描绘这样的画面?你可能会想到用着色器或者粒子系统来做复杂的模拟,但实际上,只需要几百行算术运算,就能完整地渲染出整个过程。这正是本文要做的事情——我们借助肯·珀林在1985年写下的同一个函数,当年他用这个函数来在无法真正绘制纹理的计算机上伪造出逼真的纹理,而今天我们则用它来渲染这些流动的丝状物。这个函数,就是如今人们熟知的珀林噪声。

接下来,我会通过一个流动水体的可视化项目,带你了解什么是珀林噪声,以及如何利用单一的噪声值驱动成千上万个粒子,让它们沿着弯曲的流线运动,从而形成流动的水面效果。文中代码片段使用了Squint ClojureScript方言,但背后的思路是完全语言无关的。

如果单纯使用随机值来生成自然纹理,往往会适得其反。每个像素都取纯随机数,最终得到的只会是一幅枯燥而充满颗粒感的噪点图。真实的表面,比如大理石或水面,之所以看起来自然,是因为相邻位置的值往往是相关的——这块地方亮一点,旁边一毫米处大概率也不会太暗。珀林噪声正是提供了一种生成这种结构化伪随机值的方法。它是一个从空间中的任意一点映射到标量值的确定性函数,具备三个让它在图形学领域大放异彩的特性:相邻位置的输入会得到平滑而无缝衔接的输出,保证了过渡的自然;同一个种子在相同输入下永远输出相同结果,因此纹理在帧与帧之间保持稳定;而且它没有天然的方向偏好,呈现出的各向同性,而不是简单的模糊随机点阵。

珀林噪声本质上是一种梯度噪声,生成过程可分为三步。首先,需要将空间划分成规则的网格,然后在每个网格顶点处放置一个伪随机的梯度矢量,用于决定方向。接着,对于网格内的任意一点,分别计算四个角上的梯度矢量指向该点的强度,即通过点积运算得到每个角对该点的贡献值。最后,将周围四个角的贡献值按权重融合在一起。如果简单地使用线性混合,每条网格线上都会留下明显的、令人不快的折痕。珀林的巧妙之处在于,他用一条特殊的缓动曲线来传递插值参数,这条曲线在起点和终点处都保持平坦,使得数值能够平顺地过渡到每个角落。

具体的公式是这样的:(defn fade [t] (* t t t (+ (* t (- (* t 6) 15)) 10)))。这个函数的一阶导数在t=0和t=1处均为零,这保证了输出在网格单元边界之间的平滑性。而线性插值本身则非常简洁:(defn lerp [t a b] (+ a (* t (- b a))))。梯度查找的过程则是将一个角的哈希值映射到一组固定的方向之一,然后返回该点相对于角点的偏移量与梯度方向的点积。一个负责洗牌置换表的小型种子伪随机数生成器,在构造时决定了每个角的梯度方向,使得整个场具有可重复性。调用者只需传入x、y、z三个坐标值,就能得到平滑的噪声值。珀林原始输出的范围大致在-1到1之间,实际使用时通常会映射到0到1区间,方便下游进行线性缩放。

有了噪声函数之后,关键就在于如何利用它来生成流畅的动画。平滑的标量值本身固然有用,但如果想创造一个朝着特定方向运动的动态效果,该怎么做呢?方法很简单,我们只需把噪声值当作一个角度,用它来确定罗盘上的方位。将这个值乘以一整圈(2π),就能把0到1的整个范围映射到所有可能的方向上。通过这样的处理,我们得到了一个流场,可以询问任意位置(x, y)处的速度矢量。由于底层噪声本身是平滑的,相邻像素的方向几乎相同,整个流场最终呈现出连贯的洋流状结构,包含涡旋、平静点和汇聚的水流等多种流动模式。

其中有一个控制参数叫noise-scale,用于调整流场的尺度。在采样噪声之前先对坐标进行缩放,如果以较粗的分辨率采样,就会形成宽广而缓慢的漩涡;如果放大采样,则会产生细碎而神经质般的小涡旋。细心的读者可能已经注意到,流场函数引入了第三个坐标t,这正是推动整个动画动起来的关键所在。珀林噪声可以定义在任意维度上,这里的实现实际上是三维的——前两个维度属于空间,第三个维度则留给了时间。每一帧,我们都会让时间坐标向前推进一点点,由于噪声在所有方向上都平滑,整个流场也随之缓慢漂移。涡流迁移,溪流弯曲,平静的区域开合变化。随着时间计数器递增,流场从一个参考系平滑地演化到下一个,从而产生了生动的动画。

要真正看见这些流动,我们必须把粒子放入流场中,让它们随着水流漂移。每个粒子都需要追踪它在上一步的位置,这样才能从之前的位置向当前位置画出一条短短的线段。当我们将几千个粒子在连续上千帧中反复更新时,它们就会沿着流场描绘出一条条曲线。由于流场本身光滑而连续,邻近的粒子会沿着相邻的路径运动,整体效果呈现出宛如染料在水中扩散般的流动线条。每个片段本身只是一条描边线,而颜色则由另一个独立尺度的噪声场来控制——在基础青色之上,叠加一层更细的噪声,使颜色在表面微微闪烁,而不是单调一致。也就是说,运动的形状来自噪声场,颜色则取自不同尺度下采样的另一个噪声场,两个通道虽然基于同一原语,却分别执行着不同的任务。

到目前为止,我们得到的一切其实还是一个静态的流场,要让真正活跃起来,还需要两个小改动。第一个改动就是刚才提到的时间维度——每一帧我们都把时间坐标往前推进一点,这个time-scale参数决定了演变的快慢,我们希望把它保持在一个较小的值,以营造柔和的变化而非剧烈的闪烁。这就是利用额外噪声维度作为时钟的诀窍,它把原本静止的渲染变成了鲜活的动画。第二个改动是让旧轨迹逐渐淡出,形成连续运动的错觉。为了制造这种淡出效果,每帧在绘制新线段之前,先在画布上覆盖一个半透明的深色矩形。这个alpha值经过大量调试,使得旧线段逐渐被“淹没”——粒子最近的轨迹明亮而醒目,半秒前的轨迹则开始消退,最终彻底消失。结果就是以一种极低成本实现的意外运动模糊,赋予水面那种反光且持续流淌的质感。调整这个alpha数值会改变整体的视觉氛围,数值高时轨迹几乎瞬间消失,数值低时则会留下长长的幽灵般的拖尾。

在边界处为了保持画面的可信度,粒子从一侧漂移出去后,会通过对向无缝铺装的方式重新出现在另一侧。当粒子包裹时,其之前的位置也必须同步包裹,以免在画布边缘留下难看的条纹。与此同时,流场中的粒子会逐渐螺旋进入少数吸引子轨道,并从画布的其他区域流失。为了保持整个表面的填充密度,每个粒子都被赋予了一个随机的寿命,当寿命耗尽时,它会在一个新的随机位置重生,并重置寿命。寿命值的初始相位也经过了错开处理,避免所有粒子在同一帧集体重生。

整个动画逐帧运行的过程大致是这样的:首先在画布上绘制一层深色半透明背景,使旧轨迹渐渐隐入深处;然后遍历成千上万个粒子,对每一个粒子,采样当前所在位置的流场方向,将其轻轻推向那个方向,并在粒子身后画出一条淡淡的彩色线段;最后,将时间向前推进一步,并调度下一帧的绘制。每秒重复六十次,最终就形成了我们看到的流动画面。

现在我们可以清楚地看到整个动画是如何组装起来的了。珀林噪声为流场提供了基础结构,第三维度的时间坐标提供了流动的方向,而半透明覆盖的淡出机制则赋予了画面运动感。所有这些想法汇聚在一起,最终呈现出的效果比每个部分单独看起来要复杂得多。完整的源代码可以在Squint游乐场上找到,而线上运行的版本则是基于Squint生成的perlin-flow.js。

原文:https://yogthos.net/posts/2026-06-17-perlin-flow.html