본문 바로가기
Unity

Unity(3) - Block Map

by Srff5123 2026. 2. 19.
728x90

저번 간단하게 움직이는 것을 만들었다

이제 그러면 캐릭터가 움직이며 다닐 맵이 필요하다

마인크래프트를 보면 맵이 블럭으로 이루어져있는데 산과 언덕 여러 지형들이 랜덤적으로 생성된다.

또한 호수나 지하에도 랜덤적으로 광물이 스폰된다. 이를 복셀월드라 부른다

 

Voxel World란 부피(Volume)와 픽셀(Pixel)의 합성어로 3차원 공간에서 정육면체 형태의 복셀 데이터를 사용해 구성된 가상세계이다.

 

복셀 기반의 환경은 3D 공간을 자유롭게 수정, 파괴, 생성할 수 있어 높은 자유도를 제공한다.

 

3차원 그리드상에 정육면체(Voxels)를 채워 넣는 방식으로 렌더링되며, 픽셀이 2D 데이터를 담당하든 복셀은 3D 데이터를 담당한다.

 

대표적으로는 마인크래프트, VR, 3D 에디터, 건축 시뮬레이션에 사용할 수 있다

복잡한 메쉬 대신 데이터를 다루어 자유로운 변경이 가능하여 Procedural Generation(절차적 생성)을 통해 방대한 세계를 만들 수 있다는 장점을 가진다.

 

그래서 우리의 목표는 이 복셀 월드를 통해 청크(Chunk) 단위로 3D 블록 지형을 만들어 보이는 면만 메쉬 생성을 하여 가볍게 렌더링을 해주고 Collison추가와 캐릭터의 설정을 통해 해당 맵을 돌아다닐 수 있게 만들어 줄 예정이다.

 

일단 우선 저번 했던 조이스틱을 통한 이동과 빈 공간을 이용한 시점 회전을 하였는데

시점 회전 부분에서 오류가 하나 있다. 시점회전에 캐스팅이 되어 점프가 눌리지 않는 오류이다.

그 부분은

해당 부분의 Raycast Target을 꺼주면 해결된다.

 

Voxel World 청크 x 청크 생성 -> 각 청크는 16 64 16 , 높이는 PerlinNoise로 샘플링을 통해 언덕과 굴곡 생성

표면은 Grass 초록색, 그 아래의 5칸은 Dirt 갈색, 더 아래는 Stone  or Coal/Iron으로

지표면의 위는 Air, WaterLevel을 설정해 해당 이하의 빈공간은 Water 파란색으로 채워준다.

 

자 이제 해보자

늘 하던 거처럼 스크립트에 파일을 만들어주자 우선

위의 설명대로 각 블록의 색상 데이터를 만들어줄 BlockType 파일을 만들어주고

내부에 해당 코드를 넣어준다.

public enum BlockType : byte
{
    Air = 0,
    Dirt = 1,
    Grass = 2,
    Stone = 3,
    Water = 4,
    Coal = 5,
    Iron = 6
}

 

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(MeshCollider))]
public class VoxelChunk : MonoBehaviour
{
    public const int SizeX = 16;
    public const int SizeZ = 16;
    public const int SizeY = 64;
    // 청크 크기 상수

    public Vector2Int ChunkCoord { get; private set; } // (chunkX, chunkZ)
    // 해당 청크가 월드에서 몇 번쨰 청크인지 저장

    private BlockType[,,] blocks; // 청크 내부 블록 데이터

    private MeshFilter mf;
    private MeshRenderer mr;
    private MeshCollider mc;
    // GetComponent를 매번 호출하지 않도록 캐싱

    // 부모 월드 참조(이웃 청크 조회용) : 청크 경계 만들기
    private VoxelWorld world;

