Я начал разработку Minecraft на движке Unity. Пока не решил, какие механики я повторю из оригинала, а какие добавлю от себя. Но сегодня я вам расскажу о моей логике построения чанка.

Далее предоставлю полный скрипт для построения чанка:

using Cysharp.Threading.Tasks;
using System.Collections.Generic;
using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainChunkBuilder
    {
        private Mesh _mesh = new();
        private int[,,] _blockIds;
        private readonly List<Vector3> _vertices = new();
        private readonly List<int> _triangles = new();
        private readonly List<Vector2> _uvs = new();
        private readonly List<Vector3> _normals = new();

        public async UniTask<Mesh> Build(int[,,] blockIds, bool isAsync)
        {
            _blockIds = blockIds;
            _mesh.Clear();
            _vertices.Clear();
            _triangles.Clear();
            _uvs.Clear();
            _normals.Clear();
            for (int z = 1; z < Settings.ChunkSize.z + 1; z++)
            {
                if (isAsync)
                    await UniTask.DelayFrame(1);

                for (int y = 0; y < Settings.ChunkSize.y; y++)
                {
                    for (int x = 1; x < Settings.ChunkSize.x + 1; x++)
                    {
                        if (_blockIds[x, y, z] != 0)
                        {
                            TryCreateLeft(x, y, z);
                            TryCreateRight(x, y, z);
                            TryCreateBack(x, y, z);
                            TryCreateForward(x, y, z);
                            TryCreateDown(x, y, z);
                            TryCreateUp(x, y, z);
                        }
                    }
                }
            }
            _mesh.vertices = _vertices.ToArray();
            _mesh.triangles = _triangles.ToArray();
            _mesh.uv = _uvs.ToArray();
            _mesh.normals = _normals.ToArray();

            return _mesh;
        }

        private void TryCreateLeft(int x, int y, int z)
        {
            if(_blockIds[x - 1, y, z] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x - 1, y, z),
                    new Vector3(x - 1, y + 1, z),
                    new Vector3(x - 1, y + 1, z - 1),
                    new Vector3(x - 1, y, z - 1)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.left);
            }
        }

        private void TryCreateRight(int x, int y, int z)
        {
            if (_blockIds[x + 1, y, z] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x, y, z - 1),
                    new Vector3(x, y + 1, z - 1),
                    new Vector3(x, y + 1, z),
                    new Vector3(x, y, z)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.right);
            }
        }

        private void TryCreateBack(int x, int y, int z)
        {
            if (_blockIds[x, y, z - 1] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x - 1, y, z - 1),
                    new Vector3(x - 1, y + 1, z - 1),
                    new Vector3(x, y + 1, z - 1),
                    new Vector3(x, y, z - 1)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.back);
            }
        }

        private void TryCreateForward(int x, int y, int z)
        {
            if (_blockIds[x, y, z + 1] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x, y, z),
                    new Vector3(x, y + 1, z),
                    new Vector3(x - 1, y + 1, z),
                    new Vector3(x - 1, y, z)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.forward);
            }
        }

        private void TryCreateDown(int x, int y, int z)
        {
            if (y != 0 && _blockIds[x, y - 1, z] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x - 1, y, z),
                    new Vector3(x - 1, y, z - 1),
                    new Vector3(x, y, z - 1),
                    new Vector3(x, y, z)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.down);
            }
        }

        private void TryCreateUp(int x, int y, int z)
        {
            if (y == Settings.ChunkSize.y - 1 || _blockIds[x, y + 1, z] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x - 1, y + 1, z - 1),
                    new Vector3(x - 1, y + 1, z),
                    new Vector3(x, y + 1, z),
                    new Vector3(x, y + 1, z - 1)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.up);
            }
        }

        private void CreateTriangles()
        {
            _triangles.AddRange(new int[]
            {
                _vertices.Count - 4,
                _vertices.Count - 3,
                _vertices.Count - 2,
                _vertices.Count - 4,
                _vertices.Count - 2,
                _vertices.Count - 1
            });
        }

        private void CreateUvs()
        {
            _uvs.AddRange(new Vector2[]
                {
                    new Vector2(0, 0),
                    new Vector2(0, 1),
                    new Vector2(1, 1),
                    new Vector2(1, 0)
                });
        }

        private void CreateNormals(Vector3 normal)
        {
            for (int i = 0; i < 4; i++)
                _normals.Add(normal);
        }
    }
}

