Я начал разработку 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);
}А тут добавляются нормали, я до конца не понимаю как они работают, но нормали должны быть перпендикулярно плоскости квадрата.
Багов с визуализацией чанков мною замечено не было, код работает корректно.
В следующих статьях я расскажу о других скриптах моей игры. Так же в моем телеграмм канале есть видео с геймплеем игры. Я периодически выкладываю туда прогресс разработки проекта.
Всем спасибо за внимание! Может кому-то поможет вся эта информация в создании своих игр.
