В этой статье мы с нуля напишем на 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 случаями. Идеи сварки вершин по ключу ребра, разрешения неоднозначностей и чанков с общими узлами работают там точно так же, так что разобравшись с двумерной версией, вы уже понимаете устройство воксельных ландшафтов.