랜덤 맵 생성기

JJW·2025년 1월 25일
0

Unity

목록 보기
19/34

오늘은 3D 환경에서 Terrian 을 통해 랜덤으로 맵을 생성하는 방법에 대해 알아보겠습니다.


클래스 정의

  • TerrainGenerator
  • 지형의 높이맵 생성, 습도 맵 생성, 바이옴 적용, 나무,바위,풀 등의 오브젝트를 배치하는 역할을 합니다.
  • BiomeGenerator
  • 높이와 습도 값을 기반으로 각 지형 구간의 바이옴 유형을 결정하는 역할을 합니다.
  • Biome
  • 각 바이옴의 속성 (높이 범위,습도 범위, 지형 텍스쳐, 해당 지형 프리팹)을 정의하는 역할을 합니다.

TerrainGenerator

using System.Linq;
using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;
#endif

[RequireComponent(typeof(Terrain))]
public class TerrainGenerator : MonoBehaviour
{
    [Header("Terrain Settings")]
    public int width = 512;                             // 지형의 너비
    public int depth = 512;                             // 지형의 깊이
    public int height = 100;                            // 지형의 최대 높이
    public float scale = 20f;                           // 노이즈 스케일

    [Header("Noise Settings")]
    public int octaves = 4;                             // 노이즈 층 수
    [Range(0, 1)]
    public float persistence = 0.5f;                    // 진폭 감소율
    public float lacunarity = 2f;                       // 주파수 증가율

    [Header("Moisture Settings")]
    public float moistureScale = 30f;                   // 습도 맵 스케일
    public Vector2 moistureOffset;                      // 습도 맵 오프셋

    [Header("Biome Settings")]
    public BiomeGenerator biomeGenerator;

    [Header("Object Settings")]
    public GameObject[] treePrefabs;
    public int treeCount = 1000;

    public GameObject[] rockPrefabs;
    public int rockCount = 500;
    public float rockHeightOffset = 0.1f;

    public GameObject[] grassPrefabs;
    public int grassCount = 1000;
    public float grassHeightOffset = 0.05f;

    [Header("Cave Settings ------- 사용 안해용")]
    public int caveWidth = 512;
    public int caveHeight = 512;
    public int initialFillProbability = 45;
    public int smoothingIterations = 5;

    [Header("River Settings ------- 사용 안해용")]
    public int riverCount = 3;
    public int riverLength = 300;
    public float riverDepth = 0.02f;

    [Header("Lake Settings ------- 사용 안해용")]
    public int lakeCount = 5;
    public int lakeRadius = 50;
    public float lakeDepth = 0.05f;

    [Header("Water Settings ------- 사용 안해용")]
    public GameObject waterPrefab; // 인스펙터에서 할당할 물 프리팹

    private Vector2 offset;

    private Terrain terrain;
    private TerrainData terrainData;
    private float[,] heights;
    private float[,] moistureMap;

    void Start()
    {
        terrain = GetComponent<Terrain>();
        offset = new Vector2(Random.Range(0f, 9999f), Random.Range(0f, 9999f));
        moistureOffset = new Vector2(Random.Range(0f, 9999f), Random.Range(0f, 9999f));

        GenerateTerrain();
        GenerateMoistureMap();
        ApplyBiomes();

        //GenerateCaves();  // 오류가 있음..
        //GenerateRivers(); // 자연스럽지가 않음..
        //GenerateLakes();  // 자연스럽지가 않음..
        PlaceObjects();
    }

    public void GenerateTerrain()
    {
        terrainData = terrain.terrainData;
        terrainData.heightmapResolution = Mathf.Max(width, depth) + 1; // 높이맵 해상도는 가장 큰 축 기준
        terrainData.size = new Vector3(width, height, depth);
        heights = GenerateHeights();
        terrainData.SetHeights(0, 0, heights);
    }

    float[,] GenerateHeights()
    {
        float[,] heights = new float[depth, width]; // [z, x] 인덱싱
        for (int z = 0; z < depth; z++)
        {
            for (int x = 0; x < width; x++)
            {
                float amplitude = 1;
                float frequency = 1;
                float noiseHeight = 0;

                for (int i = 0; i < octaves; i++)
                {
                    float sampleX = (x + offset.x) / scale * frequency;
                    float sampleZ = (z + offset.y) / scale * frequency;

                    float perlinValue = Mathf.PerlinNoise(sampleX, sampleZ) * 2 - 1;
                    noiseHeight += perlinValue * amplitude;

                    amplitude *= persistence;
                    frequency *= lacunarity;
                }

                heights[z, x] = Mathf.Clamp01(noiseHeight * 0.5f + 0.5f);
            }
        }
        return heights;
    }

    float[,] GenerateMoistureMap()
    {
        moistureMap = new float[depth, width];
        for (int z = 0; z < depth; z++)
        {
            for (int x = 0; x < width; x++)
            {
                float sampleX = (x + moistureOffset.x) / moistureScale;
                float sampleZ = (z + moistureOffset.y) / moistureScale;
                moistureMap[z, x] = Mathf.PerlinNoise(sampleX, sampleZ);
            }
        }
        return moistureMap;
    }

    void ApplyBiomes()
    {
        if (biomeGenerator == null || biomeGenerator.biomes.Length == 0)
        {
            return;
        }

        // 바이옴에 따른 텍스처 적용
        ApplyBiomeTextures();
    }

    void ApplyBiomeTextures()
    {
        Terrain terrain = GetComponent<Terrain>();
        TerrainData terrainData = terrain.terrainData;

        // 각 바이옴의 텍스처 레이어 추가
        foreach (Biome biome in biomeGenerator.biomes)
        {
            if (biome.terrainLayer != null && !terrainData.terrainLayers.Contains(biome.terrainLayer))
            {
                terrainData.terrainLayers = terrainData.terrainLayers.Concat(new TerrainLayer[] { biome.terrainLayer }).ToArray();
            }
        }

        // 텍스처 매핑
        float[,,] alphaMaps = new float[terrainData.alphamapWidth, terrainData.alphamapHeight, terrainData.terrainLayers.Length];

        for (int z = 0; z < terrainData.alphamapHeight; z++)
        {
            for (int x = 0; x < terrainData.alphamapWidth; x++)
            {
                // 높이맵과 모이스처 맵의 좌표 변환
                int heightmapX = Mathf.RoundToInt((float)x / terrainData.alphamapWidth * width);
                int heightmapZ = Mathf.RoundToInt((float)z / terrainData.alphamapHeight * depth);
                heightmapX = Mathf.Clamp(heightmapX, 0, width - 1);
                heightmapZ = Mathf.Clamp(heightmapZ, 0, depth - 1);

                float heightNormalized = heights[heightmapZ, heightmapX];
                float moisture = moistureMap[heightmapZ, heightmapX];
                BiomeType biomeType = biomeGenerator.GetBiome(heightNormalized, moisture);
                Biome currentBiome = GetBiomeByType(biomeType);

                for (int i = 0; i < terrainData.terrainLayers.Length; i++)
                {
                    if (terrainData.terrainLayers[i] == currentBiome.terrainLayer)
                    {
                        alphaMaps[z, x, i] = 1f;
                    }
                    else
                    {
                        alphaMaps[z, x, i] = 0f;
                    }
                }
            }
        }

        terrainData.SetAlphamaps(0, 0, alphaMaps);
    }

    void GenerateCaves()
    {
        CaveGenerator caveGenerator = gameObject.AddComponent<CaveGenerator>();
        caveGenerator.caveWidth = caveWidth;
        caveGenerator.caveHeight = caveHeight;
        caveGenerator.initialFillProbability = initialFillProbability;
        caveGenerator.smoothingIterations = smoothingIterations;
        caveGenerator.GenerateCave();
        caveGenerator.ApplyCaveToTerrain();
    }

    void GenerateRivers()
    {
        RiversGenerator riversGenerator = gameObject.AddComponent<RiversGenerator>();
        riversGenerator.riverCount = riverCount;
        riversGenerator.riverLength = riverLength;
        riversGenerator.riverDepth = riverDepth;
        riversGenerator.waterPrefab = waterPrefab; 
        riversGenerator.GenerateRivers(terrainData);
    }

    void GenerateLakes()
    {
        LakesGenerator lakesGenerator = gameObject.AddComponent<LakesGenerator>();
        lakesGenerator.lakeCount = lakeCount;
        lakesGenerator.lakeRadius = lakeRadius;
        lakesGenerator.lakeDepth = lakeDepth;
        lakesGenerator.waterPrefab = waterPrefab; 
        lakesGenerator.GenerateLakes();
    }

    void PlaceObjects()
    {
        PlaceTrees();
        PlaceRocks();
        PlaceGrass();
    }

    void PlaceTrees()
    {
        if (biomeGenerator == null || biomeGenerator.biomes.Length == 0)
        {
            return;
        }

        Vector3 terrainPos = terrain.transform.position;

        for (int i = 0; i < treeCount; i++)
        {
            float x = Random.Range(0f, terrainData.size.x);
            float z = Random.Range(0f, terrainData.size.z);

            // 월드 좌표를 높이맵 좌표로 변환
            int heightmapX = Mathf.RoundToInt((x / terrainData.size.x) * width);
            int heightmapZ = Mathf.RoundToInt((z / terrainData.size.z) * depth);
            heightmapX = Mathf.Clamp(heightmapX, 0, width - 1);
            heightmapZ = Mathf.Clamp(heightmapZ, 0, depth - 1);

            float y = terrain.SampleHeight(new Vector3(x, 0, z)) + terrainPos.y;
            float heightNormalized = heights[heightmapZ, heightmapX];
            float moisture = moistureMap[heightmapZ, heightmapX];

            BiomeType biomeType = biomeGenerator.GetBiome(heightNormalized, moisture);
            Biome currentBiome = GetBiomeByType(biomeType);

            Vector3 position = new Vector3(x, y, z) + terrainPos;

            if (currentBiome.vegetationPrefabs.Length > 0)
            {
                GameObject vegetation = Instantiate(
                    currentBiome.vegetationPrefabs[Random.Range(0, currentBiome.vegetationPrefabs.Length)],
                    position,
                    Quaternion.identity
                );
                vegetation.transform.parent = this.transform;
            }
        }
    }

    void PlaceRocks()
    {
        if (rockPrefabs.Length == 0)
        {
            return;
        }

        Vector3 terrainPos = terrain.transform.position;

        for (int i = 0; i < rockCount; i++)
        {
            float x = Random.Range(0f, terrainData.size.x);
            float z = Random.Range(0f, terrainData.size.z);

            // 월드 좌표를 높이맵 좌표로 변환
            int heightmapX = Mathf.RoundToInt((x / terrainData.size.x) * width);
            int heightmapZ = Mathf.RoundToInt((z / terrainData.size.z) * depth);
            heightmapX = Mathf.Clamp(heightmapX, 0, width - 1);
            heightmapZ = Mathf.Clamp(heightmapZ, 0, depth - 1);

            float y = terrain.SampleHeight(new Vector3(x, 0, z)) + terrainPos.y;

            Vector3 position = new Vector3(x, y + rockHeightOffset, z) + terrainPos;

            GameObject rock = Instantiate(rockPrefabs[Random.Range(0, rockPrefabs.Length)], position, Quaternion.identity);
            rock.transform.parent = this.transform;
        }
    }

    void PlaceGrass()
    {
        if (grassPrefabs.Length == 0)
        {
            return;
        }

        Vector3 terrainPos = terrain.transform.position;

        for (int i = 0; i < grassCount; i++)
        {
            float x = Random.Range(0f, terrainData.size.x);
            float z = Random.Range(0f, terrainData.size.z);

            // 월드 좌표를 높이맵 좌표로 변환
            int heightmapX = Mathf.RoundToInt((x / terrainData.size.x) * width);
            int heightmapZ = Mathf.RoundToInt((z / terrainData.size.z) * depth);
            heightmapX = Mathf.Clamp(heightmapX, 0, width - 1);
            heightmapZ = Mathf.Clamp(heightmapZ, 0, depth - 1);

            float y = terrain.SampleHeight(new Vector3(x, 0, z)) + terrainPos.y;

            Vector3 position = new Vector3(x, y + grassHeightOffset, z) + terrainPos;

            GameObject grass = Instantiate(grassPrefabs[Random.Range(0, grassPrefabs.Length)], position, Quaternion.identity);
            grass.transform.parent = this.transform;
        }
    }

    Biome GetBiomeByType(BiomeType type)
    {
        foreach (Biome biome in biomeGenerator.biomes)
        {
            if (biome.type == type)
                return biome;
        }
        return biomeGenerator.biomes[0]; // 기본값
    }

#if UNITY_EDITOR
    //public void OnValidate()
    //{
    //    if (terrain == null)
    //    {
    //        terrain = GetComponent<Terrain>();
    //    }

    //    GenerateTerrain();
    //    GenerateMoistureMap();
    //    ApplyBiomes();
    //    
    //}
#endif
}

  • 랜덤 맵 생성기 기능의 핵심이 되는 클래스입니다.
  • 높이 맵 생성 : Perlin Noise를 이용하여 지형의 높이 데이터를 생성해줍니다.
  • 습도 맵 생성 : 지형의 습도를 나타내는 맵을 생성하여 바이옴을 구분합니다.
  • 바이옴 적용 : 높이와 습도의 데이터를 통해 각 지형 구간에 맞는 바이옴 텍스쳐를 적용합니다.
  • 객체 배치 : Biome에서 정의한 프리팹을 배치합니다.
  • 동굴 및 강,호수의 경우에는 오류가 있거나 자연스럽게 설치가 되지않아 주석처리 하였습니다..

BiomeGenerator

using UnityEngine;

public class BiomeGenerator : MonoBehaviour
{
    public Biome[] biomes;
    public float[,] moistureMap;

    public BiomeType GetBiome(float height, float moisture)
    {
        foreach (Biome biome in biomes)
        {
            Debug.Log($"height : {height} || moisture : {moisture}");

            if (height >= biome.minHeight && height <= biome.maxHeight &&
                moisture >= biome.minMoisture && moisture <= biome.maxMoisture)
            {
                return biome.type;
            }
        }
        return BiomeType.Forest; // 기본값
    }
}
  • 지형의 각 지점에서 높이와 습도 데이터를 판단하여 바이옴 유형을 결정하는 클래스 입니다.
  • 모든 바이옴을 돌면서, 입력받은 높이와 습도가 바이옴의 범위에 속하는지 확인합니다.

Biome

using UnityEngine;

public enum BiomeType { Forest, Desert, Mountain, Snow }

[System.Serializable]
public class Biome
{
    public BiomeType type;
    public float minHeight;
    public float maxHeight;
    public float minMoisture;
    public float maxMoisture;
    public TerrainLayer terrainLayer;
    public GameObject[] vegetationPrefabs;
}
  • 바이옴의 속성을 정의하는 클래스입니다.

Perlin Noise

Perlin Noise 설명

  • 켄 퍼린이 개발한 그레이디언트 노이즈 알고리즘 입니다.

주요 특징

  • 연속성
  • 인접한 지점 간에 부드럽게 변화하는 값을 생성합니다.
  • 자연스러움
  • 자연에서 관찰되는 다양한 패턴(예: 구름, 산맥, 바다)을 모사할 수 있습니다.
  • 반복성
  • 주기적인 패턴을 생성할 수 있어 무한히 반복되는 텍스처를 만들 수 있습니다.

작동 원리

기본 개념

  • 그리드 생성 : 격자를 생성합니다. 각 격자 점은 고유한 방향 벡터를 가집니다.
  • 벡터 계산 : 특정 지점에서 가장 가까운 격자 점들을 찾고
    각 격자 점과 지점 사이의 벡터를 계산합니다.
  • 내전 계산 : 각 격자 점의 방향 벡터와 지점과의 벡터 간의 내적을 계산합니다.
  • 보간 : 계산된 내적 값을 부드러운 보간 함수를 통해 결합하여 최종 노이즈 값을 생성합니다.

단계별 과정

  • 그리드 벡터 설정 -> 지점과 격자 점 벡터 계산 -> 내적 계산 -> 보간 -> 최종 노이즈 값

특징 및 장점

  • 특징
  • 부드러운 변화
  • 다양한 스케일 지원
  • 다차원 지원
  • 장점
  • 자연스러운 퍁펀
  • 효율성
  • 확장성

테스트

  • Width(너비) : 1024,
    Depth(깊이) : 1024,
    Height(최대 높이) : 100,
    Scale(노이즈 스케일) : 100,
    Octaves(노이즈 층 수) : 10,
    Persistence(진폭 감소율) : 0.2,
    Lecunarity(주파수 증가율) : 5


  • Width(너비) : 1024,
    Depth(깊이) : 1024,
    Height(최대 높이) : 200,
    Scale(노이즈 스케일) : 200,
    Octaves(노이즈 층 수) : 4,
    Persistence(진폭 감소율) : 0.4,
    Lecunarity(주파수 증가율) : 2


  • Width(너비) : 1024,
    Depth(깊이) : 1024,
    Height(최대 높이) : 150,
    Scale(노이즈 스케일) : 100,
    Octaves(노이즈 층 수) : 6,
    Persistence(진폭 감소율) : 0.3,
    Lecunarity(주파수 증가율) : 3

  • 테스트 영상을 올릴 예정이였는데 값을 바꾸면 몇 분간 세팅하다보니 이미지로 대체하였습니다.

느낀 점

  • 이번 랜덤 맵 생성기를 개발하면서 여러 가지 도전과 학습의 기회를 가졌습니다.
    이전에 다루었던 정렬 및 검색 알고리즘보다 이해하기가 훨씬 어려웠던 점이 많았으며,
    이 과정에서 ChatGPT의 도움이 큰 힘이 되었습니다.
    만약 ChatGPT가 없었다면 많은 부분에서 어려움을 겪었을 것 같습니다.
    강,호수,동굴을 구현할 때 오류나 자연스럽지가 않아
    이번 소개글에서 빠지게 되었는데 아쉬우니 수정이 완료되는 대로 올릴 예정입니다.
    기회가 된다면 2D 지형에서도 사용 해보고 싶네요
profile
Unity 게임 개발자를 준비하는 취업준비생입니다..

0개의 댓글