Продолжаю разрабатывать Minecraft на движке Unity. В этой статье я покажу мою реализацию разрушения блоков - перестройки чанков. Это будет моя вторая реализация.

Перед прочтением этой статьи, советую ознакомиться с моей предыдущей статьей https://habr.com/ru/articles/982608/

Моя первая логика основывалась на полной перестройке чанка после удаления блока. Это работало без багов и не сильно влияло на производительность. Но я решил поэкспериментировать со своей игрой, поставил высокую скорость разрушения и блоки разрушались 1 блок за кадр или около 100 - 300 блоков в секунду. При таком раскладе игра начинала тормозить, не сильно, но заметно. Конечно я не планировал оставлять такую скорость разрушения, но, если я могу увеличить производительность, то нужно реализовывать.

Как же на меня ругался ИИ, когда я скидывал ему свой код. Он мне советовал остановиться на полной перестройке чанков (моя первая логика), но я все же ее переделал, и остался доволен результатом.

Далее расскажу о всех деталях:

Первым делом я вынес Mesh и его параметры (Vertices, Triangles и т.д.)

using System.Collections.Generic;
using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainChunkMesh
    {
        public Mesh Mesh { get; }
        public List<Vector3Int> Vertices { get; }
        public List<int> Triangles { get; }
        public List<Vector2> Uvs { get; }
        public List<Vector2> Uvs2 { get; }

        public TerrainChunkMesh()
        {
            Mesh = new();
            Mesh.MarkDynamic();
            Vertices = new();
            Triangles = new();
            Uvs = new();
            Uvs2 = new();
        }

        public void Reset()
        {
            Mesh.Clear();
            Vertices.Clear();
            Triangles.Clear();
            Uvs.Clear();
            Uvs2.Clear();
        }

        public Mesh ApplyAndGetMesh()
        {
            Mesh.Clear();
            Mesh.vertices = GetVector3Vertices();
            Mesh.triangles = Triangles.ToArray();
            Mesh.uv = Uvs.ToArray();
            Mesh.uv2 = Uvs2.ToArray();
            Mesh.RecalculateNormals();

            return Mesh;
        }

        private Vector3[] GetVector3Vertices()
        {
            Vector3[] vertices = new Vector3[Vertices.Count];
            for (int i = 0; i < vertices.Length; i++)
                vertices[i] = Vertices[i];
            return vertices;
        }
    }
}

На Uvs2 пока внимания не обращайте - это будет нужно для работы освещения, а пока это просто баласт.
3 метода:
Reset() - сбрасывает Mesh и все его параметры, я вызываю его только когда чанк полностью меняется, а именно перемещается на другую позицию. В моей логике дальние чанки не удаляются, а помещаются в пул для дальнейшего использования.
ApplyAndGetMesh() - возвращает меш с обновленными параметрами.
GetVector3Vertices() - превращаю List<Vector3Int> в Vector3[]. Vertices я превратил в List<Vector3Int> для корректного сравнения позиций, но об этом позже.

Теперь расскажу как это должно работать в теории.
При удалении блока, должны удаляться видимые грани этого блока. А грани соседних блоков, которые "примыкали" к нашему, удаляемому блоку должны показываться.
Какие грани должны быть видны а какие нет я рассказывал в предыдущей статье.

2д визуализация удаления блока. Зеленым выделены грани, которые должны отображаться, черным - которые не отображаются. На втором этапе я удаляю блок "x", красным выделил грани, которые должны удаляться, а синим - которые должны появиться.
2д визуализация удаления блока. Зеленым выделены грани, которые должны отображаться, черным - которые не отображаются. На втором этапе я удаляю блок "x", красным выделил грани, которые должны удаляться, а синим - которые должны появиться.

Теперь покажу как это работает в моем коде:

