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

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

■ 도로 생성

[도심의 도로 배치]

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

1. CityLayout.cs

using UnityEngine;

public class CityLayout
{
    public int Seed { get; private set; }
    public int Width { get; private set; }
    public int Height { get; private set; }
    public int CellSize { get; private set; }
    public ECellType[,] Cells;
    
    public CityLayout(int seed, int width, int height, int cellSize)
    {
        Seed = seed;
        Width = width;
        Height = height;
        CellSize = cellSize;
        
        Cells = new ECellType[width, height];
    }

    public Vector3 ConvertCellPosToWorld(int x, int y)
    {
        return new Vector3(x * CellSize, 0f, y * CellSize);
    }
}

[도심 정보를 저장할 클래스]

public enum ECellType
{
    Empty,
    Road,
    Building
}

[각 셀의 타입]

먼저 생성된 도심 정보를 담을 클래스를 만든다. Seed 값, 격자의 가로·세로 칸 수(Width·Height), 한 칸의 크기(CellSize), 그리고 각 셀의 타입을 저장할 2차원 배열을 선언한다.

  • 셀 타입은 일단 Empty / Road / Building 세 가지로만 둔다. 구역 분할 단계에서 Building을 주거·상업·업무 등으로 세분화한다.

도로와 건물을 실제 씬에 배치하려면 격자 좌표(x, y)를 월드 좌표로 바꿔야 하므로, 이를 변환하는 ConvertCellPosToWorld도 함께 만들어둔다.

 

2. CityGenerator.cs

public class CityGenerator : MonoBehaviour
{
    [Header("City Config")] 
    [SerializeField] private int seed;
    [SerializeField, Min(1)] private int width;
    [SerializeField, Min(2)] private int height;
    [SerializeField, Min(1)] private int cellSize;

    [Header("Road Config")] 
    [SerializeField, Min(1)] private int roadMinGap;
    [SerializeField, Min(1)] private int roadMaxGap;
    
    public CityLayout CityLayout { get; private set; }
    private Random _prng;

    public void GenerateCity()
    {
        _prng = new Random(seed);
        CityLayout = new CityLayout(seed, width, height, cellSize);
        
        GenerateRoad();
    }
}

[CityGenerator.cs]

절차적 도심 생성을 담당하는 핵심 클래스다. 인스펙터에서 값을 조절할 수 있도록 직렬화 필드를 선언하고, 이를 바탕으로 CityLayout 객체를 생성한다.

 

여기서 한 가지 짚을 점은, 난수 생성에 UnityEngine.Random이 아니라 System.Random을 썼다는 것이다.

🤔 왜 System.Random인가?
항목  UnityEngine.Random System.Random
상태 관리 전역(static) 상태 공유 인스턴스별 독립
외부 코드의 간섭 받음 받지 않음
동일 시드 재현 간섭이 없을 때만 보장 항상 보장

두 난수가 모두 같은 시드면 같은 난수열을 뽑는다. 문제는 UnityEngine.Random이 전역 상태를 공유한다는 점이다. 예를 들어,

Random.InitState(seed);
GenerateRoad();
GenerateBuilding();

// 도중에 다른 시스템이 전역 Random을 호출하면
Random.Range(...);

GenerateTree();   // 이 시점의 난수열이 어긋난다

도심 생성과 무관한 코드가 중간에 Random.Range()를 한 번 호출하는 것만으로, 그 뒤에 나오는 모든 생성 결과가 달라진다. 같은 시드에서도 결과가 흔들리는 것이다.

 

반면 System.Random은 인스턴스(_prng)가 자기만의 상태를 들고 있어 외부 호출에 영향받지 않는다. 따라서 같은 시드에서 동일한 결과를 보장받을 수 있도록 System.Random을 사용한다.

 

3. 도로 배치 함수

[도로 배치 로직]

격자에서 가로, 세로 라인 중 도로가 될 라인을 랜덤 하게 뽑아 그 라인을 도로로 지정한다. 단, 도로끼리 딱 붙어있으면 건물을 세울 공간이 없기 때문에, roadMinGap~roadMaxGap 만큼 떨어지도록 한다.

private void GenerateRoad()
{
    var row = ChoseRoadLine(height);
    var col = ChoseRoadLine(width);

    foreach (var y in row)
    {
        for (var x = 0; x < width; ++x)
        {
            CityLayout.Cells[x, y] = ECellType.Road;
        }
    }

    foreach (var x in col)
    {
        for (var y = 0; y < height; ++y)
        {
            CityLayout.Cells[x, y] = ECellType.Road;
        }
    }
}

