ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

The Book of Shaders 实战:fBm 分数布朗运动——从正弦波叠加到程序化分形噪声

The Book of Shaders 实战:fBm 分数布朗运动——从正弦波叠加到程序化分形噪声

【免费下载链接】thebookofshaders

Step-by-step guide through the abstract and complex universe of Fragment Shaders.

项目地址:https://gitcode.com/gh_mirrors/th/thebookofshaders
点击查看免费下载

本篇技术指南以《The Book of Shaders》第 13 章(13/README-ua.md 乌克兰语版,对应英文原文 13/README.md)为主线,系统讲解 GLSL 片段着色器(Fragment Shader)中"分数布朗运动"(fractal Brownian Motion,简称 fBm)的完整知识链:从一维正弦波的振幅/频率与叠加原理出发,过渡到 Perlin 噪声,再深入 fBm 的三大控制参数(octaves、lacunarity、gain),并延伸到湍流(turbulence)、山脊(ridge)、多重分形与域扭曲(domain warping)等派生技术。读完本篇,你将能读懂并亲手调优仓库中 13/2d-fbm.frag、13/clouds.frag 等真实示例,掌握用几行 GLSL 生成云朵、山脉等程序化纹理的完整方法。

起点:从正弦波理解振幅、频率与叠加

fBm 的思想源头是波。作者在书中指出,不同领域对"噪声"的理解完全不同:音乐家想到刺耳的声响,通信工程师想到干扰,天体物理学家想到宇宙微波背景辐射——但这些概念都指向同一件事:世界中的随机性。而理解随机噪声最扎实的起点,是比它更基础、更简单的对象:波。

波是某种物理量随时间发生的波动。声波是空气压力的波动,电磁波是电场与磁场的波动。描述一个波有两个关键特征:

  • 振幅(amplitude):波动的幅度大小;
  • 频率(frequency):波动的快慢。

一维简谐波的方程只有一行:

float amplitude = 1.; float frequency = 1.; y = amplitude * sin(x * frequency);

