第 15 章:噪声与程序化生成

写给 junior 的话:这一章可能是整个渲染学习中最"好玩"的一章。我们不画三角形,不拼网格,而是用数学来"造世界"——云、山、水、石头、木纹……全部来自一行行代码。准备好了吗?让我们开始这场"无中生有"的魔法之旅。


目录

  1. 什么是噪声?(不是音响里的噪音)
  2. Value Noise:从随机到平滑
  3. Gradient Noise(类 Perlin 噪声):让随机有了方向
  4. Simplex Noise:Perlin 的进化版
  5. Cellular Noise(Voronoi / Worley):细胞的世界
  6. Fractal Brownian Motion(FBM):分层的艺术
  7. Domain Warping:用噪声扭曲空间
  8. 应用实例:云、地形、水、有机纹理
  9. 常见问题 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.0100.0。观察随机格子的密度变化。思考:如果我们要让格子之间"平滑过渡",应该怎么做?


2. Value Noise:从随机到平滑

2.1 生活中的类比:测量海拔

想象你在一片平原上插了很多标杆,每个标杆上写一个随机海拔高度。现在你想知道任意位置(不在标杆上)的海拔。你会怎么做?

找到最近的四个标杆,按距离加权平均——离得越近的标杆,权重越大。这就是 Value Noise 的核心思想。

2.2 本质是什么?

Value Noise = 在网格点上放置随机值 + 插值

步骤拆解:

  1. 划分网格:把空间分成一个个格子
  2. 网格点赋值:每个格子的角上放一个随机值
  3. 插值:对于格子内的任意点,用四个角的值插值出它的值

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 = 0u = 0du/df = 0
  • f = 1u = 1du/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

  1. 确定它所在的网格格子 i_st = floor(st)
  2. 检查周围 3x3 = 9 个相邻格子(包括自己)
  3. 每个格子内有一个随机种子点 p
  4. 计算当前点到每个种子点的距离
  5. 最小距离作为输出
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 的 lacunaritygain 是什么?

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

  1. 先输出原始噪声gl_FragColor = vec4(vec3(noise(st)), 1.0)
  2. 检查范围:如果画面全白或全黑,可能输出范围不对,尝试 0.5 * n + 0.5
  3. 逐层调试 FBM:先画 octave = 1,确认对再加层
  4. 用颜色编码方向:对于 Gradient Noise,可以把梯度向量可视化

总结

噪声类型 核心思想 像什么 用在哪
Value Noise 网格点随机值 + 插值 方块云 快速预览
Gradient Noise 网格点梯度向量 + 点积 + 插值 自然云 通用纹理
Simplex Noise 单形网格 + 3 顶点插值 更圆的云 高性能场景
Cellular Noise 最近种子点距离 龟裂/细胞 石头、皮革、脉络
FBM 多层噪声叠加 山、云、地形 自然景物
Domain Warping 噪声扭曲坐标 热浪、风 让一切更自然

最后的话:噪声是图形学中最接近"魔法"的东西之一。你写的不是像素,而是规则;规则生成了无限的变化。掌握噪声,你就掌握了用代码"造物"的能力。去实验吧,去造一片云、一座山、一片海——它们都在你的指尖。


参考资源


本章配套代码位于 c:/Users/10603/Desktop/otherLearn2/akira-graphics/noise/