Perlin Noise 알고리즘의 상세 구현 과정
2차원 펄린 노이즈 알고리즘을 C++과 SFML을 사용하여 구현했습니다.
게임 엔진의 라이브러리에서 제공하지만, 알고리즘의 내부 동작 원리를 알고 싶어 직접 구현하는 해보았습니다.
Ken Perlin이 제안한 순열 방식부터 그래디언트 벡터 할당, 보간 과정까지 단계별로 구현하며 펄린 노이즈의 작동 메커니즘을 알 수 있었습니다.
int permutation[] = {
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148,
247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32,
57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175,
74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122,
60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54,
65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169,
200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64,
52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212,
207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213,
119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104,
218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241,
81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93,
222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180
};
순열에서 값을 가져옵니다. 각 모서리들에게 순열의 값을 할당합니다.
이때 X, Y는 입력의 정수부 값입니다.
int X = static_cast(std::floor(x)) & 255; // 정수부 2.3 => 2 int Y = static_cast (std::floor(y)) & 255; // 정수부 3.7 => 3 int valueTopRight = Permutation[Permutation[X + 1] + Y + 1]; // 2 + 1 + 3 + 1 = 7 => 13 int valueTopLeft = Permutation[Permutation[X] + Y + 1]; // 2 + 3 + 1 = 6 => 131 int valueBottomRight = Permutation[Permutation[X + 1] + Y]; // 2 + 1 + 3 = 6 => 131 int valueBottomLeft = Permutation[Permutation[X] + Y]; // 2 + 3 = 5 => 15
같은 그리드 점에 대해서는 항상 같은 그래디언트 벡터를 가지게 됩니다.
→ 즉 여기서 x가 2 ~ 3, y가 3~4인 입력 좌표들의 모서리는 모두 위의 모서리에 들어가며, 모서리의 기울기값은 항상 같습니다.
각 모서리가 가진 순열의 값들은 밑의 정해진 기울기 4개 중에 1개를 가져오게 됩니다.
Vector2 GetGradientVector(int v) {
int h = v & 3;
if (h == 0)
return Vector2(1.0f, 1.0f);
else if (h == 1)
return Vector2(-1.0f, 1.0f);
else if (h == 2)
return Vector2(-1.0f, -1.0f);
else
return Vector2(1.0f, -1.0f);
}
만약 매번 모서리 기울기 값에 새로운 무작위 벡터를 생성한다면, 많은 연산이 필요하고 속도가 느려질 것입니다.
특히 3차원 공간에서는 8개의 모서리 벡터를 매번 계산해야 하므로 비용이 더 큽니다.
그래서 Perlin 노이즈 알고리즘에서는 256개의 무작위 단위 벡터를 미리 계산해 놓고, 이 벡터들로부터 필요한 4개의 기울기 벡터를 선택하는 방식을 사용합니다.
이렇게 하면 매번 새로운 랜덤 벡터를 생성할 필요가 없어져 전체 계산 비용이 크게 절감됩니다.
대신 노이즈 패턴이 256 주기로 반복되는 단점이 있지만, 대부분의 경우에는 충분히 자연스럽습니다.
즉, 계산 효율성을 위해서입니다!
| 기울기 벡터 | 장점 | 단점 |
|---|---|---|
| 랜덤 | 더 자연스러운 패턴 생성 | 계산 속도 느림 |
| 고정 | 계산 속도 빠름 | 패턴의 자연스러움 감소, 반복 가능성 증가 |
각 모서리 점의 좌표는 다음과 같습니다.
모서리 점에서 입력 점까지의 거리 벡터를 구합니다.
거리 벡터를 구하는 공식은 다음과 같이 구할 수 있습니다.
xf = x - floor(x); //소수부 0.3 yf = y - floor(y); //소수부 0.7 Vector2 topRight(xf - 1.0f, yf - 1.0f); // -0.7,-0.3 Vector2 topLeft(xf, yf - 1.0f); // 0.3, -0.3 Vector2 bottomRight(xf - 1.0f, yf); // -0.7 0.7 Vector2 bottomLeft(xf, yf); // 0.3, 0.7
x = 2.3, y = 3.7, xf = 0.3, yf = 0.7
그래디언트 벡터의 영향력은 거리에 따라 커집니다.
그래서 각 모서리 점들의 영향력을 구하기 위해 내적을 이용합니다.
float dotTopRight = topRight.dot(GetGradientVector(valueTopRight)); float dotTopLeft = topLeft.dot(GetGradientVector(valueTopLeft)); float dotBottomRight = bottomRight.dot(GetGradientVector(valueBottomRight)); float dotBottomLeft = bottomLeft.dot(GetGradientVector(valueBottomLeft));
보간해야 할 값이 4개(모서리 4개) 있지만 한 번에 2개의 값만 보간할 수 있습니다.
따라서 Perlin 노이즈에 대한 보간법을 사용하는 방법은:
float Fade(float t) { // 6t5-15t4+10t3
return ((6 * t - 15) * t + 10) * t * t * t;
}
float u = Fade(xf);
float v = Fade(yf);
return Lerp(u, // x축
Lerp(v, dotBottomLeft, dotTopLeft), // Left y축
Lerp(v, dotBottomRight, dotTopRight) // right y축
);
Fade() 함수는 입력의 소수부(xf, yf)를 사용하여 부드러운 보간(smooth interpolation)을 수행합니다.
이 함수는 입력 값 t를 0에서 1 사이의 값으로 매핑하며, 다항식 곡선을 따라 변화합니다.
결과적으로, 부드러운 펄린 노이즈 값을 만들기 위해 사용됩니다.
Fade 함수를 사용하지 않으면 단순 선형 보간이 되어 모서리가 많이 꺾이게 됩니다.
u = xf = 0.1 v = yf = 0.9 Lerp(v, dotBottomLeft, dotTopLeft) = -0.8 + 0.9 * (0.6 - (-0.8)) = 0.38 Lerp(v, dotBottomRight, dotTopRight) = 0.4 + 0.9 * (-0.5 - 0.4) = -0.31 최종 결과 = Lerp(u, 0.38, -0.31) = 0.38 + 0.1 * (-0.31 - 0.38) = 0.323
u = Fade(xf) = Fade(0.1) = 0.0216 v = Fade(yf) = Fade(0.9) = 0.9959 Lerp(v, dotBottomLeft, dotTopLeft) = -0.8 + 0.9959 * (0.6 - (-0.8)) = 0.5951 Lerp(v, dotBottomRight, dotTopRight) = 0.4 + 0.9959 * (-0.5 - 0.4) = -0.4951 최종 결과 = Lerp(u, 0.5951, -0.4951) = 0.5951 + 0.0216 * (-0.4951 - 0.5951) = 0.5746
for (int y = 0; y < windowHeight; y++) {
for (int x = 0; x < windowWidth; x++) {
float persistence = 0.5f; // Persistence value adjustment
float frequency = 0.01f;
float n = Noise2D(x * frequency, y * frequency);
float value = n;
value = (value + 1.0f) / 2.0f; // Normalize to [0, 1]
sf::Color color(
static_cast(255 * value),
static_cast(255 * value),
static_cast(255 * value)
);
textureImage.setPixel(x, y, color);
}
}
훨씬 더 나은 펄린 노이즈 결과를 위해서 구현합니다.
위의 결과 값은 최종 하이트맵에 사용하기에 아직 너무 부드럽습니다.
float n = Noise2D(x * frequency, y * frequency);
각 서로 다른 진폭과 주파수를 갖는 여러 개의 노이즈를 만들어서
한 레이어의 주파수가 이전 레이어의 두 배인 경우 이 레이어를 옥타브라고 합니다.
주파수 (Frequency):
진폭 (Amplitude):
지속성 (Persistence):
float FractalBrownianMotion(float x, float y, int numOctaves) {
float result = 0.0f;
float amplitude = 1.0f;
float frequency = 0.005f;
float persistence = 0.5f; // Persistence value adjustment
for (int octave = 0; octave < numOctaves; octave++) {
float n = amplitude * Noise2D(x * frequency, y * frequency);
result += n;
amplitude *= persistence; // Amplitude decay
frequency *= 2.0f;
}
result = std::max(-1.0f, std::min(1.0f, result));
return result;
}