Хабрахабр

[Перевод] Карты из шестиугольников в Unity: неровности, реки и дороги

image

Начало: части 1-3.

Оглавление

  • Сэмплируем текстуру шума.
  • Перемещаем вершины.
  • Сохраняем плоскостность ячеек.
  • Подразделяем рёбра ячеек.

Пока наша сетка являлась строгим узором из сот. В этой части мы добавим неровности, чтобы карта выглядела естественнее.

Больше никаких ровных шестиугольников.

Шум

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

Он воспроизводим в любой точке. Хорошим кандидатом является шум Перлина. Благодаря этому можно создавать относительно плавные искажения. При комбинировании нескольких частот он также создаёт шум, который может сильно варьироваться на больших расстояниях, но остаётся почти одинаковым на малых расстояниях. Находящиеся рядом друг с другом точки обычно остаются рядом, а не разбрасываются в противоположных направлениях.

В туториале Noise я объясняю, как это сделать. Мы можем генерировать шум Перлина программно. Преимущество использования текстуры заключается в том, что это проще и намного быстрее, чем вычисление многочастотного шума Перлина. Но также мы можем выполнять сэмплирование из заранее сгенерированной текстуры шума. Поэтому она должна быть бесшовно соединяющейся и достаточно большой, чтобы повторения не бросались в глаза. Её недостаток в том, что текстура занимает больше памяти и покрывает только небольшую область шума.

Текстура шума

Мы воспользуемся текстурой, поэтому туториал Noise вам изучать необязательно. Значит, нам нужна текстура. Вот она:

Бесшовно соединяемая текстура шума Перлина.

Это изображение в оттенках серого. Показанная выше текстура содержит бесшовно соединяемый многочастотный шум Перлина. 5, а крайние значения стремятся к 0 и 1. Его среднее значение равно 0.

Если нам нужно трёхмерное искажение, то потребуется как минимум три псевдослучайных сэмпла! Но постойте, на каждую точку приходится только одно значение. Поэтому нам необходимы ещё две текстуры с разным шумом.

Это позволит нам хранить до четырёх паттернов шума в одной текстуре. Мы можем создать их или хранить разные значения шума в каждом из цветовых каналов. Вот эта текстура.

Четыре в одном.

Как создать такую текстуру?

Я использовал NumberFlow. Это созданный мной редактор процедурных текстур для Unity.

Скачайте эту текстуру и импортируйте её в проект Unity. Так как мы собираемся сэмплировать текстуру через код, она должна быть читаемой. Переключите Texture Type на Advanced и включите Read/Write Enabled. Это сохранит данные текстуры в памяти и к ним можно будет получить доступ из кода на C#. Задайте для Format значение Automatic Truecolor, иначе ничего не сработает. Мы не хотим, чтобы сжатие текстур уничтожило наш паттерн шума.

Также включите Bypass sRGB Sampling. Можно отключить Generate Mip Maps, потому что они нам не потребуются. Этот параметр обозначает, что текстура не содержит данных цвета в гамма-пространстве. Это нам не понадобится, но так будет правильно.

Импортированная текстура шума.

Когда важен параметр sRGB sampling?

При использовании режиме Linear rendering сэмплирование текстуры автоматически преобразует цветовые данные из гаммы в линейное цветовое пространство. Если бы мы хотели использовать текстуру в шейдере, то это имело бы значение. В случае нашей текстуры шума это приведёт к неверным результатам, поэтому нам этого не нужно.

Почему у меня настройки импорта текстуры выглядят иначе?

Нужно использовать настройки 2D-текстуры по умолчанию, sRGB (Color Texture) должно быть отключено, а для Compression должно быть задано значение None. Их изменили после того, как был написан этот туториал.

Сэмплирование шума

Давайте добавим функционал сэмплирования шума в HexMetrics, чтобы можно было использовать его в любом месте. Это значит, что HexMetrics должен содержать ссылку на текстуру шума.

public static Texture2D noiseSource;

Так как это не компонент, мы не можем назначить ему текстуру через редактор. Поэтому в качестве посредника мы используем HexGrid. Так как HexGrid будет действовать первым, будет вполне нормально, если мы передадим текстуру в начале его метода Awake.

public Texture2D noiseSource; void Awake ()

Однако такой подход не переживёт рекомпиляции в режиме Play. Статические переменные не сериализуются движком Unity. Чтобы решить эту проблему, переназначим текстуру и в методе события OnEnable. Этот метод будет вызываться после рекомпиляции.

void OnEnable () { HexMetrics.noiseSource = noiseSource; }

Назначаем текстуру шума.

Этот метод получает позицию в мире и создаёт 4D-вектор, содержащий четыре сэмпла шума. Теперь, когда HexMetrics имеет доступ к текстуре, давайте добавим к нему удобный метод сэмплирования шума.

public static Vector4 SampleNoise (Vector3 position) { }

Сэмплы созданы сэмплированием текстуры с помощью билинейной фильтрации, при которой в качестве UV-координат использовались координаты мира X и Z. Так как наш источник шума двухмерный, мы игнорируем третью координату мира. Если бы источник шума был трёхмерным, мы бы использовали и координату Y.

Такое приведение может быть косвенным, то есть мы можем вернуть непосредственно цвет, не включая явным образом (Vector4). В результате мы получаем цвет, который можно преобразовать в 4D-вектор.

public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear(position.x, position.z); }

Как работает билинейная фильтрация?

Объяснения UV-координат и фильтрации текстур см. в туториале Rendering 2, Shader Fundamentals.

unitypackage

Перемещение вершин

Мы исказим нашу ровную сетку из сот, по отдельности переместив каждую из вершин. Для этого давайте добавим к HexMesh метод Perturb. Он берёт неперемещённую точку и возвращает перемещённую. Для этого он использует неперемещённую точку при сэмплировании шума.

Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); }

Давайте просто сложим сэмплы шума X, Y и Z непосредственно с соответствующими координатами точки и используем это как результат.

Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x; position.y += sample.y; position.z += sample.z; return position; }

Как нам быстро изменить HexMesh, чтобы переместились все вершины? Изменением каждой вершины при добавлении в список вершин в методах AddTriangle и AddQuad. Давайте так и сделаем.

void AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); … } void AddQuad (Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) { int vertexIndex = vertices.Count; vertices.Add(Perturb(v1)); vertices.Add(Perturb(v2)); vertices.Add(Perturb(v3)); vertices.Add(Perturb(v4)); … }

Останутся ли четырёхугольники плоскими после перемещения их вершин?

Они состоят из двух треугольников, которые больше не будут лежать в одной плоскости. Скорее всего нет. Это значит, что у нас не будет резких переходов между двумя треугольниками. Однако поскольку эти треугольники имеют две общих вершины, нормали этих вершин будут сглажены. Если искажение не слишком велико, то мы по-прежнему будем воспринимать четырёхугольники плоскими.

Вершины то ли перемещены, то ли нет.

Это произошло потому, что мы добавили к точкам сэмплы шума, а они всегда являются положительными. Пока изменения не очень заметны, только пропали метки ячеек. Мы должны отцентрировать изменения, чтобы они происходили в обоих направлениях. Поэтому в результате все треугольники поднялись над своими метками, закрывая их. Сменим интервал сэмпла шума с 0–1 на −1–1.

Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += sample.x * 2f - 1f; position.y += sample.y * 2f - 1f; position.z += sample.z * 2f - 1f; return position; }

Центрированные перемещения.

Величина (сила) перемещения

Теперь очевидно, что мы исказили сетку, но эффект едва заметен. Изменение составляет в каждом измерении не более 1 единицы. То есть теоретический максимум смещения равен √3 ≈ 1.73 единиц, что будет происходить чрезвычайно редко, если вообще произойдёт. Так как внешний радиус ячеек равен 10 единицам, то перемещения относительно малы.

Давайте попробуем использовать силу 5. Решение заключается в добавлении к HexMetrics параметра силы, чтобы можно было отмасштабировать перемещения. 66 единиц, что гораздо заметнее. При этом теоретический максимум смещения будет равен √75 ≈ 8.

public const float cellPerturbStrength = 5f;

Применим силу, умножив её на сэмплы в HexMesh.Perturb.

Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength; position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength; position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength; return position; }

Увеличенная сила.

Масштаб шума

Хотя до изменения сетка выглядит хорошо, всё может пойти не так после появления уступов. Их вершины могут искажаться в непредсказуемо разных направлениях, создавая хаос. При использовании шума Перлина этого происходить не должно.

Из-за этого текстура тайлится через каждую единицу, а ячейки гораздо больше этой величины. Проблема возникает потому, что для сэмплирования шума мы используем непосредственно координаты мира. По сути, текстура сэмплируется в произвольных точках, разрушая существующую у неё целостность.

Строки сетки 10 на 10 перекрывают соты.

Давайте добавим этот масштаб в HexMetrics и присвоим ему значение 0. Нам придётся отмасштабировать сэмплирование шума, чтобы текстура покрывала гораздо бОльшую площадь. 003, а затем отмасштабируем координаты сэмплов на этот коэффициент.

public const float noiseScale = 0.003f; public static Vector4 SampleNoise (Vector3 position) { return noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); }

Внезапно оказывается, что наша текстура покрывает 333 ⅓ квадратных единиц, и её локальная целостность становится очевидной.

Отмасштабированный шум.

На самом деле, так как ячейки имеют внутренний диаметр 10√3 единиц, он никогда не будет ровно тайлиться в измерении X. Кроме того, новый масштаб увеличивает расстояния между стыками шума. Но они будут очевидны только на карте без прочих характерных особенностей. Однако из-за локальной целостности шума, при бОльшем масштабе мы всё равно сможем распознать повторяющиеся паттерны, примерно через каждые 20 ячеек, даже если детали не будут совпадать.

unitypackage

Выравнивание центров ячеек

Перемещение всех вершин придаёт карте более естественный вид, но возникает несколько проблем. Так как ячейки теперь неровные, их метки пересекаются с мешем. А в местах соединений уступов с обрывами возникают трещины. Трещины мы оставим на потом, а сейчас сосредоточимся на поверхностях ячеек.

Карта стала менее строгой, но появилось больше проблем.

Давайте просто не будем изменять координату Y в HexMesh. Проще всего решить проблему пересечений — сделать центры ячеек плоскими. Perturb.

Vector3 Perturb (Vector3 position) { Vector4 sample = HexMetrics.SampleNoise(position); position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength;
// position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength; position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength; return position; }

Выровненные ячейки.

Стоит учесть, что это снижает максимальное смещение до √50 ≈ 7. При таком изменении все вертикальные позиции останутся неизменными, и у центров ячеек, и у ступенек уступов. 07 только в плоскости XZ.

Но было бы всё равно неплохо добавить небольшое вертикальное перемещение. Это неплохое изменение, потому что оно упрощает идентификацию отдельных ячеек и не позволяет уступам становиться слишком хаотичными.

Перемещение высоты ячейки

Вместо того, чтобы применять вертикальное перемещение к каждой вершине, мы можем применить его к ячейке. В этом случае каждая ячейка останется плоской, но между ячейками всё равно сохранится вариативность. Логично будет также использовать для перемещения высоты другой масштаб, поэтому добавим его в HexMetrics. Сила в 1.5 единиц создаёт небольшую вариативность, примерно равную высоте одной ступени уступа.

public const float elevationPerturbStrength = 1.5f;

Изменим свойство HexCell.Elevation так, чтобы оно применяло это перемещение к вертикальной позиции ячейки.

public int Elevation { get { return elevation; } set { elevation = value; Vector3 position = transform.localPosition; position.y = value * HexMetrics.elevationStep; position.y += (HexMetrics.SampleNoise(position).y * 2f - 1f) * HexMetrics.elevationPerturbStrength; transform.localPosition = position; Vector3 uiPosition = uiRect.localPosition; uiPosition.z = -position.y; uiRect.localPosition = uiPosition; } }

Чтобы перемещение применялось сразу же, нам нужно явным образом задавать высоту каждой ячейки в HexGrid.CreateCell. В противном случае сетка изначально будет плоской. Сделаем это в конце, после создания UI.

void CreateCell (int x, int z, int i) { … cell.Elevation = 0; }

Перемещённые высоты с трещинами.

Использование одинаковых высот

В меше появилось множество трещин, потому что при триангуляции меша мы не используем одинаковые высоты ячеек. Давайте добавим в HexCell свойство для получения её позиции, чтобы можно было использовать её где угодно.

public Vector3 Position { get { return transform.localPosition; } }

Теперь мы можем использовать это свойство в HexMesh.Triangulate для определения центра ячейки.

void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; … }

И мы можем использовать его в TriangulateConnection при определении вертикальных позиций соседних ячеек.

void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2 ) { … Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; … HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = v2 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; … } }

Согласованное использование высоты ячеек.

unitypackage

Подразделение рёбер ячеек

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

Чётко видимые шестиугольные ячейки.

Так что давайте разобьём каждое ребро ячейки на две части, добавив вершину ребра посередине между каждой парой углов. Если бы у нас было больше вершин, то появилась бы бОльшая локальная вариативность. Triangulate должен добавлять не один, а два треугольника. Это значит, что HexMesh.

void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction); Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction); Vector3 e1 = Vector3.Lerp(v1, v2, 0.5f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, v2); AddTriangleColor(cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, v2); } }

Двенадцать сторон вместо шести.

Давайте сделаем их ещё более неровными, утроив количество вершин. Удвоение вершин и треугольников добавляет рёбрам ячейки бОльшую вариативность.

Vector3 e1 = Vector3.Lerp(v1, v2, 1f / 3f); Vector3 e2 = Vector3.Lerp(v1, v2, 2f / 3f); AddTriangle(center, v1, e1); AddTriangleColor(cell.color); AddTriangle(center, e1, e2); AddTriangleColor(cell.color); AddTriangle(center, e2, v2); AddTriangleColor(cell.color);

18 сторон.

Подразделение соединений рёбер

Разумеется, нам нужно также подразделить и соединения рёбер. Поэтому передадим новые вершины рёбер в TriangulateConnection.

if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, v1, e1, e2, v2); }

Добавим соответствующие параметры в TriangulateConnection, чтобы он мог работать с дополнительными вершинами.

void TriangulateConnection ( HexDirection direction, HexCell cell, Vector3 v1, Vector3 e1, Vector3 e2, Vector3 v2 ) { …
}

Также нам нужно вычислить дополнительные вершины рёбер для соседних ячеек. Мы можем их вычислить после соединения мостом с другой стороной.

Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 v3 = v1 + bridge; Vector3 v4 = v2 + bridge; v3.y = v4.y = neighbor.Position.y; Vector3 e3 = Vector3.Lerp(v3, v4, 1f / 3f); Vector3 e4 = Vector3.Lerp(v3, v4, 2f / 3f);

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

if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(v1, v2, cell, v3, v4, neighbor); } else { AddQuad(v1, e1, v3, e3); AddQuadColor(cell.color, neighbor.color); AddQuad(e1, e2, e3, e4); AddQuadColor(cell.color, neighbor.color); AddQuad(e2, v2, e4, v4); AddQuadColor(cell.color, neighbor.color); }

Подразделённые соединения.

Объединение вершин рёбер

Так как для описания ребра нам теперь нужно четыре вершины, то логично будет объединить их в набор. Это удобнее, чем работать с четырьмя независимыми вершинами. Создадим для этого простую структуру EdgeVertices. Она должна содержать четыре вершины, идущие в порядке по часовой стрелке по ребру ячейки.

using UnityEngine; public struct EdgeVertices { public Vector3 v1, v2, v3, v4;
}

Разве они не должны быть сериализуемыми?

На этом этапе нам не нужно хранить вершины рёбер, поэтому сериализуемыми их делать необязательно. Мы будем использовать эту структуру только при триангуляции.

Добавим ей удобный метод-конструктор, который займётся вычислением промежуточных точек ребра.

