30.10.2026 23 мин чтения Просмотров:1

Marching Squares в Unity: меш и коллайдер из сетки плотности

Пишем на C# генератор, который превращает сетку чисел в гладкий меш и EdgeCollider2D по контуру: 16 случаев, интерполяция, седловые клетки, сварка вершин, кисть для разрушаемого ландшафта и чанки.

Marching Squares в Unity: меш и коллайдер из сетки плотности

В этой статье мы с нуля напишем на C# генератор, который превращает двумерную сетку чисел в гладкий меш и набор EdgeCollider2D по контуру. Это алгоритм Marching Squares: на нём держатся разрушаемый ландшафт в духе Worms и Terraria-подобных копалок, пещеры из шума, «лужи» тумана и любые формы, которые проще описать полем значений, чем набором спрайтов. В конце у вас будет готовый компонент с кистью «копать/насыпать», сваркой вершин, сборкой контуров в цепочки и разбиением на чанки.

Идея: поле плотности и порог

Представьте сетку узлов, в каждом узле лежит число от 0 до 1 — «плотность». Выберем порог, скажем iso = 0.5. Всё, что не меньше порога, считаем твёрдым, остальное — пустотой. Задача алгоритма — провести границу между твёрдым и пустым и залить твёрдую часть треугольниками.

Marching Squares обходит сетку по клеткам — квадратам из четырёх соседних узлов. Для каждой клетки смотрим, какие из четырёх углов твёрдые. Вариантов ровно 24 = 16, и каждый вариант однозначно (почти — к исключениям вернёмся) говорит, как граница проходит через клетку. Клетки обрабатываются независимо, а форма целиком складывается из кусочков, потому что соседние клетки делят общие рёбра и точки на них совпадают.

Почему не просто рисовать квадратные тайлы? Тайл либо есть, либо нет, и граница всегда идёт лесенкой. Marching Squares срезает углы, а если хранить в узлах не 0/1, а плавные значения, граница проходит ровно там, где поле пересекает порог, — получаются настоящие дуги и наклонные склоны при той же сетке.

Соглашения: углы, рёбра, индекс случая

Договоримся о нумерации, иначе в таблицах легко запутаться. Ось Y смотрит вверх, клетка с левым нижним узлом (x, y):

  • углы: c0 — левый нижний (x, y), c1 — правый нижний (x+1, y), c2 — правый верхний (x+1, y+1), c3 — левый верхний (x, y+1);
  • рёбра: e0 — нижнее (между c0 и c1), e1 — правое (c1–c2), e2 — верхнее (c2–c3), e3 — левое (c3–c0).

Обход c0 → e0 → c1 → e1 → c2 → e2 → c3 → e3 идёт против часовой стрелки — это пригодится при построении треугольников. Индекс случая собирается из битов: твёрдый угол c0 даёт бит 1, c1 — бит 2, c2 — бит 4, c3 — бит 8.

static int CaseIndex(float v0, float v1, float v2, float v3, float iso)
{
    int index = 0;
    if (v0 >= iso) index |= 1;
    if (v1 >= iso) index |= 2;
    if (v2 >= iso) index |= 4;
    if (v3 >= iso) index |= 8;
    return index;
}

Случай 0 — клетка целиком пустая, 15 — целиком твёрдая. Ребро пересекается границей тогда и только тогда, когда его концы по разные стороны порога: e0 пересечено, если c0 и c1 различаются, и так далее. Это правило избавляет от отдельной таблицы «какие рёбра задействованы».

Хранилище плотности

Начнём с простого класса-сетки. Размеры задаём в узлах: сетка из W×H узлов содержит (W−1)×(H−1) клеток.

using UnityEngine;

public sealed class DensityGrid
{
    public readonly int Width;      // узлов по X
    public readonly int Height;     // узлов по Y
    public readonly float CellSize;
    readonly float[] values;

    public DensityGrid(int width, int height, float cellSize)
    {
        Width = width;
        Height = height;
        CellSize = cellSize;
        values = new float[width * height];
    }

    public float this[int x, int y]
    {
        get => values[y * Width + x];
        set => values[y * Width + x] = value;
    }

    public Vector2 NodePosition(int x, int y) => new Vector2(x * CellSize, y * CellSize);

    public bool IsBorder(int x, int y) =>
        x == 0 || y == 0 || x == Width - 1 || y == Height - 1;
}

Плоский массив вместо float[,] — не ради моды: его проще передать в Job System, если позже захотите перенести генерацию в потоки (об этом подробно в статье про процедурный меш, MeshData и Job System).

Где именно проходит граница: интерполяция

Самая наивная версия ставит точку границы ровно в середину пересечённого ребра. Работает, но форма получается «гранёной»: все склоны — либо горизонталь, либо 45 градусов. Правильнее найти на ребре точку, где поле равно порогу, считая, что между узлами оно меняется линейно:

// va, vb — значения на концах ребра, pa, pb — позиции концов
float t = (iso - va) / (vb - va);
Vector2 p = Vector2.Lerp(pa, pb, t);

Раз ребро пересечено, va и vb лежат по разные стороны порога, значит t попадает в отрезок [0, 1], а знаменатель не ноль. На практике всё равно стоит подстраховаться Mathf.Clamp01 и проверкой на равенство: из-за погрешностей float и случая «значение ровно равно порогу» деление может выстрелить.

Именно интерполяция делает поле плотности полезным. Если в узлах лежат только 0 и 1, t всегда 0.5 — та же середина ребра. Если же значения плавные (шум Перлина, расстояние до центра кисти), граница ложится точно по изолинии, и круглая дыра выглядит круглой даже на грубой сетке.

Неоднозначные случаи 5 и 10

Два случая из шестнадцати — «седловые»: твёрдые углы стоят по диагонали. Случай 5 — твёрдые c0 и c2, случай 10 — c1 и c3. Здесь возможны две трактовки: либо два твёрдых угла — отдельные кусочки, и граница отрезает каждый из них, либо через центр клетки идёт перешеек, и граница отрезает два пустых угла.

Если выбирать трактовку произвольно, в форме появляются дырки и случайные мостики, которые зависят от направления диагонали. Стандартное решение — спросить у центра клетки: берём среднее четырёх значений и сравниваем с порогом. Центр твёрдый — углы соединены, пустой — разделены. Поле при этом остаётся согласованным с соседями: соседние клетки делят только рёбра, а точки на рёбрах от выбора трактовки не зависят.

Приятная деталь: «соединённый» случай 5 даёт ровно те же отрезки, что «разделённый» случай 10, и наоборот. Так что таблица отрезков нужна одна, а для соединённой седловины мы просто берём строку с инвертированными битами: c ^ 0b1111.

Таблица отрезков контура

Для коллайдера нам нужен не только меш, но и контур — список отрезков между точками на рёбрах. Каждая строка таблицы — пары номеров рёбер. Строки 5 и 10 записаны в «разделённой» трактовке.

static readonly int[][] SegmentTable =
{
    System.Array.Empty<int>(), // 0
    new[] { 3, 0 },             // 1: c0
    new[] { 0, 1 },             // 2: c1
    new[] { 3, 1 },             // 3: c0 c1
    new[] { 1, 2 },             // 4: c2
    new[] { 3, 0, 1, 2 },       // 5: c0 c2, разделены
    new[] { 0, 2 },             // 6: c1 c2
    new[] { 3, 2 },             // 7: всё, кроме c3
    new[] { 2, 3 },             // 8: c3
    new[] { 0, 2 },             // 9: c0 c3
    new[] { 0, 1, 2, 3 },       // 10: c1 c3, разделены
    new[] { 1, 2 },             // 11: всё, кроме c2
    new[] { 1, 3 },             // 12: c2 c3
    new[] { 0, 1 },             // 13: всё, кроме c1
    new[] { 3, 0 },             // 14: всё, кроме c0
    System.Array.Empty<int>(), // 15
};

Проверить таблицу легко по правилу пересечения: в каждой строке должны стоять ровно те рёбра, концы которых различаются по твёрдости. Например, в случае 7 пуст только c3, значит пересечены рёбра e2 (c2–c3) и e3 (c3–c0) — строка { 3, 2 }.

Заливка без таблицы треугольников

Классические реализации хранят вторую таблицу — список треугольников на каждый случай. Её можно не писать. Посмотрите на твёрдую часть любой клетки: это квадрат, у которого прямыми срезами отрезаны пустые углы. Такая фигура всегда выпуклая, а выпуклый многоугольник триангулируется веером из одной вершины.

Поэтому алгоритм заливки такой: обходим восемь опорных точек клетки по кругу (угол, ребро, угол, ребро…), берём твёрдые углы и пересечённые рёбра — получаем вершины многоугольника в порядке против часовой стрелки. Дальше веер. Единственное исключение — разделённая седловина: там твёрдая часть состоит из двух треугольников в противоположных углах, и мы добавляем их по отдельности.

Про порядок обхода. Unity по умолчанию считает лицевой стороной треугольник, вершины которого идут по часовой стрелке с точки зрения камеры. Камера 2D-сцены смотрит вдоль +Z, наш обход идёт против часовой, поэтому в каждом треугольнике веера мы меняем местами две последние вершины. Шейдер спрайтов не отсекает заднюю сторону и простит ошибку, а вот обычные Lit-шейдеры URP молча покажут пустоту.

Сварка вершин

Если каждая клетка добавляет свои вершины, точка на общем ребре окажется в меше дважды. Для отрисовки это не смертельно, но для контура критично: мы будем склеивать отрезки в цепочки по совпадению вершин, а сравнивать float-координаты на равенство — плохая идея.

Решение — дать каждой возможной точке целочисленный ключ. Точки бывают трёх видов: узлы сетки, точки на горизонтальных рёбрах и точки на вертикальных. Горизонтальное ребро однозначно задаётся своим левым узлом, вертикальное — нижним. Значит, ключ — это индекс узла, умноженный на 3, плюс вид точки:

int NodeKey(int x, int y) => (y * grid.Width + x) * 3;
int HKey(int x, int y)    => (y * grid.Width + x) * 3 + 1; // ребро (x,y)–(x+1,y)
int VKey(int x, int y)    => (y * grid.Width + x) * 3 + 2; // ребро (x,y)–(x,y+1)

По ключу лежит индекс уже созданной вершины в обычном массиве int[] (−1 — ещё не создана). Словарь здесь тоже сработал бы, но массив быстрее и не создаёт мусора: перед каждой перестройкой он заливается −1 через System.Array.Fill.

Собираем мешер

Теперь всё вместе. Класс MarchingSquaresMesher принимает сетку и порог, а на выходе даёт списки для меша и список отрезков контура. Все списки переиспользуются между перестройками, чтобы при копании не плодить аллокации каждый кадр.

using System.Collections.Generic;
using UnityEngine;

public sealed class MarchingSquaresMesher
{
    public readonly List<Vector3> Vertices = new List<Vector3>();
    public readonly List<Vector3> Normals = new List<Vector3>();
    public readonly List<Vector2> Uvs = new List<Vector2>();
    public readonly List<int> Triangles = new List<int>();
    public readonly List<int> Segments = new List<int>(); // пары индексов вершин

    readonly List<int> polygon = new List<int>(8);
    readonly int[] cellEdges = new int[4];
    int[] vertexByKey;

    DensityGrid grid;
    float iso;
    float uvWorldSize;

    public void Build(DensityGrid grid, float iso, float uvWorldSize)
    {
        this.grid = grid;
        this.iso = iso;
        this.uvWorldSize = uvWorldSize;

        Vertices.Clear(); Normals.Clear(); Uvs.Clear();
        Triangles.Clear(); Segments.Clear();

        int keyCount = grid.Width * grid.Height * 3;
        if (vertexByKey == null || vertexByKey.Length != keyCount)
            vertexByKey = new int[keyCount];
        System.Array.Fill(vertexByKey, -1);

        for (int y = 0; y < grid.Height - 1; y++)
            for (int x = 0; x < grid.Width - 1; x++)
                MarchCell(x, y);
    }

    void MarchCell(int x, int y)
    {
        float v0 = grid[x, y], v1 = grid[x + 1, y];
        float v2 = grid[x + 1, y + 1], v3 = grid[x, y + 1];

        int c = CaseIndex(v0, v1, v2, v3, iso);
        if (c == 0) return;

        bool in0 = (c & 1) != 0, in1 = (c & 2) != 0;
        bool in2 = (c & 4) != 0, in3 = (c & 8) != 0;

        int c0 = in0 ? CornerVertex(x, y) : -1;
        int c1 = in1 ? CornerVertex(x + 1, y) : -1;
        int c2 = in2 ? CornerVertex(x + 1, y + 1) : -1;
        int c3 = in3 ? CornerVertex(x, y + 1) : -1;

        int e0 = in0 != in1 ? EdgeVertex(HKey(x, y), x, y, x + 1, y) : -1;
        int e1 = in1 != in2 ? EdgeVertex(VKey(x + 1, y), x + 1, y, x + 1, y + 1) : -1;
        int e2 = in2 != in3 ? EdgeVertex(HKey(x, y + 1), x, y + 1, x + 1, y + 1) : -1;
        int e3 = in3 != in0 ? EdgeVertex(VKey(x, y), x, y, x, y + 1) : -1;

        bool saddle = c == 5 || c == 10;
        bool connected = saddle && (v0 + v1 + v2 + v3) * 0.25f >= iso;

        if (saddle && !connected)
        {
            // Два отдельных треугольника в твёрдых углах (уже по часовой)
            if (in0) AddTriangle(c0, e3, e0);
            if (in1) AddTriangle(c1, e0, e1);
            if (in2) AddTriangle(c2, e1, e2);
            if (in3) AddTriangle(c3, e2, e3);
        }
        else
        {
            // Выпуклый многоугольник против часовой: угол, ребро, угол, ребро...
            polygon.Clear();
            AddPoint(c0); AddPoint(e0); AddPoint(c1); AddPoint(e1);
            AddPoint(c2); AddPoint(e2); AddPoint(c3); AddPoint(e3);
            for (int i = 1; i < polygon.Count - 1; i++)
                AddTriangle(polygon[0], polygon[i + 1], polygon[i]);
        }

        // Отрезки контура
        cellEdges[0] = e0; cellEdges[1] = e1; cellEdges[2] = e2; cellEdges[3] = e3;
        int[] row = SegmentTable[connected ? c ^ 0b1111 : c];
        for (int i = 0; i < row.Length; i += 2)
        {
            Segments.Add(cellEdges[row[i]]);
            Segments.Add(cellEdges[row[i + 1]]);
        }
    }

    void AddPoint(int vertex)
    {
        if (vertex >= 0) polygon.Add(vertex);
    }

    void AddTriangle(int a, int b, int c)
    {
        Triangles.Add(a); Triangles.Add(b); Triangles.Add(c);
    }

    int CornerVertex(int x, int y)
    {
        int key = NodeKey(x, y);
        int index = vertexByKey[key];
        return index >= 0 ? index : AddVertex(key, grid.NodePosition(x, y));
    }

    int EdgeVertex(int key, int ax, int ay, int bx, int by)
    {
        int index = vertexByKey[key];
        if (index >= 0) return index;

        float va = grid[ax, ay], vb = grid[bx, by];
        float t = Mathf.Approximately(va, vb) ? 0.5f : Mathf.Clamp01((iso - va) / (vb - va));
        Vector2 p = Vector2.Lerp(grid.NodePosition(ax, ay), grid.NodePosition(bx, by), t);
        return AddVertex(key, p);
    }

    int AddVertex(int key, Vector2 p)
    {
        int index = Vertices.Count;
        Vertices.Add(new Vector3(p.x, p.y, 0f));
        Normals.Add(Vector3.back);       // смотрим на камеру
        Uvs.Add(p / uvWorldSize);        // текстура тайлится по миру
        vertexByKey[key] = index;
        return index;
    }

    // NodeKey, HKey, VKey, CaseIndex и таблица SegmentTable — из разделов выше
}

Несколько пояснений к коду. Нормали ставим вручную: Vector3.back смотрит на камеру, а RecalculateNormals для плоского меша — лишняя работа. UV считаются от мировой позиции, поэтому текстура земли не «плывёт», когда вы копаете: вершины появляются и исчезают, а узор остаётся на месте.

Из отрезков — в цепочки

Мешер выдаёт кучу разрозненных отрезков. EdgeCollider2D же хочет непрерывную ломаную, поэтому отрезки надо склеить. Здесь окупается сварка вершин: концы соседних отрезков — это одна и та же вершина с одним индексом.

У каждой точки контура не больше двух соседей. Точка лежит на ребре сетки, ребро общее для двух клеток, и каждая клетка проводит через эту точку максимум один отрезок. Значит, хватит двух массивов-ссылок вместо полноценного графа. Точка с одним соседом — конец открытой цепочки (бывает на краю сетки или чанка), с двумя — середина цепочки или петли.

public sealed class ContourBuilder
{
    public readonly List<List<Vector2>> Contours = new List<List<Vector2>>();
    public int Count { get; private set; }

    int[] linkA = new int[0], linkB = new int[0];
    bool[] visited = new bool[0];

    public void Build(List<Vector3> vertices, List<int> segments)
    {
        int n = vertices.Count;
        if (linkA.Length < n)
        {
            linkA = new int[n]; linkB = new int[n]; visited = new bool[n];
        }
        for (int i = 0; i < n; i++) { linkA[i] = -1; linkB[i] = -1; visited[i] = false; }

        for (int i = 0; i < segments.Count; i += 2)
        {
            Link(segments[i], segments[i + 1]);
            Link(segments[i + 1], segments[i]);
        }

        Count = 0;
        // Проход 0 — открытые цепочки от их концов, проход 1 — замкнутые петли
        for (int pass = 0; pass < 2; pass++)
            for (int i = 0; i < n; i++)
            {
                if (visited[i] || linkA[i] < 0) continue;
                if (pass == 0 && linkB[i] >= 0) continue;
                Walk(i, vertices);
            }
    }

    void Link(int from, int to)
    {
        if (linkA[from] < 0) linkA[from] = to;
        else linkB[from] = to;
    }

    void Walk(int start, List<Vector3> vertices)
    {
        if (Count == Contours.Count) Contours.Add(new List<Vector2>());
        List<Vector2> points = Contours[Count++];
        points.Clear();

        int prev = -1, current = start;
        while (current >= 0 && !visited[current])
        {
            visited[current] = true;
            points.Add(vertices[current]);
            int next = linkA[current] != prev ? linkA[current] : linkB[current];
            prev = current;
            current = next;
        }

        if (current == start) points.Add(points[0]); // петля: замыкаем на первую точку
    }
}

Почему открытые цепочки идут первым проходом: если начать обход с середины открытой цепочки, мы уйдём в одну сторону и потеряем вторую половину — она станет отдельным контуром. Начиная с концов, мы гарантируем, что каждая цепочка собирается целиком. Петли концов не имеют, поэтому их добираем вторым проходом с любой точки.

Упрощение контура

На плавном склоне контур состоит из сотен почти коллинеарных точек. Для отрисовки это нормально, а для физики каждая точка — лишнее ребро в broadphase и проверках столкновений. Стоит выкинуть точки, которые почти лежат на прямой между соседями:

static void Simplify(List<Vector2> points, float tolerance)
{
    if (points.Count < 3) return;

    int write = 1;
    for (int read = 1; read < points.Count - 1; read++)
    {
        Vector2 a = points[write - 1], b = points[read], c = points[read + 1];
        Vector2 ac = c - a;
        float length = ac.magnitude;
        float distance = length > 1e-6f
            ? Mathf.Abs(ac.x * (b.y - a.y) - ac.y * (b.x - a.x)) / length
            : 0f;
        if (distance > tolerance) points[write++] = b;
    }
    points[write++] = points[points.Count - 1];
    points.RemoveRange(write, points.Count - write);
}

Это жадный однопроходный вариант: быстрый, без аллокаций, но на длинных пологих дугах ошибка может немного накапливаться. Если нужна гарантированная точность, используйте алгоритм Рамера — Дугласа — Пекера, он рекурсивно оставляет самые «выпирающие» точки. Допуск разумно брать в долях размера клетки: 0.05–0.1 от CellSize обычно незаметны глазу и заметно облегчают коллайдер. Меш при этом не трогаем — визуально контур остаётся точным.

Компонент на сцене

