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、湍流和域扭曲。
常见问题
- **每格都是纯色、没有过渡:**只显示了
hash21(floor(p)),没有取四角并插值。 - **出现明显方格边:**确认使用
weight而非直接用local;也要检查是否把格线辅助显示打开了。平滑插值只能减弱格状方向感,不能保证完全看不出网格。 - **噪声在宽平面上被拉伸:**构造
p时没有用目标平面比例修正,或误用了画布比例。 - **图案滑动时突然跳变:**若把
uOffset加到floor(p)之后,格点编号会整格跳;应先平移连续坐标,再求floor与fract。 - **每次刷新图案都不同:**检查是否在 JavaScript 中随机更改了种子或坐标;本章 hash 对固定输入应给固定结果。
本章小结
连续 value noise = 稳定的格点值 + 周围四角查询 + 平滑插值。floor 找单元,fract 找格内位置,hash 生成伪随机角点,mix 把角点连接起来。输入坐标的空间和尺度同算法本身一样重要。