第 15 章:噪声与程序化生成
写给 junior 的话:这一章可能是整个渲染学习中最"好玩"的一章。我们不画三角形,不拼网格,而是用数学来"造世界"——云、山、水、石头、木纹……全部来自一行行代码。准备好了吗?让我们开始这场"无中生有"的魔法之旅。
目录
- 什么是噪声?(不是音响里的噪音)
- Value Noise:从随机到平滑
- Gradient Noise(类 Perlin 噪声):让随机有了方向
- Simplex Noise:Perlin 的进化版
- Cellular Noise(Voronoi / Worley):细胞的世界
- Fractal Brownian Motion(FBM):分层的艺术
- Domain Warping:用噪声扭曲空间
- 应用实例:云、地形、水、有机纹理
- 常见问题 Q&A
1. 什么是噪声?(不是音响里的噪音)
1.1 生活中的类比:老电视的雪花屏
还记得小时候老电视收不到信号时满屏的"雪花"吗?黑白点疯狂闪烁,毫无规律——那就是纯随机(white noise)。它 chaotic、不可预测,像一把撒在地上的盐。
但自然界很少是"撒盐"式的。看看天上的云、海面的波浪、大理石的纹路——它们也是"随机"的,但有一种连贯的、平滑的美感。这种"有秩序的随机",就是我们在图形学中追求的噪声(Noise)。
1.2 本质是什么?
噪声 = 伪随机 + 空间连续性
| 特性 | 纯随机(Random) | 噪声(Noise) |
|---|---|---|
| 相邻点的值 | 毫无关系,跳来跳去 | 缓慢变化,平滑过渡 |
| 像什么 | 电视雪花、撒盐 | 云、山、水波 |
| 可重复? | 可以(伪随机种子) | 可以(确定性函数) |
| 可控性 | 几乎没有 | 频率、振幅可精确调节 |
核心洞察:噪声是一个函数 noise(x),输入一个坐标,输出一个值。同样的输入永远得到同样的输出,但输出值在空间上连续变化。
1.3 最基础的"伪随机"函数
在 GPU 上,我们没有 rand()。但我们可以用一个数学技巧来制造"看起来随机"的数:
// 一维伪随机:输入一个数,输出一个"乱糟糟"的数
float random(float x) {
return fract(sin(x * 1243758.5453123));
// sin() 把输入变成波浪
// 乘一个大数让波浪"抖"得很快
// fract() 取小数部分,把结果压到 [0, 1]
}
// 二维伪随机:输入一个坐标,输出一个"乱糟糟"的数
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
// dot() 把二维压成一维
// 后面的套路一样:sin -> 放大 -> 取小数
}
注意:这不是"真随机",而是确定性伪随机。同样的
st永远输出同样的值——这对渲染至关重要,因为 GPU 的每个像素必须独立计算,不能共享状态。
1.4 运行代码:看看纯随机长什么样
void main() {
vec2 st = vUv * 20.0; // 把 UV 放大 20 倍
float r = random(st); // 每个像素一个随机值
gl_FragColor = vec4(vec3(r), 1.0);
}
你会看到一块块"马赛克"——每个格子内是固定的随机值,格子之间毫无关联。这就是纯随机,还不是噪声。
1.5 常见误区
误区 1:"噪声就是随机数"
纠正:纯随机是
random(x),噪声是noise(x)。噪声的关键是空间连续性——相邻点的输出值是接近的。
误区 2:"噪声是不可重复的"
纠正:图形学中的噪声函数是纯数学函数,100% 可重复、可预测。这是好事——意味着你可以用同样的参数在任何地方"重建"同一片云。
1.6 动手试一试
练习:修改上面的代码,把
st的缩放从20.0改成5.0和100.0。观察随机格子的密度变化。思考:如果我们要让格子之间"平滑过渡",应该怎么做?
2. Value Noise:从随机到平滑
2.1 生活中的类比:测量海拔
想象你在一片平原上插了很多标杆,每个标杆上写一个随机海拔高度。现在你想知道任意位置(不在标杆上)的海拔。你会怎么做?
找到最近的四个标杆,按距离加权平均——离得越近的标杆,权重越大。这就是 Value Noise 的核心思想。
2.2 本质是什么?
Value Noise = 在网格点上放置随机值 + 插值
步骤拆解:
- 划分网格:把空间分成一个个格子
- 网格点赋值:每个格子的角上放一个随机值
- 插值:对于格子内的任意点,用四个角的值插值出它的值
2.3 一维 Value Noise
先从简单的一维开始:
float random(float x) {
return fract(sin(x * 1243758.5453123));
}
// 一维 Value Noise
float valueNoise1D(float x) {
float i = floor(x); // 左边的网格点索引
float f = fract(x); // 在格子内的偏移量 [0, 1]
// 两个相邻网格点的随机值
float v0 = random(i); // 左边点的值
float v1 = random(i + 1.0); // 右边点的值
// 线性插值:在 v0 和 v1 之间按 f 的比例混合
return mix(v0, v1, f);
}
但线性插值有个问题:在网格点处,导数不连续(看起来有"折角")。我们用平滑插值来修复:
// 原始的线性插值:mix(v0, v1, f)
// 平滑插值:用 f * f * (3.0 - 2.0 * f) 代替 f
// 这个函数叫 "smoothstep",它在 f=0 和 f=1 处的导数为 0
float smoothInterpolation(float v0, float v1, float f) {
float u = f * f * (3.0 - 2.0 * f); // smoothstep 曲线
return mix(v0, v1, u);
}
为什么 f * f * (3.0 - 2.0 * f) 能让导数连续?
- 当
f = 0:u = 0,du/df = 0 - 当
f = 1:u = 1,du/df = 0 - 在两端导数都是 0,意味着插值结果"温柔地"进入和离开每个网格
2.4 二维 Value Noise
二维就是在一维的基础上多了一层:
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
// 二维 Value Noise
float valueNoise(vec2 st) {
vec2 i = floor(st); // 左下角的网格点
vec2 f = fract(st); // 在格子内的偏移 [0,1] x [0,1]
// 平滑插值因子
vec2 u = f * f * (3.0 - 2.0 * f);
// 四个角的随机值,按 u.x 和 u.y 双线性插值
return mix(
mix(random(i + vec2(0.0, 0.0)), random(i + vec2(1.0, 0.0)), u.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
插值过程可视化:
(i.x, i.y+1) [v01] -------- [v11] (i.x+1, i.y+1)
| |
| P(f) |
| |
(i.x, i.y) [v00] -------- [v10] (i.x+1, i.y)
第一步:按 x 方向插值
a = mix(v00, v10, u.x) // 底边
b = mix(v01, v11, u.x) // 顶边
第二步:按 y 方向插值
result = mix(a, b, u.y)
2.5 完整可运行代码
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(random(i + vec2(0.0, 0.0)), random(i + vec2(1.0, 0.0)), u.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
void main() {
vec2 st = vUv * 20.0; // 缩放:控制"格子密度"
float n = noise(st); // 计算噪声值 [0, 1]
gl_FragColor = vec4(vec3(n), 1.0);
}
2.6 常见误区
误区:"Value Noise 就是 Perlin Noise"
纠正:Value Noise 插值的是随机值(标量),Perlin Noise 插值的是梯度影响(向量点积)。Value Noise 更简单,但视觉上会有明显的"网格感",因为所有极值都恰好落在网格点上。
2.7 动手试一试
练习:在上面的代码中,把
u = f * f * (3.0 - 2.0 * f)改成u = f(线性插值)。观察输出——你会看到明显的"十字"状瑕疵。这就是为什么要用 smoothstep。
3. Gradient Noise(类 Perlin 噪声):让随机有了方向
3.1 生活中的类比:山坡上的滚动球
想象一个球停在山坡上。球会向哪个方向滚?取决于坡度(梯度)——最陡的方向。Gradient Noise 的核心就是:不在网格点上放"高度值",而是放坡度方向(梯度向量)。
3.2 本质是什么?
Gradient Noise = 在网格点上放置随机梯度向量 + 用点积计算影响 + 插值
与 Value Noise 的区别:
| Value Noise | Gradient Noise | |
|---|---|---|
| 网格点上存什么 | 随机标量值 | 随机单位向量(梯度) |
| 如何计算像素值 | 直接插值四个角的值 | 用梯度与偏移向量点积,再插值 |
| 视觉效果 | 有网格感,像方块云 | 更自然,像真实的云和纹理 |
| 极值位置 | 恰好在网格点上 | 在网格内部,更自然 |
3.3 一步步推导
第一步:生成随机梯度向量
vec2 random2(vec2 st) {
st = vec2(
dot(st, vec2(127.1, 311.7)),
dot(st, vec2(269.5, 183.3))
);
return -1.0 + 2.0 * fract(sin(st) * 43758.5453123);
// 输出范围 [-1, 1] x [-1, 1]
}
第二步:计算每个角对当前点的影响
对于格子内的点 P,它相对于左下角 i 的偏移是 f = fract(st)。
四个角的坐标(相对 P):
- 左下角:
f - vec2(0.0, 0.0) - 右下角:
f - vec2(1.0, 0.0) - 左上角:
f - vec2(0.0, 1.0) - 右上角:
f - vec2(1.0, 1.0)
每个角的影响 = 梯度向量 dot 偏移向量
// 左下角的影响
dot(random2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0))
为什么用点积?点积衡量两个向量的"同向程度"。如果梯度指向 P,影响就大;如果垂直,影响为 0;如果反向,影响为负。
第三步:插值四个影响值
和 Value Noise 一样,用 smoothstep 插值:
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(
dot(random2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
dot(random2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)),
u.x
),
mix(
dot(random2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(random2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)),
u.x
),
u.y
);
3.4 完整可运行代码
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
// 生成随机梯度向量
vec2 random2(vec2 st) {
st = vec2(
dot(st, vec2(127.1, 311.7)),
dot(st, vec2(269.5, 183.3))
);
return -1.0 + 2.0 * fract(sin(st) * 43758.5453123);
}
// Gradient Noise(基于 Inigo Quilez 的实现)
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(
dot(random2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
dot(random2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)),
u.x
),
mix(
dot(random2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(random2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)),
u.x
),
u.y
);
}
void main() {
vec2 st = vUv * 20.0;
float n = noise(st);
// noise() 输出范围约 [-1, 1],映射到 [0, 1] 显示
gl_FragColor = vec4(vec3(0.5 * n + 0.5), 1.0);
}
3.5 常见误区
误区:"Gradient Noise 比 Value Noise 好,所以不需要学 Value Noise"
纠正:Value Noise 计算量更小,在某些场景(如快速预览、低功耗设备)仍然有用。而且理解 Value Noise 是理解 Gradient Noise 的必经之路——它们的插值框架是一样的,区别只在于"插值什么"。
误区:"点积的结果就是最终噪声值"
纠正:点积只是计算了每个角点的"影响",最终值还需要插值四个角的影响。而且 Gradient Noise 的输出范围理论上是 [-1, 1],但实际可能略超,所以通常要做一次映射。
3.6 动手试一试
练习:把
random2的返回值从return -1.0 + 2.0 * fract(...)改成return fract(...)(只输出正方向梯度)。观察输出变化——你会发现噪声变得"不对称"了,因为所有梯度都指向第一象限。
4. Simplex Noise:Perlin 的进化版
4.1 生活中的类比:从方格地砖到蜂窝地砖
想象你要给地板铺瓷砖。方格地砖(正方形网格)有个问题:每个角落要连接 4 块砖,计算量大。如果换成蜂窝状(三角形/六边形网格),每个点只需要连接 3 个邻居,而且更自然地贴合平面。
Simplex Noise 就是把正方形网格换成了单形网格(Simplex Grid),在二维是三角形,在三维是四面体。
4.2 本质是什么?
Simplex Noise = 单形网格划分 + 仅插值最近的 3 个顶点 + 梯度衰减
相比 Perlin Noise 的优势:
| 特性 | Perlin Noise | Simplex Noise |
|---|---|---|
| 维度 | n 维需要 2^n 个角点 | n 维只需要 n+1 个角点 |
| 二维 | 4 次插值 | 3 次插值 |
| 三维 | 8 次插值 | 4 次插值 |
| 计算复杂度 | O(2^n) | O(n) |
| 视觉质量 | 有轻微轴向偏差 | 各向同性(各方向均匀) |
4.3 核心思想:坐标变换
把直角坐标系"压扁"成单形坐标系:
// 单形网格的坐标变换
// 在二维,把正方形网格"斜切"成三角形网格
const vec4 C = vec4(
0.211324865405187, // (3.0 - sqrt(3.0)) / 6.0
0.366025403784439, // 0.5 * (sqrt(3.0) - 1.0)
-0.577350269189626, // -1.0 + 2.0 * C.x
0.024390243902439 // 1.0 / 41.0
);
// 把输入坐标 v 变换到单形网格
vec2 i = floor(v + dot(v, C.yy)); // 单形网格的整数坐标
vec2 x0 = v - i + dot(i, C.xx); // 在单形内的局部坐标
4.4 找到最近的三个顶点
在三角形网格中,一个点只落在某个三角形内,只需要考虑这个三角形的 3 个顶点:
// 判断 x0 在三角形的哪个"半边"
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
// 另外两个顶点
vec2 x1 = x0.xy + C.xx - i1;
vec2 x2 = x0.xy + C.zz;
4.5 距离衰减
Simplex Noise 的另一个创新:距离越远,影响越小。用 max(0.5 - distance^2, 0.0) 作为权重:
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x1, x1), dot(x2, x2)), 0.0);
m = m * m;
m = m * m; // 四次方衰减,让远处的影响快速消失
4.6 完整可运行代码
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
// Simplex Noise 2D
// 作者:Ian McEwan, Ashima Arts
// 许可证:MIT
float noise(vec2 v) {
const vec4 C = vec4(
0.211324865405187, // (3.0 - sqrt(3.0)) / 6.0
0.366025403784439, // 0.5 * (sqrt(3.0) - 1.0)
-0.577350269189626, // -1.0 + 2.0 * C.x
0.024390243902439 // 1.0 / 41.0
);
// 第一步:坐标变换到单形网格
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
// 第二步:确定另外两个顶点
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec2 x1 = x0.xy + C.xx - i1;
vec2 x2 = x0.xy + C.zz;
// 第三步:哈希(避免截断误差)
i = mod289(i);
vec3 p = permute(
permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0)
);
// 第四步:距离衰减
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x1, x1), dot(x2, x2)), 0.0);
m = m * m;
m = m * m;
// 第五步:生成梯度并归一化
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
// 第六步:计算最终值
vec3 g = vec3(0.0);
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * vec2(x1.x, x2.x) + h.yz * vec2(x1.y, x2.y);
return 130.0 * dot(m, g);
}
void main() {
vec2 st = vUv * 20.0;
float n = noise(st);
gl_FragColor = vec4(vec3(0.5 * n + 0.5), 1.0);
}
4.7 常见误区
误区:"Simplex Noise 一定比 Perlin Noise 好看"
纠正:Simplex Noise 的优势主要是性能和各向同性。视觉上两者非常接近,很多时候用 Perlin 也完全没问题。Simplex 的代码更复杂,如果项目对性能不敏感,Perlin 更易读易维护。
误区:"Simplex Noise 的代码太复杂,要全部背下来"
纠正:不需要背!理解原理(单形网格、3 个顶点、距离衰减)即可。实际项目中直接复制标准实现(上面就是)。记住:Ian McEwan / Ashima Arts 的版本是业界标准。
4.8 动手试一试
练习:对比运行 Perlin Noise 和 Simplex Noise 的代码。把它们的输出分别保存为图片,放大观察细节。你会发现 Simplex Noise 的"斑点"更圆一些,而 Perlin Noise 略带方形——这就是轴向偏差。
5. Cellular Noise(Voronoi / Worley):细胞的世界
5.1 生活中的类比:干裂的土地与长颈鹿的斑纹
想象一片土地干旱后裂成一块块"龟裂",每块龟裂的中心是一个"种子点",边界是到两个种子点距离相等的地方。这就是 Voronoi 图——自然界中随处可见:长颈鹿的斑纹、蜻蜓的翅膀、泡沫的结构。
5.2 本质是什么?
Cellular Noise = 在空间中散布随机种子点 + 计算到最近种子点的距离
不像之前的噪声是"插值"出来的,Cellular Noise 是"距离"出来的。它产生的不是平滑的渐变,而是清晰的细胞状边界。
5.3 算法步骤
对于当前像素位置 st:
- 确定它所在的网格格子
i_st = floor(st) - 检查周围 3x3 = 9 个相邻格子(包括自己)
- 每个格子内有一个随机种子点
p - 计算当前点到每个种子点的距离
- 取最小距离作为输出
vec2 random2(vec2 st) {
st = vec2(
dot(st, vec2(127.1, 311.7)),
dot(st, vec2(269.5, 183.3))
);
return fract(sin(st) * 43758.5453123);
}
float cellularNoise(vec2 st) {
vec2 i_st = floor(st);
vec2 f_st = fract(st);
float minDist = 1.0; // 初始最小距离
// 遍历周围 3x3 个格子
for(int i = -1; i <= 1; i++) {
for(int j = -1; j <= 1; j++) {
vec2 neighbor = vec2(float(i), float(j));
// 邻居格子中的随机种子点(相对格子中心偏移)
vec2 p = random2(i_st + neighbor);
// 种子点的实际位置 = 邻居格子坐标 + 种子点偏移
vec2 pointPos = neighbor + p;
// 计算当前点到种子点的距离
float dist = distance(f_st, pointPos);
// 保留最小距离
minDist = min(minDist, dist);
}
}
return minDist;
}
5.4 让种子点动起来
加上时间,让种子点在格子内"游走":
// p 原本在 [0, 1] 静止
// 现在让它随时间做圆周运动
p = 0.5 + 0.5 * sin(uTime + 6.2831 * p);
5.5 完整可运行代码
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform float uTime;
vec2 random2(vec2 st) {
st = vec2(
dot(st, vec2(127.1, 311.7)),
dot(st, vec2(269.5, 183.3))
);
return fract(sin(st) * 43758.5453123);
}
void main() {
vec2 st = vUv * 10.0; // 缩放控制细胞大小
float d = 1.0;
vec2 i_st = floor(st);
vec2 f_st = fract(st);
// 遍历 3x3 邻居格子
for(int i = -1; i <= 1; i++) {
for(int j = -1; j <= 1; j++) {
vec2 neighbor = vec2(float(i), float(j));
vec2 p = random2(i_st + neighbor);
// 让种子点动起来
p = 0.5 + 0.5 * sin(uTime + 6.2831 * p);
// 更新最小距离
d = min(d, distance(f_st, neighbor + p));
}
}
// d 是到最近种子点的距离
// step(d, 0.03) 在种子点中心画白点
gl_FragColor = vec4(vec3(d) + step(d, 0.03), 1.0);
}
5.6 常见误区
误区:"Cellular Noise 只能做细胞/龟裂效果"
纠正:Cellular Noise 的返回值是"到最近点的距离",这个距离场可以做很多事:水波纹、石头纹理、皮革、甚至作为其他效果的输入。创意在于你怎么用它。
误区:"为什么一定要检查 3x3 个格子,不能只查自己所在的格子吗?"
纠正:因为一个格子的最近种子点可能在隔壁!想象一下你站在格子边缘,最近的点很可能在相邻格子里。3x3 是保证正确的最小范围(二维)。一维只需要左右各 1 个,三维需要 3x3x3 = 27 个。
5.7 动手试一试
练习:把
min(d, distance(...))改成记录第二小的距离(d2),然后输出d2 - d。你会得到 Voronoi 的"脊线"——边界处值为 0,内部向外递增。这是做裂纹、脉络效果的经典技巧。
6. Fractal Brownian Motion(FBM):分层的艺术
6.1 生活中的类比:远山与近山
站在平原上眺望群山。远处的山峦起伏缓慢(低频),近处的丘陵起伏快速(高频)。如果把不同频率的山"叠加"在一起,就得到了真实的地形。这就是 FBM——把同一噪声以不同频率和振幅层层叠加。
6.2 本质是什么?
FBM = 同一噪声函数的多重采样 + 频率倍增 + 振幅减半
数学表达:
FBM(x) = sum( amplitude_i * noise(frequency_i * x) )
其中:
frequency_i = 2^i (每 octave 频率翻倍)
amplitude_i = 0.5^i (每 octave 振幅减半)
每一层叫做一个 Octave(八度),借用音乐术语——每高一个八度,频率翻倍。
6.3 为什么振幅要减半?
如果不减半,高频层会"淹没"低频层,最终看起来又像纯随机了。振幅减半保证:
- 第一层(低频)决定大轮廓
- 第二层添加中等细节
- 第三层添加细小纹理
- ……总贡献收敛,不会爆炸
6.4 代码实现
#define OCTAVES 6
float fbm(vec2 st) {
float value = 0.0; // 累加结果
float amplitude = 0.5; // 当前层的振幅
for(int i = 0; i < OCTAVES; i++) {
value += amplitude * noise(st); // 采样当前频率
st *= 2.0; // 频率翻倍
amplitude *= 0.5; // 振幅减半
}
return value;
}
6.5 可视化:从一层到六层
Octave 0: ~~~~~~~~~~~~ 大波浪,振幅 0.5
Octave 1: ~~~~~~~~~~ 中波浪,振幅 0.25
Octave 2: ~~~~~~~~ 小波浪,振幅 0.125
Octave 3: ~~~~~~ 更小,振幅 0.0625
Octave 4: ~~~~ 微小,振幅 0.03125
Octave 5: ~~ 极细,振幅 0.015625
-------------------------------------------
Sum: 真实云/山/纹理
6.6 完整可运行代码(动态云)
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform float uTime;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(random(i + vec2(0.0, 0.0)), random(i + vec2(1.0, 0.0)), u.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
#define OCTAVES 6
float mist(vec2 st) {
float value = 0.0;
float amplitude = 0.5;
for(int i = 0; i < OCTAVES; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
// HSB 转 RGB,给云上色
vec3 hsb2rgb(vec3 c) {
vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
return c.z * mix(vec3(1.0), rgb, c.y);
}
void main() {
vec2 st = vUv;
st.x += 0.1 * uTime; // 让云向 x 方向飘动
float m = mist(st * 5.0); // FBM 噪声
// 用 HSB 上色:色相随噪声变化,饱和度和亮度固定
gl_FragColor = vec4(hsb2rgb(vec3(m, 1.0, 1.0)), 1.0);
}
6.7 常见误区
误区:"Octave 越多越好"
纠正:Octave 越多细节越丰富,但计算量线性增长。而且超过一定数量后,高频细节已经超出屏幕分辨率,白白浪费 GPU。通常 4-8 个 octave 就够了。
误区:"FBM 只能用来做云"
纠正:FBM 是一种"通用叠加技术",可以用在任何噪声上。Value Noise + FBM = 简单地形;Perlin + FBM = 自然云;Simplex + FBM = 高效地形;Cellular + FBM = 石头/皮革纹理。
6.8 动手试一试
练习:把
st *= 2.0改成st *= 2.5(频率增长更快),把amplitude *= 0.5改成amplitude *= 0.7(振幅衰减更慢)。观察输出变化——前者细节更细碎,后者层次更"均衡"。找到你喜欢的组合!
7. Domain Warping:用噪声扭曲空间
7.1 生活中的类比:热浪中的景象
夏天路面上的热浪会让远处的景物"扭曲"、"晃动"。这不是景物本身在动,而是光线穿过的介质在变化。Domain Warping 就是这个原理——不直接画噪声,而是用噪声去扭曲坐标,再用扭曲后的坐标去画其他东西。
7.2 本质是什么?
Domain Warping = 用噪声函数偏移输入坐标 + 在原坐标上采样另一个图案
公式:
original: color = pattern(st)
warped: color = pattern(st + noise(st) * strength)
7.3 单层扭曲
vec2 st = vUv;
// 用噪声扭曲坐标
vec2 warp = vec2(
noise(st + vec2(0.0, 0.0)),
noise(st + vec2(5.2, 1.3)) // 偏移种子,让 xy 不同
);
st += warp * 0.3; // 扭曲强度
// 在扭曲后的坐标上画图案
float pattern = sin(st.x * 10.0);
7.4 双层扭曲(更自然)
Inigo Quilez 的经典技巧:用噪声扭曲噪声扭曲坐标:
vec2 warp(vec2 st) {
vec2 q = vec2(
noise(st),
noise(st + vec2(5.2, 1.3))
);
vec2 r = vec2(
noise(st + 4.0 * q + vec2(1.7, 9.2)),
noise(st + 4.0 * q + vec2(8.3, 2.8))
);
return st + r * 0.5;
}
7.5 应用:扭曲线条
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(random(i + vec2(0.0, 0.0)), random(i + vec2(1.0, 0.0)), u.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
// 画水平线
float lines(in vec2 pos, float b) {
float scale = 10.0;
pos *= scale;
return smoothstep(
0.0, 0.5 + b * 0.5,
abs((sin(pos.x * 3.1415) + b * 2.0)) * 0.5
);
}
// 旋转函数
vec2 rotate(vec2 v0, float ang) {
float sinA = sin(ang);
float cosA = cos(ang);
mat3 m = mat3(cosA, -sinA, 0, sinA, cosA, 0, 0, 0, 1);
return (m * vec3(v0, 1.0)).xy;
}
void main() {
vec2 st = vUv.yx * vec2(10.0, 3.0);
// 用噪声旋转坐标!
st = rotate(st, noise(st));
float d = lines(st, 0.5);
gl_FragColor = vec4(1.0 - vec3(d), 1.0);
}
7.6 常见误区
误区:"Domain Warping 只是"特效",不实用"
纠正:Domain Warping 是程序化生成的核心技巧之一。没有它,FBM 只能做出"像云但太规则"的东西;加上 Domain Warping,云才有了"被风吹过的"自然感。它是从"技术正确"到"艺术正确"的关键一步。
7.7 动手试一试
练习:把上面的
rotate(st, noise(st))改成st += noise(st) * 0.5。观察线条从"旋转扭曲"变成"位置偏移"的效果。然后尝试st *= 1.0 + noise(st) * 0.3(缩放扭曲)。
8. 应用实例:云、地形、水、有机纹理
8.1 云(Clouds)
核心配方:FBM + 阈值 + 颜色映射
float cloud = fbm(st * 3.0);
// 云不是连续的,有"块状"感
cloud = smoothstep(0.3, 0.7, cloud);
// 白色云 + 蓝天背景
vec3 color = mix(vec3(0.4, 0.6, 1.0), vec3(1.0), cloud);
进阶:加上 Domain Warping 让云有风的效果,加上时间让云飘动。
8.2 地形(Terrain)
核心配方:FBM + 高度着色
float height = fbm(st * 2.0);
// 海拔决定颜色
vec3 color;
if(height < 0.2) color = vec3(0.1, 0.2, 0.8); // 深海
else if(height < 0.3) color = vec3(0.9, 0.8, 0.5); // 沙滩
else if(height < 0.6) color = vec3(0.2, 0.6, 0.1); // 草地
else if(height < 0.8) color = vec3(0.4, 0.3, 0.2); // 岩石
else color = vec3(1.0); // 雪顶
进阶:用 Ridged FBM(把噪声取绝对值后翻转:1.0 - abs(noise))做山脉的锐利脊线。
8.3 水(Water)
核心配方:多层噪声 + 时间动画 + 法线扰动
float water(vec2 st, float t) {
float w = 0.0;
w += sin(st.x * 10.0 + t) * 0.5;
w += noise(st * 5.0 + t * 0.5) * 0.3;
w += noise(st * 10.0 - t * 0.3) * 0.2;
return w;
}
8.4 有机纹理(木纹、大理石、皮革)
木纹:用噪声做扭曲的正弦波
float wood(vec2 st) {
float n = noise(st * vec2(1.0, 10.0));
st.x += n * 0.5; // 用噪声扭曲 x 坐标
return sin(st.x * 20.0); // 扭曲后的正弦波 = 木纹
}
大理石:Domain Warping 的经典应用
float marble(vec2 st) {
vec2 q = vec2(noise(st), noise(st + vec2(5.2, 1.3)));
vec2 r = vec2(
noise(st + 4.0 * q + vec2(1.7, 9.2)),
noise(st + 4.0 * q + vec2(8.3, 2.8))
);
return sin(st.x * 10.0 + 4.0 * r.x);
}
8.5 星空(Star Sky)
float sky(vec2 st) {
// 高频噪声 + 阈值 = 稀疏的白点
float stars = noise(st * 1000.0);
return step(0.99, stars); // 只有最亮的点显示
}
9. 常见问题 Q&A
Q1:为什么我的噪声看起来有"网格"或"方块"?
A:可能原因:
- 使用了线性插值而不是 smoothstep——检查
u = f * f * (3.0 - 2.0 * f) - 使用了 Value Noise 而不是 Gradient Noise——Value Noise 天然有网格感
- 频率太低,格子太大——尝试增大
st的缩放系数
Q2:噪声的输出范围是多少?
A:
- Value Noise:
[0, 1] - Gradient Noise / Simplex Noise:理论
[-1, 1],实际可能略超 - FBM:取决于 octave 数量和振幅衰减。如果振幅逐层减半,总和收敛到
[-1, 1]
Q3:为什么 Simplex Noise 的代码里有那么多"魔法数字"?
A:那些数字来自严谨的数学推导:
0.211324865405187 = (3 - sqrt(3)) / 6—— 单形网格的斜切系数0.366025403784439 = (sqrt(3) - 1) / 2—— 坐标变换系数130.0—— 归一化系数,让输出范围恰好约 [-1, 1]
不需要记住推导,但要知道它们不是随便写的。
Q4:FBM 的 lacunarity 和 gain 是什么?
A:
- Lacunarity(缺项):频率的倍增因子,默认 2.0。越大,细节越细碎。
- Gain:振幅的衰减因子,默认 0.5。越大,高频层贡献越多,结果更"粗糙"。
float fbm(vec2 st, int octaves, float lacunarity, float gain) {
float value = 0.0;
float amplitude = 0.5;
for(int i = 0; i < octaves; i++) {
value += amplitude * noise(st);
st *= lacunarity;
amplitude *= gain;
}
return value;
}
Q5:噪声在 3D 中怎么用?
A:3D 噪声是 2D 的自然扩展:
- 云体:用 3D FBM,切片渲染
- 地形:2D FBM 生成高度图,3D 噪声添加洞穴
- 动画纹理:用
noise(vec3(st, time)),时间作为第三维
所有 2D 噪声函数都可以扩展到 3D——只是网格从正方形变成立方体(或 3D 单形)。
Q6:实际项目中,我该用哪种噪声?
A:快速决策表:
| 场景 | 推荐噪声 | 理由 |
|---|---|---|
| 快速原型/学习 | Value Noise | 代码最短,最易理解 |
| 通用云/地形 | Perlin / Simplex | 质量高,资源多 |
| 性能敏感的 3D | Simplex Noise | 复杂度 O(n) 而非 O(2^n) |
| 石头/细胞/裂纹 | Cellular Noise | 独特的距离场效果 |
| 水面/热浪扭曲 | Domain Warping + 任意噪声 | 扭曲坐标而非直接输出 |
| 星空/颗粒 | 纯 random() + 阈值 | 不需要连续性 |
Q7:如何调试噪声 shader?
A:
- 先输出原始噪声:
gl_FragColor = vec4(vec3(noise(st)), 1.0) - 检查范围:如果画面全白或全黑,可能输出范围不对,尝试
0.5 * n + 0.5 - 逐层调试 FBM:先画
octave = 1,确认对再加层 - 用颜色编码方向:对于 Gradient Noise,可以把梯度向量可视化
总结
| 噪声类型 | 核心思想 | 像什么 | 用在哪 |
|---|---|---|---|
| Value Noise | 网格点随机值 + 插值 | 方块云 | 快速预览 |
| Gradient Noise | 网格点梯度向量 + 点积 + 插值 | 自然云 | 通用纹理 |
| Simplex Noise | 单形网格 + 3 顶点插值 | 更圆的云 | 高性能场景 |
| Cellular Noise | 最近种子点距离 | 龟裂/细胞 | 石头、皮革、脉络 |
| FBM | 多层噪声叠加 | 山、云、地形 | 自然景物 |
| Domain Warping | 噪声扭曲坐标 | 热浪、风 | 让一切更自然 |
最后的话:噪声是图形学中最接近"魔法"的东西之一。你写的不是像素,而是规则;规则生成了无限的变化。掌握噪声,你就掌握了用代码"造物"的能力。去实验吧,去造一片云、一座山、一片海——它们都在你的指尖。
参考资源
- The Book of Shaders - Noise
- Inigo Quilez - Articles on Noise
- Ashima Arts - WebGL Noise
- Steven Worley - Cellular Texture Basis Function
本章配套代码位于 c:/Users/10603/Desktop/otherLearn2/akira-graphics/noise/