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

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

▼ 이전 글

 

1. 절차적 도심 생성 - 도로 생성

■ 도로 생성 실제 도심을 내려다보면 도로가 격자 형태로 나 있고, 그 사이에 생긴 블록마다 건물이 들어선다. 도시 생성의 기준이 되는 이 도로망을 먼저 만들어보자.1. CityLayout.csusing UnityEngine;

hate-errorlog.tistory.com

■ Voronoi 알고리즘이란?

[도심의 세분화된 구역]

도시의 전경을 보면 건물들이 마구잡이로 들어선 것이 아니라, 거주·상업·공업 등 용도별 구역으로 나뉘고 각 구역에는 그 용도에 맞는 건물이 배치되어 있다.

 

따라서 도심을 절차적으로 생성할 때도 건물을 무작위로 흩뿌리는 것이 아니라, 먼저 구역을 나눈 뒤 그 구역에 맞는 건물을 배치해야 한다. 이 "구역 나누기"를 위해 Voronoi 알고리즘을 사용한다.

보로노이(Voronoi)는 평면에 임의의 점(Seed)을 여러 개 찍고, 각 위치를 가장 가까운 Seed에 할당하여 영역을 나누는 알고리즘이다.

 

거리를 재는 기준(유클리드·맨해튼·체비쇼프 등)에 따라 영역의 형태가 달라진다. 다만 기본 보로노이만 사용하면 두 가지 한계가 있다.

  1. 구역의 경계가 직선으로 떨어져 어색하게 보인다.
  2. Seed가 무작위로 배치되기 때문에 각 구역의 면적 비율을 설정하기 어렵다.

따라서 일반적인 유클리드 거리를 기본으로 삼되, 여기에 가중치와 노이즈를 더해 Seed별 영역과 경계를 자연스럽게 보완한다.

 

■ 구역 분할 구현

가중치를 적용하지 않은 순수 Voronoi 알고리즘으로 먼저 구현해 결과를 확인하고, 거기서부터 확장해 나가는 방식으로 설명하겠다.

1. 씨앗 심기

public enum ECellType
{
    Empty,
    Road,
    Residential,     // 거주
    Commercial,      // 상업
    Office,          // 업무
    Industrial,      // 공장
    Park             // 공원
}

 

기존에는 건물이 배치될 셀을 Building 하나로 통합했지만, 구역을 세분화해야 하므로 ECellType을 위와 같이 용도별로 나누었다.

public class CityGenerator : MonoBehaviour
{
    [Header("Voronoi")] 
    [SerializeField] private List<ECellType> seedTypes;
    
    private List<(ECellType type, Vector2Int pos)> PlaceSeed()
    {
        var used = new HashSet<Vector2Int>();
        var result = new List<(ECellType type, Vector2Int pos)>();
        
        foreach (var type in seedTypes)
        {
            Vector2Int p;
            
            do
            {
                p = new Vector2Int(_prng.Next(0, width), _prng.Next(0, height));    
            } while (used.Add(p) == false);
            
            result.Add((type, p));
        }

        return result;
    }
}

[씨앗 심기 메서드]

List<ECellType>에 심을 씨앗의 타입들을 저장한다. 중복을 허용한 이유는, 같은 타입의 씨앗이 여러 개 있으면 그 용도가 더 많은 구역을 차지하게 되어 면적 비율을 거칠게나마 조절할 수 있기 때문이다.

 

반환값은 (타입, 씨앗이 심긴 좌표)의 List다. 심을 씨앗 수만큼 순회하면서 각 씨앗이 심길 좌표를 정하는데, 이미 다른 씨앗이 있는 칸에 겹치지 않도록 HashSet으로 좌표를 관리한다.

  • HashSet.Add는 이미 있는 값이면 false를 반환하므로, 실패하면 새 좌표를 다시 뽑는다.

단, 이 코드는 seedTypes 개수가 전체 칸 수(width × height)보다 많으면 빈 좌표를 찾지 못해 무한 루프에 빠진다. 그대로 사용할 경우 씨앗 개수가 맵 크기를 넘지 않도록 주의해야 한다.

2. 가장 가까운 씨앗 찾기

public class CityGenerator : MonoBehaviour
{
    private ECellType FindNearestSeedType(Vector2Int pos, 
		    List<(ECellType type, Vector2Int pos)> seeds)
    {
        var bestDis = float.MaxValue;
        var bestType = ECellType.Empty;

        foreach (var s in seeds)
        {
            var dx = pos.x - s.pos.x;
            var dy = pos.y - s.pos.y;
            var distance =  dx * dx + dy * dy;
            
            if(distance >= bestDis) continue;
            
            bestDis = distance;
            bestType = s.type;
        }

        return bestType;
    }
}