在本书网页版中,这段代码会被simpleFunction容器渲染成可交互的实时函数绘图(其底层渲染由 src/main.js 中定义的preFunction/postFunctionGLSL 前缀/后缀拼装而成,见 src/main.js#L7-L107)。作者建议你动手做三个实验:

  1. 改变frequency与amplitude的取值,观察曲线行为;
  2. 用第 5 章的整形函数(shaping functions)让振幅随时间变化;
  3. 用整形函数让频率随时间变化。

做完后两项,你就完成了对正弦波的调制:前者是 AM(振幅调制),后者是 FM(频率调制)。

波的另一个迷人性质是叠加(superposition):多个波相加,会产生全新的整体形态。下面这段可交互代码把 5 条不同频率、不同振幅的正弦波叠在一起,并引入了时间变量u_time让它们各自漂移:

float amplitude = 1.; float frequency = 1.; y = sin(x * frequency); float t = 0.01 * (-u_time * 130.0); y += sin(x * frequency * 2.1 + t) * 4.5; y += sin(x * frequency * 1.72 + t * 1.121) * 4.0; y += sin(x * frequency * 2.221 + t * 0.437) * 5.0; y += sin(x * frequency * 3.1122 + t * 4.269) * 2.5; y *= amplitude * 0.06;

配套练习是三个非常直观的问题:

  • 调整各分量波的频率与振幅,观察总和曲线的变化;
  • 能否构造出两条相互抵消的波?那会是什么样子?
  • 能否让叠加后的波互相增强?

音乐中的音阶也遵循同样的频率逻辑:频率翻倍或减半对应一个"八度"(octave)的跳跃。这个"八度"概念,正是 fBm 中 octaves(八度/倍频程)一词的来源。

从正弦波到 Perlin 噪声:为什么不用一堆正弦波拼随机

既然叠加正弦波能产生复杂形态,为什么还要引入噪声?作者的解释很关键:Perlin 噪声在基本形态上与正弦波"看起来、感觉起来"非常相似——它的振幅比较稳定,频率被限制在中心频率附近的狭窄区间内。但与正弦波不同,它不那么规则,因此只需叠加少数几个缩放版本,就能轻易制造出"随机"的外观。

而如果只用正弦波来假装随机,你需要非常非常多的不同频率的波,才能掩盖它们固有的周期性与规律性。这就是 Perlin 噪声在程序化生成中不可替代的原因。

仓库中 13/1d-fbm.frag 注释掉了三行,正好对比三种画法:y = random(st.x*0.001+u_time)(纯随机)、y = noise(st.x*3.+u_time)(单层值噪声)、y = fbm(st.x*3.0+u_time)(fBm),取消注释即可直观对比三者的视觉差异。

fBm 核心:octaves、lacunarity 与 gain

把不同迭代次数的噪声叠加起来,每次迭代按固定倍数提高频率(称为 lacunarity,隙度)、按固定比例降低振幅(称为 gain,增益),就能在噪声中获得更细的颗粒度与更多细节。这项技术就叫fractal Brownian Motion(fBm,分数布朗运动),也叫"分形噪声"。书中给出的一维 fBm 最小实现如下:

// 属性 const int octaves = 1; // 八度(迭代次数) float lacunarity = 2.0; // 隙度(每次迭代频率的倍增系数) float gain = 0.5; // 增益(每次迭代振幅的衰减系数) // 初始值 float amplitude = 0.5; float frequency = 1.; // 按八度循环 for (int i = 0; i < octaves; i++) { y += amplitude * noise(frequency * x); frequency *= lacunarity; amplitude *= gain; }

三个参数的作用可归纳为下表:

参数含义常见取值影响
octaves噪声叠加的迭代层数1~10,超过 4 后细节收益递减层数越多,曲线/纹理细节越丰富
lacunarity相邻两层频率的倍增系数典型值 2.0控制细节层的频率间距,>1 时高频细节越拉越开
gain相邻两层振幅的衰减系数典型值 0.5控制高频细节的贡献权重,越小高频越弱

配套实验建议:

  • 把octaves从 1 依次增加到 2、4、8、10,观察细节如何逐层变多;
  • 当层数超过 4 后,改变lacunarity的值观察频率结构变化;
  • 当层数超过 4 后,改变gain的值观察高频细节强弱变化。

自相似性与"真正的分形"

书中强调,随着八度增加,曲线会呈现出自相似性(self-similarity):放大曲线的任意一小段,看起来与整体差不多;每一段与其他段都大同小异。这是数学分形的重要属性,而 fBm 循环正是对这一属性的模拟。

但严格来说,fBm不是真正的分形:因为循环在有限次迭代后停止了。理论上,如果让循环无限持续、叠加无穷多个噪声分量,就会得到真正的数学分形。不过在实际计算机图形学中,我们永远受限于可分辨的最小细节——当物体小过一个像素时,再叠加无穷项就没有意义了。结论是:有时需要很多项,但永远不需要无限项。

二维 fBm 实战:逐行解读 2d-fbm.frag

仓库 13/2d-fbm.frag 给出了二维 fBm 的完整可运行实现,代码结构非常清晰:

① 随机函数:基于点积+正弦的经典哈希式随机:

float random (in vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123); }

② 值噪声:取整数格点(floor)与小数部分(fract),对正方形四角的随机值做平滑插值,插值因子用平滑曲线f * f * (3.0 - 2.0 * f)(即 smoothstep):

float noise (in vec2 st) { vec2 i = floor(st); vec2 f = fract(st); float a = random(i); float b = random(i + vec2(1.0, 0.0)); float c = random(i + vec2(0.0, 1.0)); float d = random(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) + (c - a)* u.y * (1.0 - u.x) + (d - b) * u.x * u.y; }

③ fBm 主体:默认 6 个八度,振幅从 0.5 起步、每层减半,坐标每层翻倍:

#define OCTAVES 6 float fbm (in vec2 st) { float value = 0.0; float amplitude = .5; float frequency = 0.; for (int i = 0; i < OCTAVES; i++) { value += amplitude * noise(st); st *= 2.; amplitude *= .5; } return value; }

④ 主函数:校正宽高比后放大坐标st*3.0再送入 fBm,得到分形灰度图案:

void main() { vec2 st = gl_FragCoord.xy/u_resolution.xy; st.x *= u_resolution.x/u_resolution.y; vec3 color = vec3(0.0); color += fbm(st*3.0); gl_FragColor = vec4(color,1.0); }

文档给出的三个调参练习直接对应源码行号:将第 37 行的#define OCTAVES 6改小(如 1~4)观察细节减少;修改第 47 行st *= 2.;改变 lacunarity;修改第 48 行amplitude *= .5;改变 gain。

补充:更精细的一维动画版本 13/1d-fbm.frag 和覆盖 1D/2D/3D 三种维度的 13/noise.frag 也值得阅读——后者对 2D 噪声引入了旋转矩阵(mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.50));)来消除轴向偏差(axial bias),并对每层坐标加入shift = vec2(100.0)偏移以避免晶格对齐。

fBm 与程序化地形:为什么山脉天生自相似

fBm 最常见的应用是程序化景观生成(procedural landscape)。作者指出,fBm 的自相似性对山脉建模近乎完美,原因是:真实山脉由侵蚀过程塑造,而侵蚀在极大范围的尺度上都会留下自相似的外观——大山的轮廓与山体上的一块岩石有统计上的相似结构。这正是 fBm 输出特性的天然匹配。若对此方向感兴趣,书中推荐阅读 Inigo Quiles 关于高级噪声的文章《advanced noise》。

湍流(turbulence):对噪声取绝对值制造尖谷

用几乎相同的循环,但对带符号的噪声取绝对值,就得到湍流效果——本质是 fBm,但通过abs()把负值翻折成正,在函数上形成尖锐的谷(sharp valleys):

for (int i = 0; i < OCTAVES; i++) { value += amplitude * abs(snoise(st)); st *= 2.; amplitude *= .5; }

仓库中的完整实现位于 13/turbulence.frag:它基于 Ian McEwan 与 Ashima Arts 的 GLSL 2D simplex noise(snoise,见文件头注释与 13/turbulence.frag#L27-L84),主循环即上述三段式逻辑,默认#define OCTAVES 3。下图是效果(来自 13/turbulence-long.png):

山脊(ridge):把尖谷倒转成尖峰

同一算法家族的另一位成员是山脊:把尖锐的谷"倒扣"过来变成尖锐的脊。书中给出核心三行:

n = abs(n); // 制造褶皱 n = offset - n; // 把褶皱倒转,尖谷变尖脊 n = n * n; // 进一步锐化褶皱

仓库 13/ridge.frag 把这三行封装成ridge(h, offset)函数,并在ridgedMF中做真正的多重分形叠加:除了标准的freq *= lacunarity; amp *= gain;之外,还引入了prev——用上一八度的输出缩放当前八度(sum += n*amp*prev;),这正是文档所说的"让后续噪声函数基于循环中先前项的结果进行缩放"。其效果图见 13/ridge-long.png:

多重分形(multifractals):乘法叠加与"依赖前层"的缩放

当不再严格逐层累加,而是把噪声分量相乘,或者用依赖于循环中先前项的量来缩放后续噪声函数时,我们就离开了严格分形的定义,进入了数学上尚未严格定义的"多重分形"领域。不过定义模糊并不妨碍它在图形学中的实用性——多重分形模拟在现代商业地形生成软件中非常普遍。

ridge.frag中的ridgedMF(ridged multifractal)正是这种思路的实例:它既对每层做了abs → offset - n → n²的整形,又把前一层输出prev乘入本层贡献。想要深入,书中推荐 Kenton Musgrave 所著《Texturing and Modeling: a Procedural Approach》第 3 版第 16 章(该书已绝版,但图书馆与二手市场仍有流通;注意网上出售的是 1994 年第 1 版的 PDF,不含任何地形建模内容,不值得购买)。

域扭曲(Domain Warping):用 fBm 扭曲 fBm 的空间

用 fBm 的输出作为坐标偏移量,再去采样另一个 fBm,就是domain warping(域扭曲/空间扭曲)——Inigo Quiles 称之为"用 fBm 扭曲 fBm 的空间",如同《盗梦空间》里梦中之梦。下图是f(p) = fbm(p + fbm(p + fbm(p)))的效果(来自 13/quiles.jpg):

仓库中的温和版实现是 13/clouds.frag,用两级扭曲生成云状纹理。其核心逻辑是:

vec2 q = vec2(0.); q.x = fbm( st + 0.00*u_time); // 第一层 fBm 作为 x 偏移 q.y = fbm( st + vec2(1.0)); // 第一层 fBm 作为 y 偏移 vec2 r = vec2(0.); r.x = fbm( st + 1.0*q + vec2(1.7,9.2)+ 0.15*u_time ); // 第二层用 q 扭曲坐标 r.y = fbm( st + 1.0*q + vec2(8.3,2.8)+ 0.126*u_time); float f = fbm(st+r); // 第三层采样

随后用多层mix在深青、暖灰、深蓝、亮青之间插值,并乘以(f*f*f + .6*f*f + .5*f)的幂次整形,得到带自相似性的云朵纹理。注意vec2 shift = vec2(100.0);与旋转矩阵的使用(13/clouds.frag#L42-L52),与noise.frag的防偏差处理一脉相承。

书中对域扭曲的评价非常中肯:它极有用、极有趣,但也极难精通——需要相当的经验才能用好。一个实用的技巧是:用噪声的导数(梯度)来位移坐标。Perlin 与 Fabrice Neyret 的经典论文《Flow Noise》(2001)正是基于这一思想;一些现代 Perlin 噪声实现会同时计算函数值与其解析梯度;若某个程序化函数拿不到"真实"梯度,也可以用有限差分近似,只是精度较低、计算量更大。

工具推荐与总结

章节末尾为读者工具箱推荐了LYGIA 的 generative 函数库——一组可复用的 GLSL 程序化生成函数,专为可复用性、性能与灵活性设计,可轻松接入任意项目与框架,是学习用随机与噪声做生成艺术的优质资源。

本章动手清单速览

  • 用simpleFunction交互块调制正弦波的振幅与频率(AM/FM),再做波的叠加与抵消实验;
  • 将 13/1d-fbm.frag 中注释掉的random/noise/fbm三行依次启用,对比三种信号;
  • 将 13/2d-fbm.frag 第 37 行的OCTAVES从 6 逐级降到 1,观察细节退化的梯度;
  • 在 >4 个八度下分别调整 2d-fbm 的 lacunarity(第 47 行)与 gain(第 48 行);
  • 对比 13/turbulence.frag 与 13/ridge.frag:前者对snoise取绝对值,后者做abs → offset - n → n²并乘以前层输出;
  • 用 13/clouds.frag 体会三层 fBm 嵌套扭曲坐标、把灰度噪声变成云朵的完整过程。

从一条正弦波到云海与山脉,fBm 的全部秘密就藏在"八度叠加、频率倍增、振幅衰减"这一个循环里;而湍流、山脊、多重分形与域扭曲,都只是对这个循环的微小变奏。掌握它们,你就拿到了程序化纹理生成的核心钥匙。

【免费下载链接】thebookofshaders

Step-by-step guide through the abstract and complex universe of Fragment Shaders.

项目地址:https://gitcode.com/gh_mirrors/th/thebookofshaders
点击查看免费下载

相关推荐

上一篇:OI-wiki 最小生成树(MST)全解:从 Kruskal、Prim、Borůvka 到次小生成树与 Kruskal 重构树
下一篇:OpenJarvis LLM-Guided Spec Search 实战指南:本地-云端协作的 Agent 配置自搜索与验收

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进