← 图形原理 · 交互课 课题八 · 程序化生成 · 第 1 课

噪声:随机数不是一个函数

全课题骨架:程序化生成 = 不摆位置、只定规则 —— 同一颗种子 + 同一个规则,必须长出同一片内容。 而"像自然"靠两件事:横着看,每个格点上一个随机数,中间按核平滑地连起来;竖着看,把很多层按幂律叠起来。
这一课只讲第一根柱子:噪声 = 一张"看起来随机、其实处处光滑"的场。 而它的起点是一个极容易被忽略的事实 —— 随机数发生器没有"距离"这个概念:它能给你第 7 号和第 8 号两个数, 却不知道"7 和 8 挨得很近,理应给两个相近的数"。所以直接把随机数当高度,得到的是一片雪花,不是地形。
五段路:把随机数绑到格点上(12.1)→ 决定格点之间怎么过渡(12.2)→ 决定格点上放什么(12.3)→ 把很多层叠起来(12.4)→ 总账(12.5)。 每一段只增加一样东西,其余控件一个字不动。 保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 噪声本来就是"每个像素算一个标量函数", 与真机在片元里逐像素求值是同一件事,只是并行度不同;读数真机可证 —— 格点恒等为 0、核的端点导数、三种跳变、 值域界、极值位置、2/3、功率谱斜率、可复现,都是本页真算出来的数,每条都能拿笔核。

分段:
960(一格 1 px) ← 拖它:一格占多少个像素
插值核:
格点上放什么: | 0.50
显示: | (五张表与十条判据在后台跑)

(脚本启动中…)

