■ PerlinNoise
PerlinNoise는 1983년 Ken Perlin이 개발한 그래디언트 기반의 노이즈(Gradient Noise)함수로, 지형의 절차적 생성, 텍스처 생성, 구름이나 연기 같은 자연 현상 표현 등 다양한 절차적 콘텐츠 생성에 활용된다.
1. Noise함수
Noise함수는 입력값에 따라 “의사 난수(Pseudo-random)”값을 반환하는 함수다. 일반적인 난수와 달리, 입력값이 비슷할수록 결과값도 유사해지는 부드러운 변화를 가진다.
// 호출할 때마다 완전히 다른 값.
Random.Range(0f, 1f);
// 항상 같은 입력에 같은 결과.
Mathf.PerlinNoise(x, y);
// 입력이 조금 달라지면, 출력도 조금만 변화 -> 부드러운 노이즈
Mathf.PerlinNoise(x + 0.01f, y);
위 사진을 보면 왼쪽이 일반적인 Noise함수이고, 오른쪽이 PerlinNoise함수이다. 차이를 보면 다음과 같다.
Value Noise | PerlinNoise(Gradient Noise) |
각 픽셀이 완전히 랜덤한 값으로 생성. | 인접한 좌표끼리 값이 부드럽게 변화. |
이웃한 픽셀 간 연결성이나 연속성 없음. | 파도, 구름, 지형 등의 연속적인 자연 패턴을 묘사하기 적합. |
2. Perlin Noise 원리
Perlin Noise 함수는 각 격자의 꼭짓점마다 랜덤한 방향벡터(Value Noise에서는 스칼라 값)를 저장하고, 이 벡터와 입력 좌표 간의 관계를 이용해 의사 난수 값을 생성한다.
입력값(x, y)에 대해 가장 인접한 정수 격자 셀의 4개의 꼭짓점을 찾는다.
- 이 꼭짓점들에는 미리 생성된 “랜덤한 방향벡터”가 저장되어 있고, 해당 위치로부터 임의의 방향을 가리킨다.
인접한 정수 격자 셀의 각 꼭짓점에서, “입력 위치까지 향하는 벡터(오프셋 벡터)”를 구하고, 해당 격자점에 저장된 방향 벡터와 내적$(g_n \cdot d_n)$한다.
▶ 내적은 두 벡터가 서로 얼마나 같은 방향을 향하고 있는지를 수치화하는 도구이다.
- Perlin Noise는 각 격자점이 입력 위치에 얼마나 영향을 미치는지를 평가하고자 한다.
- 이때, 격자점의 방향 벡터와 입력 위치로 향하는 벡터의 내적값이 곧 그 영향력이 된다.
- 방향이 유사하고 거리가 가까울수록 내적값이 커지고, 해당 격자점이 생성되는 노이즈에 더 크게 기여한다.
4개의 격자점에서 구한 내적 값(영향력)을 현재 위치(x,y)에 맞게 부드럽게 연결하기 위해 보간을 진행한다. 내적값들은 정수 격자에서만 정의된 값이므로, 실수 위치에서의 값을 계산하려면 보간이 필요하다.
1. X축 보간(좌↔우)
입력값(x,y)의 x좌표 소수점 부분을 기준으로 왼쪽과 오른쪽 격자점의 내적값을 보간한다.
- 단순 선형 보간(Lerp)은 직선적이라서, 곡선의 미분이 끊겨버린다. 따라서 아래와 같은 수식(Fade함수)을 적용해 보간 가중치를 곡선 형태로 만들어주어야 한다.
$$ f(t) = 6t^5 - 15t^4 + 10t^3 $$
float fx = x - Mathf.Floor(x);
float sx = Fade(fx);
float r1 = Lerp(p1, p2, sx);
float r2 = Lerp(p3, p4, sx);
2. Y축 보간(상 ↕ 하)
위에서 얻은 r1, r2를 다시 y좌표 소수점 부분을 기준으로 보간하여 최종적인 perlinNoise값을 얻는다.
float fy = y - Mathf.Floor(y);
float sy = Fade(fy);
float result = Lerp(r1, r2, sy);
■ Perlin Noise구현(Unity)
Mathf.PerlinNoise()는 이미 Unity에서 제공하고 있지만, 직접 구현해보면 PerlinNoise가 어떤 원리로 작동하는지 훨씬 더 명확하게 이해할 수 있다.
1. Gradient벡터 생성
// 정수 좌표마다, 랜덤한 방향 벡터 반환
private Vector2 GetGradient(Vector2Int vec)
{
var hash = Hash(vec.x, vec.y);
var angle = (hash % 360) * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)).normalized;
}
// 의사 난수를 생성하는 해시 함수
private int Hash(int x, int y)
{
var seed = 63689;
var hash = x;
hash = hash * seed + y;
hash = (hash << 13) ^ hash;
return (hash * (hash * hash * 15731 + 789221) + 1376312589) & 0x7fffffff;
}
PerlinNoise의 핵심은 격자점(정수 좌표)마다 일관되면서도 무작위적인 방향 벡터(Gradient)가 존재한다.
하지만 단순히 Random.Range()로 방향을 생성하면 매번 다른 값이 나오기 때문에, 입력된 (x,y)좌표마다 항상 같은 방향벡터를 반환하도록 GetGradient()를 구해야 한다.
Hash 함수 원리
1. 기초 섞기 (Seed 적용)
→ hash = hash * seed + y;
→ (x,y)좌표마다 고유한 조합을 만든다.
2. 비트 섞기
→ hash = (hash << 13) ^ hash;
→ 단순한 패턴을 깨트리기 위한 시프트 + XOR 연산을 진행한다.
3. 확산시키기
→ 난수처럼 넓게 퍼지도록 고정 상수를 사용한다.
→ $h = h (h\cdot h\cdot A + B) + C$
4. 부호 제거
→ & 0x7fffffff;
→ C#의 int는 부오 있는 32비트 정수이므로, 가장 앞 비트를 제거하여 양수로 만든다.
이렇게 나온 난수값을 통해 0~360도 사이의 각도를 구하고, Cos, Sin함수를 사용해 단위 원 위의 방향벡터를 만들어 각 위치마다 고정된 방향을 가지는 벡터를 만든다.
2. Noise값 계산
public float PerlinNoise(float x, float y)
{
// p1(x1, y1) ------ p2(x2, y1)
// | |
// | |
// p3(x1, y2) ------ p4(x2, y2)
var xToInt = Mathf.FloorToInt(x);
var yToInt = Mathf.FloorToInt(y);
// 입력값에서 인접한 4개의 정수 그리드 좌표
var vertex = new Vector2Int[]
{
new(xToInt, yToInt),
new(xToInt + 1, yToInt),
new(xToInt, yToInt + 1),
new(xToInt + 1, yToInt + 1)
};
var gradient = new Vector2[4]; // 각 격자의 꼭짓점 그라디언트 벡터
var dis = new Vector2[4]; // 꼭짓점에서 입력값으로 향하는 벡터
var influence = new float[4]; // 영향력 저장 변수
for (var i = 0; i < 4; ++i)
{
gradient[i] = GetGradient(vertex[i]);
dis[i] = new Vector2(x, y) - vertex[i];
influence[i] = Vector2.Dot(gradient[i], dis[i]);
}
// 보간 시작
var fx = Fade(x - xToInt);
var fy = Fade(y - yToInt);
// x축 보간
var i1 = Mathf.Lerp(influence[0], influence[1], fx); // y0 줄
var i2 = Mathf.Lerp(influence[2], influence[3], fx); // y1 줄
// y축 보간 후 결과 반환
return Mathf.Lerp(i1, i2, fy);
}
// Ken Perlin의 Fade 함수 수식
private float Fade(float t)
{
return 6 * Mathf.Pow(t, 5) - 15 * Mathf.Pow(t, 4) + 10 * Mathf.Pow(t, 3);
}
Noise값을 계산하는 방법은 다음과 같이 진행한다.
- Gradient & 내적 계산
- 각 격자점에서 방향벡터(Gradient)와 입력점으로 향하는 벡터를 내적해, 해당 격자점의 영향력을 계산한다.
- X축 보간
- 한 줄에 있는 두 점(좌, 우)을 Fade(x) 값 기준으로 보간한다.
- y축 보간
- 위에서 얻은 두 보간 결과(i1, i2)를 Fade(y) 기준으로 다시 보간하여 마무리한다.
3. NoiseMap 생성
private void SetPerlinNoise2DArray()
{
_noiseMap = new float[height, width];
for (var y = 0; y < height; ++y)
{
for (var x = 0; x < width; ++x)
{
var sampleX = (float)x / width * noiseScale;
var sampleY = (float)y / height * noiseScale;
var raw = _customPerlin.PerlinNoise(sampleX, sampleY);
var normalized = Mathf.Clamp01((raw + 1f) * 0.5f);
_noiseMap[y, x] = normalized;
}
}
}
private Texture2D CreateNoiseTexture()
{
var tex = new Texture2D(width, height);
tex.filterMode = FilterMode.Bilinear;
for (var y = 0; y < height; ++y)
{
for (var x = 0; x < width; ++x)
{
var v = _noiseMap[y, x];
var color = new Color(v, v, v);
tex.SetPixel(x, y, color);
}
}
tex.Apply();
return tex;
}
각 위치에서 PerlinNoise값을 구하기 위해, **[x~width], [y~height]**의 좌표를 0.0~1.0 범위로 정규화한다. 그 후 noiseScale을 곱해 노이드의 확대/축소 정도를 조절한다.
또한 PerlinNoise 함수의 반환값은 내적 결과이기 때문에 -1~1 범위를 가지며, 이를 시각화 가능한 0~1 범위로 정규화하기 위해 1을 더한 뒤 0.5를 곱한다.
- 텍스쳐를 생성할 때 filterMode를 Bilinear로 설정하면, 픽셀 사이가 자연스럽게 보간 되어 부드러운 Perlin패턴을 표현할 수 있다.
Perlin Noise의 기본은 여기까지이며, 다음은 프랙탈 합을 이용한 자연 지형 생성으로 이어진다.
■ 전체 코드
using UnityEngine;
public class CustomPerlinNoise
{
public float PerlinNoise(float x, float y)
{
// p1(x1, y1) ------ p2(x2, y1)
// | |
// | |
// p3(x1, y2) ------ p4(x2, y2)
var xToInt = Mathf.FloorToInt(x);
var yToInt = Mathf.FloorToInt(y);
// 입력값에서 인접한 4개의 정수 그리드 좌표
var vertex = new Vector2Int[]
{
new(xToInt, yToInt),
new(xToInt + 1, yToInt),
new(xToInt, yToInt + 1),
new(xToInt + 1, yToInt + 1)
};
var gradient = new Vector2[4]; // 각 격자의 꼭지점 그라디언트 벡터를 저장할 변수
var dis = new Vector2[4]; // 꼭지점에서 입력값으로 향하는 벡터를 저장할 변수
var influence = new float[4]; // 영향력을 저장할 변수
for (var i = 0; i < 4; ++i)
{
gradient[i] = GetGradient(vertex[i]);
dis[i] = new Vector2(x, y) - vertex[i];
influence[i] = Vector2.Dot(gradient[i], dis[i]);
}
// 보간 시작
var fx = Fade(x - xToInt);
var fy = Fade(y - yToInt);
// x축 보간
var i1 = Mathf.Lerp(influence[0], influence[1], fx); // y0 줄
var i2 = Mathf.Lerp(influence[2], influence[3], fx); // y1 줄
// y축 보간 후 결과 반환
return Mathf.Lerp(i1, i2, fy);
}
// 정수 좌표마다, 랜덤한 방향 벡터 반환
private Vector2 GetGradient(Vector2Int vec)
{
var hash = Hash(vec.x, vec.y);
var angle = (hash % 360) * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)).normalized;
}
// 정수 입력마다 고정된 값을 생성하면서, 전체적으로 보면 무작위성처럼 보이는 값을 반환
// (Random.Range는 완전 랜덤한 값을 반환하기 때문에, 의사 난수(Deterministic Hash) 기반이 아님)
private int Hash(int x, int y)
{
var seed = 63689;
var hash = x;
hash = hash * seed + y;
hash = (hash << 13) ^ hash;
return (hash * (hash * hash * 15731 + 789221) + 1376312589) & 0x7fffffff;
}
// Ken Perlin의 Fade 함수 수식
private float Fade(float t)
{
return 6 * Mathf.Pow(t, 5) - 15 * Mathf.Pow(t, 4) + 10 * Mathf.Pow(t, 3);
}
}
[CustomPerlinNoise.cs]
using System;
using UnityEngine;
public class GenerateNoiseMap : MonoBehaviour
{
[Header("Component")]
[SerializeField] private SpriteRenderer noiseSprite;
[Header("Setting")]
[SerializeField] private int width;
[SerializeField] private int height;
[SerializeField] private float noiseScale = 0.1f;
private float[,] _noiseMap;
private CustomPerlinNoise _customPerlin;
private void Start()
{
_customPerlin = new CustomPerlinNoise();
SetPerlinNoise2DArray();
noiseSprite.sprite = Sprite.Create(CreateNoiseTexture(), new Rect(0, 0, width, height),
new Vector2(0.5f, 0.5f));
}
private void SetPerlinNoise2DArray()
{
_noiseMap = new float[height, width];
for (var y = 0; y < height; ++y)
{
for (var x = 0; x < width; ++x)
{
var sampleX = (float)x / width * noiseScale;
var sampleY = (float)y / height * noiseScale;
var raw = _customPerlin.PerlinNoise(sampleX, sampleY);
var normalized = Mathf.Clamp01((raw + 1f) * 0.5f);
_noiseMap[y, x] = normalized;
}
}
}
private Texture2D CreateNoiseTexture()
{
var tex = new Texture2D(width, height);
tex.filterMode = FilterMode.Bilinear;
for (var y = 0; y < height; ++y)
{
for (var x = 0; x < width; ++x)
{
var color = new Color(_noiseMap[y, x], _noiseMap[y, x], _noiseMap[y, x]);
tex.SetPixel(x, y, color);
}
}
tex.Apply();
return tex;
}
}
[GenerateNoiseMap.cs]
'Unity,C# > 절차적생성(PCG)' 카테고리의 다른 글
[C#, Unity, 절차적 생성] 절차적 던전 생성 - 2. 방 생성 (0) | 2025.05.12 |
---|---|
[C#, Unity, 절차적 생성] 절차적 던전 생성 - 1. Binary Space Partitioning (1) | 2025.05.07 |
[C#, Unity, 절차적 생성] 절차적 지형 생성 - 3.터레인 생성 (0) | 2025.04.28 |
[C#, Unity, 절차적 생성] 절차적 지형 생성 - 2.프랙탈 합 (2) | 2025.04.24 |
[C#, Unity, 절차적 생성] Recursive Backtracking 알고리즘을 사용한 절차적 미로 생성 (4) | 2025.04.12 |