    public void Initialize(VoxelWorld world, Vector2Int coord, Material mat)
    {
        this.world = world;
        ChunkCoord = coord;
        // 어떤 청크 좌표인지와 해당 청크가 월드에 속하는지 확인

        mf = GetComponent<MeshFilter>();
        mr = GetComponent<MeshRenderer>();
        mc = GetComponent<MeshCollider>();

        mr.sharedMaterial = mat;
        // 청크 렌더러에 머테리얼 할당
        // sharedMaterial 모든 천크가 같은 머테리얼을 공유하면 메모리/배치에 유리
 
        mc.convex = false;
        // 복잡한 지형 메시는 convex로 만들 수 없고 성능도 안좋음
        mc.isTrigger = false;
        // 실제 충돌로 사용할 것이기 떄문에 false로

        blocks = new BlockType[SizeX, SizeY, SizeZ];
        // 블록 데이터 생성
    }

    public void SetBlock(int x, int y, int z, BlockType type)
    {
        blocks[x, y, z] = type;
        // 블록 저장 함수 , 블록 변경 시 청크 리빌드
    }

    public BlockType GetBlockLocal(int x, int y, int z)
    {
        if (x < 0 || x >= SizeX || y < 0 || y >= SizeY || z < 0 || z >= SizeZ)
            return BlockType.Air;
        return blocks[x, y, z];

        // 청크 내부 좌표로 블록 타입을 얻음
        // 범위 받으면 air로 처리함

    }

    // 이웃 청크까지 포함해서 조회
    private BlockType GetBlockWithNeighbors(int x, int y, int z)
    {
        if (x >= 0 && x < SizeX && y >= 0 && y < SizeY && z >= 0 && z < SizeZ)
            return blocks[x, y, z];
        // 먼저 청크 내부인 경우 blocks에서 꺼내줌

        int worldX = ChunkCoord.x * SizeX + x;
        int worldZ = ChunkCoord.y * SizeZ + z;
        return world.GetBlockWorld(worldX, y, worldZ);
        // 청크 밖좌표가 들어오면
        // 월드 좌표로 변환하여 해당 블록의 타입 설정
    }

    public void BuildMesh()
    {
        var verts = new List<Vector3>(8192);
        var tris = new List<int>(16384);
        var cols = new List<Color32>(8192);
        var uvs = new List<Vector2>(8192);  // 라인용 UV
        // verts = 정점, tris = 삼각형, cols = 정점 색, uvs = shader Graph가 테두리 라인 만들 때 필요

        int vCount = 0;
        // 현재까지 추가된 정점 수
        // 삼각형 인덱스 만들 때 기준이 되는 값

        for (int x = 0; x < SizeX; x++)
            for (int y = 0; y < SizeY; y++)
                for (int z = 0; z < SizeZ; z++)
                {
                    // 청크의 모든 블록 순회
                    BlockType t = blocks[x, y, z];
                    if (t == BlockType.Air) continue;
                    // air부분은 스킵
                    Vector3 basePos = new Vector3(x, y, z);
                    // 블록 로컬 위치
                    if (IsFaceVisible(x + 1, y, z)) AddFace(FaceDir.PosX, basePos, t, ref vCount, verts, tris, cols, uvs);
                    if (IsFaceVisible(x - 1, y, z)) AddFace(FaceDir.NegX, basePos, t, ref vCount, verts, tris, cols, uvs);
                    if (IsFaceVisible(x, y, z + 1)) AddFace(FaceDir.PosZ, basePos, t, ref vCount, verts, tris, cols, uvs);
                    if (IsFaceVisible(x, y, z - 1)) AddFace(FaceDir.NegZ, basePos, t, ref vCount, verts, tris, cols, uvs);
                    if (IsFaceVisible(x, y + 1, z)) AddFace(FaceDir.PosY, basePos, t, ref vCount, verts, tris, cols, uvs);
                    if (IsFaceVisible(x, y - 1, z)) AddFace(FaceDir.NegY, basePos, t, ref vCount, verts, tris, cols, uvs);
                    // 보이는 면만 생성, 6방향 이웃 블록 검사 후 이웃이 Air라면 외부 노출로 면으로 생성
                    // 컬링.
                }

        Mesh mesh = new Mesh();
        mesh.indexFormat = (verts.Count > 65000)
            ? UnityEngine.Rendering.IndexFormat.UInt32
            : UnityEngine.Rendering.IndexFormat.UInt16;

        // 메시 객체 넣고 데이터 넣기
        // Unity의 메시 인덱스는 기본 16bit, 정점이 많아지면 32 필요 즉 청크가 거지거나, 맵이 복잡해지면 필요

        mesh.SetVertices(verts);
        mesh.SetTriangles(tris, 0);
        mesh.SetColors(cols);
        mesh.SetUVs(0, uvs); 

        // 실제 메쉬 데이터 넣기

        mesh.RecalculateNormals(); // 조명 계산
        mesh.RecalculateBounds(); // 경계면, 컬링  / 렌더 최적화에 사용 


        // 렌더링
        mf.sharedMesh = mesh;

        // 충돌
        mc.sharedMesh = null;
        mc.sharedMesh = mesh;
        // collider 갱신

        Debug.Log($"[{name}] verts={mesh.vertexCount}, colors={mesh.colors32.Length}, uvs={mesh.uv.Length}, tris={mesh.triangles.Length / 3}");
        // 변화를 감지 못하는 경우가 있어 확인
    }

