Как стать автором
Обновить

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

Время на прочтение95 мин
Количество просмотров18K
Автор оригинала: Jasper Flick
image


Части 1-3: сетка, цвета и высоты ячеек

Части 4-7: неровности, реки и дороги

Части 8-11: вода, объекты рельефа и крепостные стены

Части 12-15: сохранение и загрузка, текстуры, расстояния

Части 16-19: поиск пути, отряды игрока, анимации

Части 20-23: туман войны, исследование карты, процедурная генерация

Части 24-27: круговорот воды, эрозия, биомы, цилиндрическая карта

Часть 4: Неровности


Оглавление


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

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


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

Шум


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

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

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

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


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


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

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

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

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


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

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

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

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



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

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

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

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


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

	public static Texture2D noiseSource;

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

	public Texture2D noiseSource;

	void Awake () {
		HexMetrics.noiseSource = noiseSource;

		…
	}

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

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


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

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

	public static Vector4 SampleNoise (Vector3 position) {
	}

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

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

	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 единицам, то перемещения относительно малы.

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

	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

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


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


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

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

	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.

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

	public const float cellPerturbStrength = 4f;


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


Коэффициент цельности от 0.75 до 0.95.

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

	public const float solidFactor = 0.8f;


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

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

	public const float elevationStep = 3f;


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

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

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


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

unitypackage

Часть 5: карты большего размера


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

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


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

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


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


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

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

	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 − 1 = 2 147 483 647. Это на единицу меньше 231 из-за нуля.

Чтобы распознать изменение цвета ячейки, нам нужно тоже превратить его в свойство. Переименуем его в 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?
В каждом кадре методы Update включённых компонентов на каком-то этапе вызываются в произвольном порядке. После их завершения то же самое происходит с методами LateUpdate. То есть существует два этапа обновления, ранний и поздний.

Так как по умолчанию наш компонент по умолчанию включён, нам больше не нужно явным образом выполнять триангуляцию в 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

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


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

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


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

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

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

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


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

Поворот


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

Уровень зума не важен для вращения, достаточно только скорости. Добавим общую переменную 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 по умолчанию нет. Нам придётся создать её самим. Перейдём в параметры ввода и дублируем самую верхнюю запись Vertical. Изменим название дубликата на Rotation и сменим клавиши на QE и запятую (,) с точкой (.).


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

Я скачал 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 и прикрепим к соответствующему методу. Также я добавил ему метку.


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

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

	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. Чтобы избежать этого, проверим границы в HexGrid.GetCell и вернём null, когда запрашивается несуществующая ячейка.

	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

Часть 6: реки


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

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


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

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


Существует три способа добавления рек к сетке шестиугольников. Первый способ — позволить им течь от ячейки к ячейке. Именно так это реализовано в 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;

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

	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, то длина центральной линии будет равна ½. Так как среднее ребро находится посередине между ними, то его длина должна быть равна ¾.

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


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

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

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

	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;
	}

Теперь мы можем использовать его вместе с ⅙ в 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°. Можно сгладить этот эффект, слегка увеличив ширину центральной линии. Вместо интерполяции с ½ используем ⅔.

		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;
		}


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

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


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


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

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

	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. Кроме того, он не должен больше передавать ячейки как аргумент. Поэтому Triangulate может потерять свой параметр. И он должен делегировать очистку и применение данных меша HexMesh.

	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>?
Мы уже использовали обобщённые списки, например List<int> для списка целых чисел. Поместив <T> после объявления класса ListPool, мы сообщаем, что это обобщённый класс. Для обобщения можно использовать любое название, но обычно используют 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 и подключив его.



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

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


Подобъект 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 в направлении течения реки.

При использовании такой спецификации UV будут корректными при триангуляции исходящей реки, но окажутся неверными и их нужно будет перевернуть при триангуляции входящей реки. Чтобы упростить работу, добавим в TriangulateRiverQuad параметр bool reversed. Воспользуемся им для переворачивания 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 до 1 на протяжении всей ячейки плюс одного соединения. Это можно сделать, увеличив координату V в каждом сегменте на 0.2. Если мы поставим в центр 0.4, то в середине она станет 0.6, а на ребре достигнет 0.8. Тогда в соединении ячейки значение будет 1.

Если река течёт в противоположном направлении, мы по-прежнему можем поставить в центр 0.4, но в середине оно становится 0.2, а на ребре — 0. Если мы продолжим это до соединения ячейки, то в результате получим -0.2. Это нормально, потому что аналогично 0.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-координатами, мы можем перейти к анимированию рек. Этим займётся шейдер реки, чтобы нам не пришлось постоянно обновлять меш.

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

Анимация создаётся сдвигом координат V на основании времени игры. Unity позволяет получать его значение с помощью переменной _Time. Её компонент 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.005. Это значит, что для полного цикла паттерна потребуется 200 секунд.

			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. Для обозначения этого нужно использовать тэги шейдера. Затем добавим в строку #pragma surface ключевое слово alpha. Пока мы здесь, можно удалить ключевое слово fullforwardshadows, потому что мы всё равно не отбрасываем тени.

		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

Часть 7: дороги


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


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

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


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

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


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);
		}
	}

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


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

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

	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.

После этого добавим к HexGridChunk общее поле HexMesh roads и включим его в Triangulate. Соединим его в инспекторе с объектом Roads.

	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


Смешение дорог.

Так мы создали плавное линейное смешение от середины к краю, которое выглядит не очень красиво. Чтобы это походило на дорогу, нам нужна сплошная область, за которой идёт быстрый переход к непрозрачной области. Для этого можно использовать функцию smoothstep. Она преобразует линейную прогрессию от 0 до 1 в S-образную кривую.


Линейная прогрессия и smoothstep.

Функция smoothstep имеет параметр минимума и максимума для умещения кривой в произвольном интервале. Входные значения за пределами интервала ограничиваются, чтобы кривая оставалась плоской. Давайте используем 0.4 в начале кривой и 0.7 в конце. Это значит, что координата U от 0 до 0.4 будет полностью прозрачной. А координаты U от 0.7 до 1 будут полностью непрозрачными. Переход происходит между 0.4 и 0.7.

			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
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 23: ↑23 и ↓0+23
Комментарии1

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань