梯度噪声的隐藏优雅
摘要
对Perlin噪声的详细解释及其在ClojureScript中的实现,包括代码片段和移动水丝的动画。
<p><a href="https://lobste.rs/s/ept8fv/hidden_elegance_gradient_noise">评论</a></p>
查看缓存全文
缓存时间: 2026/06/18 16:04
# 梯度噪声的隐藏优雅之处
来源: https://yogthos.net/posts/2026-06-17-perlin-flow.html
你会如何渲染一个场景,让人联想到深蓝绿色的水面,从下方照亮,数千条微弱的青色丝线在其中漂移、旋转?你的直觉可能是使用着色器或创建粒子模拟,但你可以只用几百行算术运算就渲染出整个画面。这正是我们在本文中要做的事——使用 Ken Perlin 在 1985 年编写的同一个函数来渲染这些丝线,该函数原本是为了在一台无法真正绘制纹理的计算机上模拟纹理,如今我们称之为 Perlin 噪声。我将通过一个流动水面的可视化示例,向你解释 Perlin 噪声到底是什么,以及如何用一个噪声值引导数千个粒子,让它们沿着弯曲的流线运动,从而形成流动的表面。代码片段使用了 Squint (https://github.com/squint-cljs/squint) 的 ClojureScript 方言,但其中的思想与具体语言无关。
## 什么是 Perlin 噪声?
简单地使用随机值并不是创建自然纹理的正确方法。在每个像素上使用纯随机值会产生单调的噪点,混乱且颗粒感强。而真实表面(如大理石或水面)是平滑的,因为相邻点的值往往是相关的——这里明亮的大理石,在一毫米外很可能仍然相当亮。Perlin 噪声提供了一种生成这种结构化伪随机性的方法。它是一个从空间中的点到一个标量值的确定性函数,具有三个使其在图形学中神奇的特性,具体如下:
- 附近的输入产生附近的输出,没有接缝,从而实现平滑过渡。
- 相同的种子总是给出相同的输出,因此纹理在帧之间保持稳定。
- 没有偏好方向,使其各向同性,不像简单的模糊随机点网格。
在内部,它只是三步生成的梯度噪声。首先,我们需要一个网格形式的瓦片空间,然后在每个角点上放置一个伪随机梯度向量来提供方向。对于单元内的任意点,我们需要通过点积计算每个角点的梯度指向该点的强度。最后,我们将周围角点的贡献混合起来。
简单的线性混合会在每条网格线上留下难看的可见折痕。Perlin 则通过一个淡入淡出曲线来传递插值参数,该曲线是一个多项式,其形状在起点和终点处都是平坦的,从而使值平滑地过渡到每个角点:
```clojure
(defn fade [t]
(* t t t (+ (* t (- (* t 6) 15)) 10)))
```
上面的公式就是 `6t5 − 15t4 + 10t3`,其一阶导数在 `t = 0` 和 `t = 1` 处均为零,这恰好保证了输出在单元边界上是平滑的。线性插值本身也同样简单:
```clojure
(defn lerp [t a b]
(+ a (* t (- b a))))
```
梯度查找将角点哈希到一组固定方向中的一个,并返回该方向与单元内点的偏移量的点积:
```clojure
(defn grad [hash x y z]
(let [h (bit-and hash 15)
u (if (< h 8) x y)
v (if (< h 4) y (if (or (== h 12) (== h 14)) x z))]
(+ (if (zero? (bit-and h 1)) u (- u))
(if (zero? (bit-and h 2)) v (- v)))))
```
一个带有种子的伪随机数生成器在构造时打乱恒等置换表,以决定每个角点获得哪个梯度,从而使得场可重现。调用者无需关心这些细节,只需将所需的 `x`、`y` 和 `z` 传递给 `noise3`,即可获得一个平滑的值。Perlin 的原始输出大致在 `[-1, 1]` 范围内,实现将其重新映射到 `[0, 1]`,以便下游消费者可以线性地将其缩放到自己的正数范围:
```
(/ (+ 1 n) 2)
```
这就是整个噪声引擎的简要概括。现在我们有了噪声,让我们看看如何用它来创建平滑的动画。
## 从数值到流动
平滑的标量值固然不错,但如果我们想创建一个沿特定方向运动的动画呢?好吧,要做到这一点,我们只需将噪声值视为一个角度,从而得到一个指南针方向。然后,我们将其乘以一整圈(`2π`),这样整个 `[0, 1]` 范围就能映射到所有可能的方向:
```clojure
(defn create-flow-field [{:keys [noise noise-scale force-scale time-scale]
:or {noise-scale 0.003 force-scale 1 time-scale 0.15}}]
(let [noise3 (:noise3 noise)]
{:force-at (fn [x y t]
(let [theta (* (noise3 (* x noise-scale) (* y noise-scale) (* t time-scale))
js/Math.PI 2)]
#js {:x (* (js/Math.cos theta) force-scale)
:y (* (js/Math.sin theta) force-scale)}))}))
```
用这个技巧,我们得到了一个流场,可以询问某个像素在 `(x, y)` 处的速度向量。由于底层噪声是平滑的,附近的像素获得几乎相同的方向,整个场看起来就像一幅连贯的洋流图,包含漩涡、平静区域和汇聚的溪流。
`noise-scale` 旋钮控制流场的缩放系数。在采样前缩小坐标,会以粗分辨率对噪声进行采样,产生宽广而缓慢的漩涡。相反,放大坐标会产生细碎的小漩涡。细心的读者可能已经注意到,该函数接受第三个坐标 `t`,我们稍后会回到它。现在,我先留个提示:这将是实现运动的关键成分。
## 绘制曲线
为了实际看到流场,我们需要将粒子放入场中,让它们随波逐流。每个粒子需要跟踪其之前的位置,因为它会被当地的流场所移动,这样我们就可以画出一条从它之前位置到当前位置的短线:
```clojure
(defn update-particle! [p force]
(set! (.-lifetime p) (dec (.-lifetime p)))
(if (neg? (.-lifetime p))
(respawn! p)
(do (set! (.-prevX p) (.-x p))
(set! (.-prevY p) (.-y p))
(set! (.-x p) (+ (.-x p) (.-x force)))
(set! (.-y p) (+ (.-y p) (.-y force)))
(wrap! p (.-width p) (.-height p))))
p)
```
当我们连续几千帧对数千个粒子运行这个函数时,它们会在场中留下一条轨迹。由于场是平滑且连续的,相邻粒子留下相邻的轨迹。整体效果看起来就像染料在流动的水中扩散所形成的流线。每个线段本身只是一条描边的线,其颜色由第二层更精细的噪声着色,因此色彩会在表面上闪烁,而不是显示为单一的纯青色:
```clojure
(defn- draw-segment! [p noise2]
(let [v (noise2 (* (.-x p) 0.004) (* (.-y p) 0.004))
hue (+ 185 (* v 30))
light (+ 55 (* v 25))]
(set! (.-strokeStyle ctx)
(str "hsla(" hue ", 80%, " light "%, 0.3)"))
(doto ctx
(.beginPath)
(.moveTo (.-prevX p) (.-prevY p))
(.lineTo (.-x p) (.-y p))
(.stroke))))
```
因此,运动的形状来自噪声场,而颜色则来自另一个以不同尺度采样的独立噪声场。这样,我们就有了同一个基本原语的两个通道,分别完成不同的工作。
## 两个使其动起来的小改动
到目前为止,我们所做的一切产生的是一个静态的流场。接下来,我们需要两个小改动,将其变成一个生动的动画。
### 1. 时间只是第三个维度
还记得 `force-at` 中那个未使用的 `t` 吗?我们之前说过会回来讨论它。其实,我没有提到的是,Perlin 噪声可以定义在任何维度上,而这里的实现实际上是 3D 的。前两个维度是空间,第三个维度是时间。每一帧,我们都让 `t` 稍微增加一点,由于噪声在所有方向上都是平滑的,整个流场就会随之漂移。漩涡移动,河流弯曲,平静区域开合。当我们递增计数器时,场会平滑地从一帧演变到下一帧:
```clojure
(swap! state update :time inc)
```
`time-scale` 参数控制这个演变的快慢。我们希望它保持较小值,以产生柔和的变化,而不是频闪效果。就这样,使用一个额外的噪声维度作为时钟,将静态渲染变成了动画。如果你想知道,你也可以自由地推广它——3D 动画也可以用 4D 噪声类似地创建。
### 2. 轨迹逐渐沉入深处
最后一步是确保我们的轨迹随时间逐渐消失,从而产生连续运动,旧轨迹淡出,新轨迹随时间出现。为了达到闪烁的效果,我们希望避免完全清除画布。相反,每一帧在绘制新线段之前,先在场景上绘制一个半透明的深色矩形:
```clojure
(set! (.-fillStyle ctx) "rgba(3, 18, 26, 0.03)")
(.fillRect ctx 0 0 width height)
```
`0.03` 的 alpha 值起了很大作用,它产生了旧线段慢慢被淹没的效果。粒子最近的轨迹明亮发光,半秒前的轨迹开始淡出,然后完全消失。结果是一种廉价且偶然的运动模糊,赋予了水面反射且持续流动的特质。调整这个 alpha 值会改变整体氛围——值越大,轨迹消失得越快;值越小,轨迹会模糊成长长的幽灵状条纹。
## 连接边缘
另一个需要考虑的问题是如何让表面在边界处看起来可信。在这里,我们可以让从一侧漂出边界的粒子出现在另一侧,使用环形无缝平铺包裹。当粒子包裹时,它的之前位置也需要一起包裹,以避免在画布上画出难看的条纹:
```clojure
(defn- wrap-delta [v extent]
(cond (>= v extent) (- extent)
(neg? v) extent
:else 0))
```
在流场中,粒子会螺旋进入少数几个吸引子轨道,并从画布的其他区域流出。为了让整个表面一直有粒子填充,每个粒子需要有一个随机的生命周期,这样当它过期时,可以在新的随机位置重生并重置生命周期。生命周期也需要从一开始就错开,这样重生不会集中在同一帧。
## 主循环
以下是整个机制每帧运行的方式:
```clojure
(defn draw []
(let [{:keys [width height particles time]} @state
force-at (:force-at field)
noise2 (:noise2 noise)
n (alength particles)]
;; 淡出旧轨迹,沉入深处
(set! (.-fillStyle ctx) "rgba(3, 18, 26, 0.03)")
(.fillRect ctx 0 0 width height)
(set! (.-lineWidth ctx) 1)
(set! (.-lineCap ctx) "round")
;; 对每个粒子:采样当前流场,移动,绘制线段
(dotimes [i n]
(let [p (aget particles i)]
(update-particle! p (force-at (.-x p) (.-y p) time))
(draw-segment! p noise2)))
;; 推进时钟并调度下一帧
(swap! state update :time inc)
(reset! raf-id (js/requestAnimationFrame draw))))
```
如果你从上到下阅读这个函数,可以看到发生的具体步骤。首先,在画布上绘制一个深色背景;然后,对每个粒子(共几千个)采样噪声场,看看水流向哪个方向;接着,粒子被推向那个方向,并在每个粒子后面画出一条淡淡的彩色线段。每秒执行六十次,就形成了最终的动画。
## 为什么这样有效
现在我们可以看到整个动画是如何组合起来的。噪声为流场提供了基础,第三个维度提供了时间箭头,而淡出效果创造了运动感。所有这些思想组合在一起,产生了一种远大于其各部分之和的复杂感。
完整的源代码可以在 Squint playground (https://squint-cljs.github.io/squint/?src=gzip%3AH4sIAAAAAAAAE51a%2FY7bNhL%2FP08xcVGclKwd2fvRrH29y26vvRboHYI2wCU1Fi0tjSxmZVIlKVvaIIe%2Bw90T9kkOQ4qS%2FLm5CotYEueLM8OZH6kECaYCVmW%2BQKXq8wnMNWJy9wRgNoMfERNM4PUP%2F%2Fw7cA2lxgSkyGvYZDxHWJQ8T7hYgskQClSr0jDDpQDDFjmOngAEORqYMwjWMmeG5%2FgUglJovhSYDBfcDHXGUzNUfJkZIMUQhSEpBwhSAXN3CxCs9YYVT4G519UdBMQtFQTPoYKoukomiy%2Bu08uQBISey2lfQ%2FBev%2FgHM9mIr8rcsVZSwSt2wppXDMaXragjl7diDK%2FYDq2BTlHwHI5bQSNHrWBr%2BOIxI47YdDUGtu6csX%2BxdeNqy%2FXihBWtreYEkYHxRUj%2B7yu8mFxfXF99Mbm%2BCul68iSw%2BZayBGFu7iB4BsRpyEX2Phi636vQuh%2FGNp6OK0dVwNwAg8Wd9WnLsgDWF79ULIF5xnQGFdTwQNN0yZC5uTCRgB0eX3bmlhDwFII%2FQwYvQ2Lshtbd0EUItXuiwH75JWQwnoT%2BjjxQwUObxM8d6QMq%2BdeeahiHIekbQtkF6DDlJAxJ%2FRAolu0Uh8CUYvXQLYs5Uwo4vO%2FmaVYFBGyJhuiAe3OY9m96o%2B8bE7rR92BWRc%2BfsUJmcCgk19gVCKeJ1j0EXBg5tCZBoJhYIkwur3q5V7gJ9qmwQGbgcjyhJdsSKrEECHr1iNy8XRlmM%2FiG6wzV77%2F95x0zqEFnZZrmCDK1pYgnKAw3db8mnVkhmMCi7lU7qlEAQSI1%2Fgpz3tl%2BCREMx%2B36CPretjPm3XJOc0mp8AwCJZYhxZxTfP3Sm83gb7Jc5JhALuV9WbjyCFpCUMzf3sFzeBfCRrFCQ5wjE3ndmmX4CjUZdjmedLZQmIo2hC4APmE4Gd%2FqdiGycTtvSqdfD%2Ft1w9LayQyr3dlVJ2qQY6l3WepHWR52WR5OsLzt5uhtpJkeZ3i3y1A%2FwvDTLsPDIwxpReuy8vacoqyJsvaGnKJ8IMoHb8EJyhICW0bTU4rXnuiUzo0nOqVuNoNYKoFqSNWVozBTm89QFlDMeVLdAXkO1ywvmUFbg10eH7yWLh15UgGrgNXAHu4gsIW7SWvgSRW2g6ea4I0rtA3b2xDenSK%2B6RPfhPDTKeLbPnHARQw34WmWW9hjeRueNul2y6TbR%2BTvm3T7iEmzGRjFcy6QKeDCoCosHJPCV8yXEJcLHLoAgw%2BwPi5SULlQBWwehyaOcP2pGMaRlxAsKVRpRWsnfQjpmfyUYEwZ37wN%2F4jUW7twraA6bGXfdrL7Y5%2Bi4o%2FP0GXUTdjM0yl%2BCK1FLrQ34dacW4rwD2u7tdraOR7QedvTuU8X%2Bj68d81m8BpVzgXI0hSloT3Dv%2BfD8dn4bgYKV6wAI2EenY3vjlQGQqHPYQwiJNzTn6PtYZO2h91B0HQ1amgtNAD4MG0omx%2F3eO4ezz9aUDObUXndpBzzBJ5DwZThcY56B%2B4QzdARzT9M77HWTSt1woY6ZjlCKlXs76ldu9sjHrLXVCr40BcRjaLofEvQuCcKotH48uPHDnH5dr41tcYBH6ZODDPOgLbnm9aiBiBmaCx%2B9n4MnkHVn1hIb%2Bq9N6Zn2dEk9F399Xcw6SfLZ%2B81fJhWVq2niaWmEmRY2PfAruRpvcWkuTjE9DGkv50w%2BvB2QSR%2FbHhiMsjQbl1yniJN63jUbMw8Gc3vO5FywU3tAuMnVpGhNUwLheu39EQ376D2cqetiPZmumLV97tvW3pnpvt32ljrfj66RP5WbiBlChjEUqqEC%2Bq9q1IbsHszWnA59eUFi%2B%2BBC80TBKwMCjOCG0gVW%2BGfNAlayTWuUNhFy%2FINqzVsMM%2BhFAkq%2BMXx%2FHJGsJWB5mKZowWtRI9Clsts5Lcm9HqYYG4YzNeNNpu%2BsRQJBH%2F5EvzbkMBOc9ttRQUu%2Fwrr0D64wXZsirlG%2FxB1sSaVT2FebMW1WzJJBUHPqmA0rKAIHXGnOKl3qWqicsL8FmqToRgKafxWLWnhV6DRPG1lU5NubpMq3KNxGdLStY9EGx7RVO9JqTsJ9japD2t6t63pnae11IXLpNe%2BDIIuuGI5IQWKdYobYMYoFhupQKoFN9pCvUQxV%2Bo9ilCo%2FT0JjJlYM00ZqDCuaQWaDFf0zCBVqDPIZexgyD1iYcsAbDJJe6NSpSxGKGRR5sxgMiKBZDqlW8GFwIQExaVSlLNaNgZQsqIi0zYahARtFLJ7YLGSulGgWNFmqkJdsI2gzOnV1%2B0CpZhI5Cok37nkKnp1T9THaJvFWrRHAVvZIai17uWCaHJpO76Cuu9eNEWTDe1IW1Eo1KNhv6zQXr5dKmWR9MuiXTW2iFoPHJRGrX%2F7nXWBPbCwi3Vn0BnWOrdoXiTy%2BCpwfjmRvM4Zj641%2B2un0zsHPLZc7O8usSslRT%2Fe2%2FFsFw3l5IYZVPD7b%2F%2F16e5ykIul87cUMTZD3ZEJ5XEwWqL5Orc197b%2BLqGmksi4tDV4YMUO%2BjXHZX8wUkiV2j5tbfIpQq7jNUK3BToTvMSeT3gCcdgq3B2NtX6DlaFbbeocId5p%2BoOE6yJn9XSRy%2Fh%2BVkjNaUVPU15hMjOymEazHFMzjWYPQy4SrKbX19fXs0LajcgQ17TRmAopsNM%2BYkWBIvkq43lCqhcyqfvTCSH2tHGb2c7TpnKe%2FUoKQ4Y3URlMkkFzjmjpkkJBsOLCneKNhgmueYyveYX5D1SSSNuGi0RuQhhbMOqUODwEwdaJ2CSaXERXER3UWhqHGYN9GNmA00bIdBcDjl%2FCdAsFji5gugUEoyi6bhEOaEPNPmBGruBDAxWiFiZEMG2RrQUo8zsnDSISYSUollICOBGC512VUKj5AxWHri5ubLoIgepfVlXnojYjspbkW2dER7NTBZ21LjrhgbO0DYWobYWeq5nacbbsIJtT1uWwY6eqahRsYFBUgyOajvBkWzwjjeaNYkKnUq1sClJ6RRD5Xy%2FbHh%2B6qDGtZdziuw7b9Qo1HVi2ZVpvhcIj2S208xFeWdldcyKllOSTqyjac9YL6%2BaegBCuot7XE7BHwcR1Q4efIx%2FB9lTyZxBtNbFWUQug8nodHWyK40n%2FoLc7Fd7F6bs7BM%2B%2Fg9ZOXDu7BS%2BgmeYnSTgOvD%2BJfQvnf%2BztgeirmmHKALI4a7eewOirhrMTbGkELoCAlvVpA29SrrSBDVt3IZ7NCHA1nVYTONKFQpZYWKaYyVCByZggXvpSRx%2FnMgTNVujAf7cRP9z6D7iRxnuBDEZFqTObLA0uOJzqXTFiyi5Rap9vaKrWFRqXtlMtatpfIG0VziDldB7lymXBtLaekAJB8aKwolpgR9I8bOxPnLxBM2caiDHNmYFY5rJULQoktDhs9Fs45M4OuuW2dhP1ZxDPWrRBFfnCeamBFO5NryKWtF2hQ42Xl0S3hvP%2BKshdlXkOl83o5HIPMGqj5D3%2B6MqQscDRKBhkOmfBwCoYnMHL6PMzGDTyBp%2BfQTQ6D9sKlUgCy6ZqG%2BwCl1y8Zt16CkYEK97IXVTmAVhHR%2BeIjq46BMxGztw%2B4iT%2FPla9ulMYe8CwX8z8uQYE7RGHa7Q9MO7iA%2F5opDkE6hG0OctyFEvCdl5r923JVvK8jCkZE6buIeV57nLUrhgwXBhNuU07GsV4rt3u2sgNU4ldXwliceYFLvnafyT3CUorm3ABF8szUJjmGBu%2BJr6Y%2Ba8%2FPvykvhf8gVouWHB%2BBuOXZzC5okBHFOmmFRH1Dxgb24aoBW1V%2BN0dg0DXyq3k8YHhr1jh1cpSJF5N78vUTg%2Fwnx27cPqPj5Zmb%2FNRQNBFdjulbCL0C832OvXL9GDNcXoavMNF3G1JaHIe9rzXLxT%2BWqI2N4Kv7Fb0Gxti0tTPYG1Ltkthuy23c%2BUJvHKi7qysmIkY8x1RPHF7pQZQ2fudzh5%2BWl%2B%2FO50Xn0Xn48mYfWImeJv%2BH3f0cPbPbjrDnGuDAhVJG7Ek%2BZrg%2FPfNyw79wcDRD5r%2F1tG5Y98ZVtFnPwfW5%2BH%2FAAdbYsWgIgAA) 查看,而上面运行的版本来自由 Squint 生成的 perlin-flow.js (https://yogthos.net/files/perlin-flow.js)。
相似文章
@docmilanfar: 我非常喜欢我们最近关于"Geometry of Noise"的论文的解释性文章 arXiv:2602.18428
本文提供了理论解释,说明为什么扩散模型可以在没有显式噪声水平条件的情况下生成干净的样本,将其归因于高维几何,并分析为什么某些模型参数化成功而其他模型崩溃。
@pallavishekhar_: 梯度下降背后的数学原理 在此阅读:https://outcomeschool.com/blog/math-behind-gradient-descent…
这篇博客文章通过逐步的数值示例和直观理解,解释了梯度下降(训练机器学习模型所使用的基本优化算法)背后的数学原理。
利用梯度惩罚潜在动力学实现平滑梦想与高效采样
GPLD为DreamerV3引入了梯度惩罚潜在动力学正则化器,强制转换学习中的局部平滑性,提高了连续控制任务(尤其是复杂运动)的样本效率。
Show HN: Inkwash——一款水彩素描应用及技术解析
Inkwash 是一款基于 WebGL2 的水彩素描应用,可模拟颜料流动与纸张交互,由 Claude Fable 5 生成。本文解释了实现逼真水彩效果的浮点纹理与着色器技术管线。
Clojure 速度几乎媲美 C(需借助一些优化)
本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。