Остановимся подробнее на каждом блоке кода:

public async UniTask<Mesh> Build(int[,,] blockIds, bool isAsync)
        {
            _blockIds = blockIds;
            _mesh.Clear();
            _vertices.Clear();
            _triangles.Clear();
            _uvs.Clear();
            _normals.Clear();
            for (int z = 1; z < Settings.ChunkSize.z + 1; z++)
            {
                if (isAsync)
                    await UniTask.DelayFrame(1);

                for (int y = 0; y < Settings.ChunkSize.y; y++)
                {
                    for (int x = 1; x < Settings.ChunkSize.x + 1; x++)
                    {
                        if (_blockIds[x, y, z] != 0)
                        {
                            TryCreateLeft(x, y, z);
                            TryCreateRight(x, y, z);
                            TryCreateBack(x, y, z);
                            TryCreateForward(x, y, z);
                            TryCreateDown(x, y, z);
                            TryCreateUp(x, y, z);
                        }
                    }
                }
            }
            _mesh.vertices = _vertices.ToArray();
            _mesh.triangles = _triangles.ToArray();
            _mesh.uv = _uvs.ToArray();
            _mesh.normals = _normals.ToArray();

            return _mesh;
        }

Метод (таск) возвращает меш. Как раз таки меш и отвечает за геометрию объекта.

1 строчка кода: Принимаемые параметры:

blockIds - представляет из себя трехмерный массив, содержащий id каждого блока. Id может быть либо 0, либо 1.
0 - отсутствие блока
1 - наличие блока. Позже вариантов id будет больше, но для простоты восприятия пока только 1.

isAsync - если true, то код будет выполняться асинхронно, не тормозя другие процессы.
false - код будет выполняться синхронно, останавливая другие процессы.
isAsync = false я использую на старте игры
isAsync = true я использую во время игры, когда нужно подгрузить чанки вблизи игрока.

4 - 8 строчки кода:

У Mesh и Листов вызываю метод Clear() перед использованием, так как класс привязан к конкретному чанку, а у чанка я могу удалить блок или установить в него новый блок.
_vertices - массив точек для построения полигонов и uv-координат и нормалей. Позже поймете, надеюсь).
_triangles - массив полигонов. Значения внутри массива - int. По сути это ссылки на _vertices. 3 последовательных значения - это 1 полигон. Наверное пока не совсем понятно, но скоро все поймете.
_uvs - массив координат на текстуре. _uvs.Count должен совпадать с _vertices.Count. Каждая точка в _vertices имеет координату на текстуре в _uvs.
_normals - нужны для правильной работы со светом. Так же _normals.Count должен совпадать с _vertices.Count.

9 - 29 строчки кода: Построение меша (заполнение листов: _vertices, _triangles, _uvs и _normals)

Идет перебор blockIds.
Settings.ChunkSize - это размер чанка. Количество блоков в каждой оси.
blockIds больше Settings.ChunkSize, так как для построения чанка, я перебираю каждый бок и у каждого блока проверяю id его соседей. На границах чанка не все соседи есть. Например у крайних левых блоков нету соседей слева, там уже другой чанк. И что бы не обращаться к соседнему чанку, blockIds увеличен на 2 по оси x и z. По оси y увеличивать не нужно, так как выше и ниже других чанков не будет.
blockIds = [Settings.ChunkSize.x + 2, Settings.ChunkSize.y, Settings.ChunkSize.z + 2]
По этому внутри циклов x и z сдвинуты на 1.
11 - 12 строчки. Идет проверка на асинхронность. Выделяется 1 кадр внутри z-цикла. можно перенести эти строчки в y-цикл или x-цикл, но тогда чанки будут появляться дольше, а fps игры будет выше, так как программа будет чаще останавливаться на 1 кадр.
18 - 26 строчки: идет проверка блока на 0, если не 0, то пробуем строить стенки блока-куба.