using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainQuadBuilder
    {
        private readonly AllBlocks _allBlocks;
        private readonly TerrainChunkMesh _chunkMesh;

        public TerrainQuadBuilder(AllBlocks allBlocks, TerrainChunkMesh chunkMesh)
        {
            _allBlocks = allBlocks;
            _chunkMesh = chunkMesh;
        }

        public void BuildLeftQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x - 1, y, z - 1)
                }, Vector3.left, blockId);
        }

        public void BuildRightQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
                {
                    new Vector3Int(x, y, z - 1),
                    new Vector3Int(x, y + 1, z - 1),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x, y, z)
                }, Vector3.right, blockId);
        }

        public void BuildBackQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z - 1),
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x, y + 1, z - 1),
                    new Vector3Int(x, y, z - 1)
                }, Vector3.back, blockId);
        }

        public void BuildForwardQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
               {
                    new Vector3Int(x, y, z),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x - 1, y, z)
               }, Vector3.forward, blockId);
        }

        public void BuildDownQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z),
                    new Vector3Int(x - 1, y, z - 1),
                    new Vector3Int(x, y, z - 1),
                    new Vector3Int(x, y, z)
                }, Vector3.down, blockId);
        }

        public void BuildUpQuad(int x, int y, int z, int blockId)
        {
            Build(new Vector3Int[]
                {
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x, y + 1, z - 1)
                }, Vector3.up, blockId);
        }

        private void Build(Vector3Int[] vertices, Vector3 side, int blockId)
        {
            _chunkMesh.Vertices.AddRange(vertices);
            CreateTriangles();
            CreateUvs(side, blockId - 1);
            CreateUvs2();
        }

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

        private void CreateUvs(Vector3 side, int blockId)
        {
            Vector2Int position;
            if (side == Vector3.up)
                position = _allBlocks.GetBlock(blockId).upTexturePosition;
            else if (side == Vector3.down)
                position = _allBlocks.GetBlock(blockId).downTexturePosition;
            else
                position = _allBlocks.GetBlock(blockId).sideTexturePosition;

            _chunkMesh.Uvs.AddRange(new Vector2[]
            {
                    new Vector2(position.x / _allBlocks.TextureSize.x, position.y / _allBlocks.TextureSize.y),
                    new Vector2(position.x / _allBlocks.TextureSize.x, (position.y + 1) / _allBlocks.TextureSize.y),
                    new Vector2((position.x + 1) / _allBlocks.TextureSize.x, (position.y + 1) / _allBlocks.TextureSize.y),
                    new Vector2((position.x + 1) / _allBlocks.TextureSize.x, position.y / _allBlocks.TextureSize.y)
            });
        }

        private void CreateUvs2()
        {
            _chunkMesh.Uvs2.AddRange(new Vector2[]
                {
                    new Vector2(0, 0.99f),
                    new Vector2(0, 1),
                    new Vector2(1, 1),
                    new Vector2(1, 0.99f)
                });
        }
    }
}

Это логика построения граней блока, я вынес ее в этот отдельный класс, так как методы построения будут вызываться из разных мест, об этом позже. Как это работает я рассказывал в предыдущей статье.

using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainQuadDestroyer
    {
        private readonly TerrainChunkMesh _chunkMesh;

        public TerrainQuadDestroyer(TerrainChunkMesh chunkMesh)
        {
            _chunkMesh = chunkMesh;
        }

        public void DestroyLeftQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x - 1, y, z - 1)
                };
            Destroy(vertices);
        }

        public void DestroyRightQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x, y, z - 1),
                    new Vector3Int(x, y + 1, z - 1),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x, y, z)
                };
            Destroy(vertices);
        }

        public void DestroyBackQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z - 1),
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x, y + 1, z - 1),
                    new Vector3Int(x, y, z - 1)
                };
            Destroy(vertices);
        }

        public void DestroyForwardQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x, y, z),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x - 1, y, z)
                };
            Destroy(vertices);
        }

        public void DestroyDownQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x - 1, y, z),
                    new Vector3Int(x - 1, y, z - 1),
                    new Vector3Int(x, y, z - 1),
                    new Vector3Int(x, y, z)
                };
            Destroy(vertices);
        }

        public void DestroyUpQuad(int x, int y, int z)
        {
            Vector3Int[] vertices = new Vector3Int[]
                {
                    new Vector3Int(x - 1, y + 1, z - 1),
                    new Vector3Int(x - 1, y + 1, z),
                    new Vector3Int(x, y + 1, z),
                    new Vector3Int(x, y + 1, z - 1)
                };
            Destroy(vertices);
        }

        private void Destroy(Vector3Int[] vertices)
        {
            int firstverticeId = int.MaxValue;
            for (int i = 0; i < _chunkMesh.Vertices.Count; i += 4)
            {
                if(_chunkMesh.Vertices[i] == vertices[0] &&
                    _chunkMesh.Vertices[i + 1] == vertices[1] &&
                    _chunkMesh.Vertices[i + 2] == vertices[2] &&
                    _chunkMesh.Vertices[i + 3] == vertices[3])
                {
                    firstverticeId = i;
                    break;
                }
            }
            if(firstverticeId != int.MaxValue)
            {
                int lastId = _chunkMesh.Vertices.Count - 4;
                for (int i = 0; i < 4; i++)
                {
                    (_chunkMesh.Vertices[firstverticeId + i], _chunkMesh.Vertices[lastId + i]) = (_chunkMesh.Vertices[lastId + i], _chunkMesh.Vertices[firstverticeId + i]);
                    (_chunkMesh.Uvs[firstverticeId + i], _chunkMesh.Uvs[lastId + i]) = (_chunkMesh.Uvs[lastId + i], _chunkMesh.Uvs[firstverticeId + i]);
                    (_chunkMesh.Uvs2[firstverticeId + i], _chunkMesh.Uvs2[lastId + i]) = (_chunkMesh.Uvs2[lastId + i], _chunkMesh.Uvs2[firstverticeId + i]);
                }
                _chunkMesh.Vertices.RemoveRange(lastId, 4);
                _chunkMesh.Uvs.RemoveRange(lastId, 4);
                _chunkMesh.Uvs2.RemoveRange(lastId, 4);

                lastId = _chunkMesh.Triangles.Count - 6;
                _chunkMesh.Triangles.RemoveRange(lastId, 6);
            }
        }
    }
}