private List<int> ChoseRoadLine(int length)
{
    var result = new List<int>();
    var cur = 0;

    while (cur < length)
    {
        result.Add(cur);
        cur += _prng.Next(roadMinGap, roadMaxGap + 1);
    }

    return result;
}

private void OnValidate()
{
		if (roadMinGap > roadMaxGap)
		{
			roadMinGap = roadMaxGap;
		}
}

[도로 배치 코드]

격자에서 가로·세로 라인 중 도로가 될 줄을 골라, 그 줄 전체를 도로로 채운다. 0번 줄에서 시작해 roadMinGap~roadMaxGap 사이의 랜덤 간격만큼 건너뛰며 다음 도로 줄을 정하고, 이를 격자 끝까지 반복한다.

  • 일정한 간격이면 완전히 균일한 바둑판이 되는데, 범위 안에서 랜덤으로 배치하면 격자 크기가 제각각이 되어 실제 도시에 가까운 느낌이 난다.
  • Next(min, max)는 상한 값 max를 포함하지 않는다. 따라서 roadMaxGap까지 실제로 나오게 하려면 상한을 한 칸 올려줘야 한다.

 

■ 커스텀 인스펙터 및 기즈모 뷰

도로와 구역이 제대로 생성됐는지 매번 플레이 모드로 들어가 확인하는 건 번거롭다. 씬 뷰에서 바로 결과를 보고, 버튼 한 번으로 다시 생성할 수 있도록 커스텀 인스펙터와 기즈모 뷰를 만든다.

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(CityGenerator))]
public class CityGeneratorEditor : Editor
{
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();
        
        var generator = (CityGenerator)target;
        
        GUILayout.Space(10);
        
        if (GUILayout.Button("Generate"))
        {
            generator.GenerateCity();
            SceneView.RepaintAll();
        }
    }
}

기본 인스펙터 아래에 Generate 버튼을 추가했다. 버튼을 누르면 GenerateCity()를 호출하고 씬 뷰를 다시 그려, 플레이 모드 없이도 에디터에서 즉시 결과를 확인할 수 있다.

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[RequireComponent(typeof(CityGenerator))]
public class CityGizmoDrawer : MonoBehaviour
{
    [Header("Gizmos")]
    [SerializeField] private bool isDrawing;
    [SerializeField, Range(0.5f, 1f)] private float fillRatio = 0.9f;
    [SerializeField] private float height = 0.1f;

    [Header("Type Color")] 
    [SerializeField] private List<TypeColor> typeColors;

    private CityGenerator _generator;

    private void OnDrawGizmos()
    {
        if (isDrawing == false) return;

        if (_generator == null)
        {
            _generator = GetComponent<CityGenerator>();
        }
        
        var layout = _generator.CityLayout;
        if (layout == null) return;
        
        DrawCall(layout);
    }

    private void DrawCall(CityLayout layout)
    {
        var size = layout.CellSize * fillRatio;
        var cubeSize = new Vector3(size, height, size);

        for (var x = 0; x < layout.Width; ++x)
        {
            for (var y = 0; y < layout.Height; ++y)
            {
                var cell = layout.Cells[x, y];
                if(cell == ECellType.Empty) continue;
                
                Gizmos.color = typeColors.FirstOrDefault(c => c.Type == cell)
                    ?.Color ?? Color.magenta;

                var pos = layout.ConvertCellPosToWorld(x, y);
                Gizmos.DrawCube(pos, cubeSize);
            }
        }
    }
}

[Serializable]
public class TypeColor
{
    public ECellType Type;
    public Color Color;
}

핵심은 DrawCall()이다. 셀 타입과 색을 한 쌍으로 묶는 TypeColor 클래스를 만들어 인스펙터에서 타입별 색을 지정하고, 각 셀을 그 색의 큐브로 그려 구역을 한눈에 구분한다.

 

앞서 만든 ConvertCellPosToWorld로 격자 좌표를 월드 좌표로 바꿔 큐브를 놓는다. 매칭되는 색이 없으면 마젠타로 그려, 색 지정이 빠진 타입을 바로 눈치챌 수 있다.

728x90

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

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

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
1. 절차적 도심 생성 - 도로 생성
상단으로

티스토리툴바