30 - 33 строчки:

Присваиваем мешу листы, преобразовывая их в массивы.

private void TryCreateRight(int x, int y, int z)
        {
            if (_blockIds[x + 1, y, z] == 0)
            {
                _vertices.AddRange(new Vector3[]
                {
                    new Vector3(x, y, z - 1),
                    new Vector3(x, y + 1, z - 1),
                    new Vector3(x, y + 1, z),
                    new Vector3(x, y, z)
                });
                CreateTriangles();
                CreateUvs();
                CreateNormals(Vector3.right);
            }
        }

Пример построения правой стенки блока-куба.

В начале идет проверка id блока стоящего правее. Если id == 0, то значит там нету блока и стенку нужно рисовать. Если бы id соседнего блока было не 0, это бы значило, что там есть блок. И игрок не будет видеть правую стенку текущего блока, так как соседний блок будет ее перекрывать, а следовательно эту стенку рисовать не нужно, производительность)

Помним, что _vertices это массив точек с координатами. Это координаты относительно центра чанка. Я добавляю 4 точки, так как квадрат состоит из 4 точек, а квадрат является стороной блока-куба.

Точки я добавляю в таком порядке
Точки я добавляю в таком порядке
Вот в таком порядке будут добавляться точки для правой стенки.
Вот в таком порядке будут добавляться точки для правой стенки.

Для простоты представим, что в TryCreateRight() мы передаем x = 1, y = 0, z = 1. Помним, что x = 0 и z = 0 мы не передаем никогда, так как циклы начинаются с 1. Это будет самый первый блок. А строить мы начинаем с координат (0,0,0).
Значит точки должны иметь следующие координаты:
1. (1, 0, 0)
2. (1, 1, 0)
3. (1, 1, 1)
4. (1, 0, 1)
Подставим x, y и z в эти формулы 7 - 10 строчки кода:
new Vector3(x, y, z - 1),
new Vector3(x, y + 1, z - 1),
new Vector3(x, y + 1, z),
new Vector3(x, y, z)
и понимаем для чего нужно +1 и -1

Для других сторон будет так:

private void CreateTriangles()
        {
            _triangles.AddRange(new int[]
            {
                _vertices.Count - 4,
                _vertices.Count - 3,
                _vertices.Count - 2,
                _vertices.Count - 4,
                _vertices.Count - 2,
                _vertices.Count - 1
            });
        }

Следующим этапом строим треугольники, а именно 2 треугольника на квадрат. Нумерацию точек я начал с 0, так будет нагляднее. Треугольники рисуются по часовой стрелке.
1 треугольник - 0, 1, 2
2 треугольник - 0, 2, 3
Предыдущим этапом я добавлял _vertices, а именно добавил 4 точки. Представим что это первый квадрат который рисуется. _vertices.Count будет равен 4. И что бы добраться до id нужных точек, я делаю _vertices.Count - 4, _vertices.Count - 3 и т. д.

Так как я делаю все по по порядку добавляю в _vertices 4 точки, а потом сразу добавляю полигоны, я могу не беспокоиться о том, что значения _vertices.Count будут некорректные.

private void CreateUvs()
        {
            _uvs.AddRange(new Vector2[]
                {
                    new Vector2(0, 0),
                    new Vector2(0, 1),
                    new Vector2(1, 1),
                    new Vector2(1, 0)
                });
        }

На этом этапе добавляется текстура к квадрату. (0, 0) - левый нижний угол текстуры. (1, 1) - правый верхний угол текстуры. Точки соответствуют порядку добавления точек в _vertices.

private void CreateNormals(Vector3 normal)
        {
            for (int i = 0; i < 4; i++)
                _normals.Add(normal);
        }

А тут добавляются нормали, я до конца не понимаю как они работают, но нормали должны быть перпендикулярно плоскости квадрата.

Багов с визуализацией чанков мною замечено не было, код работает корректно.

В следующих статьях я расскажу о других скриптах моей игры. Так же в моем телеграмм канале есть видео с геймплеем игры. Я периодически выкладываю туда прогресс разработки проекта.

Всем спасибо за внимание! Может кому-то поможет вся эта информация в создании своих игр.