А это уже логика удаления граней блока. Остановимся подробнее на методе Destroy().
Метод принимает массив vertices. Помним что одна грань состоит из 4 точек в пространстве.
После чего, в цикле идет поиск последовательности из этих 4 позиций.
Вот как раз тут и нужен Vector3Int а не Vector3. Vector3 использует значения float, и 1 может превратиться в 1,0000000000001 или 0,99999999999, а мне бы этого не хотелось. Конечно вероятность этого мала, но я решил что с Vector3Int я буду спать спокойней.


После того как последовательность из 4 точек найдена, нужно удалить эти точки, а также связанные с ними полигоны и uv-координаты.

Что бы оптимизировать удаление элементов с List<>, я перемещаю удаляемые элементы в концы списков, меняю местами элементы в Vertices, Uvs и Uvs2, используя "Кортежи" для удобства. С Triangles я такого не делаю, а просто удаляю последние элементы, так как все Triangles строятся по одной схеме, все последовательности из 6 элементов абсолютно одинаковые.

using Cysharp.Threading.Tasks;
using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainChunkBuilder
    {
        private readonly TerrainChunk _chunk;
        private readonly TerrainChunkMesh _chunkMesh;
        private readonly TerrainQuadBuilder _quadBuilder;

        public TerrainChunkBuilder(TerrainChunk chunk, TerrainChunkMesh chunkMesh, TerrainQuadBuilder quadBuilder)
        {
            _chunk = chunk;
            _chunkMesh = chunkMesh;
            _quadBuilder = quadBuilder;
        }

        public async UniTask<Mesh> Build(bool isAsync)
        {
            _chunkMesh.Reset();
            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 (_chunk.BlockIds[x, y, z] != 0)
                        {
                            if (_chunk.BlockIds[x - 1, y, z] == 0)
                                _quadBuilder.BuildLeftQuad(x, y, z, _chunk.BlockIds[x, y, z]);

                            if (_chunk.BlockIds[x + 1, y, z] == 0)
                                _quadBuilder.BuildRightQuad(x, y, z, _chunk.BlockIds[x, y, z]);

                            if (_chunk.BlockIds[x, y, z - 1] == 0)
                                _quadBuilder.BuildBackQuad(x, y, z, _chunk.BlockIds[x, y, z]);

                            if (_chunk.BlockIds[x, y, z + 1] == 0)
                                _quadBuilder.BuildForwardQuad(x, y, z, _chunk.BlockIds[x, y, z]);
                            
                            if (y != 0 && _chunk.BlockIds[x, y - 1, z] == 0)
                                _quadBuilder.BuildDownQuad(x, y, z, _chunk.BlockIds[x, y, z]);

                            if (y == Settings.ChunkSize.y - 1 || _chunk.BlockIds[x, y + 1, z] == 0)
                                _quadBuilder.BuildUpQuad(x, y, z, _chunk.BlockIds[x, y, z]);
                        }
                    }
                }
            }
            return _chunkMesh.ApplyAndGetMesh();
        }
    }
}

Вот так у меня строится меш с нуля.

using UnityEngine;