Осталось собрать всё в MonoBehaviour: сетка, генерация из шума, кисть, перестройка меша и коллайдеров. Перестраиваем не в момент правки, а один раз за кадр в LateUpdate по флагу: если игрок за кадр «мазнул» кистью несколько раз, работа всё равно выполнится однократно.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public sealed class DensityTerrain : MonoBehaviour
{
    [SerializeField] int width = 129;
    [SerializeField] int height = 65;
    [SerializeField] float cellSize = 0.25f;
    [SerializeField, Range(0f, 1f)] float iso = 0.5f;
    [SerializeField] float noiseScale = 0.08f;
    [SerializeField] float uvWorldSize = 4f;
    [SerializeField] float simplifyTolerance = 0.02f;

    DensityGrid grid;
    readonly MarchingSquaresMesher mesher = new MarchingSquaresMesher();
    readonly ContourBuilder contours = new ContourBuilder();
    readonly List<EdgeCollider2D> colliders = new List<EdgeCollider2D>();
    Mesh mesh;
    bool dirty;

    void Awake()
    {
        mesh = new Mesh { name = "Density Terrain" };
        mesh.MarkDynamic();
        GetComponent<MeshFilter>().sharedMesh = mesh;

        grid = new DensityGrid(width, height, cellSize);
        float seed = Random.Range(0f, 1000f);
        for (int y = 0; y < height; y++)
            for (int x = 0; x < width; x++)
                grid[x, y] = grid.IsBorder(x, y)
                    ? 0f
                    : Mathf.PerlinNoise(seed + x * noiseScale, seed + y * noiseScale);

        dirty = true;
    }

    void OnDestroy()
    {
        Destroy(mesh);
    }

    void LateUpdate()
    {
        if (!dirty) return;
        dirty = false;
        Rebuild();
    }

    void Rebuild()
    {
        mesher.Build(grid, iso, uvWorldSize);

        mesh.Clear();
        mesh.indexFormat = mesher.Vertices.Count > 65535 ? IndexFormat.UInt32 : IndexFormat.UInt16;
        mesh.SetVertices(mesher.Vertices);
        mesh.SetNormals(mesher.Normals);
        mesh.SetUVs(0, mesher.Uvs);
        mesh.SetTriangles(mesher.Triangles, 0, false);
        mesh.RecalculateBounds();

        contours.Build(mesher.Vertices, mesher.Segments);
        int used = 0;
        for (int i = 0; i < contours.Count; i++)
        {
            List<Vector2> points = contours.Contours[i];
            Simplify(points, simplifyTolerance);
            if (points.Count < 3) continue; // крошка, физике не нужна

            if (used == colliders.Count) colliders.Add(gameObject.AddComponent<EdgeCollider2D>());
            colliders[used].enabled = true;
            colliders[used].SetPoints(points);
            used++;
        }
        for (int i = used; i < colliders.Count; i++)
            colliders[i].enabled = false;
    }

    // Simplify — из раздела выше
}

Шум Перлина с порогом 0.5 сразу даёт что-то похожее на пещеры: примерно половина площади твёрдая, формы органические. Масштаб шума управляет размером пещер, а сдвиг порога — соотношением камня и пустоты. Подробнее о том, как выжать из Mathf.PerlinNoise разнообразие (октавы, смещения, маски), — в отдельной статье про шум Перлина. А если нужны пещеры более «архитектурные», поле можно заполнить результатом клеточного автомата и сгладить его размытием: Marching Squares превратит пиксельную карту в аккуратный рельеф.

Обратите внимание на IsBorder: крайние узлы принудительно пустые. Тогда твёрдое никогда не упирается в край сетки и все контуры получаются замкнутыми петлями. Если края оставить твёрдыми, контуры там оборвутся и у коллайдера появятся «открытые» края, через которые объекты смогут провалиться внутрь массива.

Почему именно EdgeCollider2D

Edge-коллайдер описывает только линию, внутренности у него нет. Для ландшафта это то, что нужно: физические тела всегда находятся снаружи камня, а контур с дырками и островами описывается просто набором независимых ломаных — никаких проблем с вложенностью. Обратная сторона: если тело окажется внутри массива (заспавнили неудачно или протолкнули на большой скорости), оно не вытолкнется наружу. Для быстрых снарядов включайте у Rigidbody2D режим Continuous, а точку спавна проверяйте по самой сетке плотности: значение в ближайших узлах выше порога — значит, там камень.