해당 셀이 어느 씨앗과 가장 가까운지 계산하기 위해 유클리드 거리(피타고라스 정리)를 사용한다. 셀의 위치와 씨앗의 위치의 각 성분을 뺀 뒤, 이를 제곱하여 더하면 거리가 나온다.

  • 단, 제곱근(sqrt)은 계산하지 않는다. 필요한 것은 거리의 실제 값이 아니라 씨앗 간 대소 관계뿐이기 때문.

계산된 거리가 기존 최솟값(bestDis)보다 작으면 bestDis와 bestType을 갱신한다. 모든 씨앗을 순회한 뒤 최종적으로 반환되는 타입이, 해당 셀이 속한 영역이다.

 

3. 결과 확인

public class CityGenerator : MonoBehaviour
{
    public void GenerateCity()
    {
        _prng = new Random(seed);
        CityLayout = new CityLayout(seed, width, height, cellSize);
        
        GenerateRoad();
        GenerateArea();
    }

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

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

                CityLayout.Cells[x, y] = FindNearestSeedType(
		                new Vector2Int(x, y), allSeed);
            }
        }
     }
 }

구역을 분할하는 GenerateArea()에서 먼저 PlaceSeed()로 씨앗 위치를 얻어온 뒤, 모든 셀을 순회하며 각 셀을 가장 가까운 씨앗의 타입으로 채운다.

  • 단, 이미 도로(Road)로 지정된 셀은 구역 분할 대상에서 제외한다.

[인스펙터 설정]
[생성 결과]

각 셀이 가장 가까운 씨앗에 따라 구역으로 나뉜 모습을 확인할 수 있다. 다만 지금은 경계가 직선이고 각 구역의 면적 비율도 조절할 수 없으니, 다음 단계에서 가중치를 더해 결과를 다듬어보자.

■ 가중치와 최소 씨앗 거리

public class CityGenerator : MonoBehaviour
{
    [Header("Voronoi")] 
    [SerializeField] private List<SeedConfig> seedConfigs;
    [SerializeField] private int minDistance = 10;
    [SerializeField] private int maxAttempts = 10;
}

[Serializable]
public class SeedConfig
{
    public ECellType Type;
    [Range(0.1f, 1f)] public float Weight = 0.1f;
}

이제 각 씨앗이 가중치 값도 가져야 하므로, 씨앗의 타입과 가중치를 함께 저장하는 SeedConfig 클래스를 만든다.

 

1. 최소 씨앗 거리 추가

씨앗이 서로 너무 가까이 붙어서 생성되는 것을 방지하기 위해, 씨앗끼리의 거리가 최소 거리를 만족하지 못하면 좌표를 다시 뽑도록 구현한다.

private List<(ECellType type, Vector2Int pos, float weight)> PlaceSeed()
{
		var result = new List<(ECellType type, Vector2Int pos, float weight)>();
		        
		foreach (var s in seedConfigs)
		{
				Vector2Int p;
				var attempts = 0;
				do
				{
						p = new Vector2Int(_prng.Next(0, width), _prng.Next(0, height));
						attempts++;
				} while (CheckSeedMinDistance(p, result) == false
						&& attempts < maxAttempts);
						
				result.Add((s.Type, p, s.Weight));
		}
		
		return result;
}

private bool CheckSeedMinDistance(Vector2Int pos, List<(ECellType type, Vector2Int pos
				, float weight)> seeds)
{
		foreach (var s in seeds)
		{
				var dis = Vector2Int.Distance(pos, s.pos);
				if (dis < minDistance) return false;
		}
		
		return true;
}

씨앗을 심는 PlaceSeed() 안에서, 새 좌표가 기존 씨앗들과 최소 거리를 만족하는지 CheckSeedMinDistance()로 확인한다. 만족하지 못하면 좌표를 다시 뽑는다.

 

단, 조건을 만족하는 좌표가 계속 안 나오면 무한히 시도할 수 있으므로, maxAttempts로 시도 횟수에 상한을 둔다. 시도 횟수를 모두 소진하면 더 이상 다시 뽑지 않고, 마지막으로 뽑힌 좌표에 그대로 씨앗을 배치한다.

  • 기존에는 HashSet으로 좌표 중복을 막았지만, 최소 거리 검사가 같은 좌표(거리 0)도 함께 걸러내므로 별도의 중복 체크는 더 이상 필요 없다.

 

2. 가중치 계산

private ECellType FindNearestSeedType(Vector2Int pos, List<(ECellType type, 
		    Vector2Int pos, float weight)> seeds)
{
		var bestDist = float.MaxValue;
		var bestType = ECellType.Empty;
		
		for (var i = 0; i < seeds.Count; i++)
		{
				var dx = pos.x - seeds[i].pos.x;
				var dy = pos.y - seeds[i].pos.y;
				var distSq =  dx * dx + dy * dy;
				
				var weighted = distSq / seeds[i].weight;
				            
				if(weighted >= bestDist) continue;
				            
				bestDist = weighted;
				bestType = seeds[i].type;
		}
		
		return bestType;
}