namespace MyGame.Gameplay.Terrain
{
    public sealed class TerrainChunkRebuilder
    {
        private readonly TerrainChunk _chunk;
        private readonly TerrainChunkMesh _chunkMesh;
        private readonly TerrainQuadBuilder _quadBuilder;
        private readonly TerrainQuadDestroyer _quadDestroyer;

        public TerrainChunkRebuilder(TerrainChunk chunk, TerrainChunkMesh chunkMesh, TerrainQuadBuilder quadBuilder, TerrainQuadDestroyer quadDestroyer)
        {
            _chunk = chunk;
            _chunkMesh = chunkMesh;
            _quadBuilder = quadBuilder;
            _quadDestroyer = quadDestroyer;
        }

        public Mesh DeleteBlock(Vector3Int id, TypeBlockPosition typeBlockPosition)
        {
            int blockId;

            if (typeBlockPosition == TypeBlockPosition.Left)
            {
                blockId = _chunk.BlockIds[id.x - 1, id.y, id.z];
                if (blockId != 0)
                    _quadBuilder.BuildRightQuad(id.x - 1, id.y, id.z, blockId);
            }
            else if(typeBlockPosition == TypeBlockPosition.Right)
            {
                blockId = _chunk.BlockIds[id.x + 1, id.y, id.z];
                if (blockId != 0)
                    _quadBuilder.BuildLeftQuad(id.x + 1, id.y, id.z, blockId);
            }
            else if (typeBlockPosition == TypeBlockPosition.Back)
            {
                blockId = _chunk.BlockIds[id.x, id.y, id.z - 1];
                if (blockId != 0)
                    _quadBuilder.BuildForwardQuad(id.x, id.y, id.z - 1, blockId);
            }
            else if (typeBlockPosition == TypeBlockPosition.Forward)
            {
                blockId = _chunk.BlockIds[id.x, id.y, id.z + 1];
                if (blockId != 0)
                    _quadBuilder.BuildBackQuad(id.x, id.y, id.z + 1, blockId);
            }
            else
            {
                DestroyQuads(id);
                BuildNeighborsQuads(id);
            }

            return _chunkMesh.ApplyAndGetMesh();
        }



        private void DestroyQuads(Vector3Int id)
        {
            int blockId;

            blockId = _chunk.BlockIds[id.x - 1, id.y, id.z];
            if (blockId == 0)
                _quadDestroyer.DestroyLeftQuad(id.x, id.y, id.z);

            blockId = _chunk.BlockIds[id.x + 1, id.y, id.z];
            if (blockId == 0)
                _quadDestroyer.DestroyRightQuad(id.x, id.y, id.z);

            if (id.y == 0 || _chunk.BlockIds[id.x, id.y - 1, id.z] == 0)
                _quadDestroyer.DestroyDownQuad(id.x, id.y, id.z);

            if (id.y == Settings.ChunkSize.y - 1 || _chunk.BlockIds[id.x, id.y + 1, id.z] == 0)
                _quadDestroyer.DestroyUpQuad(id.x, id.y, id.z);

            blockId = _chunk.BlockIds[id.x, id.y, id.z - 1];
            if (blockId == 0)
                _quadDestroyer.DestroyBackQuad(id.x, id.y, id.z);

            blockId = _chunk.BlockIds[id.x, id.y, id.z + 1];
            if (blockId == 0)
                _quadDestroyer.DestroyForwardQuad(id.x, id.y, id.z);
        }

        private void BuildNeighborsQuads(Vector3Int id)
        {
            int blockId;

            blockId = _chunk.BlockIds[id.x - 1, id.y, id.z];
            if (blockId != 0 && id.x != 1)
                _quadBuilder.BuildRightQuad(id.x - 1, id.y, id.z, blockId);

            blockId = _chunk.BlockIds[id.x + 1, id.y, id.z];
            if (blockId != 0 && id.x != Settings.ChunkSize.x)
                _quadBuilder.BuildLeftQuad(id.x + 1, id.y, id.z, blockId);

            if (id.y != 0 && _chunk.BlockIds[id.x, id.y - 1, id.z] != 0)
                _quadBuilder.BuildUpQuad(id.x, id.y - 1, id.z, _chunk.BlockIds[id.x, id.y - 1, id.z]);

            if (id.y != Settings.ChunkSize.y - 1 && _chunk.BlockIds[id.x, id.y + 1, id.z] != 0)
                _quadBuilder.BuildDownQuad(id.x, id.y + 1, id.z, _chunk.BlockIds[id.x, id.y + 1, id.z]);

            blockId = _chunk.BlockIds[id.x, id.y, id.z - 1];
            if (blockId != 0 && id.z != 1)
                _quadBuilder.BuildForwardQuad(id.x, id.y, id.z - 1, blockId);

            blockId = _chunk.BlockIds[id.x, id.y, id.z + 1];
            if (blockId != 0 && id.z != Settings.ChunkSize.z)
                _quadBuilder.BuildBackQuad(id.x, id.y, id.z + 1, blockId);
        }
    }

