柏林噪声 | Perlin Noise

在很多游戏中,比如 Minecraft 的地形生成,怪物猎人的云,大部分游戏中的火焰,水面的纹理都是基于 Perlin Noise 实现的

而这篇文章我们将学习 Perlin Noise 的基本思想,以及使用 C++ 实现一个基本的二维 Perlin Noise

通过随机数生成的普通噪声往往是杂乱无章的没有规律的,而柏林噪声使得噪声具有规律性一定连续性的噪声,非常适合用于程序化生成地形山川、大理石的纹路、生锈金属的斑驳等等

我们可以通过如下代码生成一个普通噪声纹理

 1#include "ppm_writer.h"
 2
 3#include <iostream>
 4#include <vector>
 5
 6int main()
 7{
 8    constexpr int width = 400;
 9    constexpr int height = 400;
10
11    std::vector<Vec4> data;
12    data.resize(width * height);
13
14    for (int i = 0; i < height; ++i) {
15        for (int j = 0; j < width; ++j) {
16            float value = rand() / (float) (RAND_MAX + 1.0);
17            value = value * 255.0f;
18            data[i * width + j] = Vec4(value, value, value, 255.0f);
19        }
20    }
21
22    PPMWriter ppmWriter;
23    ppmWriter.setFileName("normal_noise.ppm");
24    ppmWriter.setImageSize(width, height);
25    ppmWriter.writePPM(data);
26
27    return 0;
28}

而使用 Perlin Noise 生成的噪声纹理是这样的

可以看到相比于 普通噪声,柏林噪声的噪声纹理更具有连续性,而游戏中对普通的 Perlin Noise 算法进行改进,就能实现地形、程序纹理等效果了。

Perlin Noise 的思想

Perlin Noise 的核心目标是:生成自然、平滑、且具有连续梯度变化的随机纹理

Perlin Noise 的核心思想是:既然 普通噪声 全都是随机生成的,那么我们不全都随机生成,只选取一些点进行随机生成,然后剩下的部分就通过插值来生成这样就存在一些连续性。并且为了获得具有连续梯度变化的纹理,我们应该在这些选取的点上生成一个随机梯度向量,让这些点具有方向性。而我们要做的不是对这些梯度向量进行插值,而是遍历其他的点,使用选定的点到其他的点的向量与选定的点的梯度向量进行点乘,然后对点乘的结果进行插值。

所以总结一下就是:

  1. 选定一些点,通常是整数点如 (0, 0), (1, 0), (0, 1), (1, 1) 等等,在这些点上生成一个随机梯度向量,并保存起来
  2. 遍历其他点,使用 选定的点到其他点的向量 与 选定的点的梯度向量 进行点乘,然后对点乘的结果进行双非线性插值

我们现在思考一下这个算法,如果我们不使用梯度向量,直接对选取的点生成一个随机数值,然后对这些随机数进行插值得到的将会是一些具有块状方格的纹理 有点类似 Minecraft 的一些方块上的纹理。这并不是我们想要的,我们想表达出的是平滑的而不是块状的。

为了解决这个问题,Ken Perlin 引入了 梯度向量 和 位移向量 的点乘。

为什么点乘能产生平滑纹理?

  • 当目标点 P 正好落于网格节点上时,位移向量 d = (0, 0),点乘结果恒等于 0。保证了拼接时候无缝。
  • 当目标点 P 对应的位移向量与梯度向量夹角小于90度时,点乘结果为正(上坡);
  • 当目标点 P 对应的位移向量与梯度向量夹角大于90度时,点乘结果为负(下坡);
  • 当目标点 P 对应的位移向量与梯度向量夹角等于90度时,点乘结果为 0

当一个像素位于网格中央时,它会同时收到周围 4 个顶点产生的斜面的影响。我们把这 4 个不同方向倾斜的斜面平滑地“缝合”在一起,网格内部就会自然隆起平缓的山丘与谷底,从而彻底消除了网格线与方块感。

