12 Noise 的基本原理:从随机格点到连续图案

前置知识:第 6 章的 floor、fract、mix,以及第 7 章的比例修正。
本章示例:格点、线性插值与平滑 value noise,完整源码。

学完本章能做什么

你将能解释程序化 noise 的三个环节:为格点生成稳定的伪随机数据、找到当前位置的邻居、在邻居之间插值。你也会知道“随机值”“value noise”和“多层噪声”不是同一件事。

先看现象

示例有三个模式。格点值模式是一格一格的色块;线性插值模式变成连续的明暗过渡,但格线附近常能看见方向变化;平滑插值模式让过渡更柔和。三个模式使用完全相同的格点值,只改变如何查询格点之间的位置。打开“显示格线”能看清单元边界。

1. Noise 不是每个像素独立抽签

如果每个片元都取一个互不相关的随机值,图像像电视雪花。许多自然效果需要附近位置得到相近的结果:移动一点,颜色或高度只变化一点。程序化 noise 通常是确定性函数 n(p):相同输入位置 p 总得到相同输出;邻近输入的输出按某种连续规则变化。

这里的“随机”是伪随机。它由固定计算产生,不随每一帧重新抽签。GLSL ES 没有一个可依赖的通用 noise(p) 内置函数;Three.js 的 ShaderMaterial 也不会自动给你注入一个。我们先自己实现一个简单的二维 value noise。

2. 把连续坐标拆成格点编号与格内位置

示例是一块正对相机的 3:2 平面。为了让每个噪声单元在屏幕上接近正方形,先把 UV 换成按目标平面比例修正的坐标:

// 片元阶段;p 以“每单位平面高度有 uScale 个格子”计量。
vec2 p = ( vUv - 0.5 ) * vec2( uAspect, 1.0 ) * uScale;
p.x += uOffset;
vec2 cell = floor( p ); // 当前整数格点 / 单元编号
vec2 local = fract( p ); // 单元内部的 0~1 位置

当 p.x = -0.2 时,floor(-0.2) = -1.0,fract(-0.2) = 0.8;两者相加仍为原来的 -0.2。因此公式在原点两侧都成立。uScale 增大时,同一平面容纳更多格子,图案看起来更细。uOffset 改变查询位置,整片已有噪声图案在平面上滑动;它不是每帧重生成随机数。

这里的 uAspect 是目标平面的宽高比。画布窗口比例变化而平面本身比例不变时,不应把窗口比例误用进 noise 坐标。若改用世界空间或物体表面坐标,则应按对应空间的真实长度重新定义尺度。

3. 给每个格点一个稳定的数

hash21 接受二维格点编号,返回一个看似无规律的 0~1 数。下面是示例里的教学用浮点 hash:

// 片元阶段;输入是适度大小的格点编号,输出在 [0, 1) 内。
float hash21( vec2 cell ) {
  vec2 q = fract( cell * vec2( 0.1031, 0.11369 ) );
  q += dot( q, q.yx + 19.19 );
  return fract( ( q.x + q.y ) * q.x * q.y );
}

两个不同的小数乘数让 x、y 以不同周期进入 fract,交叉的 dot 与乘法把分量混合,再次取 fract 把结果留在 [0,1)。19.19 是用于混合的经验常数,没有几何意义,也不应当作安全随机数种子。这个函数方便观察原理,并不保证各设备逐位相同;输入绝对值过大时,浮点精度还会使不同格点难以区分。第 17 章会专门讨论这些设备差异与替代实现。

只使用 hash21(cell) 时,单元内部每个片元得到同一值,边界发生跳变。这叫格点随机值图案,还不是连续 value noise。

4. 四个角点与双线性插值

对当前单元 cell,取左下、右下、左上、右上四个角的值:

// 片元阶段;cell 是整数格点坐标。
float a = hash21( cell );
float b = hash21( cell + vec2( 1.0, 0.0 ) );
float c = hash21( cell + vec2( 0.0, 1.0 ) );
float d = hash21( cell + vec2( 1.0, 1.0 ) );

float bottom = mix( a, b, local.x );
float top = mix( c, d, local.x );
float value = mix( bottom, top, local.y );

mix(a,b,t) 等于 a × (1-t) + b × t。先在下边和上边分别按 x 插值,再按 y 在两条边之间插值。在单元角点恰好得到该角的 hash 值;相邻单元共用边上的两个角,所以插值值在边界两侧一致。结果在 [0,1) 范围内,并且连续,但跨格线时变化速度通常突然改变,容易看出方格结构。

5. 平滑插值:让格线处的斜率衔接

先把格内位置 local 变成权重:

// 片元阶段;local 分量都在 [0, 1) 内。
vec2 weight = local * local * ( 3.0 - 2.0 * local );
float value = mix( mix( a, b, weight.x ),
                   mix( c, d, weight.x ), weight.y );

t²(3-2t) 正是 smoothstep(0.0, 1.0, t) 在区间内的多项式形式。它在两端的导数为 0,因此相邻单元不仅值相接,跨边界的一阶变化率也相接。这一版 value noise 是连续且一阶导数连续的;二阶导数一般仍在格线上改变。smoothstep 改的是插值权重,并没有改变那四个随机角点。

示例在最后把 value 映射到深蓝至暖黄的线性 RGB 颜色,并通过 #include <colorspace_fragment> 输出。颜色渐变用于观察数值,不会改变 noise 的 [0,1) 返回范围。

6. 如何使用这个函数

把上面的计算封装成 float valueNoise(vec2 p) 后,输入的 p 可以来自 UV、局部坐标或世界坐标;输入单位决定图案是贴在物体表面、随物体移动,还是固定在世界中。输出可用于颜色、透明遮罩、纹理采样偏移、顶点高度等。例如:

// 片元阶段;p 为按平面比例修正的二维坐标。
float n = valueNoise( p );
float mask = smoothstep( 0.45, 0.55, n );
vec3 color = mix( darkColor, lightColor, mask );

这把连续 noise 变成边缘柔和的斑块。阈值越高,亮色区域通常越少;实际覆盖率取决于该 noise 的数值分布,不能仅凭“阈值 0.5”断定正好覆盖一半。现在只用一层 noise。第 14 章才会叠加不同尺度的层次,讨论 fBm、湍流和域扭曲。

常见问题

本章小结

连续 value noise = 稳定的格点值 + 周围四角查询 + 平滑插值。floor 找单元,fract 找格内位置,hash 生成伪随机角点,mix 把角点连接起来。输入坐标的空间和尺度同算法本身一样重要。

本章练习