public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 1f / 3f); v3 = Vector3.Lerp(corner1, corner2, 2f / 3f); v4 = corner2; }

Теперь мы можем добавить в HexMesh отдельный метод триангуляции для создания веера треугольников между центром ячейки и одним из её рёбер.

void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); }

И метод для триангуляции полосы четырёхугольников между двумя рёбрами.

void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); }

Это позволит нам упростить метод Triangulate.

void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); TriangulateEdgeFan(center, e, cell.color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } }

Перейдём к TriangulateConnection. Теперь мы можем использовать TriangulateEdgeStrip, но нужно внести и другие замены. Там, где мы раньше использовали v1, нам нужно использовать e1.v1. Аналогично, v2 становится e1.v4, v3 становится e2.v1, а v4 становится e2.v4.

void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v4 + bridge ); if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1.v1, e1.v4, cell, e2.v1, e2.v4, neighbor); } else { TriangulateEdgeStrip(e1, cell.color, e2, neighbor.color); } HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { Vector3 v5 = e1.v4 + HexMetrics.GetBridge(direction.Next()); v5.y = nextNeighbor.Position.y; if (cell.Elevation <= neighbor.Elevation) { if (cell.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e1.v4, cell, e2.v4, neighbor, v5, nextNeighbor ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } } else if (neighbor.Elevation <= nextNeighbor.Elevation) { TriangulateCorner( e2.v4, neighbor, v5, nextNeighbor, e1.v4, cell ); } else { TriangulateCorner( v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor ); } }

Подразделение уступов

Нам нужно подразделить и уступы. Поэтому передадим рёбра TriangulateEdgeTerraces.

if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor); }

Теперь мы должны изменить TriangulateEdgeTerraces так, чтобы он выполнял интерполяцию между рёбрами, а не между парами вершин. Давайте предположим, что у EdgeVertices есть для этого удобный статический метод. Это позволит нам упростить TriangulateEdgeTerraces, а не усложнять его.

void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, 1); TriangulateEdgeStrip(begin, beginCell.color, e2, c2); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, i); TriangulateEdgeStrip(e1, c1, e2, c2); } TriangulateEdgeStrip(e2, c2, end, endCell.color); }

Метод EdgeVertices.TerraceLerp просто выполняет интерполяцию уступов между всеми четырьмя парами вершин двух рёбер.

public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); return result; }

Подразделённые уступы.

unitypackage

Заново соединяем обрывы и уступы

Пока мы игнорировали трещины в местах соединений обрывов и уступов. Настало время решить эту проблему. Давайте сначала рассмотрим случаи «обрыв-склон-склон» (ОСС) и «склон-обрыв-склон» (СОС).

Дыры в меше.

Это означает, что теперь они не лежат ровно на боку обрыва, что приводит к трещине. Проблема возникает потому, что вершины границ переместились. Иногда эти дыры незаметны, а иногда бросаются в глаза.

Это значит, что нам нужно управлять тем, будет ли перемещена точка. Решение состоит в том, чтобы не перемещать вершину границы. Проще всего будет создать альтернативу AddTriangle, которая вообще не перемещает вершины.

void AddTriangleUnperturbed (Vector3 v1, Vector3 v2, Vector3 v3) { int vertexIndex = vertices.Count; vertices.Add(v1); vertices.Add(v2); vertices.Add(v3); triangles.Add(vertexIndex); triangles.Add(vertexIndex + 1); triangles.Add(vertexIndex + 2); }

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

void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = HexMetrics.TerraceLerp(begin, left, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), Perturb(v2), boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.TerraceLerp(begin, left, i); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(Perturb(v1), Perturb(v2), boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(Perturb(v2), Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); }

Стоит заметить следующее: поскольку мы не используем v2 для получения какой-то другой точки, то можно переместить её сразу же. Это простая оптимизация и она уменьшает объём кода, так что давайте внесём её.

void TriangulateBoundaryTriangle ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1); AddTriangleUnperturbed(Perturb(begin), v2, boundary); AddTriangleColor(beginCell.color, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i); AddTriangleUnperturbed(v1, v2, boundary); AddTriangleColor(c1, c2, boundaryColor); } AddTriangleUnperturbed(v2, Perturb(left), boundary); AddTriangleColor(c2, leftCell.color, boundaryColor); }

Неперемещённые границы.

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

Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(right), b);

То же самое справедливо для метода TriangulateCornerCliffTerraces.

Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(left), b);

Дыры пропали.

Двойные обрывы и склон

Во всех оставшихся проблемных случаях присутствуют два обрыва и один склон.

Большая дыра из-за единственного треугольника.

Эта проблема устраняется с помощью ручного перемещения единственного треугольника в блоке else в конце TriangulateCornerTerracesCliff.

else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); }

То же самое относится и к TriangulateCornerCliffTerraces.

else { AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary); AddTriangleColor(leftCell.color, rightCell.color, boundaryColor); }

Избавились от последних трещин.

unitypackage

Доработка

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

Неискажённая и искажённая сетки.

Похоже, что сила 5 для искажения ячеек слишком велика.

Искажение ячеек от 0 до 5.

Это гарантирует нам, что максимальное смещение по XZ будет равно √32 ≈ 5. Давайте уменьшим её до 4, чтобы повысить удобство сетки, не делая при этом её слишком правильной. 66 единицам.

public const float cellPerturbStrength = 4f;

Сила искажения ячеек 4.
Ещё одно значение, которое можно изменять — это коэффициент цельности. Если мы увеличим его, то плоские центры ячеек станут больше, то есть появится больше места для будущего содержимого. Разумеется, при этом они станут более шестиугольными.
Коэффициент цельности от 0.75 до 0.95.

8 немного упростит нашу жизнь в будущем. Небольшое увеличение коэффициента цельности до 0.

public const float solidFactor = 0.8f;

Коэффициент цельности 0.8.

Это удобно, когда нужно убедиться в правильной генерации меша, но мы с этим уже закончили. Наконец, можно заметить, что разницы между уровнями высот слишком резкие. Давайте уменьшим её до 1 единицы на ступень уступа, то есть до 3.

public const float elevationStep = 3f;

Шаг высоты уменьшен до 3.

Но сейчас она имеет значение 1. Также мы можем изменить силу искажения высоты. 5, что равно половине шага высоты, что нас устроит.

Благодаря этому повышается вариативность карты. Небольшие шаги высот позволяют логичнее использовать все семь уровней высоты.

Используем семь уровней высот.

unitypackage

  • Разделяем сетку на фрагменты.
  • Управляем камерой.
  • Раскрашиваем цвета и высоты по отдельности.
  • Используем увеличенную кисть ячеек.

Пока что мы работали с очень маленькой картой. Настало время её увеличить.

Пришла пора увеличить масштаб.

Фрагменты сетки

Мы не можем сделать сетку слишком большой, потому что упрёмся в пределы того, что можно уместить в один меш. Как же решить эту проблему? Использовать несколько мешей. Для этого надо разделить нашу сетку на несколько фрагментов. Мы используем прямоугольные фрагменты постоянного размера.

Разбиение сетки на сегменты 3 на 3.

Определим их в HexMetrics. Давайте используем блоки 5 на 5, то есть по 25 ячеек на фрагмент.

public const int chunkSizeX = 5, chunkSizeZ = 5;

Какой размер фрагмента можно считать подходящим?

При использовании больших фрагментов мешей нужно меньше, но их размер будет больше. Сложно сказать. Но чем меньше фрагменты по размерам, тем лучше они отсекаются пирамидой видимости (frustum culling), что приводит к отрисовке меньшего количества треугольников. При этом снижается количество вызовов отрисовки. Логичнее будет просто выбрать размер и при необходимости подстроить его в дальнейшем.

Теперь мы не можем использовать для сетки любой размер, он должен быть кратным размеру фрагмента. Поэтому давайте изменим HexGrid так, чтобы он задавал её размер не в отдельных ячейках, а во фрагментах. Зададим по умолчанию размер 4 на 3 фрагментов, то есть всего 12 фрагментов или 300 ячеек. Так мы получим удобную тестовую карту.

public int chunkCountX = 4, chunkCountZ = 3;

Мы по-прежнему пользуемся width и height, но теперь они должны стать частными. И переименуем их в cellCountX и cellCountZ. Воспользуйтесь редактором, чтобы переименовать все вхождения этих переменных за один раз. Теперь будет понятно, когда мы имеем дело с количеством фрагментов или ячеек.

// public int width = 6;
// public int height = 6; int cellCountX, cellCountZ;

Указываем размер во фрагментах.

Выделим создание ячеек в отдельный метод, чтобы не засорять Awake. Изменим Awake так, чтобы при необходимости из количества фрагментов вычислялось количество ячеек.

void Awake () { HexMetrics.noiseSource = noiseSource; gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateCells(); } void CreateCells () { cells = new HexCell[cellCountZ * cellCountX]; for (int z = 0, i = 0; z < cellCountZ; z++) { for (int x = 0; x < cellCountX; x++) { CreateCell(x, z, i++); } } }

Префаб фрагмента

Для описания фрагментов сетки нам понадобится новый тип компонентов.

using UnityEngine;
using UnityEngine.UI; public class HexGridChunk : MonoBehaviour {
}

Далее мы создадим префаб фрагмента. Мы сделаем это, продублировав объект Hex Grid и переименовав его в Hex Grid Chunk. Удалим его компонент HexGrid и добавим вместо него компонент HexGridChunk. Затем превратим его в префаб и удалим объект из сцены.

Префаб фрагмента с собственным canvas и мешем.

Так как экземпляры этих фрагментов будет создавать HexGrid, дадим ему ссылку на префаб фрагмента.

public HexGridChunk chunkPrefab;

Теперь с фрагментами.

Мы будем отслеживать их с помощью массива, а для его заполнения воспользуемся двойным циклом. Создание экземпляров фрагментов во многом походит на создание экземпляров ячеек.

HexGridChunk[] chunks; void Awake () { … CreateChunks(); CreateCells(); } void CreateChunks () { chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(transform); } } }

Инициализация фрагмента похожа на то, как мы инициализировали сетку шестиугольников. Она задаёт всё в Awake и выполняет триангуляцию в Start. Для неё требуется ссылка на её canvas и меш, а также массив для ячеек. Однако фрагмент не будет создавать эти ячейки. Этим по-прежнему будет заниматься сетка.

public class HexGridChunk : MonoBehaviour { HexCell[] cells; HexMesh hexMesh; Canvas gridCanvas; void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; } void Start () { hexMesh.Triangulate(cells); }
}

Присвоение ячеек фрагментам

HexGrid по-прежнему создаёт все ячейки. Это нормально, но теперь нам нужно добавлять каждую ячейку к подходящему фрагменту, а не задавать их и с помощью собственного меша и canvas.

void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab);
// cell.transform.SetParent(transform, false); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); cell.color = defaultColor; … Text label = Instantiate<Text>(cellLabelPrefab);
// label.rectTransform.SetParent(gridCanvas.transform, false); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z); label.text = cell.coordinates.ToStringOnSeparateLines(); cell.uiRect = label.rectTransform; cell.Elevation = 0; AddCellToChunk(x, z, cell); } void AddCellToChunk (int x, int z, HexCell cell) { }

Мы можем найти правильный фрагмент с помощью целочисленного деления x и z на размеры фрагмента.

void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; }

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

void AddCellToChunk (int x, int z, HexCell cell) { int chunkX = x / HexMetrics.chunkSizeX; int chunkZ = z / HexMetrics.chunkSizeZ; HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX]; int localX = x - chunkX * HexMetrics.chunkSizeX; int localZ = z - chunkZ * HexMetrics.chunkSizeZ; chunk.AddCell(localX + localZ * HexMetrics.chunkSizeX, cell); }

Затем HexGridChunk.AddCell помещает ячейку в её собственный массив, а потом он задаёт родительские элементы для ячейки и её UI.

public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); }

Подчистка

На этом этапе HexGrid может избавиться от своих дочерних элементов canvas и меша шестиугольников, а также от кода.

// Canvas gridCanvas;
// HexMesh hexMesh; void Awake () { HexMetrics.noiseSource = noiseSource; // gridCanvas = GetComponentInChildren<Canvas>();
// hexMesh = GetComponentInChildren<HexMesh>(); … } // void Start () {
// hexMesh.Triangulate(cells);
// } // public void Refresh () {
// hexMesh.Triangulate(cells);
// }

Так как мы избавились от Refresh, то HexMapEditor больше не должен его использовать.

void EditCell (HexCell cell) { cell.color = activeColor; cell.Elevation = activeElevation;
// hexGrid.Refresh(); }

Очищенная сетка шестиугольников.

Но иерархия объектов станет другой. После запуска режима Play карта по-прежнему выглядит такой же. Hex Grid теперь создаёт дочерние объекты фрагментов, которые содержат ячейки, а также их меш и canvas.

Дочерние фрагменты в режиме Play.

Изначально мы задали ширину метки 5. Возможно, у нас есть какие-то проблемы с метками ячеек. Но теперь у нас могут быть такие координаты, как −10, в которых три символа. Этого было достаточно для отображения двух символов, которых нам хватало на маленькой карте. Чтобы исправить это, увеличим ширину метки ячейки до 10, или даже больше. Они не поместятся и будут урезаны.

Расширенные метки ячеек.

Так как мы генерируем всю сетку при запуске, то на создание больших карт может потребоваться много времени. Теперь мы можем создавать гораздо более крупные карты! Но после завершения у нас будет огромное пространство для экспериментов.

Исправляем редактирование ячеек

На текущем этапе редактирование похоже не работает, потому что мы больше не обновляем сетку. Нам нужно обновлять отдельные фрагменты, поэтому добавим метод Refresh в HexGridChunk.

public void Refresh () { hexMesh.Triangulate(cells); }

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

Проще всего сделать так, чтобы каждая ячейка знала, какому фрагменту она принадлежит. Но как узнать, какой фрагмент нам обновлять? Так что давайте дадим HexCell ссылку на его фрагмент. Тогда ячейка сможет обновлять свой фрагмент при изменении этой ячейки.

public HexGridChunk chunk;

HexGridChunk может при добавлении присваивать себя к ячейке.

public void AddCell (int index, HexCell cell) { cells[index] = cell; cell.chunk = this; cell.transform.SetParent(transform, false); cell.uiRect.SetParent(gridCanvas.transform, false); }

Соединив их, добавим к HexCell и метод Refresh. При каждом обновлении ячейки она будет просто обновлять свой фрагмент.

void Refresh () { chunk.Refresh(); }

Нам необязательно делать HexCell.Refresh общим, потому что сама ячейка лучше знает, когда она были изменена. Например, после того, как была изменена её высота.

public int Elevation { get { return elevation; } set { … Refresh(); } }

На самом деле нам нужно обновлять её только тогда, когда её высота сменилась на другое значение. Ей даже не нужно ничего вычислять заново, если мы назначим ей ту же высоту, что и раньше. Поэтому мы можем выйти из начала сеттера.

public int Elevation { get { return elevation; } set { if (elevation == value) { return; } … } }

Однако при этом мы также пропустим вычисления в первый раз, когда высоте задаётся значение 0, потому что это значение высоты сетки по умолчанию. Чтобы избежать этого, сделаем так, чтобы первоначальным значением было такое, какое мы никогда не используем.

int elevation = int.MinValue;

Что такое int.MinValue?

Это наименьшее значение, которое может иметь integer. Так как в C# integer —
это 32-битное число, то существует 232 возможных integer, разделённых на положительные значения, отрицательные значения и ноль. Для обозначения отрицательности числа используется один бит.

Мы никогда не будем использовать такой уровень высоты! Минимум — это −231 = −2 147 483 648.

Это на единицу меньше 231 из-за нуля. Максимум равен 231 − 1 = 2 147 483 647.

Чтобы распознать изменение цвета ячейки, нам нужно тоже превратить его в свойство. Переименуем его в Color с прописной буквы, а затем превратим в свойство с частной переменной color. Значением цвета по умолчанию будет прозрачный чёрный, что нас устраивает.

public Color Color { get { return color; } set { if (color == value) { return; } color = value; Refresh(); } } Color color;

Теперь при запуске режима Play мы получаем null-reference exceptions. Так получается потому, что мы присвоили цвету и высоте значения по умолчанию до назначения ячейки её фрагменту. Это нормально, что мы на этом этапе не обновляем фрагменты, потому что мы триангулируем их после завершения всей инициализации. Другими словами, мы обновляем фрагмент, только если он назначен.

void Refresh () { if (chunk) { chunk.Refresh(); } }

Мы наконец снова можем изменять ячейки! Однако возникает проблема. При рисовании вдоль границ фрагментов появляются швы.

Ошибки на границах фрагментов.

А эти соседи могут оказаться в других фрагментах. Это логично, потому что при изменении одной ячейки все соединения с её соседями тоже меняются. Простейшее решение заключается в обновлении всех соседних ячеек, если они отличаются.

void Refresh () { if (chunk) { chunk.Refresh(); for (int i = 0; i < neighbors.Length; i++) { HexCell neighbor = neighbors[i]; if (neighbor != null && neighbor.chunk != chunk) { neighbor.chunk.Refresh(); } } } }

Хоть это и работает, у нас может получиться, что мы несколько раз обновляем один фрагмент. А когда мы начнём раскрашивать по несколько ячеек за раз, всё станет ещё хуже.

Вместо этого мы просто запишем, что необходимо обновление, и триангулируем после завершения изменения. Но мы не обязаны сразу же триангулировать после обновления фрагмента.

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

Мы используем LateUpdate вместо Update, чтобы гарантировать, что триангуляция происходит после завершения изменения для текущего кадра.

public void Refresh () {
// hexMesh.Triangulate(cells); enabled = true; } void LateUpdate () { hexMesh.Triangulate(cells); enabled = false; }

В чём разница между Update и LateUpdate?

После их завершения то же самое происходит с методами LateUpdate. В каждом кадре методы Update включённых компонентов на каком-то этапе вызываются в произвольном порядке. То есть существует два этапа обновления, ранний и поздний.

Так как по умолчанию наш компонент по умолчанию включён, нам больше не нужно явным образом выполнять триангуляцию в Start. Поэтому этот метод можно удалить.

// void Start () {
// hexMesh.Triangulate(cells);
// }

Фрагменты 20 на 20, содержащие 10 000 ячеек.

Обобщённые списки

Хотя мы значительно изменили способ триангуляции сетки, HexMesh по-прежнему остаётся тем же. Всё, что ему нужно для работы — это массив ячеек. Ему не важно, один ли меш шестиугольников, или их несколько. Но мы пока не рассматривали использование нескольких мешей. Возможно, здесь можно что-то улучшить?

Они используются только при триангуляции. Используемые HexMesh списки по сути являются временными буферами. Поэтому на самом деле нам нужен только один набор списков, а не по одному набору для каждого объекта меша шестиугольников. А фрагменты триангулируются по одному за раз. Этого можно добиться, сделав списки статическими.

static List<Vector3> vertices = new List<Vector3>(); static List<Color> colors = new List<Color>(); static List<int> triangles = new List<int>(); void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); meshCollider = gameObject.AddComponent<MeshCollider>(); hexMesh.name = "Hex Mesh";
// vertices = new List<Vector3>();
// colors = new List<Color>();
// triangles = new List<int>(); }

Действительно ли статические списки так сильно важны?

Это изменение внести очень просто и оно отражает способ использования списков. Поэтому стоит это сделать, несмотря на то, что пока нас не очень беспокоит производительность.

В случае карты 20 на 20 фрагментов это экономит больше 100МБ. Это немного увеличивает эффективность кода, потому что при использовании обобщённых списков необходимо намного меньше выделяемой памяти.

unitypackage

Управление камерой

Большая камера — это замечательно, но она бесполезна, если мы не можем её увидеть. Чтобы осмотреть всю карту, нам нужно перемещать камеру. Также пригодится изменение масштаба. Поэтому давайте создадим камеру, позволяющую выполнять эти действия.

Сбросим его компонент transform, чтобы он переместился в начало координат, не меняя его поворот и масштаб. Создадим объект-пустышку и назовём его Hex Map Camera. Сделаем основную камеру дочерним элементом Stick, и сбросим её компонент transform. Добавим ему дочерний объект под названием Swivel, а ему добавим дочерний объект Stick.

Иерархия камеры.

Зададим ему поворот (45, 0, 0). Задача шарнира камеры (Swivel) — управление углом, под которым камера смотрит на карту. Зададим ей позицию (0, 0, -45). Ручка (Stick) управляет расстоянием, на котором находится камер.

Назначим этот компонент корню иерархии камеры. Теперь нам нужен компонент для управления этой системой. Дадим ему ссылку на шарнир и ручку, получая их в Awake.

using UnityEngine; public class HexMapCamera : MonoBehaviour { Transform swivel, stick; void Awake () { swivel = transform.GetChild(0); stick = swivel.GetChild(0); }
}

Камера карты шестиугольников.

Зум

Первая функция, которую мы создадим — это изменение масштаба (зум). Мы можем управлять текущим уровнем зума с помощью переменной float. Значение 0 означает, что мы полностью отдалились, а значение 1 — что мы полностью приблизились. Давайте начинать с максимального зума.

float zoom = 1f;

Зум обычно выполнятся колесом мыши или аналоговым управлением. Мы можем реализовать его с помощью оси ввода Mouse ScrollWheel по умолчанию. Добавим метод Update, проверяющий наличие дельты ввода, и при её наличии вызывающего метод изменения зума.

void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } } void AdjustZoom (float delta) { }

Для изменения уровня зума мы просто будем прибавлять к нему дельту, а затем ограничивать значение (clamp), чтобы оставаться в интервале 0–1.

void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); }

При отдалении и приближении зума расстояние до камеры должно соответствующим образом меняться. Это можно сделать, меняя позицию ручки по Z. Добавим две общие переменные float для настройки позиции ручки при минимальном и максимальном зуме. Так как мы разрабатываем относительно небольшую карту, зададим значения -250 и -45.

public float stickMinZoom, stickMaxZoom;

После изменения зума выполняем линейную интерполяцию между этими двумя значениями на основании нового значения зума. Затем обновляем позицию ручки.

void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); }

Минимальное и максимальное значение Stick.

Обычно при отдалении зума камера переходит в вид сверху. Теперь зум работает, но пока он не очень полезен. Поэтому добавим переменные min и max и для шарнира. Мы можем это реализовать поворотом шарнира. Зададим им значения 90 и 45.

public float swivelMinZoom, swivelMaxZoom;

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

void AdjustZoom (float delta) { zoom = Mathf.Clamp01(zoom + delta); float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom); stick.localPosition = new Vector3(0f, 0f, distance); float angle = Mathf.Lerp(swivelMinZoom, swivelMaxZoom, zoom); swivel.localRotation = Quaternion.Euler(angle, 0f, 0f); }

Минимальное и максимальное значение Swivel.

Их можно найти в Edit / Project Settings / Input. Скорость изменения зума можно настраивать изменением чувствительности параметров ввода колеса мыши. 1 на 0. Например, изменив их с 0. 025, мы получим более медленное и плавное изменение зума.

Параметры ввода колеса мыши.

Перемещение

Теперь перейдём к перемещению камеры. Движение в направлении X и Z мы должны реализовать в Update, как и в случае с зумом. Мы можем использовать для этого оси ввода Horizontal и Vertical. Это позволит нам перемещать камеру стрелками и клавишами WASD.

void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustPosition (float xDelta, float zDelta) { }

Простейший подход заключается в получении текущей позиции системы камеры, прибавлении к ней дельт X и Z и присвоении результата позиции системы.

void AdjustPosition (float xDelta, float zDelta) { Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta); transform.localPosition = position; }

Благодаря этому камера будет перемещаться при удерживании стрелок или WASD, но не с постоянной скоростью. Она будет зависеть от частоты кадров. Для определения расстояния, на которое нужно переместиться, мы используем дельту времени, а также требуемую скорость движения. Поэтому добавим общую переменную moveSpeed и зададим ей значение 100, а затем перемножим её на дельту времени, чтобы получить дельту позиции.

public float moveSpeed; void AdjustPosition (float xDelta, float zDelta) { float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += new Vector3(xDelta, 0f, zDelta) * distance; transform.localPosition = position; }

Скорость перемещения.

Но при перемещении вдоль обеих осей одновременно (по диагонали) движение будет быстрее. Теперь мы можем двигаться с постоянной скоростью вдоль осей X или Z. Это позволит использовать его в качестве направления. Чтобы исправить это, нам нужно нормализовать вектор дельты.

void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float distance = moveSpeed * Time.deltaTime; Vector3 position = transform.localPosition; position += direction * distance; transform.localPosition = position; }

Диагональное движение теперь реализовано верно, но внезапно оказывается, что камера продолжает довольно долго двигаться даже после отпускания всех клавиш. Это происходит, потому что оси ввода не мгновенно перескакивают к предельным значениям сразу после нажатия клавиш. Им требуется для этого какое-то время. То же самое справедливо для отпускания клавиш. На возврат к нулевым значениям осей уходит время. Однако, так как мы нормализовали значения ввода, максимальная скорость поддерживается постоянно.

Мы можем применить самое предельное значение осей в качестве коэффициента затухания движения. Мы можем настроить параметры ввода, чтобы избавиться от задержек, но они дают ощущение плавности, которое стоит сохранить.

Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta)); float distance = moveSpeed * damping * Time.deltaTime;

Движение с затуханием.

Но при отдалении оно оказывается слишком медленным. Теперь движение работает хорошо, по крайней мере, при увеличении зума. Это можно сделать, заменив одну переменную moveSpeed на две для минимального и максимального зума, а затем выполнив интерполяцию. При уменьшенном зуме нам нужно ускориться. Присвоим им значения 400 и 100.

// public float moveSpeed; public float moveSpeedMinZoom, moveSpeedMaxZoom; void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized; float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta)); float distance = Mathf.Lerp(moveSpeedMinZoom, moveSpeedMaxZoom, zoom) * damping * Time.deltaTime; Vector3 position = transform.localPosition; position += direction * distance; transform.localPosition = position; }

Скорость движения меняется в зависимости от уровня зума.

На самом деле, мы можем двигаться далеко за пределы карты, но это нежелательно. Теперь мы можем быстро перемещаться по карте! Чтобы обеспечить это, нам нужно знать границы карты, поэтому необходима ссылка на сетку. Камера должна оставаться внутри карты. Добавим и подключим её.

public HexGrid grid;

Нужно запрашивать размер сетки.

После перехода в новую позицию ограничим её с помощью нового метода.

void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = ClampPosition(position); } Vector3 ClampPosition (Vector3 position) { return position; }

Позиция X имеет минимальное значение 0, а максимальное определяется размером карты.

Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); return position; }

То же самое относится к позиции Z.

Vector3 ClampPosition (Vector3 position) { float xMax = grid.chunkCountX * HexMetrics.chunkSizeX * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = grid.chunkCountZ * HexMetrics.chunkSizeZ * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); return position; }

На самом деле, это немного неточно. Исходная точка находится в центре ячейки, а не слева. Поэтому мы хотим, чтобы камера останавливалась и в центре самых правых ячеек. Для этого вычтем половину ячейки из максимума X.

float xMax = (grid.chunkCountX * HexMetrics.chunkSizeX - 0.5f) * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax);

По той же причине нужно уменьшить и максимум Z. Так как метрики немного отличаются, то нам нужно вычесть полную ячейку.

float zMax = (grid.chunkCountZ * HexMetrics.chunkSizeZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax);

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

Это можно сделать, приказав объекту EventSystem не выполнять Send Navigation Events. Можно запретить UI слушать ввод с клавиатуры.

Больше никаких событий навигации.

Поворот

Хотите увидеть, что находится за обрывом? Было бы удобно иметь возможность вращать камеру! Давайте добавим эту функцию.

Добавим общую переменную rotationSpeed и зададим ей значение 180 градусов. Уровень зума не важен для вращения, достаточно только скорости. Проверим дельту поворота в Update, сэмплируя ось Rotation и при необходимости изменяя поворот.

public float rotationSpeed; void Update () { float zoomDelta = Input.GetAxis("Mouse ScrollWheel"); if (zoomDelta != 0f) { AdjustZoom(zoomDelta); } float rotationDelta = Input.GetAxis("Rotation"); if (rotationDelta != 0f) { AdjustRotation(rotationDelta); } float xDelta = Input.GetAxis("Horizontal"); float zDelta = Input.GetAxis("Vertical"); if (xDelta != 0f || zDelta != 0f) { AdjustPosition(xDelta, zDelta); } } void AdjustRotation (float delta) { }

Скорость поворота.

Нам придётся создать её самим. На самом деле оси Rotation по умолчанию нет. Изменим название дубликата на Rotation и сменим клавиши на QE и запятую (,) с точкой (.). Перейдём в параметры ввода и дублируем самую верхнюю запись Vertical.

Ось ввода поворота.

Я скачал unitypackage, почему у меня нет этого ввода?

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

Угол поворота мы будем отслеживать и изменять в AdjustRotation. После чего будем поворачивать всю систему камеры.

float rotationAngle; void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); }

Так как полный круг равен 360 градусам, свернём угол поворота, чтобы он находился в интервале от 0 до 360.

void AdjustRotation (float delta) { rotationAngle += delta * rotationSpeed * Time.deltaTime; if (rotationAngle < 0f) { rotationAngle += 360f; } else if (rotationAngle >= 360f) { rotationAngle -= 360f; } transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f); }

Поворот в действии.

Если проверить его, то можно заметить, что движение абсолютно. Теперь поворот работает. Для пользователя было бы гораздо удобнее, чтобы движение выполнялось относительно углу обзора камеры. Поэтому после поворота на 180 градусов движение станет противоположным ожидаемому. Мы можем это сделать, умножив текущий поворот на направление движения.

void AdjustPosition (float xDelta, float zDelta) { Vector3 direction = transform.localRotation * new Vector3(xDelta, 0f, zDelta).normalized; … }

Относительное перемещение.

unitypackage

Расширенные возможности редактирования

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

Необязательные цвет и высота

Мы можем сделать цвета необязательными, добавив в toggle group пустой вариант выбора. Дублируем один из переключателей цвета и заменим его метку на --- или что-то подобное для обозначения того, что это не цвет. Затем изменим аргумент его события On Value Changed на −1.

Недействительный индекс цвета.

Мы можем использовать его, чтобы определять, нужно ли применять к ячейкам цвет. Разумеется, этот индекс является недействительным для массива цветов.

bool applyColor; public void SelectColor (int index) { applyColor = index >= 0; if (applyColor) { activeColor = colors[index]; } } void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } cell.Elevation = activeElevation; }

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

bool applyElevation = true; void EditCell (HexCell cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } }

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

Необязательные цвет и высота.

Чтобы включить высоту, нужен новый метод, который мы соединим с UI.

public void SetApplyElevation (bool toggle) { applyElevation = toggle; }

Соединив его с переключателем высоты, убедитесь, что в верхней части списка методов используется динамический метод bool. Правильные версии не отображают в инспекторе флажок.

Передаём состояние переключателя высоты.

Или оба варианта, как обычно. Теперь мы можем выбирать только раскрашивание цветами или только высоту. Мы можем даже выбрать не изменять ни то, ни другое, но пока для нас это не особо полезно.

Переключение между цветом и высотой.

Почему при выборе цвета высота отключается?

Вероятно, вы дублировали один из переключателей выбора цвета и изменяете его, но не убрали его toggle group. Такое бывает, когда все переключатели принадлежат к одной toggle group.

Размер кисти

Для поддержки изменяемого размера кисти добавим целочисленную переменную brushSize и метод для её задания через UI. Мы воспользуемся ползунком, поэтому снова должны будем преобразовывать значение из float в int.

int brushSize; public void SetBrushSize (float size) { brushSize = (int)size; }

Ползунок размера кисти.

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

Настройки ползунка размера кисти.

Этот метод будет вызывать EditCell для всех задействованных ячеек. Теперь, когда мы можем редактировать несколько ячеек одновременно, нам нужно использовать метод EditCells. Изначально выбранная ячейка будет считаться центром кисти.

void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } } void EditCells (HexCell center) { } void EditCell (HexCell cell) { … }

Размер кисти определяет радиус редактирования. При радиусе 0 это будет только одна центральная ячейка. При радиусе 1 это будет центр и его соседи. При радиусе 2 включаются соседи центра и их непосредственные соседи. И так далее.

До радиуса 3.

Сначала нам нужны координаты X и Z центра. Для редактирования ячеек нужно обойти их в цикле.

void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; }

Мы находим минимальную координату Z, вычитая радиус. Так мы определяем нулевую строку. Начиная с этой строки, мы выполняем цикл, пока не покроем строку в центре.

void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { } }

Первая ячейка нижней строки имеет ту же координату X, что и центральная ячейка. Эта координата уменьшается при увеличении номера строки.

Последняя ячейка всегда имеет координату X, равную координате центра плюс радиус.

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

for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } }

У нас пока нет метода HexGrid.GetCell с параметром координат, поэтому создадим его. Преобразуем в координаты смещений и получим ячейку.

public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; int x = coordinates.X + z / 2; return cells[x + z * cellCountX]; }

Нижняя часть кисти, размер 2.

В этом случае логика отражена зеркально и центральную строку нужно исключить. Остальную часть кисти мы покроем, выполняя цикл сверху вниз до центра.

void EditCells (HexCell center) { int centerX = center.coordinates.X; int centerZ = center.coordinates.Z; for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) { for (int x = centerX - r; x <= centerX + brushSize; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } for (int r = 0, z = centerZ + brushSize; z > centerZ; z--, r++) { for (int x = centerX - brushSize; x <= centerX + r; x++) { EditCell(hexGrid.GetCell(new HexCoordinates(x, z))); } } }

Вся кисть, размер 2.

Когда такое происходит, мы получаем index-out-of-range exception. Это работает, если только наша кисть не выходит за границы сетки. GetCell и вернём null, когда запрашивается несуществующая ячейка. Чтобы избежать этого, проверим границы в HexGrid.

public HexCell GetCell (HexCoordinates coordinates) { int z = coordinates.Z; if (z < 0 || z >= cellCountZ) { return null; } int x = coordinates.X + z / 2; if (x < 0 || x >= cellCountX) { return null; } return cells[x + z * cellCountX]; }

Чтобы избежать null-reference-exception, HexMapEditor должен перед редактированием проверять, действительно ли существует ячейка.

void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } } }

Использование нескольких размеров кистей.

Переключение видимости меток ячеек

Чаще всего нам не нужно видеть метки ячеек. Поэтому давайте сделаем их необязательными. Так как каждый фрагмент управляет своим собственным canvas, добавим метод ShowUI в HexGridChunk. Когда UI должен быть видим, мы активируем canvas. В противном случае деактивируем его.

public void ShowUI (bool visible) { gridCanvas.gameObject.SetActive(visible); }

Давайте по умолчанию спрячем UI.

void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; ShowUI(false); }

Так как видимость UI переключается для всей карты, добавим метод ShowUI и в HexGrid. Он просто передаёт запрос своим фрагментам.

public void ShowUI (bool visible) { for (int i = 0; i < chunks.Length; i++) { chunks[i].ShowUI(visible); } }

HexMapEditor получает тот же самый метод, передавая запрос сетке.

public void ShowUI (bool visible) { hexGrid.ShowUI(visible); }

Наконец, мы можем добавить переключатель к UI и подключить его.

Переключатель видимости меток.

unitypackage

  • Добавление к ячейкам данных рек.
  • Поддержка перетаскивания для отрисовки рек.
  • Создание русел рек.
  • Использование нескольких мешей на фрагмент.
  • Создание общего пула списков.
  • Триангуляция и анимирование текущей воды.

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

Реки текут с гор.

Ячейки с реками

Существует три способа добавления рек к сетке шестиугольников. Первый способ — позволить им течь от ячейки к ячейке. Именно так это реализовано в Endless Legend. Второй способ — позволить им течь между ячейками, от ребра к ребру. Так это реализовано в Civilization 5. Третий способ — вообще не создавать особых структур рек, а использовать ячейки воды, чтобы предполагать их. Так реки реализованы в Age of Wonders 3.

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

Пять возможных конфигураций рек.

Это ещё больше всё усложнит, особенно течение воды. Мы не будем поддерживать ветвящиеся или сливающиеся реки. Их мы рассмотрим в другом туториале. Также мы пока не будем озадачиваться большими объёмами воды.

Отслеживание рек

Ячейка, по которой течёт река, может рассматриваться одновременно как имеющая входящую и исходящую реку. Если она содержит начало реки, то у неё есть только исходящая река. А если она содержит конец реки, то у неё есть только входящая река. Мы можем хранить эту информацию в HexCell с помощью двух булевых значений.

bool hasIncomingRiver, hasOutgoingRiver;

Но этого недостаточно. Нам нужно знать ещё и направление этих рек. В случае исходящей реки оно указывает, куда та движется. В случае входящей реки оно указывает, откуда она пришла.

bool hasIncomingRiver, hasOutgoingRiver; HexDirection incomingRiver, outgoingRiver;

Нам понадобится эта информация при триангуляции ячеек, поэтому добавим свойства, чтобы иметь к ней доступ. Мы не будем поддерживать задание их напрямую. Для этого мы в дальнейшем добавим отдельный метод.

public bool HasIncomingRiver { get { return hasIncomingRiver; } } public bool HasOutgoingRiver { get { return hasOutgoingRiver; } } public HexDirection IncomingRiver { get { return incomingRiver; } } public HexDirection OutgoingRiver { get { return outgoingRiver; } }

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

public bool HasRiver { get { return hasIncomingRiver || hasOutgoingRiver; } }

Ещё один логичный вопрос: находится ли в ячейке начало или конец реки. Если состояние входящей и исходящей реки разное, то это как раз тот случай. Поэтому сделаем это ещё одним свойством.

public bool HasRiverBeginOrEnd { get { return hasIncomingRiver != hasOutgoingRiver; } }

И наконец, полезно будет знать, течёт ли река через определённое ребро, будь она входящей или исходящей.

public bool HasRiverThroughEdge (HexDirection direction) { return hasIncomingRiver && incomingRiver == direction || hasOutgoingRiver && outgoingRiver == direction; }

Удаление рек

Прежде чем заняться добавлением реки к ячейке, давайте сначала реализуем поддержку удаления рек. Для начала напишем метод удаления только исходящей части реки.

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

public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); }

Но это ещё не всё. Исходящая река должна куда-то двигаться дальше. Поэтому должен существовать сосед со входящей рекой. Нам нужно избавиться и от неё тоже.

public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; Refresh(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.Refresh(); }

Разве река не может вытекать из карты?

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

Удаление реки из ячейки меняет только внешний вид этой ячейки. В отличие от редактирования высоты или цвета, на соседей оно не влияет. Поэтому нам нужно обновлять только саму ячейку, но не её соседей.

public void RemoveOutgoingRiver () { if (!hasOutgoingRiver) { return; } hasOutgoingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(outgoingRiver); neighbor.hasIncomingRiver = false; neighbor.RefreshSelfOnly(); }

Этот метод RefreshSelfOnly просто обновляет фрагмент, к которой принадлежит ячейка. Так как при инициализации сетки мы не меняем реки, то нам не нужно волноваться, был ли уже назначен фрагмент.

void RefreshSelfOnly () { chunk.Refresh(); }

Удаление входящих рек работает аналогично.

public void RemoveIncomingRiver () { if (!hasIncomingRiver) { return; } hasIncomingRiver = false; RefreshSelfOnly(); HexCell neighbor = GetNeighbor(incomingRiver); neighbor.hasOutgoingRiver = false; neighbor.RefreshSelfOnly(); }

А удаление всей реки просто означает удаление и входящей, и исходящей частей реки.

public void RemoveRiver () { RemoveOutgoingRiver(); RemoveIncomingRiver(); }

Добавление рек

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

Для начала, нам ничего не нужно делать, если река уже существует.

public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } }

Далее, нам необходимо убедиться, что в нужном направлении существует сосед. Кроме того, реки не могут течь вверх. Поэтому мы должны завершить операцию, если сосед находится выше.

HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; }

Далее нам нужно очистить предыдущую исходящую реку. А также нам нужно удалить входящую реку, если она накладывается на новую исходящую реку.

RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); }

Теперь мы можем перейти к настройке исходящей реки.

hasOutgoingRiver = true; outgoingRiver = direction; RefreshSelfOnly();

И не забудьте задать входящую реку для другой ячейки после удаления её текущей входящей реки, если она существует.

neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite(); neighbor.RefreshSelfOnly();

Избавляемся от рек, текущих вверх

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

public int Elevation { get { return elevation; } set { … if ( hasOutgoingRiver && elevation < GetNeighbor(outgoingRiver).elevation ) { RemoveOutgoingRiver(); } if ( hasIncomingRiver && elevation > GetNeighbor(incomingRiver).elevation ) { RemoveIncomingRiver(); } Refresh(); } }

unitypackage

Изменение рек

Для поддержки редактирования рек нам нужно добавить в UI переключатель рек. На самом деле. нам нужна поддержка трёх режимов редактирования. Нам нужно или игнорировать реки, или добавлять их, или удалять. Мы можеи использовать простое вспомогательное перечисление переключателей, чтобы отслеживать состояние. Так как мы будем использовать его только внутри редактора, то можно определить его внутри класса HexMapEditor, вместе с полем режима реки.

enum OptionalToggle { Ignore, Yes, No } OptionalToggle riverMode;

И нам понадобится метод для изменения режима реки через UI.

public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; }

Для управления режимом реки добавим в UI три переключателя и соединим их в новую toggle group, как мы это делали с цветами. Я настроил переключатели так, чтобы их метки находились под флажками. Благодаря этому они останутся достаточно тонкими, чтобы уместить все три варианта в одной строке.

UI рек.

Почему бы не использовать раскрывающийся список?

К сожалению dropdown list в Unity не может обрабатывать рекомпиляции и в режиме Play. Если хотите, можете использовать раскрывающийся список. Пункт списка будет утерян, поэтому после рекомпиляции он будет бесполезен.

Распознавание перетаскивания

Для создания реки нам нужны и ячейка, и направление. В данный момент HexMapEditor не предоставляет нам этой информации. Поэтому нам нужно добавить поддержку перетаскивания из одной ячейки в другую.

А для распознавания перетаскивания нам нужно запомнить и предыдущую ячейку. Нам нужно знать, будет ли это перетаскивание правильным, а также определить его направление.

bool isDrag; HexDirection dragDirection; HexCell previousCell;

Изначально, когда перетаскивание не выполняется, предыдущей ячейки нет. То есть когда ввода нет или мы не взаимодействуем с картой, нужно присвоить ей значение null.

void Update () { if ( Input.GetMouseButton(0) && !EventSystem.current.IsPointerOverGameObject() ) { HandleInput(); } else { previousCell = null; } } void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { EditCells(hexGrid.GetCell(hit.point)); } else { previousCell = null; } }

Текущая ячейка — это та, которую мы нашли с помощью пересечения луча с мешем. После завершения редактирования ячеек она обновляется и становится предыдущей ячейкой для нового обновления.

void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); EditCells(currentCell); previousCell = currentCell; } else { previousCell = null; } }

После определения текущей ячейки мы можем сравнить её с предыдущей ячейкой, если она есть. Если мы получаем две разные ячейки, то у нас может быть правильное перетаскивание и нам нужно это проверить. В противном случае это точно не перетаскивание.

if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } EditCells(currentCell); previousCell = currentCell; isDrag = true; }

Как нам проверить перетаскивание? Проверив, является ли текущая ячейка соседом предыдущей. Мы проверяем это, обходя в цикле её соседей. Если мы находим совпадение, то также сразу узнаём направление перетаскивания.

void ValidateDrag (HexCell currentCell) { for ( dragDirection = HexDirection.NE; dragDirection <= HexDirection.NW; dragDirection++ ) { if (previousCell.GetNeighbor(dragDirection) == currentCell) { isDrag = true; return; } } isDrag = false; }

Не создадим ли мы при этом дёрганых перетаскиваний?

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

Благодаря этому следующее перетаскивание не будет сразу же идти в противоположном направлении. Мы можем избежать этого, запоминая предыдущее перетаскивание.

Изменение ячеек

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

void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } else if (isDrag && riverMode == OptionalToggle.Yes) { previousCell.SetOutgoingRiver(dragDirection); } } }

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

else if (isDrag && riverMode == OptionalToggle.Yes) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { otherCell.SetOutgoingRiver(dragDirection); } }

Теперь мы можем редактировать реки, однако пока не видим их. Мы можем убедиться, что это работает, изучая изменённые ячейки в инспекторе отладки.

Ячейка с рекой в инспекторе отладки.

Что такое инспектор отладки (debug inspector)?

Оно открывается значком в верхнем правом углу вкладки. Мы можем переключаться между обычным и отладочным режимами инспектора через его меню вкладки. Находясь в режиме отладки, инспектор отображает сырые данные объектов.

unitypackage

Русла рек между ячейками

При триангуляции реки нам нужно учесть две части: местоположение русла реки и текущую по нему воду. Сначала мы создадим русла, а воду оставим на потом.

Пока мы триангулируем эту область полосой из трёх quad. Простейшая часть реки находится там, где она течёт в соединениях между ячейками. Мы можем добавить к ней русло реки, опустив средний quad и добавив две стенки русла.

Добавление реки к полосе ребра.

Альтернативный подход заключается в использовании четырёх quad. Для этого в случае реки потребуется два дополнительных quad и создастся русло с двумя вертикальными стенками. Тогда мы опустим среднюю вершину, чтобы создать русло с наклонными стенками.

Всегда четыре quad.

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

Добавление вершины ребра

Переход от трёх к четырём на ребро требует создания дополнительной вершины ребра. Перепишем EdgeVertices, сначала переименовав v4 в v5, а затем переименовав v3 в v4. Действия в таком порядке гарантируют, что весь код продолжит ссылаться на правильные вершины. Используйте для этого опцию переименования или рефакторинга вашего редактора, чтобы изменения применились повсюду. В противном случае придётся вручную осматривать весь код и вносить изменения.

public Vector3 v1, v2, v4, v5;

После переименования всего добавим новую v3.

public Vector3 v1, v2, v3, v4, v5;

Добавим новую вершину в конструктор. Она находится посередине между угловыми вершинами. Кроме того, другие вершины должны теперь находиться в ½ и ¾, а не в &frac13; и &frac23;.

public EdgeVertices (Vector3 corner1, Vector3 corner2) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, 0.25f); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 0.75f); v5 = corner2; }

Добавим v3 и в TerraceLerp.

public static EdgeVertices TerraceLerp ( EdgeVertices a, EdgeVertices b, int step) { EdgeVertices result; result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step); result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step); result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step); result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step); result.v5 = HexMetrics.TerraceLerp(a.v5, b.v5, step); return result; }

Теперь HexMesh должен включить дополнительную вершину в веера треугольников ребра.

void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { AddTriangle(center, edge.v1, edge.v2); AddTriangleColor(color); AddTriangle(center, edge.v2, edge.v3); AddTriangleColor(color); AddTriangle(center, edge.v3, edge.v4); AddTriangleColor(color); AddTriangle(center, edge.v4, edge.v5); AddTriangleColor(color); }