    public enum TypeBlockPosition
    {
        Left, Right, Back, Forward, Center
    }
}

А так я перестраиваю меш после удаления блока
DeleteBlock() - принимает 2 параметра
id - позиция блока в чанке
typeBlockPosition - тип позиции. Помним что у меня массив id-блоков по x и z больше фактического значения на 2. Если размер чанка 16x128x16, то BlockIds = [18, 128, 18].
Все позиции блоков, которые будут находится внутри чанка будут иметь typeBlockPosition = TypeBlockPosition.Center.
Если id.x == 17 то TypeBlockPosition.Left
Если id.x == 0 то TypeBlockPosition.Right
...
TypeBlockPosition (Left, Right, Back и Forward) нужны для построения граней в соседних чанках.

Метод DeleteBlock() вызывается вот тут:

public void DeleteBlock(Vector3Int id, TypeBlockPosition typeBlockPosition)
        {
            _blockIds[id.x, id.y, id.z] = 0;
            Mesh mesh = _chunkRebuilder.DeleteBlock(id, typeBlockPosition);
            _meshFilter.mesh = mesh;
            _meshCollider.sharedMesh = mesh;

            if (typeBlockPosition != TypeBlockPosition.Center)
                return;

            if (id.x == 1)
                _terrainController.GetTerrainChunk(_id + Vector2Int.left).DeleteBlock(new Vector3Int(Settings.ChunkSize.x + 1, id.y, id.z), TypeBlockPosition.Left);
            else if (id.x == Settings.ChunkSize.x)
                _terrainController.GetTerrainChunk(_id + Vector2Int.right).DeleteBlock(new Vector3Int(0, id.y, id.z), TypeBlockPosition.Right);
            if (id.z == 1)
                _terrainController.GetTerrainChunk(_id + Vector2Int.down).DeleteBlock(new Vector3Int(id.x, id.y, Settings.ChunkSize.z + 1), TypeBlockPosition.Back);
            else if (id.z == Settings.ChunkSize.z)
                _terrainController.GetTerrainChunk(_id + Vector2Int.up).DeleteBlock(new Vector3Int(id.x, id.y, 0), TypeBlockPosition.Forward);
        }

Идет проверка, находится ли данный блок на границе чанка, если да, то соседние чанки также перестраиваются.

Честно сказать, мне не нравится как выглядит код, но как теперь ведет себя игра, меня устраивает. Результаты вы можете посмотреть в телеграмм канале. Там я публикую видео геймплея из игры.

Критика и предложения приветствуются, пишите что вы думаете об этом, а лучше заходите в телеграмм канал и предлагайте свои идеи там) Спасибо за внимание и проявленный интерес.

Комментарии (3)


  1. vladdos-1
    14.01.2026 16:28

    В разработке я не сильно долго, сходу вопрос: разве полная перестройка чанков - оптимизирована лучше?

    Возможно ли сделать ультра мега оптимизированный майнкрафт? И какие тонкости нужно знать


    1. MI1CTEP Автор
      14.01.2026 16:28

      ИИ пишет что полная перестройка чанков чуть хуже по оптимизации. Но по моему опыту с моим подходом производительность в разы лучше. И чем больше будет размер чанка, тем сильнее будет разница. Полная перестройка чанка лучше тем, что меньше кода, а следовательно меньше простор для потенциальных багов.
      Я как раз стремлюсь сделать оптимизированный майнкрафт, но я пока всех нюансов не знаю. Буду постепенно выкладывать статьи, по мере разработки. Сейчас начал разбираться с освещением. Встроенные инструменты в Unity для освещения не подходят для подобной игры, но сделать хотя бы так же как в оригинальном Minecraft, это та еще головоломка)


      1. vladdos-1
        14.01.2026 16:28

        Успехов, подпишусь. Буду следить за Майнкрафтом 3.0!)