본문 바로가기
게임개발/개발일지

[개발일지] 청크별로 맵 오브젝트 생성

by youcheachae 2025. 3. 13.

📝 구성                                                                                                       

정해져 있는 맵에 일정 구간만 맵 오브젝트(나무/풀/돌)를 배치해야 하는 상황이 생겼다

 

처음으로 생각한 방식은

맵 전체를 좌표를 2차원 배열로 저장한 후, 랜덤으로 좌표를 골라서 배치하는 것이었다

하지만 이 방법은 맵 크기가 꽤 커서 연산 횟수가 너무 많아질 것 같았다

 

두 번째 생각한 방식은

배치할 수 있는 포인트를 몇 개 생성한 후 그중 랜덤 포인트 안에서 랜덤좌표를 구하면 연산 횟수를 줄일 수 있다

 

포인트를 구한 후 포인트에서 일정 값을 - + 함으로써 하나의 청크를 구할 수 있다

포인트에서 일정 떨어진 곳에 최솟값과 최댓값을 구한다

 

 

나무가 배치될 청크를 생성했다

 

현재 초록색 네모 기즈모로 표시된 곳이 나무가 심어질 수 있는 청크이다.

또 돌(파란색 기즈모)과 풀(노란색 기즈모) 또한 배치될 수 있는 청크를 정해준다

나무, 돌, 풀이 배치될 청크를 생성했다

 

📝구현                                                                                                       

ScriptableObject를 사용해서 필요한 데이터들을 담은 Map Data 또한 만들어주었다

생성 쿨타임, 위에서 만든 포인트들 중 랜덤으로 구할 개수, 포인트에서 범위에 대한 최소/최대 사이즈와

한 청크 내에서 배치할 오브젝트의 최소/최대 개수를 담고 있다. 

public enum PlantType
{
    Tree,
    Rock,
    Grass
}

[CreateAssetMenu(fileName = "MapData", menuName = "MapData/CreateMapData")]
public class MapData : ScriptableObject
{
    [SerializeField] 
    private PlantType plantType;
    [SerializeField]
    private float generateSecond;       // 생성 쿨타임
    [SerializeField]
    private int selectChunkCount;       // 청크중에 고를 갯수
    [SerializeField]
    private int plantCountMin;          // 심을 최소 갯수
    [SerializeField]
    private int plantCountMax;          // 심을 최대 갯수
    [SerializeField]
    private int chunkSizeMin;           // 청크 최소 사이즈
    [SerializeField]
    private int chunkSizeMax;           // 청크 최대 사이즈

    public PlantType PlantType { get => plantType;  }
    public float GenerateSecond { get => generateSecond; }
    public int SelectChunkCount { get => selectChunkCount;  }
    public int PlantCountMin { get => plantCountMin; }
    public int PlantCountMax { get => plantCountMax; }
    public int ChunkSizeMin { get => chunkSizeMin;  }
    public int ChunkSizeMax { get => chunkSizeMax;  }
}

 

나무, 돌, 풀의 청크에 대한 Data를 지정해 준다.

나무 청크 데이터
바위 청크 데이터
풀 청크 데이터

 

📝 MapManager.cs

설명 나무 배치 위주로, 풀과 돌 배치도 동일함.

public class MapManager : MonoBehaviour
{
    [SerializeField] private Planting planting;

    [Header("=== Parent ===")]
    [SerializeField] private Transform treeParent;	// 생성할 나무들의 부모로 지정

    [Header("===Chunck===")]
    [SerializeField] Transform treeChunkParent;		// 청크들을 담아놓은 부모
    [SerializeField] Transform[] treeChunks;		// 부모 하위의 청크 

    [Header("===Prefab===")]            
    [SerializeField] List<GameObject> treePrefab;	// 나무 프리팹

    [Header("===Data===")]
    [SerializeField] List<MapData> mapData;		// 맵 데이터

    // ....
}

생성한 나무들의 부모로 지정할 Transform과 청크들을 담아놓고 있는 부모, 그 하위의 청크를 담아놓을 배열과

배치할 나무 프리팹, 위에서 생성한 MapData를 담고 있다

 

PlantType의 순서대로 Map Data를 저장한다.

Enum으로 배열에 명확하게 접근하기 위해서이다

 

 private void InitTrsnform() 
 {
     // GetComponentsInChildren : 부모까지 포함시킴 
     // 부모의 직접적인 자식만 가져오기
     treeChunks = new Transform[treeChunkParent.childCount];

     for (int i = 0; i < treeChunkParent.childCount; i++)
     {
         treeChunks[i] = treeChunkParent.GetChild(i);
     }
 }