А также в его полосы из четырёхугольников.

void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2 ) { AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); AddQuadColor(c1, c2); AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); AddQuadColor(c1, c2); AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); AddQuadColor(c1, c2); AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); AddQuadColor(c1, c2); }

Сравнение четырёх и пяти вершин на ребро.

Высота ложа реки

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

Смещения на один уровень будет достаточно. Давайте зададим это смещение в HexMetrics и выразим его как высоту.

public const float streamBedElevationOffset = -1f;

Мы можем использовать эту метрику для добавления свойства HexCell, чтобы получать вертикальную позицию ложа реки ячейки.

public float StreamBedY { get { return (elevation + HexMetrics.streamBedElevationOffset) * HexMetrics.elevationStep; } }

Создание русла

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

void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; } TriangulateEdgeFan(center, e, cell.Color); if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } }

Изменение средней вершины ребра.

Чтобы закрыть их, нам нужно изменить и другое ребро, а затем триангулировать соединение. Мы можем увидеть, как появляются первые признаки реки, но при этом возникают дыры в рельефе.

void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); bridge.y = neighbor.Position.y - cell.Position.y; EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v5 + bridge ); if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; } … }

Завершённые русла соединений рёбер.

unitypackage

Русла рек, проходящие по ячейке

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

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

Вставляем русло в треугольник.

Это становится очевидным при искажении позиций вершин. Такие русла будут намного длиннее, чем проходящие через соединения ячеек. Поэтому давайте разделим трапецоид на два сегмента, вставив ещё один набор вершин ребра посередине между центром и ребром.

Триангуляция канала.

Если у нас есть река, то мы используем этот метод, а в противном случае оставим веер треугольников. Так как триангуляция с рекой будет сильно отличаться от триангуляции без реки, давайте создадим для неё отдельный метод.

void Triangulate (HexDirection direction, HexCell cell) { Vector3 center = cell.Position; EdgeVertices e = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); } if (direction <= HexDirection.SE) { TriangulateConnection(direction, cell, e); } } void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { }

Дыры, в которых должны быть реки.

Чтобы лучше видеть, что происходит, временно отключим искажение ячейки.

public const float cellPerturbStrength = 0f; // 4f;

Неискажённые вершины.

Триангуляция напрямую сквозь ячейку

Для создания русла напрямую через часть ячейки, мы должны растянуть центр в линию. Эта линия должна иметь ту же ширину, что и русло. Мы можем найти левую вершину, переместившись на ¼ расстояния от центра до первого угла предыдущей части.

void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; }

Аналогично и для правой вершины. В этом случае нам нужен второй угол следующей части.

Vector3 centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; Vector3 centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;

Среднюю линию можно найти, создав вершины ребра между центром и ребром.

EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f) );

Далее изменим среднюю вершину среднего ребра, а также центр, потому что они станут нижними точками русла.

m.v3.y = center.y = e.v3.y;

Теперь мы можем использовать TriangulateEdgeStrip для заполнения пространства между средней линией и линией ребра.

TriangulateEdgeStrip(m, cell.Color, e, cell.Color);

Сжатые русла.

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

Так как среднее ребро находится посередине между ними, то его длина должна быть равна ¾. Если мы примем, что длина внешнего ребра равна 1, то длина центральной линии будет равна ½.

Так как длина среднего ребра равна ¾, то остаётся только ¼, по &frac18; с обеих сторон русла. Ширина русла равна ½ и должна оставаться постоянной.

Относительные длины.

Это значит, что его вторая и четвёртая вершины должны интерполироваться с шестыми долями, а не четвертями. Так как длина среднего ребра равна ¾, то &frac18; становится относительно длины среднего ребра равной &frac16;.

Вместо фиксированных интерполяций для v2 и v4 давайте используем параметр. Мы можем обеспечить поддержку такой альтернативной интерполяции, добавив в EdgeVertices ещё один конструктор.

public EdgeVertices (Vector3 corner1, Vector3 corner2, float outerStep) { v1 = corner1; v2 = Vector3.Lerp(corner1, corner2, outerStep); v3 = Vector3.Lerp(corner1, corner2, 0.5f); v4 = Vector3.Lerp(corner1, corner2, 1f - outerStep); v5 = corner2; }

Теперь мы можем использовать его вместе с &frac16; в HexMesh.TriangulateWithRiver.

EdgeVertices m = new EdgeVertices( Vector3.Lerp(centerL, e.v1, 0.5f), Vector3.Lerp(centerR, e.v5, 0.5f), 1f / 6f );

Прямые русла.

В этом случае мы не можем использовать полосу ребра, поэтому придётся делать это вручную. Сделав русло прямым, мы можем перейти ко второй части трапецоида. Давайте сначала создадим треугольники по бокам.

AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color);

Боковые треугольники.

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

AddTriangle(centerL, m.v1, m.v2); AddTriangleColor(cell.Color); AddQuad(centerL, center, m.v2, m.v3); AddQuadColor(cell.Color); AddQuad(center, centerR, m.v3, m.v4); AddQuadColor(cell.Color); AddTriangle(centerR, m.v4, m.v5); AddTriangleColor(cell.Color);

На самом деле, у нас нет альтернативы AddQuadColor, требующей только одного параметра. Пока она нам не была нужна. Поэтому давайте создадим её.

void AddQuadColor (Color color) { colors.Add(color); colors.Add(color); colors.Add(color); colors.Add(color); }

Завершённые прямые русла.

Начало и завершение триангуляции

Триангуляция части, имеющей только начало или конец реки, достаточно сильно отличается, поэтому требует собственного метода. Поэтому будем проверять это в Triangulate и вызывать соответствующий метод.

if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } }

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

void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); }

Чтобы русло не становилось мелким слишком быстро, средней вершине мы всё равно присвоим высоту ложа реки. Но центр менять не нужно.

m.v3.y = e.v3.y;

Мы можем триангулировать с одной полосой ребра и веером.

TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color);

Точки начала и конца.

Повороты в один шаг

Далее рассмотрим резкие повороты, которые зигзагом проходят между соседними ячейками. Мы будем обрабатывать их тоже в TriangulateWithRiver. Поэтому нам нужно определить, с каким типом реки мы работаем.

Река, идущая зигзагом.

В этом случае мы можем сохранить центральную линию, которую мы уже вычислили. Если у ячейки есть река, текущая в противоположном направлении, а также в направлении, с которым мы работаем, то это должна быть прямая река. В противном случае она возвращается к одной точке, свёртывая центральную линию.

Vector3 centerL, centerR; if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else { centerL = centerR = center; }

Свёрнутые русла-зигзаги.

Если есть, то нам нужно выровнять центральную линию с ребром между этой и соседней частью. Мы можем распознавать резкие повороты, проверяя, есть ли у ячейки река, проходящая через следующую или предыдущую часть ячейки. Другая сторона линии затем становится центром. Мы можем сделать это, поместив соответствующую сторону линии посередине между центром и общим углом.

if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 0.5f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 0.5f); centerR = center; } else { centerL = centerR = center; }

Приняв решение, где находятся левая и правая точки, мы можем определить получившийся центр их усреднением.

if (cell.HasRiverThroughEdge(direction.Opposite())) { … } center = Vector3.Lerp(centerL, centerR, 0.5f);

Смещённое центральное ребро.

Это вызвано поворотом центральной линии на 60°. Хотя канал имеет по обеим сторонам одинаковую ширину, он выглядит довольно сжатым. Вместо интерполяции с ½ используем &frac23;. Можно сгладить этот эффект, слегка увеличив ширину центральной линии.

else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; }

Зигзаг без сжатия.

Двухступенчатые повороты

Оставшиеся случаи находятся между зигзагами и прямыми реками. Это двухступенчатые повороты, создающие мягко изгибающиеся реки.

Извивающаяся река.

Next(). Чтобы различать две возможные ориентации, нам нужно использовать direction. Но давайте сделаем его более удобным, добавив в HexDirection расширяющие методы Next2 и Previous2. Next().

public static HexDirection Previous2 (this HexDirection direction) { direction -= 2; return direction >= HexDirection.NE ? direction : (direction + 6); } public static HexDirection Next2 (this HexDirection direction) { direction += 2; return direction <= HexDirection.NW ? direction : (direction - 6); }

Вернёмся к HexMesh.TriangulateWithRiver. Теперь мы можем распознавать направление нашей извивающейся реки с помощью direction.Next2().

if (cell.HasRiverThroughEdge(direction.Opposite())) { centerL = center + HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f; centerR = center + HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f; } else if (cell.HasRiverThroughEdge(direction.Next())) { centerL = center; centerR = Vector3.Lerp(center, e.v5, 2f / 3f); } else if (cell.HasRiverThroughEdge(direction.Previous())) { centerL = Vector3.Lerp(center, e.v1, 2f / 3f); centerR = center; } else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = centerR = center; } else { centerL = centerR = center; }

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

else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * 0.5f; } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * 0.5f; centerR = center; }

Разумеется, теперь нам нужно добавить такой метод в HexMetrics. Он просто должен усреднять два вектора соседних углов и применять коэффициент цельности.

public static Vector3 GetSolidEdgeMiddle (HexDirection direction) { return (corners[(int)direction] + corners[(int)direction + 1]) * (0.5f * solidFactor); }

Слегка сжатые кривые.

Но они недостаточно длинны, из-за чего русла немного сжаты. Наши центральные линии теперь правильно повёрнуты на 30°. Её расстояние равно внутреннему радиусу, а не внешнему. Так происходит, потому что средняя точка ребра ближе к центру, чем угол ребра. То есть мы работаем с неправильным масштабом.

Нам нужно выполнить обратную операцию. Мы уже выполняем преобразование из внешнего во внутренний радиус в HexMetrics. Поэтому давайте сделаем оба коэффициента преобразования доступными через HexMetrics.

public const float outerToInner = 0.866025404f; public const float innerToOuter = 1f / outerToInner; public const float outerRadius = 10f; public const float innerRadius = outerRadius * outerToInner;

Теперь мы можем перейти к правильному масштабу в HexMesh.TriangulateWithRiver. Русла всё равно останутся немного сжатыми из-за их поворота, но это гораздо менее выражено, чем в случае с зигзагами. Поэтому нам не нужно компенсировать это.

else if (cell.HasRiverThroughEdge(direction.Next2())) { centerL = center; centerR = center + HexMetrics.GetSolidEdgeMiddle(direction.Next()) * (0.5f * HexMetrics.innerToOuter); } else { centerL = center + HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * (0.5f * HexMetrics.innerToOuter); centerR = center; }

Плавные кривые.

unitypackage

Триангуляция по соседству с реками

Наши реки готовы. Но мы пока не триангулировали другие части ячеек, содержащих реки. Теперь мы закроем эти дыры.

Дыры рядом с руслами.

Если ячейка имеет реку, но она не течёт в текущем направлении, то в Triangulate мы будем вызывать новый метод.

if (cell.HasRiver) { if (cell.HasRiverThroughEdge(direction)) { e.v3.y = cell.StreamBedY; if (cell.HasRiverBeginOrEnd) { TriangulateWithRiverBeginOrEnd(direction, cell, center, e); } else { TriangulateWithRiver(direction, cell, center, e); } } else { TriangulateAdjacentToRiver(direction, cell, center, e); } } else { TriangulateEdgeFan(center, e, cell.Color); }

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

void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) ); TriangulateEdgeStrip(m, cell.Color, e, cell.Color); TriangulateEdgeFan(center, m, cell.Color); }

Наложение в кривых и прямых реках.

Сопоставляем русло

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

В этом случае и предыдущее, и следующее направление содержат реку. Давайте начнём с проверки того, находимся ли мы внутри кривой. Если это так, то нам нужно переместить центр к ребру.

if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } } EdgeVertices m = new EdgeVertices( Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f) );

Исправлен случай, когда река течёт с обеих сторон.

Если это так, то перемещаем центр к первому углу. Если у нас есть река в другом направлении, но не в предыдущем, то проверяем, прямая ли она.

if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } }

Исправлена половина наложения с прямой рекой.

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

if (cell.HasRiverThroughEdge(direction.Next())) { if (cell.HasRiverThroughEdge(direction.Previous())) { center += HexMetrics.GetSolidEdgeMiddle(direction) * (HexMetrics.innerToOuter * 0.5f); } else if ( cell.HasRiverThroughEdge(direction.Previous2()) ) { center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f; } } else if ( cell.HasRiverThroughEdge(direction.Previous()) && cell.HasRiverThroughEdge(direction.Next2()) ) { center += HexMetrics.GetSecondSolidCorner(direction) * 0.25f; }

Больше наложений нет.

unitypackage

Обобщение HexMesh

Мы завершили триангуляцию русел. Теперь мы можем заполнить их водой. Так как вода отличается от суши, нам нужно будет использовать другой меш с другими данными вершин и другим материалом. Было бы довольно удобно, если бы мы могли использовать HexMesh и для суши, и для воды. Так что давайте обобщим HexMesh, превратив его в класс, занимающийся данными мешей вне зависимости от того, для чего он используется. Задачу триангуляции его ячеек мы передадим HexGridChunk.

Перемещение метода Perturb

Так как метод Perturb достаточно обобщённый и будет использоваться в разных местах, давайте переместим его в HexMetrics. Для начала переименуем его в HexMetrics.Perturb. Это неверное имя метода, но оно рефакторит весь код для его правильного использования. Если в вашем редакторе кода есть специальный функционал для перемещения методов, то воспользуйтесь им.

Переместив метод внутрь HexMetrics, сделайте его общим и статическим, а потом исправьте его имя.

public static Vector3 Perturb (Vector3 position) { Vector4 sample = SampleNoise(position); position.x += (sample.x * 2f - 1f) * cellPerturbStrength; position.z += (sample.z * 2f - 1f) * cellPerturbStrength; return position; }

Перемещение методов триангуляции

В HexGridChunk заменим переменную hexMesh на общую переменную terrain.

public HexMesh terrain;
// HexMesh hexMesh; void Awake () { gridCanvas = GetComponentInChildren<Canvas>();
// hexMesh = GetComponentInChildren<HexMesh>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; ShowUI(false); }

Далее выполним рефакторинг всех методов Add… из HexMesh в terrain.Add…. Затем переместим все методы Triangulate… в HexGridChunk. После этого можно исправить названия методов Add… в HexMesh и сделать их общими. В результате все сложные методы триангуляции будут находиться HexGridChunk, а простые методы добавления данных к мешу останутся в HexMesh.

Теперь HexGridChunk. Мы ещё не закончили. Кроме того, он не должен больше передавать ячейки как аргумент. LateUpdate должен вызывать собственный метод Triangulate. И он должен делегировать очистку и применение данных меша HexMesh. Поэтому Triangulate может потерять свой параметр.