기존에는 유클리드 거리(distSq)를 그대로 비교했지만, 이제는 거리를 가중치로 나눈 값(weighted)으로 판정한다. 그리고 이 메서드는 weighted가 가장 작은 씨앗을 그 셀의 영역으로 선택한다.

 

거리 / 가중치에서 가중치가 클수록 weighted 값은 작아진다. 그런데 이 구조에서는 값이 작을수록 "더 가까운 씨앗"으로 판정되므로, 가중치가 큰 씨앗일수록 더 많은 셀을 차지하게 된다.

 

예를 들어 어떤 셀이 두 씨앗 A, B로부터 똑같이 distSq = 100만큼 떨어져 있다고 하자.

  • 씨앗 A: weight 0.5 → 100 / 0.5 = 200
  • 씨앗 B: weight 1.0 → 100 / 1.0 = 100

실제 거리는 같지만 B의 weighted가 더 작으므로(100 < 200), 이 셀은 B가 가져간다. 즉 가중치가 큰 B가 "더 가깝다"라고 취급되는 것이다.

  • 나눗셈이 거리를 깎아주는 할인율처럼 작동한다고 보면 된다. 가중치가 클수록 거리를 더 많이 깎아주므로, 멀리 있는 셀까지 자기 영역으로 끌어온다.

 

3. 도로변에만 건물 배치하기 (옵션)

현재 절차적으로 생성된 도심은 골목길이 없어, 빌딩 사이를 통과하는 도로가 없다. 따라서 굳이 도로에서 멀리 떨어진 안쪽 셀, 즉 어차피 도로에 면하지 않는 부분까지 건물로 채울 필요는 없다.

public class CityLayout
{   
    public Vector2Int? NearRoadDirection(int x, int y, int depth, System.Random rng)
    {
        Vector2Int[] dirs =
        {
            Vector2Int.up,
            Vector2Int.down,
            Vector2Int.left,
            Vector2Int.right
        };

        for (var i = 1; i <= depth; ++i)
        {
            var candidates = new List<Vector2Int>();

            foreach (var dir in dirs)
            {
                var cx = x + dir.x * i;
                var cy = y + dir.y * i;
                
                if(cx < 0 || cy < 0 || cx >= Width || cy >= Height) continue;
                if(Cells[cx, cy] == ECellType.Road) candidates.Add(dir);
            }
            
            if(candidates.Count > 0)
                return candidates[rng.Next(candidates.Count)];
        }

        return null;
    }
}    

[CityLayout.cs]

각 셀에서 상하좌우 4방향으로 거리 1부터 depth(buildingBandDepth)까지 차례로 검사하며, 가장 가까운 거리에서 도로를 찾는다. 도로가 있는 방향을 후보로 모으고, 그중 하나의 방향을 반환한다.

 

같은 거리에서 여러 방향에 도로가 인접할 수 있으므로(예: 모서리), 후보가 여럿이면 그중 랜덤한 방향을 반환한다.

  • 반환값이 null이면 지정한 범위 안에 도로가 없는 셀이므로, 구역 판정 로직을 건너뛴다.
  • bool이 아니라 방향(dir)을 반환하는 이유는, 추후 실제 건물을 배치할 때 건물 정면이 항상 인접한 도로를 바라보도록 만들기 위해서다.
public class CityGenerator : MonoBehaviour
{
    [Header("Voronoi")] 
    [SerializeField, Min(1)] private int buildingBandDepth = 2;
    
    private void GenerateArea()
    {
        var allSeed = PlaceSeed();

        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;

                CityLayout.Cells[x, y] = FindNearestSeedType(new Vector2Int(x, y), allSeed);
            }
        }
    }
}

buildingBandDepth는 도로로부터 몇 칸까지를 건물 영역으로 볼지 정하는 값이다. GenerateArea에서 각 셀이 이 범위 안에 도로를 두고 있는지 NearRoadDirection으로 확인하고, 도로가 없으면(null) 구역 판정을 건너뛴다. 그 결과 도로에 면한 셀에만 구역이 형성되어, 보이지 않는 안쪽까지 채우는 불필요한 연산을 줄인다.

■ 최종 결과

[가중치 0.1 vs 0.65 비교 이미지]

좌측은 거주구(파란색 영역)의 가중치를 0.1로, 우측은 0.65로 설정한 경우다. 가중치가 높을수록 해당 영역이 더 넓은 부분을 차지하는 것을 확인할 수 있다.

 

다음에는 PerlinNoise를 사용해 직선으로 떨어지는 경계를 자연스럽게 다듬어보자.

728x90

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

4. 절차적 도심 생성 - 건물 배치  (0) 2026.06.23
3. 절차적 도심 생성 - Perlin Noise로 경계면 다듬기  (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. 절차적 도심 생성 - 건물 배치
  • 3. 절차적 도심 생성 - Perlin Noise로 경계면 다듬기
  • 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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
2. 절차적 도심 생성 - 가중치 Voronoi를 통한 구역 분할
상단으로

티스토리툴바