청크 부모의 자식을 가져와서 배열에 저장한다.

 

void Start()
{
    planting = GetComponent<Planting>();

    InitTrsnform();

    StartCoroutine(TreePlant());
}

private IEnumerator TreePlant() 
{
    while (true) 
    {
        // 나무 생성
        planting.PlantingTree(mapData[(int)PlantType.Tree] , treeParent , treeChunks, treePrefab);

        // 대기 
        yield return new WaitForSeconds(mapData[(int)PlantType.Tree].GenerateSecond);
    }
}

나무는 일정 주기마다 재 생성되기 때문에 코루틴으로 Tree Chunk 데이터에 있는 GenerateSecond만큼 대기 후 생성하는 것을 반복한다. 

 

📝 Planting.cs

public class Planting : MonoBehaviour
{
    /// <summary>
    /// 1. 나무를 심을 청크를 몇개 고른다 (청크리스트중에서 랜덤)
    /// 2. 청크 내에서 나무를 랜덤으로 심는다
    ///     2-1. 하늘에서 raycast해서 Grond를 검사한다
    ///     2-2. 그 위치에 나무 설치 
    ///     2-3. 만약 물이면 패스 
    ///     2-4. 너무 높으면 패스
    /// </summary>

    [SerializeField] private LayerMask groundLayer;

    [SerializeField] private float maxGroundHeight;
    [SerializeField] private List<GameObject> plantPrefabList;

    [Header("===currData===")]
    [SerializeField] MapData mapData;
    [SerializeField] Transform plantingParent;

    private void Start()
    {
        maxGroundHeight = 60f;
        groundLayer = LayerMask.GetMask("Ground");
    }
}

Planting클래스에서 나무를 심는 순서는 다음과 같다

1. 일정개수만큼 청크를 랜덤으로 고른다.

2. 고른 청크 내에서 일정개수만큼 좌표를 구한다.

3. 해당 좌표에 나무를 배치한다.

 

1. 일정 갯수만큼 청크를 랜덤으로 고른다.

public void PlantingTree(MapData mapdata, Transform parentTrs, Transform[] chuck , List<GameObject> plantPrefab ) 
{

    // 임시로 담아놓기
    this.mapData = mapdata;
    this.plantPrefabList = plantPrefab;
    this.plantingParent = parentTrs;

    // 청크에서 고를 N개
    int selectChuckCount = mapdata.SelectChunkCount;       

    // 1. 전체 청크에서 N개 고르기 
    List<int> suffleChunk = Shuffle(selectChuckCount, 0 , chuck.Length - 1);

    foreach (int num in suffleChunk) 
    {
        Transform trs = chuck[num];

        // 2. 청크 내 랜덤 좌표 구하기
        
        // 3. 랜덤 좌표에서 나무 배치 
    }

}

shuffle() 함수를 사용하여 최솟값과 최댓값 사이에서 일정 개수만큼 "중복 없이" 구한다.

 

// min과 max 사이에서 selectNum 갯수만큼 중복없이 랜덤수 리턴
private List<int> Shuffle(int selectNum , int minNum, int maxNum) 
{
    // 선택할 인텍스 저장 
    List<int> selectedIndex = new List<int>();

    // 임시 리스트
    List<int> temp = new List<int>();
    for (int i = minNum; i <= maxNum; i++) 
    {
        temp.Add(i);
    }

    // 랜덤하게 3개 선택 
    for(int i = 0; i < selectNum; i++) 
    {
        // 남은 인덱스 중에서 하나 고르기
        int ran = Random.Range(0, temp.Count);

        // 선택 인덱스 저장 
        selectedIndex.Add(temp[ran]);

        // 선택된거 지우기 
        temp.RemoveAt(ran);

    }

    return selectedIndex;
}

무작위로 수를 고를 때, 이미 골랐던 수는 배제하고 골라야 한다. 기존에 고른 요소인지 체크하는 과정이 필요하다. 

이를 구현하기 위해서 피셔-에이츠 셔플 ( Frank Yates ) 알고리즘을 참고하여 구현했다

 

2. 고른 청크 내에서 일정개수만큼 좌표를 구한다.

public void PlantingTree(MapData mapdata, Transform parentTrs, Transform[] chuck , List<GameObject> plantPrefab ) 
{

    // 임시로 담아놓기
    this.mapData = mapdata;
    this.plantPrefabList = plantPrefab;
    this.plantingParent = parentTrs;

    // 청크에서 고를 N개
    int selectChuckCount = mapdata.SelectChunkCount;       

    // 1. 전체 청크에서 N개 고르기 
    List<int> suffleChunk = Shuffle(selectChuckCount, 0 , chuck.Length - 1);

    foreach (int num in suffleChunk) 
    {
        Transform trs = chuck[num];

        // 2. 청크 내 랜덤 좌표 구하기
        // 랜덤으로 N개 고르기
        int plantCount = Random.Range(mapdata.PlantCountMin , mapdata.PlantCountMax + 1);

        // 청크 기준으로(x -size, y - size ) ~(x + size, y + size) 사이의 값 
        List<int> selectPlantX = Shuffle(plantCount, (int)trs.position.x - mapdata.ChunkSizeMin, (int)trs.position.x + mapdata.ChunkSizeMax);
        List<int> selectPlantZ = Shuffle(plantCount, (int)trs.position.z - mapdata.ChunkSizeMin, (int)trs.position.z + mapdata.ChunkSizeMax);

        // 3. 랜덤 좌표에서 나무 배치 
        for (int i = 0; i < selectPlantX.Count; i++) 
        {
            RaycastToGround(selectPlantX[i] , selectPlantZ[i]);
        }
    }

}

또한 청크 내에서 [나무를 배치할 최소 개수 ~ 최대 개수] 사이에서 랜덤수를 구한 후,

위의 Shuffle 함수를 사용해서 [현재 포인트 - 청크 최솟값]과 [현재 포인트 + 청크 최댓값] 사이에서 랜덤 좌표를 구한다. 

 

3. 해당 좌표에 나무를 배치한다.

private void RaycastToGround(float x, float z) 
{
    // 기준 위치에서 Nf 정도 올려서, 바닥쪽으로 raycast
    RaycastHit hit;
    Ray ray = new Ray(new Vector3(x, 50f, z) , Vector3.down);

    // ground 레이어만 검사 
    if (Physics.Raycast(ray, out hit, 100f, groundLayer)) 
    {
        // 너무 높으면 패스
        if (hit.transform.position.y >= maxGroundHeight)
            return;

        // 이 위치에 나무 심기 
        PlantTreeOrEct(hit.point);
    }
}

private void PlantTreeOrEct(Vector3 position) 
{
    int rand = Random.Range(0, plantPrefabList.Count);

    GameObject tree = Instantiate( plantPrefabList[rand] );
    tree.transform.position = position;
    tree.transform.SetParent(plantingParent);
}

구한 좌표의 50f 위에서 아래쪽으로 Raycast를 한다.

이렇게 하는 이유는 맵이 평지가 아닌 경사가 있기 때문에 맵 위에 나무를 자연스럽게 배치하기 위해서 

일정 높이만큼 위에서 raycast를 해주는 것이다. 

 

raycast 해서 hit 된 point에 나무프리팹을 배치한다. 

 

풀과 돌 배치 코드까지 작성하고 실행하면

예쁘게 랜덤으로 잘 생성되는 것을 볼 수 있다. 

 

📝 발생한 문제점                                                                                            

2025.03.14 - [Unity] - [Unity] RaycastHit의 point와 trasform.position의 차이

 

[Unity] RaycastHit의 point와 trasform.position의 차이

해당 블로그는 아래의 내용을 개발하다 생긴 문제점과 해결방법에 대한 글입니다https://youcheachae.tistory.com/58 [개발일지] 청크별로 맵 오브젝트 생성📝 구성                                 

youcheachae.tistory.com

Raycast 도중 예상과 다른 위치에 오브젝트가 배치되는 현상을 발견하고 해결한 내용을 정리한 글입니다.

 

📝 개선사항                                                                                                     

현재 나무를 Instantiate를 통해서 생성하고 있다.

하지만 오브젝트를 반복적으로 Instantiate 하면 가비지 컬렉터가 메모리를 정리할 때 성능적으로 문제가 될 수 있다.

추후 pooling을 사용하여 나무를 pool에서 가져오는 방식으로 개선할 예정이다.

 


깃허브

https://github.com/JUNG99/LowPolyRust

 

GitHub - JUNG99/LowPolyRust

Contribute to JUNG99/LowPolyRust development by creating an account on GitHub.

github.com