void LateUpdate () { Triangulate();
// hexMesh.Triangulate(cells); enabled = false; } public void Triangulate () { terrain.Clear();
// hexMesh.Clear();
// vertices.Clear();
// colors.Clear();
// triangles.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply();
// hexMesh.vertices = vertices.ToArray();
// hexMesh.colors = colors.ToArray();
// hexMesh.triangles = triangles.ToArray();
// hexMesh.RecalculateNormals();
// meshCollider.sharedMesh = hexMesh; }

Добавим требуемые методы Clear и Apply в HexMesh.

public void Clear () { hexMesh.Clear(); vertices.Clear(); colors.Clear(); triangles.Clear(); } public void Apply () { hexMesh.SetVertices(vertices); hexMesh.SetColors(colors); hexMesh.SetTriangles(triangles, 0); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; }

А что насчёт SetVertices, SetColors и SetTriangles?

Эти методы стали достаточно недавними дополнениями класса Mesh. Они позволяют задавать данные мешей непосредственно через списки. Это значит, что нам больше не нужно создавать временные массивы при обновлении мешей.

Так как мы не используем подмеши, он всегда равен нулю. Метод SetTriangles имеет второй параметр integer, который является индексом подмеша.

Наконец, вручную присоединим дочерний объект меша к префабу фрагмента. Мы уже не можем делать это автоматически, потому что вскоре добавим второй дочерний объект меша. Переименуем его в Terrain, чтобы обозначить его назначение.

Назначаем рельеф.

Переименование дочернего объекта префаба не работает?

Можно заставить его обновиться, создав экземпляр префаба. Режим проекта не обновляется при изменении имени дочернего элемента префаба. Пока это наилучший способ изменения иерархий объектов префабов. Измените экземпляр, а затем нажмите кнопку Apply, чтобы передать эти изменения и в префаб.

Создание пулов списков

Хотя мы переместили довольно много кода, наша карта по-прежнему должна работать так же, как и раньше. Добавление ещё одного меша на фрагмент этого не изменит. Но если мы будем делать это с нынешним HexMesh, то могут возникнуть ошибки.

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

Вместо этого мы используем статический пул списков. Однако мы не вернёмся к наборам списков для каждого экземпляра HexMesh. По умолчанию такого пулинга не существует, поэтому давайте для начала сами создадим общий класс пула списков.

public static class ListPool<T> { }

Как работает ListPool<T>?

Поместив <T> после объявления класса ListPool, мы сообщаем, что это обобщённый класс. Мы уже использовали обобщённые списки, например List<int> для списка целых чисел. Для обобщения можно использовать любое название, но обычно используют T (от слова template).

Для хранения коллекции списков в пуле мы можем использовать стек. Обычно я не использую списки, потому что Unity их не сериализует, но в данном случае это неважно.

using System.Collections.Generic; public static class ListPool<T> { static Stack<List<T>> stack = new Stack<List<T>>();
}

Что означает Stack<List<T>>?

Он означает, что нам нужен стек списков. Это пример встроенных обобщённых типов. А содержание списков зависит от пула.

Добавим общий статический метод для получения списка из пула. Если стек не пуст, мы извлечём верхний список и вернём этот. В противном случае мы создадим новый список на месте.

public static List<T> Get () { if (stack.Count > 0) { return stack.Pop(); } return new List<T>(); }

Для многократного использования списков нужно добавлять их в пул после завершения работы с ними. ListPool будет заниматься очисткой списка и его записью в стек.

public static void Add (List<T> list) { list.Clear(); stack.Push(list); }

Теперь мы можем использовать пулы в HexMesh. Заменим статические списки нестатическими частными ссылками. Пометим их как NonSerialized, чтобы Unity не занималась их сохранением при рекомпиляции. Или запишем System.NonSerialized, или добавим using System; в начале скрипта.

[NonSerialized] List<Vector3> vertices; [NonSerialized] List<Color> colors; [NonSerialized] List<int> triangles; // static List<Vector3> vertices = new List<Vector3>();
// static List<Color> colors = new List<Color>();
// static List<int> triangles = new List<int>();

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

public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); colors = ListPool<Color>.Get(); triangles = ListPool<int>.Get(); }

После применения данных мешей они нам больше не нужны, поэтому здесь мы можем добавить их в пулы.

public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); hexMesh.SetColors(colors); ListPool<Color>.Add(colors); hexMesh.SetTriangles(triangles, 0); ListPool<int>.Add(triangles); hexMesh.RecalculateNormals(); meshCollider.sharedMesh = hexMesh; }

Так мы реализовали многократное использование списков вне зависимости от того, сколько мешей мы заполняем одновременно.

Необязательный коллайдер

Хотя нашему рельефу нужен коллайдер, для рек он на самом деле не требуется. Лучи будут просто проходить сквозь воду и пересекаться с руслом под ней. Давайте сделаем так, чтобы можно было настраивать наличие коллайдера для HexMesh. Реализуем это добавлением общего поля bool useCollider. Для рельефа мы его включим.

public bool useCollider;

Использование коллайдера меша.

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

void Awake () { GetComponent<MeshFilter>().mesh = hexMesh = new Mesh(); if (useCollider) { meshCollider = gameObject.AddComponent<MeshCollider>(); } hexMesh.name = "Hex Mesh"; } public void Apply () { … if (useCollider) { meshCollider.sharedMesh = hexMesh; } … }

Необязательные цвета

Цвета вершин тоже могут быть необязательными. Они нужны нам для демонстрации различных типов рельефа, но вода не меняет цвета. Мы можем сделать их необязательными так же, как сделали необязательным коллайдер.

public bool useCollider, useColors; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } … }

Разумеется, рельеф должен использовать цвета вершин, поэтому включим их.

Использование цветов.

Необязательные UV

Пока мы занимаемся этим, можно также добавить поддержку необязательных UV-координат. Хотя рельеф их не использует, они понадобятся нам для воды.

public bool useCollider, useColors, useUVCoordinates; [NonSerialized] List<Vector2> uvs; public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useColors) { colors = ListPool<Color>.Get(); } if (useUVCoordinates) { uvs = ListPool<Vector2>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useColors) { hexMesh.SetColors(colors); ListPool<Color>.Add(colors); } if (useUVCoordinates) { hexMesh.SetUVs(0, uvs); ListPool<Vector2>.Add(uvs); } … }

Не используем UV-координаты.

Чтобы использовать эту функцию, создадим методы для добавления UV-координат треугольникам и четырёхугольникам.

public void AddTriangleUV (Vector2 uv1, Vector2 uv2, Vector3 uv3) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); } public void AddQuadUV (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) { uvs.Add(uv1); uvs.Add(uv2); uvs.Add(uv3); uvs.Add(uv4); }

Давайте добавим дополнительный метод AddQuadUV для удобного добавления прямоугольной UV-области. Это стандартный случай, когда quad и его текстура совпадают, мы будем использовать его для воды реки.

public void AddQuadUV (float uMin, float uMax, float vMin, float vMax) { uvs.Add(new Vector2(uMin, vMin)); uvs.Add(new Vector2(uMax, vMin)); uvs.Add(new Vector2(uMin, vMax)); uvs.Add(new Vector2(uMax, vMax)); }

unitypackage

Текущие реки

Наконец настало время для создания воды! Мы будем делать это с помощью quad, которые будут обозначать поверхность воды. И так как мы работаем с реками, вода должна течь. Для этого мы используем UV-координаты, обозначающие ориентацию реки. Чтобы визуалировать это, нам нужен новый шейдер. Поэтому создадим новый стандартный шейдер и назовём его River. Изменим его так, чтобы UV-координаты записывались в зелёный и красный каналы albedo.

Shader "Custom/River" { … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb * IN.color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; o.Albedo.rg = IN.uv_MainTex; } ENDCG } FallBack "Diffuse"
}

Добавим в HexGridChunk общее поле HexMesh rivers. Очистим и применим его так же, как в случае с рельефом.

public HexMesh terrain, rivers; public void Triangulate () { terrain.Clear(); rivers.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); }

Будут ли у нас дополнительные вызовы отрисовки, даже если у нас нет рек?

Поэтому меш реки отрисовывается тогда, когда что-то можно увидеть. Движок Unity достаточно умён, чтобы не тратить время на отрисовку пустого меша.

Изменим префаб (через экземпляр), продублировав его объект рельефа, переименовав его в Rivers и подключив его.

Префаб фрагмента с реками.

Также настроим компонент меша шестиугольников объекта таким образом, чтобы он использовал UV-координаты, но не использовал ни цвета вершин, ни коллайдер. Создадим материал River, использующий наш новый шейдер, и сделаем так, чтобы его использовал объект Rivers.

Подобъект Rivers.

Триангулируем воду

Прежде чем мы сможем триангулировать воду, нам нужно определиться с уровнем её поверхности. Давайте сделаем её смещением высоты в HexMetrics, как мы делали с ложем реки. Так как вертикальные искажения ячейки равны половине смещения высоты, давайте используем её и для смещения поверхности реки. Так мы гарантируем, что вода никогда не окажется над рельефом ячейки.

public const float riverSurfaceElevationOffset = -0.5f;

Почему бы не сделать её немного ниже?

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

Добавим HexCell свойство для получения вертикальной позиции поверхности его реки.

public float RiverSurfaceY { get { return (elevation + HexMetrics.riverSurfaceElevationOffset) * HexMetrics.elevationStep; } }

Теперь мы можем приступить к работе в HexGridChunk! Так как мы будем создавать множество четырёхугольников рек, давайте добавим для этого отдельный метод. Дадим ему в качестве параметров четыре вершины и высоту. Это позволит нам удобно задавать вертикальную позицию всех четырёх вершин одновременно перед добавлением quad.

void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); }

Мы добавим здесь и UV-координаты четырёхугольника. Просто обойдём слева направо и снизу вверх.

rivers.AddQuad(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0f, 1f);

TriangulateWithRiver — это первый метод, к которому мы будем добавлять четырёхугольники рек. Первый quad находится между центром и серединой. Второй находится между серединой и ребром. Мы просто используем вершины, которые у нас уже есть. Так как эти вершины будут занижены, то вода в результате окажется частично под наклонными стенками русла. Поэтому нам не нужно беспокоиться о точной позиции края воды.

void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateRiverQuad(centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY); TriangulateRiverQuad(m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY); }

Первые признаки воды.

Почему меняется ширина воды?

БОльшая высота ячейки приводит к более крутым и высоким стенкам русла. Это происходит, потому что высота ячейки искажена, а ложе и поверхность реки — нет. Это сужает реку на поверхности воды.

Двигаемся с потоком

В настоящий момент UV-координаты не согласуются по направлению с течением реки. Нам нужно поддерживать здесь согласованность. Допустим, координата U будет равна 0 в левой части реки, и 1 в правой, при взгляде вниз по течению. А координата V должна изменяться от 0 до 1 в направлении течения реки.

Чтобы упростить работу, добавим в TriangulateRiverQuad параметр bool reversed. При использовании такой спецификации UV будут корректными при триангуляции исходящей реки, но окажутся неверными и их нужно будет перевернуть при триангуляции входящей реки. Воспользуемся им для переворачивания UV при необходимости.

void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { v1.y = v2.y = v3.y = v4.y = y; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } }

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

bool reversed = cell.IncomingRiver == direction; TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed );

Согласованное направление рек.

Начало и конец реки

Внутри TriangulateWithRiverBeginOrEnd нам нужно только проверять, есть ли у нас входящая река, чтобы определить направление потока. Затем мы можем вставить ещё один quad реки между серединой и ребром.

void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … bool reversed = cell.HasIncomingRiver; TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed ); }

Часть между центром и серединой — это треугольник, поэтому мы не можем использовать TriangulateRiverQuad. Единственная значимая разница здесь в том, что центральная вершина находится в середине реки. Поэтому её координата U всегда равна ½.

center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) ); }

Вода в начале и конце.

По концам есть отсутствующие части воды?

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

Однако это заметно только потому, что мы пока не исказили вершины. Мы можем избавиться от этого различия, отзеркалив четырёхугольники. После искажения симметрия всё равно нарушится.

Течение между ячейками

При добавлении воды между ячейками мы должны быть внимательны к разнице высот. Чтобы вода могла стекать по склонам и обрывам, TriangulateRiverQuad должен поддерживать два параметра высоты. Поэтому давайте добавим второй.

void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, bool reversed ) { v1.y = v2.y = y1; v3.y = v4.y = y2; rivers.AddQuad(v1, v2, v3, v4); if (reversed) { rivers.AddQuadUV(1f, 0f, 1f, 0f); } else { rivers.AddQuadUV(0f, 1f, 0f, 1f); } }

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

void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, reversed); }

Теперь мы можем добавлять quad реки и в TriangulateConnection. Находясь между ячейками, мы не можем сразу же узнать, с каким типом реки имеем дело. Чтобы определить, нужно ли переворачивание, мы должны проверить, есть ли у нас входящая река, и движется ли она в нашем направлении.

if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, cell.HasIncomingRiver && cell.IncomingRiver == direction ); }

Завершённая река.

Растягивание координат V

Пока в каждом сегменте реки у нас есть координаты V, идущие от 0 до 1. То есть на ячейку их всего четыре. Пять, если мы также добавим соединения между ячейками. Чем бы мы ни воспользовались для текстурирования реки, оно должно повторяться именно столько раз.

Это можно сделать, увеличив координату V в каждом сегменте на 0. Мы можем снизить количество повторений, растянув координаты V, чтобы они шли от 0 до 1 на протяжении всей ячейки плюс одного соединения. Если мы поставим в центр 0. 2. 6, а на ребре достигнет 0. 4, то в середине она станет 0. Тогда в соединении ячейки значение будет 1. 8.

4, но в середине оно становится 0. Если река течёт в противоположном направлении, мы по-прежнему можем поставить в центр 0. Если мы продолжим это до соединения ячейки, то в результате получим -0. 2, а на ребре — 0. Это нормально, потому что аналогично 0. 2. 8 для текстуры с режимом фильтрации repeat, так же, как 0 эквивалентен 1.

Изменение координат V.

Чтобы создать поддержку этого, нам нужно добавить в TriangulateRiverQuad ещё один параметр.

void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, float v, bool reversed ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed); } void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float v, bool reversed ) { … }

Когда направление не перевёрнуто, мы просто используем передаваемую координату внизу четырёхугольника и прибавляем 0.2 наверху.

else { rivers.AddQuadUV(0f, 1f, v, v + 0.2f); }

Мы можем работать с перевёрнутым направлением, вычитая координату из 0.8 и 0.6.

if (reversed) { rivers.AddQuadUV(1f, 0f, 0.8f - v, 0.6f - v); }

Теперь мы должны передавать правильные координаты, как будто имеем дело с исходящей рекой. Начнём с TriangulateWithRiver.

TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, 0.4f, reversed ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed );

Тогда TriangulateConnection изменим следующим образом.

TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction );

И наконец TriangulateWithRiverBeginOrEnd.

TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed ); center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); if (reversed) { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(1f, 0.2f), new Vector2(0f, 0.2f) ); } else { rivers.AddTriangleUV( new Vector2(0.5f, 0.4f), new Vector2(0f, 0.6f), new Vector2(1f, 0.6f) ); }

Растянутые координаты V.

Чтобы корректно отображать сворачивание координат V, сделаем так, чтобы в шейдере реки они оставались положительными.

if (IN.uv_MainTex.y < 0) { IN.uv_MainTex.y += 1; } o.Albedo.rg = IN.uv_MainTex;

Свёрнутые координаты V.

unitypackage

Анимирование рек

Закончив с UV-координатами, мы можем перейти к анимированию рек. Этим займётся шейдер реки, чтобы нам не пришлось постоянно обновлять меш.

Пока мы создадим простой эффект, дающий понимание о том, как работает анимация. Мы не будем создавать в этом туториале сложный шейдер реки, а займёмся этим позже.

Unity позволяет получать его значение с помощью переменной _Time. Анимация создаётся сдвигом координат V на основании времени игры. В других компонентах содержатся различные масштабы времени. Её компонент Y содержит неизменённое время, которое мы и используем.

Вместо этого будем вычитать текущее время из координаты V. Избавимся от сворачивания по V, потому что оно нам больше не понадобится. Это сдвигает координату вниз, что создаёт иллюзию текущей вниз по течению реки.

// if (IN.uv_MainTex.y < 0) {
// IN.uv_MainTex.y += 1;
// } IN.uv_MainTex.y -= _Time.y; o.Albedo.rg = IN.uv_MainTex;

Через одну секунду координата V во всех точках станет меньше нуля, поэтому мы больше не будем видеть разницы. Повторюсь, это нормально при использовании фильтрации в режиме повтора текстур. Но чтобы видеть, что происходит, мы можем брать дробную часть координаты V.

IN.uv_MainTex.y -= _Time.y; IN.uv_MainTex.y = frac(IN.uv_MainTex.y); o.Albedo.rg = IN.uv_MainTex;

Анимированные координаты V.

