마인크래프트 짭겜 개발노트 #5 "청크 메쉬 구현"

개발노트

2018. 10. 27. 20:55

청크는 Block 타입의 3차원 배열을 갖는다.

그리고 블록이 없는 공간은 null 로 두는 대신, 메쉬를 그리지도 않고 아무 행동도 하지 않는 Air 블록으로 채워넣는다. 

이렇게 하면 모든 좌표에는 블록이 존재한다고 가정하므로 코드의 일관성 측면에서 좋다.


블록이 추가, 제거되거나 기타 다른 이유로 청크를 다시 그려야 할 때마다 UpdateChunk 메소드를 호출한다.

UpdateChunk 에서는 배열의 모든 블록들을 순회하면서 Block.Mesh 메소드를 호출한다.


private void UpdateChunk()
{
    MeshData meshData = new MeshData();
 
    for (int x = 0; x < SIZE; ++x)
    {
        for (int y = 0; y < SIZE; ++y)
        {
            for (int z = 0; z < SIZE; ++z)
            {
                // 메쉬 데이터 만들기
                BlockInfo info = blockInfoArray[x, y, z];
                Block block = info.block;
                Vector3i rot = info.rotation;
                if (block == null) block = Blocks.AIR;
                meshData = block.Mesh(this, x, y, z, rot, meshData);
            }
        }
    }
 
    RenderMesh(meshData);
}


Block 의 Mesh 메소드에서는 입력받은 좌표와 회전값을 갖고 MeshData 에 자신의 메쉬를 그린다.

MeshData 클래스에는 버텍스, 삼각형 등의 메쉬 관련 정보가 담겨있다.

그렇게 완성된 MeshData 를 이용해 RenderMesh 에서 최종적으로 MeshFilter 에 넣을 메쉬를 만든다.


// Block 클래스에 있는 인터페이스들 중 일부
 
/// 
/// 각 면에 그릴 텍스쳐 정의
/// 
/// 블록 텍스쳐 아틀라스 공간상에서의 좌표
public virtual Vector2 TexturePosition(Chunk chunk, int cx, int cy, int cz, Direction face)
{
    // ...
}
 
/// 
/// 메쉬의 형태 정의
/// 
public virtual MeshData Mesh(Chunk chunk, int cx, int cy, int cz, Vector3i rotation, MeshData meshData)
{
    // ...
}
 
/// 
/// 해당 방향의 면과 접하고 있는 면을 그릴지 말지 결정
/// e.g. IsSolid(Direction.Down) == true : 이 블록의 아랫면은 solid 하므로
/// 이 면과 접하는 면(아래 블록의 윗면)은 메쉬를 그리지 않는다.
/// 
public virtual bool IsSolid(int cx, int cy, int cz, Block neighbor, Direction face)
{
    // ...
}


Block 클래스의 구현을 살짝 들여다보면 이와 같다.

Mesh 메소드에서는 블록의 형태를 이루는 버텍스 정보를 MeshData 에 기록한다.

TexturePosition 메소드에서는 face 가 가리키는 면에 어떤 텍스쳐를 사용할 지, 텍스쳐 아틀라스 상에서의 좌측하단 좌표를 반환한다.

IsSolid 메소드는 face 가 가리키는 면과 맞닿은 면을 그릴지 말지 결정한다.

잎이나 풀 블록같은 일부 블록을 제외하면 대체로 true를 반환해서 맞닿은 면을 그리지 않게 한다.


public Vector2 TexturePosition(Block block, Chunk chunk, int cx, int cy, int cz, Direction face)
{
    // 각 면에 해당하는 텍스쳐 좌표 반환
    switch (face)
    {
        case Direction.Up:
            return _textureTop;
 
        case Direction.Down:
            return _textureBottom;
 
        case Direction.North:
            return _textureNorth;
 
        case Direction.South:
            return _textureSouth;
 
        case Direction.East:
            return _textureEast;
 
        case Direction.West:
            return _textureWest;
 
        default:
            return _textureTop;
    }
}
 
public bool IsSolid(Block block, int cx, int cy, int cz, Block neighbor, Direction face)
{
    // 모든 방향에 대해 항상 true 반환.
    // 이 블록과 맞닿은 이웃 블록은 항상 겹친 면을 그리지 않는다.
    return true;
}


TexturePosition 과 IsSolid 메소드를 구현한 모습이다.


public MeshData Mesh(Block block, Chunk chunk, int cx, int cy, int cz, Vector3i rotation, MeshData meshData)
{
    // 작성한 메쉬 데이터를 렌더링과 충돌에 모두 사용한다.
    meshData.useForRendering = useForRendering;
    meshData.useForCollision = useForCollision;
    
    // 6개의 면에 대해서 각각 이웃한 블록의 IsSolid 메소드를 호출해서 해당 면을 그릴지 말지 결정한다.
 
    if (!chunk.GetBlock(cx, cy + 1, cz).IsSolid(cx, cy + 1, cz, block, Direction.Down))
    {
        meshData = FaceDataUp(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    if (!chunk.GetBlock(cx, cy - 1, cz).IsSolid(cx, cy - 1, cz, block, Direction.Up))
    {
        meshData = FaceDataDown(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    if (!chunk.GetBlock(cx, cy, cz + 1).IsSolid(cx, cy, cz + 1, block, Direction.South))
    {
        meshData = FaceDataNorth(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    if (!chunk.GetBlock(cx, cy, cz - 1).IsSolid(cx, cy, cz - 1, block, Direction.North))
    {
        meshData = FaceDataSouth(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    if (!chunk.GetBlock(cx + 1, cy, cz).IsSolid(cx + 1, cy, cz, block, Direction.West))
    {
        meshData = FaceDataEast(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    if (!chunk.GetBlock(cx - 1, cy, cz).IsSolid(cx - 1, cy, cz, block, Direction.East))
    {
        meshData = FaceDataWest(block, chunk, cx, cy, cz, rotation, meshData);
    }
 
    return meshData;
}
 
// 윗면에 삼각형을 어떻게 그릴지 정의
protected virtual MeshData FaceDataUp(Block block, Chunk chunk, int cx, int cy, int cz, Vector3i rotation, MeshData meshData)
{
    meshData.SetVertexRotation(new Vector3(cx, cy, cz), rotation, block.fixRotation);
    meshData.AddVertex(new Vector3(cx - 0.5f, cy + 0.5f, cz - 0.5f));
    meshData.AddVertex(new Vector3(cx - 0.5f, cy + 0.5f, cz + 0.5f));
    meshData.AddVertex(new Vector3(cx + 0.5f, cy + 0.5f, cz + 0.5f));
    meshData.AddVertex(new Vector3(cx + 0.5f, cy + 0.5f, cz - 0.5f));
 
    meshData.AddQuadTriangles(block.submesh);
 
    meshData.uv.AddRange(block.FaceUVs(chunk, cx, cy, cz, Direction.Up));
 
    return meshData;
}
 
// FaceDataDown, FaceDataNorth, FaceDataSouth, ...


Mesh 메소드를 구현한 모습이다. 실제로 메쉬를 어떻게 그릴지 일일이 코드로 작성한다.

FaceDataUp 메소드를 보면, 4개의 버텍스를 이용해 윗면을 그리고 있다.


여기서 가장 중요한 부분은 각 면을 그릴 때 이웃 블록의 IsSolid 메소드를 먼저 확인해 본다는 것이다.

이웃 블록의 IsSolid 메소드가 true 를 반환하면 그 쪽 면을 그리지 않는다.

이러한 방식으로 가려진 면은 그리지 않음으로써 버텍스의 수를 줄일 수 있다.


별로 중요하진 않지만 참고로 위의 코드들에 override 키워드가 안 붙어있는 이유는

설계상의 이유로 그리기 규칙은 Block 클래스를 직접 상속하지 않고 다른 모듈에서 간접적으로 구현했기 때문이다.


블록마다 메쉬를 그리지 않고 청크 메쉬에 여러 블록을 그렸기 때문에 하나의 청크 메쉬는 이렇게 생겼다.

서로 다른 텍스쳐들(흙, 잔디, 돌)과 서로 다른 모양들(정육면체와 풀모양)의 블록들이 한 메쉬 안에 모여있다.


다른 블록에 의해 가려진 부분은 그리지 않기 때문에

위와 같이 최소한의 면들만 렌더링 되고 있다.


위에서 살펴보았다시피 가려진 면은 시스템이 스스로 판단하는 게 아니라 코드로 직접 정의한 것이다.

나뭇잎이나 풀 블록 등과 같이 가려진 면을 판단하는데에 다른 규칙을 적용해야 하는 경우도 있으므로

블록마다 다르게 구현 할 수 있게 설계해야 한다.


// 풀 블록의 코드 중 일부
 
public bool IsSolid(Block block, int cx, int cy, int cz, Block neighbor, Direction face)
{
    //return false;
    return true;
}


만약 풀 블록의 IsSolid 메소드를 위와 같이 항상 true 를 반환하도록 수정하면,

다음과 같은 결과를 볼 수 있다.



블록으로 이루어진 세계라는 컨셉이기 때문에 블록을 그리는 방법을 정육면체로 했지만

Mesh 메소드를 어떻게 구현하냐에 따라서 완전히 다른 월드를 만들 수도 있다.

실제로 마인크래프트의 'No Cubes' 모드는 월드를 블록이 없는 완곡한 지형으로 바꿔준다.



직접 해보지는 않았지만 응용한다면 Astroneer 같은 월드도 얼마든지 만들 수 있을 것이다.