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

域扭曲:把"山"从噪声里拧出来

全课题骨架:程序化生成 = 不摆位置、只定规则 —— 同一颗种子 + 同一个规则,必须长出同一片内容。 上一课把随机数变成了一张场,可那张场是"各向同性"的:一团一团、鼓包与絮,没有走向、没有脊。
这一课只做一件事:不改函数,只改坐标 —— 把 f(p) 换成 f(p + h(p))。 同一张噪声、同一份源码,只因为"喂进去的坐标被另一个函数搬走了",就长出走向、山脊与峡谷; 再套几层、再把谷翻成脊、最后加一套只读高度的着色,它就成了一张地形。
四段路:一级扭曲(13.1)→ 套娃(13.2)→ 脊线(13.3)→ 什么时候它变成了"地形"(13.4)。 每一段只增加一样东西,其余控件一个字不动。 保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 域扭曲本来就是"每个像素先搬坐标、再求值", 与真机在片元里做同一件事;读数真机可证 —— 还原性、位移恒等式、三级链的终点、det J 的闭式与两条路线、 脊线的尖点、坡度角,都是本页真算出来的数,每条都能拿笔核。

分段:
0.60 ← 拖它:坐标被搬走多远
扭曲级数: | 脊线:
显示: | (四张表与十条判据在后台跑)

(脚本启动中…)

定点观察:先点「13.1 只换坐标」,再把「扭曲强度 α」从 0 拉到 1.5 → 切到「位移场」盯住那一片箭头 → 箭头长度严格正比于 α(位移均值 ÷ α 在六档上都是 0.213717);α 拉回 0 时箭头长度全为 0、画面与"没扭曲"逐位相同。

本帧的读数

(脚本启动中…)

13.1 只换坐标,就换了一种形状

本段只增加一样东西:用另一张噪声去偏移坐标(f(p + α·q(p));旋钮是扭曲强度 α; 级数固定 1、脊线关、显示固定"高度灰度")。
上一课的场有个特点:它各向同性 —— 转个角度再看还是那副样子,一团一团的鼓包,没有"走向"。 原因是坐标只有一种用法:直接喂给噪声。
域扭曲的做法只有一句话(IQ 原文:we replace f(p) with f(g(p)),而 g(p) = p + h(p)): 先拿另一张噪声算出"每个位置该被搬多远",把坐标搬走,再求值。于是同一张场被"拧"出了走向 —— 圆的等值线被拉成丝带、絮状,这正是云与大理石纹的样子。
三件事可以直接量:① α = 0 时逐位还原(本页 92160 个采样点上差 0.00e+0); ② 位移严格正比于 α(位移均值 ÷ α 在 α = 0.2/0.4/0.6/0.8/1.0/1.5 六档上全是 0.213717); ③ 局部面积被拉伸/压缩了多少 —— 也就是 det J = (1+Dx_x)(1+Dy_y) − Dx_y·Dy_x, α = 0 时它恒等于 1(逐位),α 越大它越散:中位数一路降到 0.729,最小值掉到 −5.847。
★ 本页还给 det J 配了第二条独立路线:把一个小方块映射过去、量它的带符号面积再除以 ε²。 两条路线在全域中位数上差 0.03%(0.842038 对 0.842285),折叠占比 13.16% 对 12.75%。 ★ 但面积法有自己的分辨率:把 ε 减半,它与闭式的差也跟着减半(2.71e−1 → 2.18e−1 → 1.22e−1,是 O(ε)); 再往下(ε = 1.25e−2)残差反而回升 —— 那是浮点相消,不是模型问题。
★ 最后这个符号要单独说一句:det J < 0 意味着那一小块被翻了个面(映射在它上面不再一一对应)。 本页把这种像素叫"折叠像素",并如实标注一件事:"过度扭曲会折叠"这条没有找到图形学语境下的一手权威表述 (检索到的最接近说法在可变形图像配准领域:雅可比行列式为负 ⇒ 变换不可逆),所以这里只把它当成 一个可测量的后果呈现,不声称它是规范结论。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 底色场 f(上一课那张) 4 个倍频 · 五次核 位移场 q 2 个倍频 · 两个偏移 扭曲强度 α ★ 本段只加 α 这一条 // 位移场:两个标量场拼成一个二维位移(照抄原文的偏移) function dispAt(x, y, lv) { const o = lv - 1; return { x: fieldAt(x + 0.0 + o*13.1, y + 0.0 + o*7.7, DISP), y: fieldAt(x + 5.2 + o*13.1, y + 1.3 + o*7.7, DISP) }; // 搬坐标:f(p + α·q(p)) const d = dispAt(x, y, 0); return fieldAt(x + alpha*d.x, y + alpha*d.y, BASE); 还原性(α = 0): 92160 点上差 0.00e+0 位移正比于 α: 七档全是 0.21372 局部面积(det J): 中位 1.000 → 0.729 最小 1.000 → −5.847 ★ 负号 = 那一块被翻面
定点观察:把「扭曲强度 α」从 0 拉到 1.5 → 切到「位移场」盯住那一片箭头 → 箭头越长,说明坐标被搬得越远;而它严格正比于 α(下表表① 的"位移均值 ÷ α"一列恒为 0.213717)。 ★ 把 α 拉回 0:箭头全缩成点、画面与"没扭曲"逐位相同 —— 这一条是"只换坐标"最硬的证据。
表① 扭曲强度 α 的账(级数 1、192×120 采样网格、偏导步长 h = 1e−3)。 「位移均值」是 |α·q(p)| 在全场的平均;「det J」是局部面积比: det J = (1+Dx_x)(1+Dy_y) − Dx_y·Dy_x,1 = 不变、>1 = 拉伸、<1 = 压缩、<0 = 翻面
★ 头两列可以拿笔核:位移均值 ÷ α 是常数(级数 1 时恒为 0.21372),而 α = 0 那一行 det J 恰好全是 1
★ 采样网格的分辨率是 12/192 = 0.0625 个单位(横向)—— 比它小的结构量不出来,这一点如实标在这里
★ 一个能拿笔核的例子:定点 (3.7, 2.9)、α = 1.0 时,四项偏导是 Dx_x = 0.463068、Dx_y = −0.961376、Dy_x = 0.661892、Dy_y = −1.195237 ⇒ det J = (1+0.463068)×(1−1.195237) − (−0.961376)×0.661892 = 0.350682。 读数行里给出当前档同一处的四项偏导与算出的 det J,读者可以照着核一遍
扭曲强度 α位移均值位移均值 ÷ αdet J 中位det J 最小det J 最大折叠像素占比

13.2 大形状里套小形状:把它套起来

本段只增加一样东西:把这一手嵌套起来(级数 1 / 2 / 3)(α、脊线、显示都不动)。
上一段的扭曲只有一级:它把团"拉长",但拉出来的东西大小是均匀的 —— 还是不像云、不像山。 真实的云是"大形状里套着小形状,小形状里再套着更小的"。
IQ 原文的写法就是套娃:fbm(p + fbm(p + fbm(p))) —— 把上一次搬完的坐标,再喂给下一层位移。 于是每一级都在"已经扭曲过的世界"上继续扭,层次就出来了。
代价也在同一根旋钮上:折叠越攒越多。实测(α = 0.6)级数 1 → 3 时,折叠像素占比 0.56% → 1.47% → 2.10%; 把 α 提到 1.0,同一组级数是 13.16% → 24.48% → 32.57%,det J 中位数从 0.842 掉到 0.163。
★ 本段还给了一条能拿笔核的式子:三级链的终点就是 p₃ = p₀ + α·(d₁ + d₂ + d₃) —— 表② 的图注与读数行把三个位移向量都摆出来了,读者加一下就能对上终点。
★ 顺手记一条反直觉的:级数 ≥ 2 时位移不再严格正比于 α(后一级的位置被前一级改了)—— 看表② 的"位移均值 ÷ α"那一列:级数 2 从 0.306682(α = 0.6)漂到 0.307700(α = 1.0)、 级数 3 从 0.371781 漂到 0.376592(+1.3%)。一级是严格线性的,多级不是。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 上一级搬完的坐标 (qx, qy) 而不是 (x, y) 级数 lv ∈ 1 / 2 / 3 每级换一组偏移 α 与上一段相同 ★ 本段只加"级数" // 套娃:在"已经搬过的坐标"上继续搬(原文的 fbm(p + fbm(p + …))) function mapP(x, y, alpha, lv) { let qx = x, qy = y; for (let l = 0; l < lv; l++) { const d = dispAt(qx, qy, l); // ← 注意:在 (qx, qy) 上量 qx += alpha * d.x; qy += alpha * d.y; } return { x: qx, y: qy }; // 终点才是喂给底色的坐标 位移均值: 0.128 → 0.184 → 0.223 折叠占比(α = 0.6): 0.56% → 1.47% → 2.10% 折叠占比(α = 1.0): 13.16% → 24.48% → 32.57% ★ 层次是叠出来的 代价也在同一根旋钮上
定点观察:切「级数」1 → 2 → 3 → 看位移场里那个定点被搬的折线(画面里画了 p₀ 的三段链) → 路径从一段变三段,终点越来越远(位移均值 0.128 → 0.184 → 0.223),而折叠也越攒越多。 ★ 手算核对:p₃ = p₀ + α·(d₁ + d₂ + d₃) —— 三个向量摆在表② 的图注里,加一下就是终点。
表② 级数的账(两组 α 各三档;采样网格 192×120、h = 1e−3)。 「位移均值」是 |最终坐标 − 原坐标| 在全场的平均 —— 注意它不是 α 的线性函数(后一级的位置被前一级改了)
★ 手算靶子(α = 0.6、级数 3、p₀ = (2.000, 1.000)): d₁ = (0.000000, 0.181421)、d₂ = (−0.155097, 0.178517)、d₃ = (0.134594, −0.067156) ⇒ Σ = (−0.020503, 0.292781) ⇒ p₀ + 0.6·Σ = (1.987698, 1.175669),与页面算出的终点逐位相同
α级数位移均值位移均值 ÷ αdet J 中位det J 最小折叠像素占比

13.3 把谷翻成脊:1 − |n|

本段只增加一样东西:脊线变换(关 / 1−|n| / (1−|n|)²)(α 与级数都不动)。
前两段的场已经有走向、有层次了,可它仍然是鼓包与絮 —— 起伏是圆滚滚的,没有"细而连续的高地"。 真实的山区最抓眼睛的东西恰恰是脊线:一条连续的、两边都往下掉的窄高地。
Musgrave 在《Texturing & Modeling》里的 `RidgedMultifractal` 只有三步,而且每步都有原话注释: signal = Abs(signal);(this creates the ridges)→ signal = offset - signal;(note that "offset" should be ~= 1.0,也就是 1 − |n|)→ signal *= signal;(to increase "sharpness" of ridges)。
为什么"取绝对值再翻过来"就出脊?因为 1 − |n| 在 n = 0 处有一个尖点: 左侧导数实测 +1.0000、右侧 −1.0000 ⇒ 跳变 2.0000(平方档是 3.9960,理论 2p = 4)。 噪声本来就经常穿过 0(实测 |n| < 0.05 的像素占 32.2%),每个穿过点都被这个尖点翻成一条脊 —— 而"关"那一档在 n = 0 处是光滑的(跳变 0),所以它只有鼓包、没有脊。
★ 如实标注一处简化:Musgrave 的原始实现是在每个倍频内部都做一次这个变换(并用 weight = signal·threshold 给下一层加权),本页把变换施加在合成后的场值上(一次)。两者都出脊线;本页取后者, 是为了让"本段的唯一变量"保持成一个开关。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 扭曲后的场值 n 范围 −0.444 … 0.415 脊线档 关 / p = 1 / p = 2 α 与级数不动 ★ 本段只加"脊线" // Musgrave 的三步(注释是原文注释) signal = Abs(signal); // this creates the ridges signal = offset - signal; // offset ~= 1.0 ⇒ 1 − |n| signal *= signal; // to increase "sharpness" // 本页把三步合成一个开关(p = 1 时不做最后一步) function ridge(n, p) { if (p === 0) return n; // 关 return Math.pow(Math.max(0, 1 - Math.abs(n)), p); n = 0 处的尖点: 左导 +1.0000 / 右导 −1.0000 ⇒ 跳变 2.0000(p = 1) 平方档 3.9960(理论 4) 噪声穿过 0 有多频繁: |n| < 0.05 占 32.2% r = 1−|n| 的峰值: 落在最大那一格(r → 1)
定点观察:把「脊线」从"关"切到 1−|n| → 看画面的脊线 → 原本圆滚滚的鼓包变成了细而连续的高地,而读数里那个"跳变"从 0 变成 2.0000; 再切到 (1−|n|)²,跳变变成 3.9960 —— 脊更细更锐。 ★ 一句话:脊是"尖点"造出来的,不是把鼓包压扁挤出来的。
表③ 脊线的账(α = 0.6、级数 3、240×150 采样网格;尖点用 h = 1e−5、取样点取 n = ±1e−3)。 「跳变」= |左导数 − 右导数|,理论值 2p
★ 手算:(1−|n|)^p 的导数是 −p(1−|n|)^{p−1}·sign(n) ⇒ 在 n → 0 两侧分别趋近 ∓p ⇒ 跳变 → 2p。实测 p = 2 时是 3.9960(略小于 4):差的就是取样点 |n| = 1e−3 处那个 (1−|n|)^{p−1} 因子 (2×(1−1e−3) = 1.9980,与左右导数的实测逐位对上)
脊线档变换n = 0 处的跳变(实测)理论 2p输出范围分布峰值

13.4 什么时候它变成了"地形"

本段只增加一样东西:显示档(高度灰度 / 位移场 / 地形着色 / det J 图)(α、级数、脊线都不动; ★ 本段把 α 提到 1.0 是观察装置 —— 折叠占比 32.6% 才看得清,本段的变量仍是显示档)。
走到这里,画面里已经有一张不错的场了。可它看起来还是"一团灰"—— 而"地形"这个词,读者一次都还没见到。 缺的是什么?不是高度,是光。把高度场交给一套只读高度的着色: 法线由高度图相邻像素的差分给出(n = normalize(−S·∂h/∂x, 1, −S·∂h/∂y)), 再按高度铺一层色阶、按等值间隔画等高线(ArcGIS 的定义:等高线 = 等值间隔的连线)—— 地形就"立"起来了。
这一段的 claim 只有一条,而且它可以被机器验:着色只读高度、不改几何。 切显示档时,读数里的高度指纹、坡度角中位、det J 中位、折叠像素数四项逐位不变, 变的只有亮度的分位数(下表表④ 就是这条 claim 的证据)。
顺带一条可手算的靶子:坡度角 = atan(|∇h|·S)(Esri 的写法), 它等于"法线与竖直方向的夹角" —— 在 360 个采样点(用解析梯度,不是同一份缓冲差分出来的两个数自证)上, 两种算法实测最大差 1.1e−12 度。 高度尺度 S 取 0.18 时,读数里的坡度角中位是 8.0° —— 这个量级才像地形 (离线扫过:S 大到 2.0 时中位就有 57.8°,那是"刀山")。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 高度缓冲 FB 逐像素存的场值 高度尺度 S = 0.18 光向固定 等值间隔 0.1 ★ 只读高度,不改几何 // 法线:由高度图的相邻像素差分给出 const gx = (FB[i+1] - FB[i-1]) / (2 * du); const gy = (FB[i+W] - FB[i-W]) / (2 * du); let nx = -S * gx, ny = 1, nz = -S * gy; // 坡度角(Esri 的写法)与法线夹角是同一个数 const slope = Math.atan(Math.hypot(S*gx, S*gy)) * 180 / Math.PI; // 等高线:等值间隔 const band = Math.abs(v / 0.1 - Math.round(v / 0.1)) < 0.06; 切显示档时: 高度指纹:逐位相同 坡度角中位:逐位相同 det J 中位:逐位相同 折叠像素数:逐位相同 只有亮度在变: 灰度 / 地形 / det J ★ "地形"是着色给的
定点观察:在「显示档」之间来回切(高度灰度 → 地形着色 → det J 图)→ 盯住读数里那四个几何量 → 它们一位不变,变的只有亮度分布: "地形"不是高度场里本来就有的东西,是那套只读高度的着色给出来的。 ★ 切到 det J 图:亮 = 被拉伸、暗 = 被压缩、强调色 = 被翻面(det J < 0) —— α = 1.0、级数 3 时全场有 32.6% 的像素已经被翻过面。
表④ "着色只读高度"的证据(当前 α、级数、脊线档下,240×150 采样网格)。 前四列是几何量(只由高度缓冲与位移场决定),后三列是亮度(只由显示档决定)
★ 判据是:前四列跨行逐位相同(有一个动了,就说明着色偷偷改了几何),后三列逐行不同
★ 坡度角那一列也可以拿笔核:slope = atan(√(gx² + gy²)·S), 表里给的是全场中位数;「两种算法最大差」那一行是"法线与竖直夹角"对照出来的
显示档高度指纹坡度角中位det J 中位折叠像素数亮度 p05 / p50 / p95两种算法最大差

这一课的位置

四段只做了一件事:把"喂给噪声的坐标"当成一个可以编程序的东西。 13.1 换坐标、13.2 把这一手套起来、13.3 把谷翻成脊、13.4 加一套只读高度的着色 —— 于是"一团噪声"变成了"一片地形"。
这也解释了几件工业上的事:为什么程序化纹理里到处都是 fbm(p + fbm(p)) (它就是"坐标也能当素材用");为什么云、大理石、烟都用同一套工序(域扭曲 + 分形); 为什么地形生成器里"脊线噪声"几乎总是单独一档(鼓包与脊是两种不同的高频统计); 以及为什么材质的法线可以从高度图算出来(着色只读高度,几何一点没动)。
本页那一格"如实说明":① 画面是 CPU 逐像素(示意 · 等价重现); ② 位移场只用 2 个倍频(它只负责把坐标平滑地搬走,高频位移会把画面碎成噪声 —— 这是工程取舍); ③ 脊线变换施加在合成后的场值上,而 Musgrave 的原始实现在每个倍频内部都做一次; ④ "过度扭曲会折叠"没有找到图形学语境下的一手权威表述,本页只把它当成一个可测量的后果; ⑤ 一帧整幅同步算完要 约 190 ms(1 级)/ 380 ms(3 级)/ 1650 ms(3 级 + det J 图)(判据⑩ 每轮实测), 所以交互走按行分帧(每帧 24 行、det J 档 12 行)—— 读者看到的是画面逐段补齐,判据与表走"一次到底"的那一版。

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

运行时自检 等待脚本…