Использование шума

Теперь наша река анимирована, но в направлении и скорости присутствуют резкие переходы. Наш UV-паттерн делает их довольно очевидными, но их будет сложнее распознать, если использовать более похожий на воду паттерн. Поэтому вместо отображения сырых UV давайте будем сэмплировать текстуру. Мы можем воспользоваться уже имеющейся у нас текстурой шума. Сэмплируем её и умножим цвет материала на первый канал шума.

void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.uv_MainTex; uv.y -= _Time.y; float4 noise = tex2D(_MainTex, uv); fixed4 c = _Color * noise.r; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

Назначим текстуру шума материалу реки, и убедимся, что он имеет белый цвет.

Использование текстуры шума.

К сожалению, течение при этом оказывается не очень красивым. Так как координаты V очень растянуты, текстура шума тоже растягивается вдоль реки. Одной шестнадцатой будет достаточно. Давайте попробуем растянуть его другим способом — сильно снизив масштаб координат U. Это значит, что мы будем сэмплировать только узкую полосу текстуры шума.

float2 uv = IN.uv_MainTex; uv.x *= 0.0625; uv.y -= _Time.y;

Растягивание координаты U.

Давайте также замедлим течение до четверти в секунду, чтобы завершение цикла текстуры занимало четыре секунды.

uv.y -= _Time.y * 0.25;

Текущий шум.

Смешивание шума

Всё уже выглядит гораздо лучше, но паттерн всегда остаётся одинаковым. Вода себя так не ведёт.

Это делается прибавлением к координате U времени. Так как мы используем только небольшую полосу шума, то можем варьировать паттерн, сдвигая эту полосу вдоль текстуры. Давайте попробуем коэффициент 0. Мы должны делать это медленно, в противном случае будет казаться, что река течёт вбок. Это значит, что для полного цикла паттерна потребуется 200 секунд. 005.

uv.x = uv.x * 0.0625 + _Time.y * 0.005;

Сдвигающийся шум.

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

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

float2 uv = IN.uv_MainTex; uv.x = uv.x * 0.0625 + _Time.y * 0.005; uv.y -= _Time.y * 0.25; float4 noise = tex2D(_MainTex, uv); float2 uv2 = IN.uv_MainTex; uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052; uv2.y -= _Time.y * 0.23; float4 noise2 = tex2D(_MainTex, uv2); fixed4 c = _Color * (noise.r * noise2.a);

Комбинирование двух сдвигающихся паттернов шума.

Полупрозрачная вода

Наш паттерн выглядит достаточно динамичным. Следующий шаг — сделаем его полупрозрачным.

Можно отключить их через компонент renderer объекта Rivers в префабе. Во-первых, убедимся, что вода не отбрасывает теней.

Отбрасывание теней отключено.

Для обозначения этого нужно использовать тэги шейдера. Теперь переключим шейдер в transparent mode. Пока мы здесь, можно удалить ключевое слово fullforwardshadows, потому что мы всё равно не отбрасываем тени. Затем добавим в строку #pragma surface ключевое слово alpha.

Tags { "RenderType"="Transparent" "Queue"="Transparent" } LOD 200 CGPROGRAM #pragma surface surf Standard alpha // fullforwardshadows #pragma target 3.0

Теперь мы изменим способ задания цвета реки. Вместо умножения шума на цвет будем прибавлять к нему шум. Затем используем функцию saturate для ограничения результата, чтобы он не превышал 1.

fixed4 c = saturate(_Color + noise.r * noise2.a);

Это позволит нам использовать в качестве базового цвета цвет материала. Шум увеличит его яркость и непрозрачность. Попробуем использовать синий цвет с достаточно низкой непрозрачностью (opacity). В результате мы получим голубую полупрозрачную воду с белыми вкраплениями.

Цветная полупрозрачная вода.

unitypackage

Доработка

Теперь, когда всё вроде бы работает, настало время снова исказить вершины. Кроме деформирования рёбер ячеек это сделает неровными наши реки.

public const float cellPerturbStrength = 4f;

Неискажённые и искажённые вершины.

Похоже, что они есть! Изучим рельеф на предмет возникших из-за искажений проблем. Давайте проверим высокие водопады.

Вода, усечённая обрывами.

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

Хотя вода в реальности так не течёт, это не особо заметно. Гораздо менее очевидно то, что водопады могут быть наклонными, а не спускаться прямо вниз. Поэтому просто проигнорируем это. Наш мозг проинтерпретирует это так, что нам покажется это нормальным.

Так мы создадим больше пространства между поверхностью воды и ложем реки. Проще всего избежать пропадания воды углублением русел рек. Давайте зададим HexMetrics.streamBedElevationOffset значение -1. Также это сделает стенки русла более вертикальными, поэтому не стоит слишком углубляться. Это решит основную часть проблем, а ложе при этом не станет слишком глубоким. 75. Часть воды всё равно будет обрезанной, но не водопады целиком.

public const float streamBedElevationOffset = -1.75f;

Углублённые русла.

unitypackage

  • Добавляем поддержку дорог.
  • Триангулируем дороги.
  • Комбинируем дороги и реки.
  • Улучшаем внешний вид дорог.

Первые признаки цивилизации.

Ячейки с дорогами

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

То есть всего получается четырнадцать возможных конфигураций дорог. Если позволить дорогам идти во всех шести направлениях, то ячейка может содержать от нуля до шести дорог. Чтобы справиться с этим, нам нужно использовать более общий подход, способный обрабатывать все конфигурации. Это гораздо больше, чем пять возможных конфигураций для рек.

14 возможных конфигураций дорог.

Отслеживание дорог

Простейший способ отслеживания дорог в ячейке заключается в использовании массива булевых значений. Добавим частное поле массива в HexCell и сделаем его сериализуемым, чтобы можно было видеть его в инспекторе. Зададим размер массива через префаб ячейки, чтобы он поддерживал шесть дорог.

[SerializeField] bool[] roads;

Префаб ячейки с шестью дорогами.

Добавим метод, позволяющий проверять, имеет ли ячейка дорогу в определённом направлении.

public bool HasRoadThroughEdge (HexDirection direction) { return roads[(int)direction]; }

Будет также удобно знать, есть ли в ячейке хотя бы одна дорога, поэтому добавим для этого свойство. Просто обойдём массив в цикле и будем возвращать true, как только найдём дорогу. Если дорог нет, то возвращаем false.

public bool HasRoads { get { for (int i = 0; i < roads.Length; i++) { if (roads[i]) { return true; } } return false; } }

Удаление дорог

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

public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { roads[i] = false; } } }

Разумеется, нам также нужно отключить соответствующие дорогие в соседях ячейки.

if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; }

После этого нам нужно обновить каждую из ячеек. Так как дороги являются локальными для ячеек, нам нужно обновлять только сами ячейки без их соседей.

if (roads[i]) { roads[i] = false; neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false; neighbors[i].RefreshSelfOnly(); RefreshSelfOnly(); }

Добавление дорог

Добавление дорог аналогично удалению дорог. Единственная разница в том, что мы присваиваем булевой переменной значение true, а не false. Мы можем создать частный метод, способный выполнять обе операции. Тогда можно будет использовать его и для добавления, и для удаления дороги.

public void AddRoad (HexDirection direction) { if (!roads[(int)direction]) { SetRoad((int)direction, true); } } public void RemoveRoads () { for (int i = 0; i < neighbors.Length; i++) { if (roads[i]) { SetRoad(i, false); } } } void SetRoad (int index, bool state) { roads[index] = state; neighbors[index].roads[(int)((HexDirection)index).Opposite()] = state; neighbors[index].RefreshSelfOnly(); RefreshSelfOnly(); }

У нас не может быть одновременно и реки, и дороги, идущих в одном направлении. Поэтому перед добавлением дороги будем проверять, есть ли для неё место.

public void AddRoad (HexDirection direction) { if (!roads[(int)direction] && !HasRiverThroughEdge(direction)) { SetRoad((int)direction, true); } }

Кроме того, дороги нельзя комбинировать с обрывами, потому что они слишком резкие. Или возможно, стоит прокладывать дорогу через низкий обрыв, но не через высокий? Чтобы определить это, нам нужно создать метод, сообщающий нам разницу высот в определённом направлении.

public int GetElevationDifference (HexDirection direction) { int difference = elevation - GetNeighbor(direction).elevation; return difference >= 0 ? difference : -difference; }

Теперь мы можем сделать так, чтобы дороги добавлялись при достаточно малой разнице высот. Я ограничусь только склонами, то есть максимумом в 1 единицу.

public void AddRoad (HexDirection direction) { if ( !roads[(int)direction] && !HasRiverThroughEdge(direction) && GetElevationDifference(direction) <= 1 ) { SetRoad((int)direction, true); } }

Удаление неправильных дорог

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

При этом всегда будут обновляться обе ячейки, поэтому нам больше не нужно явным образом вызывать RefreshSelfOnly в SetOutgoingRiver. Нам достаточно будет задать для дороги false, вне зависимости от того, была ли дорога.

public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } HexCell neighbor = GetNeighbor(direction); if (!neighbor || elevation < neighbor.elevation) { return; } RemoveOutgoingRiver(); if (hasIncomingRiver && incomingRiver == direction) { RemoveIncomingRiver(); } hasOutgoingRiver = true; outgoingRiver = direction;
// RefreshSelfOnly(); neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite();
// neighbor.RefreshSelfOnly(); SetRoad((int)direction, false); }

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

public int Elevation { get { return elevation; } set { … for (int i = 0; i < roads.Length; i++) { if (roads[i] && GetElevationDifference((HexDirection)i) > 1) { SetRoad(i, false); } } Refresh(); } }

unitypackage

Редактирование дорог

Редактирование дорог работает точно так же, как и редактирование рек. Поэтому для HexMapEditor требуется ещё один переключатель, плюс метод для задания его состояния.

OptionalToggle riverMode, roadMode; public void SetRiverMode (int mode) { riverMode = (OptionalToggle)mode; } public void SetRoadMode (int mode) { roadMode = (OptionalToggle)mode; }

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

void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } if (roadMode == OptionalToggle.No) { cell.RemoveRoads(); } if (isDrag) { HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite()); if (otherCell) { if (riverMode == OptionalToggle.Yes) { otherCell.SetOutgoingRiver(dragDirection); } if (roadMode == OptionalToggle.Yes) { otherCell.AddRoad(dragDirection); } } } } }

Мы можем быстро добавить в UI панель дорог, скопировав панель рек и изменив метод, вызываемый переключателями.

Чтобы исправить это, я изменил схему панели цветов, чтобы он соответствовал более компактным панелям дорог и рек. В результате у нас получится довольно высокий UI.

UI с дорогами.

Поэтому я добавил пункт для оранжевого. Так как теперь я использую для цветов две строки по три варианта, есть место ещё для одного цвета.

Пять цветов: жёлтый, зелёный, синий, оранжевый и белый.

Можно использовать инспектор, чтобы убедиться, что всё работает. Теперь мы можем редактировать дороги, однако пока они не видны.

Ячейка с дорогами в инспекторе.

unitypackage

Триангулирование дорог

Для отображения дорог их нужно триангулировать. Это аналогично созданию меша для рек, только в рельефе не будет появляться русла.

Сначала создадим новый стандартный шейдер, который для раскраски поверхности дороги снова будет использовать UV-координаты.

Shader "Custom/Road" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = fixed4(IN.uv_MainTex, 1, 1); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; } ENDCG } FallBack "Diffuse"
}

Создадим материал дороги, использующий этот шейдер.

Материал Road.

Этот меш не должен отбрасывать теней и обязан использовать только UV-координаты. Настроим префаб фрагмента так, чтобы он получал ещё один дочерний меш шестиугольников для дорог. Быстрее всего это сделать через экземпляр префаба — дублировать объект Rivers и заменить его материал.

Дочерний объект Roads.

Соединим его в инспекторе с объектом Roads. После этого добавим к HexGridChunk общее поле HexMesh roads и включим его в Triangulate.

public HexMesh terrain, rivers, roads; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); }

Объект Roads соединён.

Дороги между ячейками

Давайте сначала рассмотрим сегменты дорог между ячейками. Как и реки, дороги закрывают два средних quad. Мы полностью закроем эти четырёхугольники соединений четырёхугольниками дорог, чтобы можно было использовать позиции тех же шести вершин. Добавим для этого в HexGridChunk метод TriangulateRoadSegment.

void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); }

Так как нам больше не нужно беспокоиться о течении воды, координата V не потребуется, поэтому мы присвоим ей везде значение 0. Мы можем использовать координату U, чтобы обозначить, находимся ли мы посередине дороги или сбоку. Давайте в середине она будет равна 1, а по обоим бокам равна 0.

void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6 ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); roads.AddQuadUV(0f, 1f, 0f, 0f); roads.AddQuadUV(1f, 0f, 0f, 0f); }

Сегмент дороги между ячейками.

Добавим к методу булев параметр для передачи этой информации. Логично будет вызывать этот метод в TriangulateEdgeStrip, но только в том случае, если дорога на самом деле есть.

void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad ) { … }

Разумеется, теперь мы будем получать ошибки компилятора, потому что пока эта информация пока не передаётся. В качестве последнего аргумента во всех случаях вызова TriangulateEdgeStrip можно добавить false. Однако мы можем также объявить, что по умолчанию значение этого параметра равно false. Благодаря этому параметр станет необязательным и ошибки компиляции исчезнут.

void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { … }

Как работают необязательные параметры (optional parameters)?

Воспринимайте их как сокращение вместо написания альтернативных методов, заполняющее отсутствующие аргументы. Например, метод

int MyMethod (int x = 1, int y = 2) { return x + y; }

эквивалентен трём методам

int MyMethod (int x, int y) { return x + y; } int MyMethod (int x) { return MyMethod(x, 2); } int MyMethod () { return MyMethod(1, 2}; }

Порядок здесь важен. Необязательные параметры можно пропускать справа налево. Последний параметр отбрасывается первым. Они всегда идут после обязательных параметров.

Для триангуляции дороги просто вызовем TriangulateRoadSegment со средними шестью вершинами, если он необходим.

void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, EdgeVertices e2, Color c2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuadColor(c1, c2); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } }

Так мы обрабатываем плоские соединения ячеек. Для поддержки дорог на уступах нам также нужно сообщить TriangulateEdgeTerraces, куда следует добавить дорогу. Он может просто передать эту информацию TriangulateEdgeStrip.

void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, 1); TriangulateEdgeStrip(begin, beginCell.Color, e2, c2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, endCell.Color, hasRoad); }

TriangulateEdgeTerraces вызывается внутри TriangulateConnection. Именно здесь мы можем определить, есть ли на самом деле дорога, идущая в текущем направлении, и при триангуляции ребра, и при триангуляции уступов.

if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces( e1, cell, e2, neighbor, cell.HasRoadThroughEdge(direction) ); } else { TriangulateEdgeStrip( e1, cell.Color, e2, neighbor.Color, cell.HasRoadThroughEdge(direction) ); }

Сегменты дороги между ячейками.

Рендеринг поверх ячеек

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

Это происходит, потому что треугольники дорог точно накладываются на треугольники рельефа. Однако при перемещении камеры сегменты могут мерцать, а иногда и полностью пропаадать. Устранить эту проблему можно в два этапа. Треугольники для рендеринга выбираются произвольным образом.

Этого можно добиться, выполняя их рендеринг после отрисовки обычной геометрии, то есть поместив их в более позднюю очередь рендеринга. Во-первых, мы хотим отрисовывать дороги после отрисовки рельефа.

Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" }

Во-вторых, нам нужно гарантировать, что дороги отрисовываются поверх треугольников рельефа, находящихся в той же позиции. Это можно сделать, добавив смещение теста глубины. Она позволит GPU считать, что треугольники ближе к камере, чем они есть на самом деле.

Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" } LOD 200 Offset -1, -1

Дороги через ячейки

