域扭曲:把"山"从噪声里拧出来
全课题骨架:程序化生成 = 不摆位置、只定规则 —— 同一颗种子 + 同一个规则,必须长出同一片内容。
上一课把随机数变成了一张场,可那张场是"各向同性"的:一团一团、鼓包与絮,没有走向、没有脊。
这一课只做一件事:不改函数,只改坐标 —— 把 f(p) 换成 f(p + h(p))。
同一张噪声、同一份源码,只因为"喂进去的坐标被另一个函数搬走了",就长出走向、山脊与峡谷;
再套几层、再把谷翻成脊、最后加一套只读高度的着色,它就成了一张地形。
四段路:一级扭曲(13.1)→ 套娃(13.2)→ 脊线(13.3)→ 什么时候它变成了"地形"(13.4)。
每一段只增加一样东西,其余控件一个字不动。
保真度:画面走 CPU 逐像素(示意 · 等价重现) —— 域扭曲本来就是"每个像素先搬坐标、再求值",
与真机在片元里做同一件事;读数真机可证 —— 还原性、位移恒等式、三级链的终点、det J 的闭式与两条路线、
脊线的尖点、坡度角,都是本页真算出来的数,每条都能拿笔核。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「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 意味着那一小块被翻了个面(映射在它上面不再一一对应)。
本页把这种像素叫"折叠像素",并如实标注一件事:"过度扭曲会折叠"这条没有找到图形学语境下的一手权威表述
(检索到的最接近说法在可变形图像配准领域:雅可比行列式为负 ⇒ 变换不可逆),所以这里只把它当成
一个可测量的后果呈现,不声称它是规范结论。
| 扭曲强度 α | 位移均值 | 位移均值 ÷ α | 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%)。一级是严格线性的,多级不是。
p₃ = p₀ + α·(d₁ + d₂ + d₃) —— 三个向量摆在表② 的图注里,加一下就是终点。| α | 级数 | 位移均值 | 位移均值 ÷ α | 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
给下一层加权),本页把变换施加在合成后的场值上(一次)。两者都出脊线;本页取后者,
是为了让"本段的唯一变量"保持成一个开关。
1−|n| → 看画面的脊线 →
原本圆滚滚的鼓包变成了细而连续的高地,而读数里那个"跳变"从 0 变成 2.0000;
再切到 (1−|n|)²,跳变变成 3.9960 —— 脊更细更锐。
★ 一句话:脊是"尖点"造出来的,不是把鼓包压扁挤出来的。| 脊线档 | 变换 | 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°,那是"刀山")。
| 显示档 | 高度指纹 | 坡度角中位 | 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 行)—— 读者看到的是画面逐段补齐,判据与表走"一次到底"的那一版。
本页用到的做法,出处在哪
- 域扭曲的定义与套娃写法 —— Inigo Quilez(作者本人)Domain Warping:
"Warping simply means we distort the domain with another function g(p) before we evaluate f.
Basically, we replace f(p) with f(g(p))",并给出 "g(p) = p + h(p)" ⇒ "f( p + h(p) )";
正文代码里的一级是
vec2 q = vec2(fbm(p+vec2(0,0)), fbm(p+vec2(5.2,1.3))); return fbm(p + 4.0*q);, 三级就是fbm(p + fbm(p + fbm(p)));同文说明那两个偏移"don't have any special meaning, they are used to get different fBM values by using one single fbm() implementation"; 还有一句可以当本课的引言:"This has been used since 1984, when Ken Perlin himself created his first procedural marble texture": iquilezles.org/articles/warp/ - 脊线(Ridged Multifractal) —— F. Kenton Musgrave,《Texturing & Modeling: A Procedural Approach》
(Ebert / Musgrave / Peachey / Perlin / Worley,Academic Press 1998)配套源码里的 `RidgedMultifractal`,
三步的注释逐字为:
signal = Abs(signal); /* this creates the ridges */、signal = offset - signal; /* (note that "offset" should be ~= 1.0) */、signal *= signal; /* to increase "sharpness" of ridges */: design.shapinguptobeamom.com/lwsdk/sample/rapts/pts_frctl.c; 原始论文(地形合成与侵蚀)F. K. Musgrave, C. E. Kolb, R. S. Mace, The Synthesis and Rendering of Eroded Fractal Terrains,SIGGRAPH 1989: dl.acm.org/doi/pdf/10.1145/74334.74337; Blender 官方文档对同一件事的权威定义:"Creates sharp peaks. Calculates the absolute value of the noise, creating "canyons", and then flips the surface upside down.": docs.blender.org/manual/en/latest/render/shader_nodes/textures/noise.html - 高度场 → 法线 —— Catlike Coding,Rendering 6: Bumpiness(用中心差分推切线再叉乘得法线,
写作
normal = float3(u1 - u2, 1, v1 - v2)): catlikecoding.com/unity/tutorials/rendering/part-6/; NVIDIA GPU Gems 2 第 2 章(Asirvatham & Hoppe,几何 clipmap:法线 = 两个网格对齐切向量的叉积): developer.nvidia.com/gpugems/gpugems2/…chapter-2-terrain-rendering-using-gpu-based-geometry - 等高线与坡度角 —— Esri ArcGIS:How Contouring works(等高线 = 等值间隔处的连线,
示例里就是"用双线性插值定位 830 米这条阈值线"):
desktop.arcgis.com/en/arcmap/latest/tools/spatial-analyst-toolbox/how-contouring-works.htm;
How Slope works(
slope_degrees = ATAN(√([dz/dx]² + [dz/dy]²)) × 57.29578, 以及"坡度就是地表法线与椭球法线的夹角"): desktop.arcgis.com/en/arcmap/latest/tools/spatial-analyst-toolbox/how-slope-works.htm - ★ 一处诚实标注:「过度扭曲会导致自交 / 折叠」这一条没有找到图形学语境下的一手权威表述
(检索到的最接近说法出现在可变形图像配准领域:雅可比行列式为负 ⇒ 变换不可逆、出现折叠;
那是另一个领域,且本次未逐字核对原文)。所以本页只把
det J < 0当成 一个可测量的后果(折叠像素占比)呈现,不声称它是本文域的规范结论。