    private bool IsFaceVisible(int nx, int ny, int nz)
    {
        BlockType n = GetBlockWithNeighbors(nx, ny, nz);
        return n == BlockType.Air;
        // 이웃 블록이 Air면 그 면은 보이는 부분
    }

    private enum FaceDir { PosX, NegX, PosY, NegY, PosZ, NegZ }
    // 어떤 면을 만들지 식별

    // 각 면의 4개 정점(블록 로컬 기준)
    private static readonly Vector3[] facePosX = { new(1, 0, 0), new(1, 0, 1), new(1, 1, 1), new(1, 1, 0) };
    private static readonly Vector3[] faceNegX = { new(0, 0, 1), new(0, 0, 0), new(0, 1, 0), new(0, 1, 1) };
    private static readonly Vector3[] facePosY = { new(0, 1, 0), new(1, 1, 0), new(1, 1, 1), new(0, 1, 1) };
    private static readonly Vector3[] faceNegY = { new(0, 0, 1), new(1, 0, 1), new(1, 0, 0), new(0, 0, 0) };
    private static readonly Vector3[] facePosZ = { new(1, 0, 1), new(0, 0, 1), new(0, 1, 1), new(1, 1, 1) };
    private static readonly Vector3[] faceNegZ = { new(0, 0, 0), new(1, 0, 0), new(1, 1, 0), new(0, 1, 0) };
    // 각 면은 정점 4개 , 면이 바라보는 방향이 달라지는 것을 방지하기 위해 면마다 정점 순서가 다름

    private static void AddFace(
        FaceDir dir, Vector3 basePos, BlockType type,
        ref int vCount,
        List<Vector3> verts, List<int> tris, List<Color32> cols, List<Vector2> uvs)

        // 한 블록의 한 면을 추가하는 함수
    {
        Vector3[] f = dir switch
        {
            FaceDir.PosX => facePosX,
            FaceDir.NegX => faceNegX,
            FaceDir.PosY => facePosY,
            FaceDir.NegY => faceNegY,
            FaceDir.PosZ => facePosZ,
            _ => faceNegZ, // 면 방향에 따라 4개 정점 템플리 골라옴
        };

        // 정점 추가, 블록 위치 + 면 템플릿 통해 실제 로컬 정점 좌표 생성
        verts.Add(basePos + f[0]);
        verts.Add(basePos + f[1]);
        verts.Add(basePos + f[2]);
        verts.Add(basePos + f[3]);

        // 컬러 추가, 한 면은 정점이 4개로 컬러도 4개 필요
        // Color32 메모리 절약
        Color32 c = BlockColor32(type);
        cols.Add(c); cols.Add(c); cols.Add(c); cols.Add(c);

        // UV 4개
        uvs.Add(new Vector2(0f, 0f));
        uvs.Add(new Vector2(1f, 0f));
        uvs.Add(new Vector2(1f, 1f));
        uvs.Add(new Vector2(0f, 1f));
        // shader Graph에서 가장 자리를 감지해 테두리 만들기


        // 삼각형(와인딩) - 면 방향별 뒤집힘 방지
        if (dir == FaceDir.NegX || dir == FaceDir.NegY || dir == FaceDir.NegZ)
        {
            tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 1);
            tris.Add(vCount + 0); tris.Add(vCount + 3); tris.Add(vCount + 2);
        }
        else
        {
            tris.Add(vCount + 0); tris.Add(vCount + 1); tris.Add(vCount + 2);
            tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 3);
        }

        // 쿼드는 삼각형 2개로 나우어 주어야 함
        // 와인딩 = 삼각형 정점 순서, 유니티는 한방향(Front face)만 렌더링
   

        vCount += 4;
        // 면(정점 4개) 하나 추가
    }

    private static Color32 BlockColor32(BlockType t) => t switch
    {
        BlockType.Grass => new Color32(64, 192, 64, 255),
        BlockType.Dirt => new Color32(115, 77, 38, 255),
        BlockType.Stone => new Color32(140, 140, 148, 255),
        BlockType.Water => new Color32(50, 100, 210, 255),
        BlockType.Coal => new Color32(40, 40, 40, 255),
        BlockType.Iron => new Color32(190, 165, 140, 255),
        _ => new Color32(255, 0, 255, 255),

        // 블록 타입별 색을 버텍스 컬러로 결정
    };
}
using System.Collections.Generic;
using UnityEngine;

public class VoxelWorld : MonoBehaviour
    // 월드 관리 클래스
{
    [Header("Rendering")] // Header : Inspector에서 제목
    [SerializeField] private Material voxelMaterial; // 청크 메쉬 렌더러에 넣어줄 머테리얼
    // 외부에서는 수정 못하게 Private로 하고 inspector에서 조절 가능하게 하여 편집하기 쉽게

    [Header("World Size (in chunks)")]
    [SerializeField] private int chunksX = 7;
    [SerializeField] private int chunksZ = 7;
    // 월드는 블록이 아닌 청크 단위로 , 1 청크 = 16칸, 7*16으로 100보다 조금 크게 생성

    [Header("Terrain")]
    [SerializeField] private int baseHeight = 28;
    [SerializeField] private int amplitude = 18;
    [SerializeField] private float noiseScale = 0.06f;
    // baseHeight를 중심으로 Amplitude(진폭)만큼 위아래로 흔들게 함
    // PerlinNoise : 자연스러운 질감을 생성하는 알고리즘
    // 진폭에 따라 작으면 부드러운 큰 언덕, 크면 자잘한 굴곡으로된 거친 지형

    [SerializeField] private int topDirtDepth = 5;
    [SerializeField] private int waterLevel = 22;
    // 지표면 아래 몇층을 흙으로 할지, 
    // 물 높이 설정 -> 지형이 낮으면 물로 채워줌

    [Header("Ore Chances (only in Stone)")]
    [Range(0f, 1f)][SerializeField] private float coalChance = 0.02f;
    [Range(0f, 1f)][SerializeField] private float ironChance = 0.01f;
    // 돌로 이루어진 부분에서 광물이 생길 확률
    // inspector에서 조절하여 시뮬

    private readonly Dictionary<Vector2Int, VoxelChunk> chunks = new();
    // 청크를 저장할 Dictionary , key = 청크 좌표, value = 해당 좌표의 VoxelChunk 컴포넌트
    // 월드 좌표롤 블록을 조회하는 경우 해당 좌표가 속한 청크를 즉시 찾기 위해서 Dictionay로 해줌
    // 여기서 readonly란 : 런타임에 딕셔너리 참조 자체가 바뀌지 않게 하여 안정성을 높혀줌 


    void Start()
    {
        GenerateInitialWorld(); // 게임 시작 월드 생성
    }

    private void GenerateInitialWorld()
    {
        for (int cx = 0; cx < chunksX; cx++)
            for (int cz = 0; cz < chunksZ; cz++)
            {
                CreateChunk(new Vector2Int(cx, cz)); // 월드의 청크 격자를 돌며 청크 생성
            }

        foreach (var kv in chunks)
            kv.Value.BuildMesh();

        // 이웃 참조를 위해 청크를 다 만든 뒤 메쉬 빌드
        // 청크 경게면은 이웃 청크를 봐야 정확하기에 다만든 다음 메쉬 빌드

    }

    private void CreateChunk(Vector2Int coord)
    {
        var go = new GameObject($"Chunk_{coord.x}_{coord.y}");
        // 런타임에 청크 오브젝트 만들기, var = 변수의 자료형을 자동으로 저장, c++의 auto
        go.transform.SetParent(transform, false);
        // 생성된 청크를 VoxelWorld 오브젝의 자식으로 넣어줌
        // false는 월드좌표를 유지해 로컬 변환을 막아줌
        go.transform.position = new Vector3(coord.x * VoxelChunk.SizeX, 0f, coord.y * VoxelChunk.SizeZ);
        // 청크의 월드 좌표를 청크 크기 단위로 만들어줌
        var chunk = go.AddComponent<VoxelChunk>();
        chunk.Initialize(this, coord, voxelMaterial);
        // y = 0 청크는 바닥 기준으로, 블록의 높이는 청의의 내부 y로 처리
        // 컴포넌트, 좌표, 머테리얼 전달, 독립적으로 월드 조회 
        FillChunkBlocks(chunk);
        // 청크의 3D 블록 배열을 실제 데이터로 채워줌

        chunks.Add(coord, chunk);
        // 딕셔너리 등록
    }

    private void FillChunkBlocks(VoxelChunk chunk)
    {
        int startX = chunk.ChunkCoord.x * VoxelChunk.SizeX;
        int startZ = chunk.ChunkCoord.y * VoxelChunk.SizeZ;

        // 월드 시작 좌표 계산 청크 로컬의 0,0이 월드에서 어디인지 

        for (int lx = 0; lx < VoxelChunk.SizeX; lx++)
            for (int lz = 0; lz < VoxelChunk.SizeZ; lz++)
            {
                int wx = startX + lx;
                int wz = startZ + lz;

                // 청크 내부의 로컬 좌표를 월드 좌표로 변환
                // 노이즈의 연속된 지형과 청크마다 0부터 시작 시 경계가 끊어지기 떄문
                

                int h = SampleHeight(wx, wz);
                // 높이

                for (int y = 0; y < VoxelChunk.SizeY; y++)
                {
                    BlockType t;
                    // y축을 아래부터 위까지 돌면서 블록 타입 결정

                    if (y > h)
                    {
                        // 높이 위는 공기, 일정 높이 이하이면 물 -> 호수나 바다 만들기
                        t = (y <= waterLevel) ? BlockType.Water : BlockType.Air;
                    }
                    else
                    {
                        // 지표면/지하
                        if (y == h) // 잔디
                            t = BlockType.Grass;
                        else if (y >= h - (topDirtDepth - 1)) // 흙
                            t = BlockType.Dirt;
                        else
                            t = PickStoneOrOre(wx, y, wz); // 돌, 광물생성 함수
                    }

                    chunk.SetBlock(lx, y, lz, t); // 계산된 블록 타입을 청크 데이터에 넣어주기
                }
            }
    }

    private int SampleHeight(int wx, int wz)
    {
        float n = Mathf.PerlinNoise(wx * noiseScale, wz * noiseScale); // 0..1
        return baseHeight + Mathf.RoundToInt((n - 0.5f) * 2f * amplitude);

        // PerilinNoise 0 ~ 1범위의 부드러운 노이즈 생성
        // noiseScale값을 조절해 지형 스케일 제어
    }

    private BlockType PickStoneOrOre(int wx, int y, int wz)
    {
        // y가 낮을 수록 1로
        float depth01 = 1f - (y / (float)VoxelChunk.SizeY);

        float r = Hash01(wx, y, wz);
        // 좌표 기반의 난수 생성, UnityRandom은 안정적이지 않음

        float coal = coalChance + depth01 * 0.01f;
        float iron = ironChance + depth01 * 0.006f;
        // 깊이 내려가면 확률이 올라감

        if (r < iron) return BlockType.Iron;
        if (r < iron + coal) return BlockType.Coal;
        return BlockType.Stone;
        // iron을 먼저 체크하여 저 희귀하게 만들어줌
    }

    // 월드 좌표로 블록 조회 (청크 경계면 생성)
    public BlockType GetBlockWorld(int wx, int y, int wz)
    {
        if (y < 0 || y >= VoxelChunk.SizeY) return BlockType.Air;
        // 월드 밖은 비어있는 상태 = Air ,0

        int cx = Mathf.FloorToInt(wx / (float)VoxelChunk.SizeX);
        int cz = Mathf.FloorToInt(wz / (float)VoxelChunk.SizeZ);
        // 월드 좌표가 어떤 청크에 속하는지

        int lx = wx - cx * VoxelChunk.SizeX;
        int lz = wz - cz * VoxelChunk.SizeZ;
        // 월드 좌표를 청크 로컬 좌표로 변환

        var key = new Vector2Int(cx, cz);
        if (!chunks.TryGetValue(key, out var chunk))
            return BlockType.Air;
        // 청크가 존재하는지 찾고, 없으면 air로 

        return chunk.GetBlockLocal(lx, y, lz);
        // 내부 데이터 배열에서 블록 타입 가져오기
    }

    // 안정적인 의사 난수(0..1) : 좌표 기반 (Random.state 안 건드림)
    private static float Hash01(int x, int y, int z)
    {
        unchecked
        {
            int h = x * 73856093 ^ y * 19349663 ^ z * 83492791; // 격자 해시에서 쓰는 값
            h = (h << 13) ^ h; // 비트시프트 활용 xor
            int v = (h * (h * h * 15731 + 789221) + 1376312589);
            return (v & 0x7fffffff) / 2147483647f; // 부호 비트 제거 -> 양수로하고
            // 0 ~ 1범위 float로 정규화하여 반환
        }
    }
}

다음 블럭을 그리고 출력하기 위한 VoxelChunk하나와 해당 데이터를 가지고 월드를 생성해줄 VoxelWorld를 만들어 준다.

이제 유니티 창에서 Hierachy에 빈 오브젝트로 VoxelWorld를 생성하고 오브젝트에 만든 VoxelWolrd 스크립를 넣어줌 

다음 Material을 만들기 위해 Crate -> Material을 만들어 주고 오브젝트에 넣어줌 다음 기존의 맵에 있던 Plane을 없에준다.

 

그런 다음 Player의 위치를 수정하여 위에서 맵으로 떨어지는 식으로 나오게끔 배치를 해주고 캐릭터 Control에서 이동할 수 있는

조건을 조금 늘려준다

다음 Project에서 Assets -> Create -> Shader Graph -> URP -> Lit Shader Graph를 만들어 준다.

만들었으면 아까 만든 Material에 해당 shader graph를 넣어주고

 

이제 shader Graph의 노드를 연결해주어야 한다. 일단 그전에 해당 Render face를 both로 바꾸어준다

이와 똑같이 노드를 연결해주면 마인크래프트처럼 얇은 윤곽선을 그려줄 수 있게 된다.

랜덤 블록 맵 만들기 성공이다.

 

다음 글에서는 부수는것과 인벤토리를 만들어 블록 아이템을 먹고 다시 설치하는 부분까지 해볼 예정이다.

728x90

'Unity' 카테고리의 다른 글

Unity(6) - Atlas Block Map & Crafting Table (2)  (0) 2026.03.10
Unity(5) - Inventory & Crafting  (1) 2026.03.05
Unity(4) - Ray Cast & Block Destroy  (0) 2026.02.24
Unity(2) - Basic Move, Rotation  (0) 2026.02.13
Unity (1)  (0) 2026.02.11