При триангуляции рек нам приходилось иметь дело не более чем с двумя направлениями реки на ячейку. Мы могли выделить пять возможных вариантов и по-разному их триангулировать, чтобы создать правильно выглядящие реки. Однако в случае дорог существует четырнадцать возможных вариантов. Мы не будем использовать отдельные подходы для каждого из этих вариантов. Вместо этого мы будем обрабатывать каждое из шести направлений ячейки одинаковым образом, вне зависимости от конкретной конфигурации дорог.

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

Триангуляция части дороги.

Добавим метод TriangulateRoad с соответствующими параметрами. Для триангуляции этой схемы нам нужно знать центр ячейки, левую и правую средние вершины и вершины ребра.

void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { }

Для построения сегмента дороги нам нужна одна дополнительная вершина. Она находится между левой и правой средними вершинами.

void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e ) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); }

Теперь мы также можем добавить оставшиеся два треугольника.

TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR);

Также нам нужно добавить UV-координаты треугольников. Две из их вершин находятся в середине дороги, а оставшаяся — на ребре.

roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) );

Пока давайте ограничимся только ячейками, в которых нет рек. В этих случаях Triangulate просто создаёт веер треугольников. Переместим этот код в отдельный метод. Затем добавим вызов TriangulateRoad, когда дорога на самом деле есть. Левую и правую средние вершины можно найти интерполяцией между центром и двумя угловыми вершинами.

void Triangulate (HexDirection direction, HexCell cell) { … if (cell.HasRiver) { … } else { TriangulateWithoutRiver(direction, cell, center, e); } … } void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoadThroughEdge(direction)) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e ); } }

Дороги, проходящие по ячейкам.

Рёбра дорог

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

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

void TriangulateRoadEdge (Vector3 center, Vector3 mL, Vector3 mR) { roads.AddTriangle(center, mL, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); }

Часть ребра дороги.

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

void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e, bool hasRoadThroughCellEdge ) { if (hasRoadThroughCellEdge) { Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); } else { TriangulateRoadEdge(center, mL, mR); } }

Теперь TriangulateWithoutRiver должен будет вызывать TriangulateRoad, когда через ячейку проходят любые дороги. И он должен будет передавать информацию о том, проходит ли дорога через текущее ребро.

void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { TriangulateRoad( center, Vector3.Lerp(center, e.v1, 0.5f), Vector3.Lerp(center, e.v5, 0.5f), e, cell.HasRoadThroughEdge(direction) ); } }

Дороги с завершёнными рёбрами.

Сглаживание дорог

Дороги теперь завершены. К сожалению, такой подход создаёт выпуклости в центрах ячеек. Размещение левой и правой вершины посередине между центром и углами нас устраивает, когда есть соседняя с ними дорога. Но если её нет, то возникает выпуклость. Чтобы избежать этого, в таких случаях мы можем размещать вершины ближе к центру. Если конкретнее, то интерполируя с ¼, а не с ½.

Так как их два, мы можем поместить результат в Vector2. Давайте создадим отдельный метод, чтобы выяснить, какие интерполяторы нужно использовать. Его компонент X будет интерполятором левой точки, а компонент Y — интерполятором правой точки.

Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; return interpolators; }

Если существует дорога, идущая в текущем направлении, мы можем разместить точки посередине.

Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } return interpolators; }

В противном случае варианты могут быть разными. Для левой точки мы можем использовать ½, если есть дорога, идущая в предыдущем направлении. Если её нет, то мы должны использовать ¼. То же самое относится к правой точке, но с учётом следующего направления.

Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) { Vector2 interpolators; if (cell.HasRoadThroughEdge(direction)) { interpolators.x = interpolators.y = 0.5f; } else { interpolators.x = cell.HasRoadThroughEdge(direction.Previous()) ? 0.5f : 0.25f; interpolators.y = cell.HasRoadThroughEdge(direction.Next()) ? 0.5f : 0.25f; } return interpolators; }

Теперь можно использовать этот новый метод для определения используемых интерполяторов. Благодаря этому дороги сгладятся.

void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Color); if (cell.HasRoads) { Vector2 interpolators = GetRoadInterpolators(direction, cell); TriangulateRoad( center, Vector3.Lerp(center, e.v1, interpolators.x), Vector3.Lerp(center, e.v5, interpolators.y), e, cell.HasRoadThroughEdge(direction) ); } }

Сглаженные дороги.

unitypackage

Комбинирование рек и дорог

На текущем этапе у нас есть функциональные дороги, но только если отсутствуют реки. Если в ячейке есть река, то дороги не будут триангулироваться.

Дорог возле рек нет.

Зададим ему обычные параметры. Давайте создадим метод TriangulateRoadAdjacentToRiver для обработки этой ситуации. Будем вызывать его в начале метода TriangulateAdjacentToRiver.

void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { if (cell.HasRoads) { TriangulateRoadAdjacentToRiver(direction, cell, center, e); } … } void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { }

Для начала сделаем то же, что и для дорог без рек. Будем проверять, проходит ли дорога через текущее ребро, получать интерполяторы, создавать средние вершины и вызывать TriangulateRoad. Но так как на пути будут появляться реки, нам нужно отодвигать от них дороги. В результате центр дороги окажется в другой позиции. Мы используем для хранения этой новой позиции переменную roadCenter. Изначально она будет равна центру ячейки.

void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); }

Так мы создадим частичные дороги в ячейках с реками. Направления, через которые проходят реки, будут прорезать в дорогах пробелы.

Дороги с пробелами.

Начало или конец реки

Давайте сначала рассмотрим ячейки, содержащие или начало, или конец реки. Чтобы дороги не накладывались на воду, давайте отодвинем центр дороги от реки. Чтобы получить направление входящей или исходящей реки, добавим HexCell свойство.

public HexDirection RiverBeginOrEndDirection { get { return hasIncomingRiver ? incomingRiver : outgoingRiver; } }

Теперь мы можем использовать это свойство в HexGridChunk.TriangulateRoadAdjacentToRiver, чтобы отодвигать центр дороги в противоположном направлении. Достаточно будет сдвинуться на треть к среднему ребру в этом направлении.

bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);

Изменённые дороги.

Мы сделаем это, добавив дополнительные треугольники рёбер дороги, когда находимся близко к реке. Далее нам нужно закрыть пробелы. А если река есть в следующем направлении, то мы добавляем треугольник между центром дороги, средней правой точкой и центром ячейки. Если в предыдущем направлении есть река, то мы добавляем треугольник между центром дороги, центром ячейки и средней левой точкой.

Мы будем делать это вне зависимости от конфигурации реки, поэтому поместим этот код в конец метода.

Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (cell.HasRiverThroughEdge(direction.Previous())) { TriangulateRoadEdge(roadCenter, center, mL); } if (cell.HasRiverThroughEdge(direction.Next())) { TriangulateRoadEdge(roadCenter, mR, center); }

Разве нельзя использовать оператор else?

Возможно существование рек, идущих в обоих направлениях одновременно. Он сработает не во всех случаях.

Готовые дороги.

Прямые реки

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

Дороги, накладывающиеся на прямую реку.

Если это так, то у нас имеется прямая река. Если ячейка не имеет начала или конца реки, то мы можем проверить, идут ли входящая и исходящая реки в противоположных направлениях.

if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { }

Чтобы определить, где река относительна к текущему направлению, нам нужно проверять соседние направления. Река находится или слева, или справа. Так как мы делаем это и в конце метода, то кэшируем эти запросы в булевы переменные. Это ещё и упростит чтение нашего кода.

bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction); bool previousHasRiver = cell.HasRiverThroughEdge(direction.Previous()); bool nextHasRiver = cell.HasRiverThroughEdge(direction.Next()); Vector2 interpolators = GetRoadInterpolators(direction, cell); Vector3 roadCenter = center; if (cell.HasRiverBeginOrEnd) { roadCenter += HexMetrics.GetSolidEdgeMiddle( cell.RiverBeginOrEndDirection.Opposite() ) * (1f / 3f); } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { if (previousHasRiver) { } else { } } Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x); Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y); TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge); if (previousHasRiver) { TriangulateRoadEdge(roadCenter, center, mL); } if (nextHasRiver) { TriangulateRoadEdge(roadCenter, mR, center); }

Нам нужно сдвинуть центр дороги к угловому вектору, указывающему в противоположную от реки сторону. Если река проходит через предыдущее направление, то это второй сплошной угол. В противном случае, это первый сплошной угол.

else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } }

Чтобы сдвигуть дорогу так, чтобы она оказалась по соседству с рекой, нам нужно сдвинуть центр дороги на половину расстояния к этому углу. Затем нам также придётся переместить центр ячейки на четверть расстояния в этом направлении.

else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { Vector3 corner; if (previousHasRiver) { corner = HexMetrics.GetSecondSolidCorner(direction); } else { corner = HexMetrics.GetFirstSolidCorner(direction); } roadCenter += corner * 0.5f; center += corner * 0.25f; }

Разделённые дороги.

Это нормально, когда дороги находятся по обеим сторонам реки. Мы разделили сеть дорог внутри этой ячейки. Это нелогично, поэтому давайте избавимся от таких частей. Но если с одной стороны нет дороги, то у нас появится небольшой кусок изолированной дороги.

Если её нет, то проверим другое направление той же стороны реки на наличие дороги. Убедимся, что есть дорога, идущая в текущем направлении. Если ни там, ни там нет проходящей дороги, то выходим из метода до выполнения триангуляции.

if (previousHasRiver) { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Next()) ) { return; } corner = HexMetrics.GetSecondSolidCorner(direction); } else { if ( !hasRoadThroughEdge && !cell.HasRoadThroughEdge(direction.Previous()) ) { return; } corner = HexMetrics.GetFirstSolidCorner(direction); }

Усечённые дороги.

А как насчёт мостов?

Мосты и другие структуры мы рассмотрим в будущем туториале. Пока мы ограничимся только дорогами.

Реки-зигзаги

Следующим тип рек — это зигзаги. Такие реки не разделяют дорожную сеть, поэтому нам достаточно только переместить центр дороги.

Зигзаги, проходящие сквозь дороги.

Если они являются соседними, то у нас есть зигзаг. Проще всего проверять наличие зигзагов сравнением направлений входящих и исходящих рек. Это приводит к двум возможным вариантам, зависящим от направления течения.

if (cell.HasRiverBeginOrEnd) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { … } else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { }

Мы можем сдвинуть центр дороги с помощью одного из углов направления входящей реки. Выбираемый угол зависит от направления течения. Отодвинем центр дороги от этого угла с коэффициентом 0.2.

else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) { roadCenter -= HexMetrics.GetSecondCorner(cell.IncomingRiver) * 0.2f; } else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { roadCenter -= HexMetrics.GetFirstCorner(cell.IncomingRiver) * 0.2f; }

Дорога, отодвинутая от зигзагов.

Внутри кривых рек

Последняя конфигурация рек — плавная кривая. Как и в случае с прямой рекой, эта тоже может разъединять дороги. Но в этом случае стороны будут другими. Сначала нам нужно поработать с внутренней частью кривой.

Искривлённая река с наложенными дорогами.

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

else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) { … } else if (previousHasRiver && nextHasRiver) { }

Нам нужно отодвинуть центр дороги по направлению к текущему ребру ячейки, немного укоротив дорогу. Подойдёт коэффициент 0.7. Центр ячейки тоже должен сместиться с коэффициентом 0.5.

else if (previousHasRiver && nextHasRiver) { Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; }

Укороченные дороги.

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

else if (previousHasRiver && nextHasRiver) { if (!hasRoadThroughEdge) { return; } Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) * HexMetrics.innerToOuter; roadCenter += offset * 0.7f; center += offset * 0.5f; }

Отсечённые дороги.

Снаружи кривых рек

После проверки всех предыдущих случаев, единственным оставшимся вариантом осталась внешняя часть искривлённой реки. Снаружи есть три части ячейки. Нам нужно найти среднее направление. Получив его, мы сможем переместить центр дороги в сторону этого ребра на коэффициент 0.25.

else if (previousHasRiver && nextHasRiver) { … } else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; }

Изменённые снаружи дороги.

Проще всего будет проверить все три направления дороги относительно середины. В качестве последнего шага нам нужно усечь дороги и на этой стороне реки. Если дорог нет, мы прекращаем работу.

else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } if ( !cell.HasRoadThroughEdge(middle) && !cell.HasRoadThroughEdge(middle.Previous()) && !cell.HasRoadThroughEdge(middle.Next()) ) { return; } roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f; }

Дороги до и после отсечения.

Реки игнорируют дороги, а дороги приспосабливаются к рекам. После обработки всех вариантов рек наши реки и дороги могут сосуществовать.

Комбинирование рек и дорог.

unitypackage

Внешний вид дорог

До этого момента мы использовали в качестве цветов дорог их UV-координаты. Так как менялась только координата U, то мы на самом деле отображали переход между серединой и краем дороги.

Отображение UV-координат.

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

Просто воспользуемся цветом материала. Мы начнём с использования для дорог сплошного цвета. Я сделал его красным.

void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

Красные дороги.

Но давайте продолжим и смешаем дорогу с рельефом, используя в качестве коэффициента смешения координату U. И это уже выглядит гораздо лучше!

void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; float blend = IN.uv_MainTex.x; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = blend; }

Похоже, это ничего не поменяло. Так получилось, потому что наш шейдер непрозрачен. Теперь ему нужно альфа-смешение. В частности, нам нужен шейдер смешивающейся поверхности декалей. Мы можем получить требуемый шейдер, добавив к директиве #pragma surface строку decal:blend.

#pragma surface surf Standard fullforwardshadows decal:blend

Смешение дорог.

Чтобы это походило на дорогу, нам нужна сплошная область, за которой идёт быстрый переход к непрозрачной области. Так мы создали плавное линейное смешение от середины к краю, которое выглядит не очень красиво. Она преобразует линейную прогрессию от 0 до 1 в S-образную кривую. Для этого можно использовать функцию smoothstep.

Линейная прогрессия и smoothstep.

Входные значения за пределами интервала ограничиваются, чтобы кривая оставалась плоской. Функция smoothstep имеет параметр минимума и максимума для умещения кривой в произвольном интервале. 4 в начале кривой и 0. Давайте используем 0. Это значит, что координата U от 0 до 0. 7 в конце. А координаты U от 0. 4 будет полностью прозрачной. Переход происходит между 0. 7 до 1 будут полностью непрозрачными. 7. 4 и 0.

float blend = IN.uv_MainTex.x; blend = smoothstep(0.4, 0.7, blend);

Быстрый переход между непрозрачной и прозрачной областями.

Дорога с шумом

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

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

Чтобы получить доступ к позиции мира в поверхностном шейдере, добавим ко входной структуре float3 worldPos.

struct Input { float2 uv_MainTex; float3 worldPos; };

Теперь мы можем использовать эту позицию в surf для сэмплирования основной текстуры. Уменьшите также масштаб координат, иначе текстура слишком часто будет повторяться.

float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025); fixed4 c = _Color; float blend = IN.uv_MainTex.x;

Исказим переход, умножив координату U на noise.x. Но поскольку значения шума в среднем равны 0.5, при этом большинство дорог исчезнет. Чтобы избежать этого, перед умножением прибавим к шуму 0.5.

float blend = IN.uv_MainTex.x; blend *= noise.x + 0.5; blend = smoothstep(0.4, 0.7, blend);

Искажённые края дорог.

Это придаст дорогам ощущение грязи, соответствующее нечётким краям. Чтобы закончить с этим, исказим также и цвет дорог.

Так мы получим в среднем половину значения цвета. Умножим цвет на другой канал шума, допустим на noise.y. Так как это слишком много, немного уменьшим масштаб шума и прибавим константу, чтобы сумма могла достичь 1.

fixed4 c = _Color * (noise.y * 0.75 + 0.25);

Неровные дороги.

unitypackage

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть