噪声:随机数不是一个函数
全课题骨架:程序化生成 = 不摆位置、只定规则 —— 同一颗种子 + 同一个规则,必须长出同一片内容。
而"像自然"靠两件事:横着看,每个格点上一个随机数,中间按核平滑地连起来;竖着看,把很多层按幂律叠起来。
这一课只讲第一根柱子:噪声 = 一张"看起来随机、其实处处光滑"的场。
而它的起点是一个极容易被忽略的事实 —— 随机数发生器没有"距离"这个概念:它能给你第 7 号和第 8 号两个数,
却不知道"7 和 8 挨得很近,理应给两个相近的数"。所以直接把随机数当高度,得到的是一片雪花,不是地形。
五段路:把随机数绑到格点上(12.1)→ 决定格点之间怎么过渡(12.2)→ 决定格点上放什么(12.3)→ 把很多层叠起来(12.4)→ 总账(12.5)。
每一段只增加一样东西,其余控件一个字不动。
保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 噪声本来就是"每个像素算一个标量函数",
与真机在片元里逐像素求值是同一件事,只是并行度不同;读数真机可证 —— 格点恒等为 0、核的端点导数、三种跳变、
值域界、极值位置、2/3、功率谱斜率、可复现,都是本页真算出来的数,每条都能拿笔核。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「12.2 格点之间怎么过渡」,再依次点四个「插值核」按钮 → 盯住格点那条竖线(切到「灰度 + 格点」看得最清)→ 零阶跳变被"线性"杀掉、一阶跳变被"三次"杀掉、二阶跳变只有"五次"才压得下去;而格点上的值一位都没变。
本帧的读数
(脚本启动中…)
12.1 随机数不是一个函数
本段只增加一样东西:格点随机(块内常数)(旋钮是「格点数 n」;后面的核、格点上放什么、gain 都还没登场)。
想要"一片随机地形",最直觉的做法是每处取一个随机数。可随机数发生器不是空间上的函数 ——
它认得"第 i 号数",不认得"x 与 y 挨得近"。于是相邻两处取到的数毫无关系,画面就是一片雪花。
这一段的做法是把它退到最朴素的形式:格点上各放一个随机数,格内取常数(核取"低角"那一档)。
于是 n = 960 时一格正好一个像素,就是标准的白噪声;把 n 调到 8,就变成一格 120 px 的大方块 ——
但块内依然是常量,块与块之间是断的:没有任何一段斜坡。
这件事可以直接量:相邻像素平均绝对差,在 n = 960 时实测 0.659(每像素独立随机数的理论值 E|a−b| = 2/3 = 0.6667),
到 n = 4 只剩 0.0010;而 lag-1 自相关从 0.059 跳到 0.997。
它只有"完全无关"和"完全相同"两端,中间那种"相似"在这一档里根本不存在。
| 格点数 n | 一格多少像素 | 相邻像素平均绝对差 | 与 2/3 之比 | lag-1 自相关 |
|---|
12.2 让相邻的地方像一点:格点之间怎么过渡
本段只增加一样东西:格点之间的过渡方式(插值核)(格点数沿用上一段,其余控件一字不改)。
上一段的问题很清楚:格点之间有"值",但没有"过渡" —— 所以相邻像素要么完全一样、要么毫无关系。
要让它们"渐渐变过去",就得让每个像素读的是周围四个格点值的加权平均,权重只由"离哪个格点多近"决定。
于是问题变成:这个权重函数(核)取什么形状?四档正好是一条完整的演进:
最近邻(恒取低角 = 上一段的块)→ 线性 t → 三次 3t²−2t³ → 五次 6t⁵−15t⁴+10t³。
判据是"在格点上光不光滑",也就是三种跳变:零阶(函数值本身跳)、一阶(斜率跳)、二阶(曲率跳)。
实测(格点 x = 4、y = 3.5、步长 h = 1e−3):零阶 5.49e−1 → 6.27e−4(线性杀掉);一阶 1.65e−1 → 9.87e−4(三次杀掉);
二阶 3.76 → 3.75e−2(只有五次才压下去)。
★ 这正是 Perlin 2002 那篇论文的全部内容 —— 摘要原话:Two deficiencies in the original Noise algorithm are corrected:
second order interpolation discontinuity and unoptimal gradient computation;
把 3t²−2t³ 换成 6t⁵−15t⁴+10t³,因为后者 has zero first and second derivatives at both t=0 and t=1。
★ 还有一件事必须当场说清:四个核在格点上的值逐位相同(差 0) —— 格点是骨架,核只是肉。
| 核 | k(0.5) | k′(0) | k′(1) | k″(0) | k″(1) | 零阶跳变 | 一阶跳变 | 二阶跳变 |
|---|
12.3 方格的痕迹从哪来:格点上别放值,放方向
本段只增加一样东西:格点上放什么(一个值 / 一个方向)(核、格点数、显示方式都不动)。
上一段把"过渡"修得很漂亮了,可只要退后一步看整幅图,方格还在。原因是上一段量出来的那条性质:
值噪声的起伏永远"长在格点上"。这不是巧合 —— 格内任一点的值是四个角值的凸组合,
它不可能超过四个角里最大的那个,所以全局极值只能在格点取到(实测:采样极值落在格线上,
到最近格点的距离 0.0083 / 0.0083 格,而采样网格自己的步长是 0.0167 格 —— 差在分辨率以内)。
要让它不长在格点上,就得让格点上的数不再是"峰/谷"。Perlin 的做法很干脆:格点上放一个随机方向(梯度),
取"这一点相对格角的偏移 d"与它的点积。那么在格点上 d = 0 ⇒ 点积 = 0 ——
格点从"极值"变成了"零点"。(★ 诚实标注:这条"格点处恒为 0"的性质,Perlin 本人两篇论文里都没有点明;
公开的权威推导见 Matt Zucker 的数学 FAQ 与 HandWiki 的 Perlin noise 条目,出处见页末。)
实测:切到"方向"之后,格点处的 |值| = 0.00e+0,而采样极值挪到了格点之间(离最近格点 0.4917 / 0.3250 格,
是网格步长的 20~30 倍)—— 方格痕迹就是这么消失的。
顺带一个可手算的量:单位梯度、二维时,取值范围的上界是 ±√(N/4) = ±1/√2 = 0.70711;
实测 2304 个格上的 min/max 是 −0.6815 / 0.6738 —— 贴着上界,但从未越界。
| 档 | 格点处的 |值| | 采样网格极值 | 极值离最近格点 | 全场 min / max | 与格点值区间的关系 |
|---|
12.4 一层不够:按 gain 把倍频叠起来
本段只增加一样东西:把 5 个倍频按 gain 叠起来(层数固定 5,唯一旋钮是 gain;其余控件不动)。
上一段已经有了"光滑、无方格"的场,可它只有一个频率 —— 看上去是一堆一样大的气泡,既不像山也不像云。
真实的云与地形在所有尺度上都有结构:大形状只有几个,小起伏却多得数不清。
做法只有三行(IQ 原文的写法):t += a * noise(f*x); f *= 2; a *= G; ——
频率逐层翻倍、振幅逐层乘 gain,于是"把横向放大 U 倍"等价于"把纵向放大 U⁻ᴴ 倍",也就是自相似。
这一段的账要分成两件不同的事来算,这是本课最容易被含糊过去、也最容易被写错的地方:
① 每层贡献的振幅比 = gain(表④ 实测 gain = 0.5 / 0.6 / 0.707 / 0.9 时分别是 0.510 / 0.612 / 0.721 / 0.918,
理论就是 gain 本身;能量比 = gain²,实测 0.260 / 0.374 / 0.520 / 0.842,理论 0.25 / 0.36 / 0.50 / 0.81);
② 合成场的功率谱斜率 ∝ f^−B,其中 B = 2H+2(二维径向口径)、H = −log₂ gain。
gain = 0.50 时实测 −3.85,理论 −4.00;gain = 0.707 实测 −2.86(理论 −3.00);gain = 1.00 实测 −1.88(理论 −2.00)。
★ "每层振幅减半"不等于"幅度谱 ∝ f⁻¹"。后者只在"直接拿每层振幅对频率"这种口径下成立;
按功率谱密度的定义,要把那一层的带宽除掉,于是指数上多出一个 1(二维再多一个 1,维数也进去了一项)。
换成一维剖面口径,同一张场的指数是 −(2H+1):实测 −3.01 / −2.53 / −2.11 / −1.54 / −1.30
(理论 −3.00 / −2.47 / −2.00 / −1.30 / −1.00)。
这正是 Inigo Quilez 那篇 fBm 里那张谱图的口径(H = 1、G = 0.5 ⇒ 9 dB/octave、能量 ∝ f⁻³)。
这一条是本轮写设计稿时写错、被一手源纠正过来的,所以特地摆在页面上。
| gain | H | 每层振幅比 | 每层能量比 | 2D 谱斜率(实测) | 2D 理论 −(2H+2) | 1D 谱斜率(实测) | 1D 理论 −(2H+1) |
|---|
12.5 总账:这一课能拿笔核的东西
本段不增加任何变量(控件与 12.4 完全一致),只把四条链的账并排放在一起。
回头看这五段,其实只有三件事:把随机数绑到格点上(12.1)→ 决定格点之间怎么过渡(12.2)→
决定格点上放什么(12.3)→ 把很多层叠起来(12.4)。骨架从第一段起就没再动过,
每一段只是把其中一样换掉。
这一课最值钱的不是那些"很小"的数,而是那几个恰好为 0 的数:格点恒等(四个核差 0)、
方向档在格点上的取值(0.00e+0)、以及"零阶/一阶/二阶跳变里恰好有一阶变成 0"——
能被断言为 0 的东西,才是真正证过的性质。
还要如实说清两件工程上的事:① 本页整幅同步算完,1 层约 30 ms、5 层约 130 ms(960×600 实测),
所以不做分片 —— 代价是拖 gain 时会有约 0.1 s 的迟滞;② 本页的梯度是二维、单位长度的 64 个方向里挑一个,
而 Perlin 原文用的是三维的 12 个棱方向(模长 √2,再 padding 到 16 个桶以免除法)——
两套约定只是"梯度集合"不同,算法结构一字不差。
下一课指针:这张场现在能画"云"和"起伏"了,但它还是"一团的"——
把坐标本身拧一下(f(p + h(p)),域扭曲),同一张场立刻能长出山脊与峡谷;再往下一课就是把"规则"从
"相邻处要相似"换成"每个 N×N 的局部图案必须来自输入"(WFC)。
| 看什么 | 本页实测 | 能不能拿笔算 |
|---|
这一课的位置
五段只做了一件事:把"随机"从一个没有距离概念的数,变成一张处处光滑、且在各个尺度上都有结构的场。
12.1 与 12.2 一起回答"横着怎么连"(格点给骨架、核给肉),12.3 回答"骨架该长什么样才能不露出方格",
12.4 回答"竖着怎么叠"(自相似)。
这也解释了几件工业上的事:为什么贴图里那些"程序化噪声"总是"格点 + 核 + 分形"三层结构;
为什么 Perlin 2002 要把三次核换成五次核(不是"更好看",而是二阶不连续会在跨格线处留下可见的痕迹);
为什么"值噪声"在 GPU Gems 之后基本被"梯度噪声"取代(值档的极值锁死在格点上,方格是结构性的、不是分辨率问题);
以及为什么程序化地形一定要 fBm —— 单一频率的噪声无论怎么调,都只能是"一堆一样大的气泡"。
本页那一格"如实说明":① 画面是 CPU 逐像素(示意 · 等价重现),真机上它在片元里做同一件事;
② 梯度集合是二维、单位长度的 64 个方向,Perlin 原文是三维的 12 个棱方向(模长 √2,padding 到 16 个桶);
③ 本页的 fBm 做了 t / Σa 归一化(只影响显示幅度,不改谱斜率);
④ 功率谱斜率报了两个口径,因为这两个口径在文献里都被使用过,差的就是"维数那一项";
⑤ 整幅同步算完(1 层 30 ms / 5 层 130 ms),所以拖 gain 时有约 0.1 s 迟滞 —— 这是如实写出来的代价,不是没发现。
本页用到的做法,出处在哪
- 噪声的参考实现与置换表 —— Ken Perlin 本人页面《Java Reference Implementation of Improved Noise》(2002):
fade(t) = t*t*t*(t*(t*6 - 15) + 10)、lerp、grad(hash & 15, 注释明写 INTO 12 GRADIENT DIRECTIONS)与 256 项置换表p[]: mrl.cs.nyu.edu/~perlin/noise/。 - 为什么是五次核 —— Ken Perlin,Improving Noise(SIGGRAPH 2002):摘要原话
"Two deficiencies in the original Noise algorithm are corrected: second order interpolation discontinuity and
unoptimal gradient computation";旧核
s(t)=3t²−2t³的二阶导在 t=0/1 处不为零 ⇒ 跨整数边界二阶不连续; 新核 "has zero first and second derivatives at both t=0 and t=1";12 个棱方向的两条理由(避开轴向聚簇、省掉 24 次乘法) 与"padding 到 16 个方向以免除以 12": mrl.cs.nyu.edu/~perlin/paper445.pdf。 原始论文(1985,ACM 标记为 Seminal Paper): mrl.cs.nyu.edu/~perlin/doc/oscar.html、 dl.acm.org/doi/10.1145/325165.325247。 - "格点处恰好为 0"(★ 诚实标注:Perlin 本人的两篇论文都没有点明这一条)—— Matt Zucker《The Perlin noise math FAQ》:"the noise function becomes zero when x and y are both whole numbers ... the dot product of that vector and g(x₀,y₀) will evaluate to zero": mzucker.github.io/html/perlin-noise-math-faq.html; 另有 "the noise function will pass through 0 at every node": handwiki.org/wiki/Perlin_noise。
- 取值范围 ±½√n(二维 ⇒ ±1/√2 ≈ 0.707)—— "the output range of Perlin noise is not −1 to 1. It's ±½√n": eev.ee/blog/2016/05/29/perlin-noise/。
- fBm、域扭曲、值噪声与梯度噪声 —— Inigo Quilez(作者本人四篇):fBm 的三种写法与
"G = 2⁻ᴴ"、H=1(G=0.5)对应 9 dB/octave、能量 ∝ f⁻³:
iquilezles.org/articles/fbm/;
域扭曲
f(g(p))与多级套娃(下一课的正题): iquilezles.org/articles/warp/; 值噪声 = "格点随机值 + 插值"、并点出 cubic 版的连续性瑕疵: iquilezles.org/articles/morenoise/; 梯度噪声 = "格点上放梯度、对偏移量做点积": iquilezles.org/articles/gradientnoise/。