定点观察:先点「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。 它只有"完全无关"和"完全相同"两端,中间那种"相似"在这一档里根本不存在。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 种子 20261008 格点密度 n n = 960 ⇒ 一格 1 px n = 8 ⇒ 一格 120 px 核固定在 「取低角」 ★ 本段只加"格点随机" // ① 置换表:256 项洗一遍,再倍写成 512 const PERM = shuffle(256, mulberry32(SEED)); // ② 格点 (i,j) → 那一个随机数(每层用 o 偏移) const h2 = (i, j, o) => { const a = PERM[(i + o * 37) & 255], b = (j - o * 91) & 255; return PERM[(a + b) & 255] / 255; // → [0,1) }; // ③ 混合:u = v = 0 ⇒ 四个角只用一个(核取低角) return (1-v)*((1-u)*a + u*b) + v*((1-u)*c + u*d); 相邻像素的账: n = 960 |Δ| = 0.659 理论 2/3 = 0.667 n = 4 |Δ| = 0.0010 自相关: n = 960 → 0.059 n = 4 → 0.997 两端之间没有"相似"
定点观察:把「格点数 n」从 960 一路拉到 4 → 盯住块与块之间那条缝 → 缝宽永远是 0(块内是常量、格线上一跳而过),读数里的"相邻像素平均绝对差"从 0.659 掉到 0.0010, 而自相关从 0.059 跳到 0.997。★ 这两个数是"同一个东西的两种说法": n = 960 时相邻像素就是两个独立随机数,所以 |Δ| 恰好命中 2/3(下表表① 把它逐档摊开)。
表① 相邻像素的账(按像素网格取样:一行 960 个像素,相邻两个像素在格坐标上相距 n/960 格)。 「平均绝对差」的手算靶子是 2/3:两个独立的 [−1,1] 均匀随机数,其差的期望 E|a−b| = ∫∫|a−b|/4 = 2/3 —— 块档在 n = 960 时就是"每像素一个独立随机数",所以它必须命中这个数
★ 最后一列是"自相关"(lag = 1):它只有两个落点 —— 块内 ≈ 1、跨块 ≈ 0,中间没有值
格点数 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) —— 格点是骨架,核只是肉。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 四个角上的值 a = V(i, j ) b = V(i+1, j ) c = V(i, j+1) d = V(i+1, j+1) 格点间距不变 ★ 本段只加"核" const KERNELS = { near: (t) => 0, // 块内常数(上一段) lin: (t) => t, // 一阶跳变 cub: (t) => t*t*(3 - 2*t), // 二阶跳变(Perlin 改之前) qui: (t) => t*t*t*(t*(t*6 - 15) + 10), // 两端一二阶导都为 0 }; const u = kf(x-i), v = kf(y-j); // 核只在这里起作用 return (1-v)*((1-u)*a + u*b) + v*((1-u)*c + u*d); ★ 四个核 = 四种"肉",骨架(格点值)不变 三种跳变(实测): 零阶:0.549 → 6.3e−4 一阶:0.165 → 9.9e−4 二阶:3.76 → 3.8e−2 格点上的值: 四档差 = 0(逐位) = 置换表里的那个数 ★ 骨架不动,只换肉
定点观察:在 12.1 的方块上逐个切「插值核」 → 盯住格点那条竖线(切到「灰度 + 格点」看得最清)→ 三种跳变一个一个被压下去:线性先杀掉零阶、三次再杀掉一阶、五次才把二阶也压住; 而格点上的值一位都没变。★ 表② 把四个核的端点导数(解析值,可手算)与三种跳变(实测值)并排摆开。
表② 四个核。前三列是解析地拿笔算得出来的:线性 k′≡1;三次 k′(0)=k′(1)=0 但 k″(0)=+6、k″(1)=−6;五次 k′≡k″≡0。后三列是实测(测在格点 x = 4、y = 3.5 那条竖线上,步长 h = 1e−3)
★ 读法:哪个阶的跳变恰好变成 0,就说明这个核把那个阶的不连续修掉了。注意"最近邻"那一档核是退化的(恒取低角), 它的不连续不在核的导数上,而在"权重根本不过渡",所以它卡在零阶
★ 五次那一格 3.75e−2 不是真跳变:把 h 缩十倍它跟着缩十倍(3.63e−1 → 3.75e−2 → 3.76e−3), 而三次那一格不随 h 变(3.69 → 3.76)—— 判据就是"同比例缩 ⇒ 是差分分辨率,不是不连续"
核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 —— 贴着上界,但从未越界。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 同一个格点, 两种填法: ① 一个值 V(i,j) ② 一个方向 G(i,j) 核与 n 都不动 ★ 本段只换"放什么" // ① 填法一:格点上一个值(12.1/12.2 用的) const V = (i,j,o) => h2(i,j,o) * 2 - 1; // ② 填法二:格点上一个单位方向(64 选 1,二维) const GV = (i,j,o) => GDIR[(h2(i,j,o) * 64) & 63]; // 点积:偏移 d = (fx, fy) 从格角量起 ⇒ 格点处 d = 0 const va = a.x*fx + a.y*fy, vb = b.x*(fx-1) + b.y*fy; const vc = c.x*fx + c.y*(fy-1), vd = d.x*(fx-1) + d.y*(fy-1); return (1-v)*((1-u)*va + u*vb) + v*((1-u)*vc + u*vd); ★ 格点处 d = 0 ⇒ 四个点积全是 0 ⇒ 场值恰为 0 格点处 |值|: 值档:0.99(极值) 方向档:0.00e+0 极值离最近格点: 值档:0.008 格 方向档:0.492 格 范围(1/√2 = 0.707): 实测 −0.682 / 0.674
定点观察:把「格点上放什么」从"一个值"切到"一个方向"(其余一个字不动)→ 盯住格点标记那几处 → 值档下格点是亮斑/暗斑(极值就在格点上);方向档下格点全变成 0, 极值挪到格点之间(实测 0.49 格) —— 方格痕迹就是这么消失的。★ 表③ 把两个档放在同一把尺子上, 并给出"值档绝不会越出格点值区间"与"方向档贴着 ±1/√2 但从不越界"两条界。
表③ 格点上放什么:值 vs 方向。值档"不越界"是可以拿笔证的 —— 格内任一点是四个角值的凸组合,所以它一定落在四个角的最大/最小之间; 方向档"贴着 1/√2 但不越界"则是二维单位梯度的理论界(±√(N/4))
★ 第 4 列是"极值离最近格点的距离",单位是"格":值档那一格小到与采样网格步长同量级(所以只能说"在格点上"), 方向档那一格是它的 50 倍 —— 两个档的差别不在大小,在"极值站在哪儿"
档格点处的 |值|采样网格极值极值离最近格点全场 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⁻³)。 这一条是本轮写设计稿时写错、被一手源纠正过来的,所以特地摆在页面上。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 上一层那张场 层数固定 = 5 gain ∈ [0.50, 1.00] 频率 ×2、 振幅 ×gain ★ 本段只加 gain let t = 0, a = 1, f = 1, s = 0; // s = 归一化用量 for (let o = 0; o < oct; o++) { t += a * octaveAt(f*x, f*y, o, kind, kf); f *= 2; a *= gain; // ← 自相似就在这两行 s += a; } return t / s; ★ 频率翻倍、振幅按 gain 衰减 ★ 每层都是上一层的缩小版 ⇒ 自相似 每层振幅比: gain 0.5 实测 0.510 理论 = gain 本身 功率谱斜率(2D): gain 0.50 → −3.85 理论 −(2H+2) = −4.00 1D 口径少 1: 实测 −3.01 / 理论 −3.00
定点观察:拖「gain」从 0.50 到 1.00 → 看画面的细节密度与读数里的功率谱斜率 → 每层振幅比永远等于 gain(这一个好算),可功率谱斜率是 −(2H+2):gain = 0.5 时实测 −3.85、理论 −4.00。 ★ 表④ 把五个 gain 档的四个量并排:每层振幅比、每层能量比、2D 谱斜率、同一张场的 1D 谱斜率 —— 最后两列差的就是"维数那一项",不是哪个算错了。
表④ 分形的账。前两列是层与层之间的比(好算:振幅 ×gain、能量 ×gain²); 后四列是整张场的功率谱斜率(2D 径向平均 / 1D 剖面两个口径,各自附理论值)
★ H = −log₂ gain;2D 口径理论 −(2H+2)、1D 口径理论 −(2H+1)
★ 2D 口径:256×256 网格、Hann 窗、径向分环平均、拟合 r ∈ [8, 90];1D 口径:2048 点、32 行平均、 拟合 bins 12..100(= 1.5×scale 到 0.78×最高倍频)—— 频带必须停在最高倍频的截止之下, 否则会把没有信号的那一段噪声底也圈进拟合,斜率假陡(本页第一版取到 200,量出 −4.53 而不是 −3.01)
★ 凡"实测"都要能回答"它自己带多少误差":下面每一行都附了理论值,两列的偏差见读数行
gainH每层振幅比每层能量比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 叠多少层 骨架 第一段起没动过 ★ 每段只换一样 function fieldAt(x, y, g) { // g = {kind, kf, oct, gain} let t = 0, a = 1, f = 1, s = 0; for (let o = 0; o < g.oct; o++) { t += a * octaveAt(f*x, f*y, o, g.kind, g.kf); f *= 2; a *= g.gain; s += a; } return t / s; 恰好为 0 的三处: 格点恒等 = 0 方向档格点 = 0 某个阶的跳变 = 0 能拿笔算的: 2/3、gain、gain² −(2H+2) / −(2H+1) ±1/√2 = 0.707
定点观察:把五张表并排看一遍 → 找哪一列恰好是 0 → "格点恒等 0"说的是"核只是肉、格点才是骨架";"方向档格点 0"说的是"方格痕迹为什么消失"; "某个阶的跳变 0"说的是"这个核把哪一阶的不连续修掉了"。★ 恰好为 0 比"很小"值钱: 只有它才能被写成一条不带容差的断言。
表⑤ 总账(表①–④ 的四个数并排;点「12.5 总账」时刷新)
看什么本页实测能不能拿笔算

这一课的位置

五段只做了一件事:把"随机"从一个没有距离概念的数,变成一张处处光滑、且在各个尺度上都有结构的场。 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 迟滞 —— 这是如实写出来的代价,不是没发现。

本页用到的做法,出处在哪

运行时自检 等待脚本…