Компоненты-коллайдеры не удаляются, а выключаются и переиспользуются: AddComponent и Destroy каждый кадр при копании — лишняя нагрузка и мусор. Помните, что точки SetPoints задаются в локальных координатах объекта — ровно в тех же, что и вершины меша, поэтому никакого преобразования не нужно.

Кисть: копать и насыпать

Самое интересное — правка поля во время игры. Наивная кисть ставит 0 во все узлы внутри круга. Получится дыра, но её край будет угловатым: на границе круга значения скачут с 0 сразу на исходные, и интерполяция ставит точку где придётся.

Хитрость в том, чтобы кисть сама была полем расстояния. Метод добавим в DensityTerrain: он правит сетку и ставит флаг перестройки. Посчитаем для узла значение, которое равно 0 глубоко внутри круга, ровно 0.5 на его границе и 1 снаружи, причём между этими уровнями меняется линейно в пределах одной клетки:

public void Paint(Vector2 localCenter, float radius, bool add)
{
    float cellSize = grid.CellSize;
    int minX = Mathf.Max(1, Mathf.FloorToInt((localCenter.x - radius) / cellSize) - 1);
    int maxX = Mathf.Min(grid.Width - 2, Mathf.CeilToInt((localCenter.x + radius) / cellSize) + 1);
    int minY = Mathf.Max(1, Mathf.FloorToInt((localCenter.y - radius) / cellSize) - 1);
    int maxY = Mathf.Min(grid.Height - 2, Mathf.CeilToInt((localCenter.y + radius) / cellSize) + 1);

    for (int y = minY; y <= maxY; y++)
        for (int x = minX; x <= maxX; x++)
        {
            float distance = Vector2.Distance(grid.NodePosition(x, y), localCenter);
            // 0 внутри, 0.5 на окружности, 1 снаружи; переход шириной в две клетки
            float brush = Mathf.Clamp01((distance - radius) / cellSize * 0.5f + 0.5f);
            grid[x, y] = add
                ? Mathf.Max(grid[x, y], 1f - brush)
                : Mathf.Min(grid[x, y], brush);
        }

    dirty = true;
}

При копании берём минимум из текущего значения и кисти: камень вне круга не трогается, а внутри гарантированно становится пустотой. При насыпании — максимум с инвертированной кистью. Поскольку значение кисти линейно зависит от расстояния, интерполяция на рёбрах ставит точки границы почти точно на окружность заданного радиуса, и дыра получается круглой даже на сетке с крупными клетками. Цикл начинается с 1 и заканчивается на Width - 2, чтобы кисть не трогала пустую рамку.

Вызов из кода игрока или снаряда:

Vector2 local = terrain.transform.InverseTransformPoint(hitPoint);
terrain.Paint(local, explosionRadius, add: false);

Если объект ландшафта не масштабирован и не повёрнут, локальные координаты совпадают с мировыми со сдвигом, но привычка всегда переводить точку через InverseTransformPoint избавит от странных багов, когда дизайнер всё-таки растянет объект.

Чанки: не перестраивать весь мир

На сетке 129×65 полная перестройка занимает доли миллисекунды, и можно ни о чём не думать. Но уровень размером 2000×500 клеток целиком за кадр уже не пересчитать, да и незачем: взрыв меняет крошечный участок. Поэтому большие поля режут на чанки — например, по 32×32 клетки, каждый со своим мешем, своими коллайдерами и своим флагом «грязный».

Ключевой момент — общие узлы. Чанк из N клеток опирается на N+1 узел по каждой оси, и крайний ряд узлов принадлежит сразу двум соседним чанкам. Удобнее всего хранить одну общую сетку плотности на весь уровень, а каждому чанку давать только диапазон клеток, которые он обрабатывает. Тогда при правке узла надо пометить грязными все чанки, которые этот узел касается:

void MarkDirtyAround(int nodeX, int nodeY)
{
    // Узел на границе касается и левого, и правого чанка
    int cx0 = Mathf.Max(0, (nodeX - 1) / ChunkCells);
    int cx1 = Mathf.Min(chunksX - 1, nodeX / ChunkCells);
    int cy0 = Mathf.Max(0, (nodeY - 1) / ChunkCells);
    int cy1 = Mathf.Min(chunksY - 1, nodeY / ChunkCells);

    for (int cy = cy0; cy <= cy1; cy++)
        for (int cx = cx0; cx <= cx1; cx++)
            chunks[cy * chunksX + cx].Dirty = true;
}

Для кисти не нужно вызывать это для каждого узла: достаточно пометить чанки, попавшие в прямоугольник minX…maxX × minY…maxY, расширенный на один узел. Мешер для чанка меняется минимально: циклы идут по его диапазону клеток, а ключи вершин можно считать в локальной системе чанка, чтобы массив vertexByKey был размером с чанк, а не с весь уровень.

У чанков есть следствие для контуров: на границе двух чанков петля разрезается на две открытые цепочки. Для коллайдеров это не проблема — два Edge-коллайдера, стыкующиеся в одной точке, работают как один. А вот если вам нужен цельный контур (например, для обводки шейдером или расчёта площади острова), собирайте его отдельно по всей сетке.

Когда взрывов много и они затрагивают десятки чанков за кадр, перестройку стоит размазать по нескольким кадрам — очередь грязных чанков и бюджет времени на кадр. Как это устроить без фризов, разобрано в статье про time slicing. Сам проход по клеткам хорошо ложится на Burst: данные — плоские массивы, клетки независимы. Для этого вместо List используйте NativeList, а сварку вершин делайте отдельным проходом (Job System и Burst подробно разобраны в отдельной статье).

Типичные ошибки

  • Меш не виден. Почти всегда порядок вершин: треугольники смотрят от камеры, а шейдер отсекает заднюю сторону. Проверьте, что в веере вы меняете местами вершины, или временно возьмите материал с отключённым culling.
  • Тонкие щели между клетками. Признак того, что точки на общем ребре считаются в двух клетках по-разному: например, одна клетка интерполирует от a к b, а соседняя — от b к a, и из-за округления float получаются разные числа. Сварка по ключу ребра решает это раз и навсегда.
  • Случайные мостики и дырки по диагонали. Не обработаны седловые случаи 5 и 10 или трактовка в меше расходится с трактовкой в контуре. Решение о соединённости должно приниматься один раз на клетку и использоваться в обеих частях.
  • Коллайдер «залипает» и тела дрожат. Слишком много коротких рёбер. Упрощайте контур и не делайте клетки мельче, чем нужно для геймплея.
  • Индексы переполнились. На большой сетке без чанков вершин легко становится больше 65535. Либо включайте IndexFormat.UInt32, либо режьте на чанки — второе почти всегда лучше.
  • Мусор в профайлере при каждом взрыве. Новые списки и массивы в каждой перестройке. Все буферы — поля, очищаемые через Clear, а коллайдеры — переиспользуемые компоненты.

Куда двигаться дальше

Получившийся компонент — уже рабочая основа для разрушаемого 2D-ландшафта. Дальше его можно развивать в нескольких направлениях. Добавьте в узлы второй канал — тип материала (земля, камень, металл), и кисть сможет пропускать неразрушимые участки, а меш — разбиваться на сабмеши по материалам. Храните поле как byte вместо float: 256 уровней плотности хватает для гладких краёв, а памяти и трафика при сохранении уходит вчетверо меньше. Для красивого края пустите вдоль собранного контура отдельную полосу-меш с текстурой травы или кромки.

И наконец, всё сказанное переносится в 3D почти дословно: Marching Cubes — тот же алгоритм для кубических клеток с восемью углами и 256 случаями. Идеи сварки вершин по ключу ребра, разрешения неоднозначностей и чанков с общими узлами работают там точно так же, так что разобравшись с двумерной версией, вы уже понимаете устройство воксельных ландшафтов.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также

Fixed-point в Unity: детерминированная математика для lockstep
29.10.2026

Fixed-point в Unity: детерминированная математика для lockstep

Почему float даёт разные результаты на разных устройствах и как написать на C# свой тип с фиксированной точкой: умножение без переполнения, корень, синус, вектор, случайность и контрольная сумма для lockstep и реплеев.