3. 절차적 도심 생성 - Perlin Noise로 경계면 다듬기

2026. 6. 23. 20:19·Unity,C#/절차적생성(PCG)
728x90

▼ 이전 글

 

2. 절차적 도심 생성 - 가중치 Voronoi를 통한 구역 분할

▼ 이전 글 1. 절차적 도심 생성 - 도로 생성■ 도로 생성 실제 도심을 내려다보면 도로가 격자 형태로 나 있고, 그 사이에 생긴 블록마다 건물이 들어선다. 도시 생성의 기준이 되는 이 도로망을

hate-errorlog.tistory.com

▼ 참고 자료

 

[C#, Unity, 절차적 생성] 절차적 지형 생성 - 1.PerlinNoise

■ PerlinNoise PerlinNoise는 1983년 Ken Perlin이 개발한 그래디언트 기반의 노이즈(Gradient Noise)함수로, 지형의 절차적 생성, 텍스처 생성, 구름이나 연기 같은 자연 현상 표현 등 다양한 절차적 콘텐츠 생

hate-errorlog.tistory.com

■ Perlin Noise 함수

PerlinNoise에 대해서는 이전 블로그 글에서 설명한 바가 있으므로, 여기서는 간단히만 보고 넘어가겠다.

[PerlinNosie 함수]

PerlinNoise의 핵심은 완전한 난수 함수가 아니라, 입력값이 비슷할수록 비슷한 출력값을 내놓는다는 점이다.

 

즉, 입력으로 (1.1, 1.2)를 넣은 결과와 (1.11, 1.21)을 넣은 결과는 서로 비슷한 값이 나온다. Unity의 Mathf.PerlinNoise는 이런 값을 0~1 범위로 돌려준다. 이 "입력이 가까우면 출력도 매끄럽게 이어지는" 성질을 이용해, 보로노이로 생성된 도심의 직선 경계를 자연스럽게 흔들어보자.

 

■ 경계면 다듬기 구현

1. 필요한 멤버 추가

public class CityGenerator : MonoBehaviour
{
    [Header("Perlin Noise")] 
    [SerializeField, Range(0.01f, 0.2f)] private float frequency;
    [SerializeField, Min(0)] private float strength;
    [SerializeField, Range(0, 0.9f)] private float sparsity;
    [SerializeField, Range(0, 0.2f)] private float sparsityScale;
}    

경계면 다듬기에 사용할 두 가지 값을 추가하자.

  • frequency: 경계가 얼마나 자주 굽이치는지를 정한다. 값이 작으면 완만하게 큰 곡선으로 출렁이고, 값이 크면 짧은 간격으로 자글자글하게 흔들린다.
  • strength: 경계를 얼마나 세게 흔들지를 정한다. 값이 작으면 원래 직선에서 살짝만 비뚤어지고, 값이 클수록 직선에서 크게 벗어나 구불구불해진다.

나머지 두 변수 sparsity/sparsityScale 는 "빈 공간 생성에 사용하며, 자세한 건 뒤에서 다룬다.

 

2. 좌표 계산

private void GenerateArea()
{
    var allSeed = PlaceSeed();

    var warpOffset = new Vector4(_prng.Next(0, 10000), _prng.Next(0, 10000),
        _prng.Next(0, 10000), _prng.Next(0, 10000));

    for (var x = 0; x < width; ++x)
    {
        for (var y = 0; y < height; ++y)
        {
            if (CityLayout.Cells[x, y] == ECellType.Road)
                continue;

            if (CityLayout.NearRoadDirection(x, y, buildingBandDepth, _prng) == null)
                continue;

            var p = WarpCell(x, y, warpOffset);
            CityLayout.Cells[x, y] = FindNearestSeedType(p, allSeed);
        }
    }
}

private Vector2 WarpCell(int x, int y, Vector4 warpOffset)
{
    var wx =
        (Mathf.PerlinNoise(
            x * frequency + warpOffset.x,
            y * frequency + warpOffset.y) - 0.5f) * strength;

    var wy =
        (Mathf.PerlinNoise(
            x * frequency + warpOffset.z,
            y * frequency + warpOffset.w) - 0.5f) * strength;

    return new Vector2(wx + x, wy + y);
}

추가된 것이 많으니 천천히 살펴보면 다음과 같다.

 

-> warpOffset

var warpOffset = new Vector4(_prng.Next(0, 10000), _prng.Next(0, 10000),
		_prng.Next(0, 10000), _prng.Next(0, 10000));

PerlinNoise입력값에 더해질 offset값을 만든다. 이 값은 입력값에 더해져서 서로 다른 시드마다 더 다른 결과를 나오게 한다.

 

-> 좌표 흔들기

private Vector2 WarpCell(int x, int y, Vector4 warpOffset)
{
    var wx =
        (Mathf.PerlinNoise(
            x * frequency + warpOffset.x,
            y * frequency + warpOffset.y) - 0.5f) * strength;

    var wy =
        (Mathf.PerlinNoise(
            x * frequency + warpOffset.z,
            y * frequency + warpOffset.w) - 0.5f) * strength;

    return new Vector2(wx + x, wy + y);
}

여기서 중요한 건, 보로노이로 나뉜 경계선을 Perlin Noise로 "다시 그리는" 게 아니라는 점이다. 경계는 여전히 거리 비교(FindNearestSeedType)로만 결정된다.

 

다만 거리를 재기 전에 그 입력 좌표를 Perlin Noise로 살짝 흔들어준다. 각 셀이 "원래 위치"가 아니라 "살짝 어긋난 위치"에서 가장 가까운 씨앗을 찾으므로, 결과적으로 경계선이 구불구불해진다.

 

왜 frequency를 곱할까?

Unity의 Mathf.PerlinNoise는 정수 좌표에서 항상 0.5를 반환한다. Perlin Noise가 입력 점에서 가장 인접한 4개의 정수 격자점들의 그래디언트와 내적해 값을 구하는데, 입력이 정확히 격자점에 떨어지면 그 값이 0(Unity 기준 0.5)으로 고정되기 때문이다.

 

셀 좌표는 (0, 1, 2, …) 정수라, 그대로 넣으면 전부 0.5가 되어 흔들림이 0이 되어 버린다.

그래서 frequency(0.01~0.2)를 곱해 (0, 0.01, 0.02, …) 같은 소수 좌표로 바꿔준다. 이 값이 클수록 인접한 셀 사이의 입력 차이가 커져, 경계가 더 자글자글하게 흔들린다.

 

🤔 그럼 frequency만 곱하면 되는 거 아닌가요?

[Offset을 더하는 이유]

PerlinNoise(x * frequency, y * frequency)처럼 offset 없이 써도 코드는 멀쩡히 돌아간다. 단, 시드를 바꿔도 매번 똑같은 도심만 나온다.

 

Perlin Noise는 같은 입력에 항상 같은 출력을 주는 결정적(deterministic) 함수다. 난수처럼 보이지만 실제로는 미리 정해진 거대한 지형 같은 것이고, 우리는 좌표에 해당하는 높이를 읽어올 뿐이다. offset이 없으면 시드가 무엇이든 늘 이 지형의 같은 자리를 읽으므로, 흔들림 패턴도 항상 같다.

 

여기서 warpOffset이 그 "읽는 위치"를 옮긴다. _prng.Next(0, 10000)로 시드마다 다른 offset을 뽑아 더하면, 노이즈 지형에서 읽기 시작하는 지점이 통째로 이동해 시드마다 다른 경계 패턴이 나온다.

 

-0.5와 strength

Mathf.PerlinNoise의 결과는 0~1이라, 그대로 쓰면 좌표가 한쪽(양의 방향)으로만 밀린다. 여기서 0.5를 빼 -0.5~0.5로 옮기면 양쪽으로 흔들 수 있다. 마지막으로 strength를 곱해 흔들림의 세기를 정한 뒤, 원래 좌표에 더해 반환한다.

 

4개의 offset을 쓰는 이유

warpOffset은 Vector4라 4개의 offset을 담는데, wx는 (x, y)를, wy는 (z, w)를 쓴다. 즉 x축 흔들림과 y축 흔들림이 노이즈 지형의 서로 다른 자리를 읽는다.

 

만약 둘이 같은 offset을 쓰면 wx와 wy가 항상 같은 값이 되고, 그러면 모든 셀이 (d, d) 꼴이 되어 항상 45도 대각선 방향으로만 밀린다. offset을 다르게 줘야 두 축이 독립적으로 흔들려, 경계가 모든 방향으로 자연스럽게 일그러진다.

 

3. 빈 공간 만들기(옵션)

지금은 도로에 인접한 셀이 건물로 전부 꽉 들어차 있다. 하지만 너무 빽빽한 밀도 대신 조금 느슨한 밀도를 원한다면, 일부 셀은 건물 배치를 건너뛰도록 만들면 된다.

    private void GenerateArea()
    {
        var allSeed = PlaceSeed();
        var warpOffset = new Vector4(_prng.Next(0, 10000), _prng.Next(0, 10000)
            ,_prng.Next(0, 10000),  _prng.Next(0, 10000));
        
        // 밀도 계산에 사용할 오프셋
        var sparsityOffset = new Vector2(_prng.Next(0, 10000), _prng.Next(0, 10000));

        for (var x = 0; x < width; ++x)
        {
            for (var y = 0; y < height; ++y)
            {
                if(CityLayout.Cells[x, y] == ECellType.Road) continue;
                if(CityLayout.NearRoadDirection(x,y, buildingBandDepth, _prng) == null) continue;
								
								// 해당 좌표의 노이즈 값 계산
                var s = Mathf.PerlinNoise((x + sparsityOffset.x) * sparsityScale,
                    (y + sparsityOffset.y) * sparsityScale);
                
                // 노이즈 값이 밀도 미만이면 스킵
                s = Mathf.Clamp01(s);
                if(s < sparsity) continue;

                var p = WarpCell(x, y, warpOffset);
                CityLayout.Cells[x, y] = FindNearestSeedType(p, allSeed);
            }
        }
    }

여기서도 PerlinNoise를 사용한다. 각 셀의 좌표로 노이즈 값(s, 0~1)을 구하고, 이 값이 sparsity보다 작거나 같으면 그 셀을 비운다(continue). 즉 sparsity가 클수록 더 많은 셀이 걸러져 빈 공간이 늘어난다.

 

그냥 난수(Random)로 셀마다 비울지 말지를 정하면, 인접한 셀끼리 아무 상관이 없어 도심 곳곳에 점처럼 구멍이 뚫린 것처럼 어색해진다. 반면 PerlinNoise는 인접한 좌표에 비슷한 값을 돌려주므로, 빈 공간이 한 덩어리로 뭉쳐서 생긴다. 그래서 "이 일대는 비어 있다"는 식의 자연스러운 공터가 만들어진다.

  • 워핑(WarpCell)과 달리, 여기서는 노이즈 값(s)을 좌표를 흔드는 데 쓰지 않고 비울지 말지를 가르는 임계값 판정에만 쓴다. 같은 PerlinNoise지만 용도가 다르다.
  • Mathf.PerlinNoise()는 드물게 0~1 범위를 벗어나는 값을 반환할 수 있으므로, 안정성을 위해 Mathf.Clamp01()을 사용하여 값을 정규화한다.

 

■ 최종 결과

[strength : 0~70 까지의 변화(주파수 값 0.08고정)]

strength를 0에서 70까지 키운 결과다. 0일 때는 경계가 직선으로 떨어지는 순수 Voronoi 형태이고, 값이 커질수록 경계가 점점 크게 일그러지며 구불구불해지는 것을 볼 수 있다.

[건물 밀도: 0.1~0.9 까지의 변화]

sparsity를 0.1에서 0.9까지 키운 결과다. 값이 낮을수록 도로변이 건물로 빽빽하게 들어차고, 높일수록 노이즈를 따라 군데군데 빈 공터가 한 덩어리씩 생겨 밀도가 느슨해진다.

 

이렇게 가중치·워핑·밀도 값만 조절해도 같은 알고리즘에서 서로 다른 분위기의 도심을 만들어낼 수 있다. 다음에는 이렇게 나눈 구역 위에 실제 모듈러 prefab으로 건물을 배치하는 로직을 작성해 보자.

728x90

'Unity,C# > 절차적생성(PCG)' 카테고리의 다른 글

4. 절차적 도심 생성 - 건물 배치  (0) 2026.06.23
2. 절차적 도심 생성 - 가중치 Voronoi를 통한 구역 분할  (0) 2026.06.23
1. 절차적 도심 생성 - 도로 생성  (0) 2026.06.23
[C#, Unity, 절차적 생성] 절차적 던전 생성 - 5. 벽 생성  (0) 2025.05.22
[C#, Unity, 절차적 생성] 절차적 던전 생성 - 4. 복도 생성  (1) 2025.05.21
'Unity,C#/절차적생성(PCG)' 카테고리의 다른 글
  • 4. 절차적 도심 생성 - 건물 배치
  • 2. 절차적 도심 생성 - 가중치 Voronoi를 통한 구역 분할
  • 1. 절차적 도심 생성 - 도로 생성
  • [C#, Unity, 절차적 생성] 절차적 던전 생성 - 5. 벽 생성
브라더스톤
브라더스톤
유티니, C#과 관련한 여러 정보를 끄적여둔 블로그입니다. Email : dkavmdk98@gmail.com
  • 브라더스톤
    젊은 프로그래머의 슬픔
    브라더스톤
  • 전체
    오늘
    어제
    • 개발 노트 (62)
      • Unity,C# (37)
        • Unity 정보 (10)
        • 알고리즘 (11)
        • 자료구조 (3)
        • 절차적생성(PCG) (13)
      • 게임수학 (16)
      • C++ (8)
        • 자료구조 (8)
      • 게임 (1)
        • 리치마작 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    외적
    이진공간분할
    스택
    정렬알고리즘
    커스텀 윈도우
    CustomWindow
    절차적지형생성
    게임수학
    unity
    BSP
    C#
    최단경로찾기
    c++
    절차적던전생성
    알고리즘
    벡터
    pcg
    자료구조
    PerlinNoise
    절차적생성
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
3. 절차적 도심 생성 - Perlin Noise로 경계면 다듬기
상단으로

티스토리툴바