由此我们可以写出如下代码

  1#include "ppm_writer.h"
  2
  3#include <cmath>
  4#include <cstdlib>
  5#include <iostream>
  6#include <vector>
  7
  8// 二维向量结构体
  9struct Vec2 {
 10    float x, y;
 11
 12    Vec2() : x(0), y(0) {}
 13    Vec2(float x_, float y_) : x(x_), y(y_) {}
 14
 15    // 向量点乘
 16    float dot(const Vec2& other) const {
 17        return x * other.x + y * other.y;
 18    }
 19};
 20
 21// 线性插值
 22inline float lerp(float a, float b, float t) {
 23    return a + t * (b - a);
 24}
 25
 26// Ken Perlin 的 5 次缓动曲线: 6t^5 - 15t^4 + 10t^3
 27inline float fade(float t) {
 28    return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f);
 29}
 30
 31class PerlinNoise2D {
 32private:
 33    int m_grid_size;
 34    std::vector<Vec2> m_gradients; // 保存网格顶点的随机梯度向量
 35
 36public:
 37    PerlinNoise2D(int gridSize) : m_grid_size(gridSize) {
 38        m_gradients.resize(gridSize * gridSize);
 39        
 40        // 随机初始化每一个网格节点上的单位梯度向量
 41        for (int i = 0; i < gridSize * gridSize; ++i) {
 42            float angle = (rand() / (float)RAND_MAX) * 2.0f * 3.1415926535f;
 43            m_gradients[i] = Vec2(std::cos(angle), std::sin(angle));
 44        }
 45    }
 46
 47    float noise(float pixel_x, float pixel_y, int img_width, int img_height) const {
 48        // 1. 将图像坐标映射到网格空间 [0, m_grid_size - 1]
 49        float x_i = (pixel_x / (float)img_width) * (m_grid_size - 1);
 50        float y_i = (pixel_y / (float)img_height) * (m_grid_size - 1);
 51
 52        // 获取当前像素所在的网格单元左下角整数坐标
 53        int x0 = static_cast<int>(std::floor(x_i));
 54        int y0 = static_cast<int>(std::floor(y_i));
 55        int x1 = x0 + 1;
 56        int y1 = y0 + 1;
 57
 58        // 2. 计算相对坐标 (u, v) \in [0, 1]
 59        float u = x_i - x0;
 60        float v = y_i - y0;
 61
 62        // 3. 获取 4 个顶点的梯度向量
 63        Vec2 g00 = m_gradients[y0 * m_grid_size + x0];
 64        Vec2 g10 = m_gradients[y0 * m_grid_size + x1];
 65        Vec2 g01 = m_gradients[y1 * m_grid_size + x0];
 66        Vec2 g11 = m_gradients[y1 * m_grid_size + x1];
 67
 68        // 4. 计算顶点到当前点的位移向量,并做点乘
 69        float n00 = g00.dot(Vec2(u, v));
 70        float n10 = g10.dot(Vec2(u - 1.0f, v));
 71        float n01 = g01.dot(Vec2(u, v - 1.0f));
 72        float n11 = g11.dot(Vec2(u - 1.0f, v - 1.0f));
 73
 74        // 5. 应用 Fade 曲线平滑插值权重
 75        float u_f = fade(u);
 76        float v_f = fade(v);
 77
 78        // 6. 双线性插值
 79        float nx0 = lerp(n00, n10, u_f);
 80        float nx1 = lerp(n01, n11, u_f);
 81        float n = lerp(nx0, nx1, v_f);
 82
 83        // 将 [-1, 1] 的噪声值映射到 [0, 1]
 84        return (n + 1.0f) * 0.5f;
 85    }
 86};
 87
 88int main() {
 89    constexpr int width = 400;
 90    constexpr int height = 400;
 91    constexpr int gridSize = 10; // 10x10 的控制网格
 92
 93    PerlinNoise2D perlin(gridSize);
 94    std::vector<Vec4> data(width * height);
 95
 96    for (int y = 0; y < height; ++y) {
 97        for (int x = 0; x < width; ++x) {
 98            float val = perlin.noise(x, y, width, height);
 99            
100            // 截断到 [0, 255]
101            float color = std::min(255.0f, std::max(0.0f, val * 255.0f));
102            data[y * width + x] = Vec4(color, color, color, 255.0f);
103        }
104    }
105
106    PPMWriter ppmWriter;
107    ppmWriter.setFileName("perlin_noise.ppm");
108    ppmWriter.setImageSize(width, height);
109    ppmWriter.writePPM(data);
110
111    return 0;
112}