柏林噪声 | 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 的核心思想是:既然 普通噪声 全都是随机生成的,那么我们不全都随机生成,只选取一些点进行随机生成,然后剩下的部分就通过插值来生成这样就存在一些连续性。并且为了获得具有连续梯度变化的纹理,我们应该在这些选取的点上生成一个随机梯度向量,让这些点具有方向性。而我们要做的不是对这些梯度向量进行插值,而是遍历其他的点,使用选定的点到其他的点的向量与选定的点的梯度向量进行点乘,然后对点乘的结果进行插值。
所以总结一下就是:
- 选定一些点,通常是整数点如 (0, 0), (1, 0), (0, 1), (1, 1) 等等,在这些点上生成一个随机梯度向量,并保存起来
- 遍历其他点,使用 选定的点到其他点的向量 与 选定的点的梯度向量 进行点乘,然后对点乘的结果进行双非线性插值
我们现在思考一下这个算法,如果我们不使用梯度向量,直接对选取的点生成一个随机数值,然后对这些随机数进行插值得到的将会是一些具有块状方格的纹理
有点类似 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}