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

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

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

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

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

Часть 20: туман войны


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

В этой части мы добавим на карту эффект тумана войны.

Теперь серия будет создаваться на Unity 2017.1.0.


Теперь мы видим, что можем и не можем видеть.

Данные ячеек в шейдере


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

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

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

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

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

Управление данными ячеек


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

using UnityEngine;

public class HexCellShaderData : MonoBehaviour {
	
	Texture2D cellTexture;
}

При создании или загрузке новой карты нам нужно создавать новую текстуру с правильным размером. Поэтому добавим ему метод инициализации, создающий текстуру. Мы используем текстуру в формате RGBA без mip-текстур и линейное цветовое пространство. Нам не нужно смешивать данные ячеек, поэтому используем точечную фильтрацию (point filtering). Кроме того, данные не должны сворачиваться. Каждый пиксель текстуры будет содержать данные одной ячейки.

	public void Initialize (int x, int z) {
		cellTexture = new Texture2D(
			x, z, TextureFormat.RGBA32, false, true
		);
		cellTexture.filterMode = FilterMode.Point;
		cellTexture.wrapMode = TextureWrapMode.Clamp;
	}

Должен ли размер текстуры соответствовать размеру карты?
Нет, в ней просто должно быть достаточно пикселей для хранения всех ячеек. При точном соответствии размеру карты скорее всего будет создана текстура с размерами, не являющимися степенями двойки (non-power-of-two, NPOT), а такой формат текстур является не самым эффективным. Хоть мы и можем настроить код на работу с текстурами размером в степень двойки, это незначительная оптимизация, которая усложняет доступ к данным ячеек.

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

	public void Initialize (int x, int z) {
		if (cellTexture) {
			cellTexture.Resize(x, z);
		}
		else {
			cellTexture = new Texture2D(
				cellCountX, cellCountZ, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
		}
	}

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

	Texture2D cellTexture;
	Color32[] cellTextureData;
	
	public void Initialize () {
		…
		
		if (cellTextureData == null || cellTextureData.Length != x * z) {
			cellTextureData = new Color32[x * z];
		}
		else {
			for (int i = 0; i < cellTextureData.Length; i++) {
				cellTextureData[i] = new Color32(0, 0, 0, 0);
			}
		}
	}

Что такое Color32?
Стандартные несжатые RGBA-текстуры содержат пиксели размером четыре байта. Каждый из четырёх цветовых каналов получает по байту, то есть они имеют 256 возможных значений. При использовании структуры Unity Color её компоненты с плавающей запятой в интервале 0–1 преобразуются в байты в интервале 0–255. При сэмплировании GPU выполняет обратное преобразование.

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

Созданием и инициализацией данных ячеек в шейдере должен заниматься HexGrid. Поэтому добавим ему поле cellShaderData и создадим компонент внутри Awake.

	HexCellShaderData cellShaderData;

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexUnit.unitPrefab = unitPrefab;
		cellShaderData = gameObject.AddComponent<HexCellShaderData>();
		CreateMap(cellCountX, cellCountZ);
	}

При создании новой карты должен инициироваться и cellShaderData.

	public bool CreateMap (int x, int z) {
		…

		cellCountX = x;
		cellCountZ = z;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		cellShaderData.Initialize(cellCountX, cellCountZ);
		CreateChunks();
		CreateCells();
		return true;
	}

Изменение данных ячеек


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

	public HexCellShaderData ShaderData { get; set; }

В HexGrid.CreateCell присвоим этому свойству компонент данных шейдера.

	void CreateCell (int x, int z, int i) {
		…

		HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.ShaderData = cellShaderData;
		
		…
	}

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

Добавим в HexCellShaderData метод RefreshTerrain, чтобы упростить эту задачу для конкретной ячейки. Давайте пока оставим этот метод пустым.

	public void RefreshTerrain (HexCell cell) {
	}

Изменим HexCell.TerrainTypeIndex так, чтобы он вызывал этот метод, а не приказывал обновлять фрагменты.

	public int TerrainTypeIndex {
		get {
			return terrainTypeIndex;
		}
		set {
			if (terrainTypeIndex != value) {
				terrainTypeIndex = value;
//				Refresh();
				ShaderData.RefreshTerrain(this);
			}
		}
	}

Также вызовем его в HexCell.Load после получения типа рельефа ячейки.

	public void Load (BinaryReader reader) {
		terrainTypeIndex = reader.ReadByte();
		ShaderData.RefreshTerrain(this);
		elevation = reader.ReadByte();
		RefreshPosition();
		…
	}

Индекс ячейки


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

	public int Index { get; set; }

Этот индекс уже есть в HexGrid.CreateCell, поэтому просто присвоим его созданной ячейке.

	void CreateCell (int x, int z, int i) {
		…
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.Index = i;
		cell.ShaderData = cellShaderData;

		…
	}

Теперь HexCellShaderData.RefreshTerrain может использовать этот индекс для задания данных ячейки. Давайте сохранять индекс типа рельефа в альфа-компоненте её пикселя, просто преобразовывая тип в byte. Это позволит поддерживать до 256 типов рельефа, чего нам будет вполне достаточно.

	public void RefreshTerrain (HexCell cell) {
		cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
	}

Чтобы применить данные к текстуре и передать её в GPU, нам нужно вызывать Texture2D.SetPixels32, а затем Texture2D.Apply. Как и в случае с фрагментами, мы отложим эти операции на LateUpdate, чтобы можно было выполнять их не чаще раза за кадр, вне зависимости от количества изменившихся ячеек.

	public void RefreshTerrain (HexCell cell) {
		cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
		enabled = true;
	}
	
	void LateUpdate () {
		cellTexture.SetPixels32(cellTextureData);
		cellTexture.Apply();
		enabled = false;
	}

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

	public void Initialize (int x, int z) {
		…
		enabled = true;
	}

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


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

Удалим из HexMesh устаревшие общие поля useColors и useTerrainTypes. Заменим их одним полем useCellData.

//	public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates;
//	public bool useTerrainTypes;
	public bool useCollider, useCellData, useUVCoordinates, useUV2Coordinates;

Выполним рефакторинг-переименование списка terrainTypes в cellIndices. Давайте также рефакторим-переименуем colors в cellWeights — это имя подойдёт больше.

//	[NonSerialized] List<Vector3> vertices, terrainTypes;
//	[NonSerialized] List<Color> colors;
	[NonSerialized] List<Vector3> vertices, cellIndices;
	[NonSerialized] List<Color> cellWeights;
	[NonSerialized] List<Vector2> uvs, uv2s;
	[NonSerialized] List<int> triangles;

Изменим Clear так, чтобы при использовании данных ячеек он получал два списка вместе, а не по отдельности.

	public void Clear () {
		hexMesh.Clear();
		vertices = ListPool<Vector3>.Get();
		if (useCellData) {
			cellWeights = ListPool<Color>.Get();
			cellIndices = ListPool<Vector3>.Get();
		}
//		if (useColors) {
//			colors = ListPool<Color>.Get();
//		}
		if (useUVCoordinates) {
			uvs = ListPool<Vector2>.Get();
		}
		if (useUV2Coordinates) {
			uv2s = ListPool<Vector2>.Get();
		}
//		if (useTerrainTypes) {
//			terrainTypes = ListPool<Vector3>.Get();
//		}
		triangles = ListPool<int>.Get();
	}

Выполним такое же группирование в Apply.

	public void Apply () {
		hexMesh.SetVertices(vertices);
		ListPool<Vector3>.Add(vertices);
		if (useCellData) {
			hexMesh.SetColors(cellWeights);
			ListPool<Color>.Add(cellWeights);
			hexMesh.SetUVs(2, cellIndices);
			ListPool<Vector3>.Add(cellIndices);
		}
//		if (useColors) {
//			hexMesh.SetColors(colors);
//			ListPool<Color>.Add(colors);
//		}
		if (useUVCoordinates) {
			hexMesh.SetUVs(0, uvs);
			ListPool<Vector2>.Add(uvs);
		}
		if (useUV2Coordinates) {
			hexMesh.SetUVs(1, uv2s);
			ListPool<Vector2>.Add(uv2s);
		}
//		if (useTerrainTypes) {
//			hexMesh.SetUVs(2, terrainTypes);
//			ListPool<Vector3>.Add(terrainTypes);
//		}
		hexMesh.SetTriangles(triangles, 0);
		ListPool<int>.Add(triangles);
		hexMesh.RecalculateNormals();
		if (useCollider) {
			meshCollider.sharedMesh = hexMesh;
		}
	}

Удалим все методы AddTriangleColor и AddTriangleTerrainTypes. Заменим их соответствующими методами AddTriangleCellData, которые добавляют индексы и веса за один раз.

	public void AddTriangleCellData (
		Vector3 indices, Color weights1, Color weights2, Color weights3
	) {
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellWeights.Add(weights1);
		cellWeights.Add(weights2);
		cellWeights.Add(weights3);
	}
		
	public void AddTriangleCellData (Vector3 indices, Color weights) {
		AddTriangleCellData(indices, weights, weights, weights);
	}

Сделаем то же самое в соответствующих метода AddQuad.

	public void AddQuadCellData (
		Vector3 indices,
		Color weights1, Color weights2, Color weights3, Color weights4
	) {
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellWeights.Add(weights1);
		cellWeights.Add(weights2);
		cellWeights.Add(weights3);
		cellWeights.Add(weights4);
	}

	public void AddQuadCellData (
		Vector3 indices, Color weights1, Color weights2
	) {
		AddQuadCellData(indices, weights1, weights1, weights2, weights2);
	}

	public void AddQuadCellData (Vector3 indices, Color weights) {
		AddQuadCellData(indices, weights, weights, weights, weights);
	}

Рефакторинг HexGridChunk


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

	static Color weights1 = new Color(1f, 0f, 0f);
	static Color weights2 = new Color(0f, 1f, 0f);
	static Color weights3 = new Color(0f, 0f, 1f);

Давайте начнём с исправления TriangulateEdgeFan. Раньше ему требовался тип, а теперь нужен индекс ячейки. Заменим код AddTriangleColor и AddTriangleTerrainTypes соответствующим кодом AddTriangleCellData.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float index) {
		terrain.AddTriangle(center, edge.v1, edge.v2);
		terrain.AddTriangle(center, edge.v2, edge.v3);
		terrain.AddTriangle(center, edge.v3, edge.v4);
		terrain.AddTriangle(center, edge.v4, edge.v5);

		Vector3 indices;
		indices.x = indices.y = indices.z = index;
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);

//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);

//		Vector3 types;
//		types.x = types.y = types.z = type;
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
	}

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

		TriangulateEdgeFan(center, e, cell.Index);

Далее TriangulateEdgeStrip. Здесь всё немного сложнее, но используем тот же подход. Также рефакторим-переименуем имена параметров c1 и c2 в w1 и w2.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color w1, float index1,
		EdgeVertices e2, Color w2, float index2,
		bool hasRoad = false
	) {
		terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

		Vector3 indices;
		indices.x = indices.z = index1;
		indices.y = index2;
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);

//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);

//		Vector3 types;
//		types.x = types.z = type1;
//		types.y = type2;
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);

		if (hasRoad) {
			TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4);
		}
	}

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

		TriangulateEdgeStrip(
			m, weights1, cell.Index,
			e, weights1, cell.Index
		);
		
	…
		
			TriangulateEdgeStrip(
				e1, weights1, cell.Index,
				e2, weights2, neighbor.Index, hasRoad
			);
	
	…
	
	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color w2 = HexMetrics.TerraceLerp(weights1, weights2, 1);
		float i1 = beginCell.Index;
		float i2 = endCell.Index;

		TriangulateEdgeStrip(begin, weights1, i1, e2, w2, i2, hasRoad);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color w1 = w2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			w2 = HexMetrics.TerraceLerp(weights1, weights2, i);
			TriangulateEdgeStrip(e1, w1, i1, e2, w2, i2, hasRoad);
		}

		TriangulateEdgeStrip(e2, w2, i1, end, weights2, i2, hasRoad);
	}

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

	void TriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		else {
			terrain.AddTriangle(bottom, left, right);
			Vector3 indices;
			indices.x = bottomCell.Index;
			indices.y = leftCell.Index;
			indices.z = rightCell.Index;
			terrain.AddTriangleCellData(indices, weights1, weights2, weights3);
//			terrain.AddTriangleColor(weights1, weights2, weights3);
//			Vector3 types;
//			types.x = bottomCell.TerrainTypeIndex;
//			types.y = leftCell.TerrainTypeIndex;
//			types.z = rightCell.TerrainTypeIndex;
//			terrain.AddTriangleTerrainTypes(types);
		}

		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}

Далее в TriangulateCornerTerraces.

	void TriangulateCornerTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
		Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
		Color w3 = HexMetrics.TerraceLerp(weights1, weights2, 1);
		Color w4 = HexMetrics.TerraceLerp(weights1, weights3, 1);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;

		terrain.AddTriangle(begin, v3, v4);
		terrain.AddTriangleCellData(indices, weights1, w3, w4);
//		terrain.AddTriangleColor(weights1, w3, w4);
//		terrain.AddTriangleTerrainTypes(indices);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v3;
			Vector3 v2 = v4;
			Color w1 = w3;
			Color w2 = w4;
			v3 = HexMetrics.TerraceLerp(begin, left, i);
			v4 = HexMetrics.TerraceLerp(begin, right, i);
			w3 = HexMetrics.TerraceLerp(weights1, weights2, i);
			w4 = HexMetrics.TerraceLerp(weights1, weights3, i);
			terrain.AddQuad(v1, v2, v3, v4);
			terrain.AddQuadCellData(indices, w1, w2, w3, w4);
//			terrain.AddQuadColor(w1, w2, w3, w4);
//			terrain.AddQuadTerrainTypes(indices);
		}

		terrain.AddQuad(v3, v4, left, right);
		terrain.AddQuadCellData(indices, w3, w4, weights2, weights3);
//		terrain.AddQuadColor(w3, w4, weights2, weights3);
//		terrain.AddQuadTerrainTypes(indices);
	}

Затем в TriangulateCornerTerracesCliff.

	void TriangulateCornerTerracesCliff (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (rightCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b
		);
		Color boundaryWeights = Color.Lerp(weights1, weights3, b);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;

		TriangulateBoundaryTriangle(
			begin, weights1, left, weights2, boundary, boundaryWeights, indices
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, weights2, right, weights3,
				boundary, boundaryWeights, indices
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleCellData(
				indices, weights2, weights3, boundaryWeights
			);
//			terrain.AddTriangleColor(weights2, weights3, boundaryColor);
//			terrain.AddTriangleTerrainTypes(indices);
		}
	}

И немного иначе в TriangulateCornerCliffTerraces.

	void TriangulateCornerCliffTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (leftCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b
		);
		Color boundaryWeights = Color.Lerp(weights1, weights2, b);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;

		TriangulateBoundaryTriangle(
			right, weights3, begin, weights1, boundary, boundaryWeights, indices
		);

		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, weights2, right, weights3,
				boundary, boundaryWeights, indices
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleCellData(
				indices, weights2, weights3, boundaryWeights
			);
//			terrain.AddTriangleColor(weights2, weights3, boundaryWeights);
//			terrain.AddTriangleTerrainTypes(indices);
		}
	}

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

	void TriangulateBoundaryTriangle (
		Vector3 begin, Color beginWeights,
		Vector3 left, Color leftWeights,
		Vector3 boundary, Color boundaryWeights, Vector3 indices
	) {
		Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, 1);

		terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
		terrain.AddTriangleCellData(indices, beginWeights, w2, boundaryWeights);
//		terrain.AddTriangleColor(beginColor, c2, boundaryColor);
//		terrain.AddTriangleTerrainTypes(types);

		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color w1 = w2;
			v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
			w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, i);
			terrain.AddTriangleUnperturbed(v1, v2, boundary);
			terrain.AddTriangleCellData(indices, w1, w2, boundaryWeights);
//			terrain.AddTriangleColor(c1, c2, boundaryColor);
//			terrain.AddTriangleTerrainTypes(types);
		}

		terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
		terrain.AddTriangleCellData(indices, w2, leftWeights, boundaryWeights);
//		terrain.AddTriangleColor(c2, leftColor, boundaryColor);
//		terrain.AddTriangleTerrainTypes(types);
	}

Последний метод, который требует изменений — TriangulateWithRiver.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		terrain.AddTriangle(centerL, m.v1, m.v2);
		terrain.AddQuad(centerL, center, m.v2, m.v3);
		terrain.AddQuad(center, centerR, m.v3, m.v4);
		terrain.AddTriangle(centerR, m.v4, m.v5);

		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddQuadCellData(indices, weights1);
		terrain.AddQuadCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);

//		terrain.AddTriangleColor(weights1);
//		terrain.AddQuadColor(weights1);
//		terrain.AddQuadColor(weights1);
//		terrain.AddTriangleColor(weights1);

//		Vector3 types;
//		types.x = types.y = types.z = cell.TerrainTypeIndex;
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);

		…
	}

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


Рельеф использует данные ячеек.

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


Использование индексов ячеек как индексов текстур рельефа.

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

Передача данных ячеек в шейдер


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

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

	public void Initialize (int x, int z) {
		…
		else {
			cellTexture = new Texture2D(
				x, z, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
			Shader.SetGlobalTexture("_HexCellData", cellTexture);
		}

		…
	}

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

		else {
			cellTexture = new Texture2D(
				x, z, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
			Shader.SetGlobalTexture("_HexCellData", cellTexture);
		}
		Shader.SetGlobalVector(
			"_HexCellData_TexelSize",
			new Vector4(1f / x, 1f / z, x, z)
		);

Доступ к данным шейдера


Создадим в папке материалов новый include-файл шейдера под названием HexCellData. Внутри него определим переменные для информации о текстуре и размере данных ячеек. Также создадим функцию для получения данных ячеек для заданных данных меша вершины.

sampler2D _HexCellData;
float4 _HexCellData_TexelSize;

float4 GetCellData (appdata_full v) {
}


Новый include-файл.

Индексы ячеек хранятся в v.texcoord2, как это было и с типами рельефа. Давайте начнём с первого индекса — v.texcoord2.x. К сожалению, мы не можем напрямую использовать индекс для сэмплирования текстуры данных ячеек. Нам придётся преобразовать его в UV-координаты.

Первый этап создания координаты U — деление индекса ячейки на ширину текстуры. Мы можем это сделать, умножив его на _HexCellData_TexelSize.x.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
}

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

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
}

Координата V находится делением строки на высоту текстуры.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = row * _HexCellData_TexelSize.y;
}

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

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = (v.texcoord2.x + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}

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

float4 GetCellData (appdata_full v, int index) {
	float2 uv;
	uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}

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

float4 GetCellData (appdata_full v, int index) {
	float2 uv;
	uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
	float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
}

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

	float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
	data.w *= 255;
	return data;

Чтобы воспользоваться этим функционалом, включим HexCellData в шейдер Terrain. Так как я поместил этот шейдер в Materials / Terrain, то нужно использовать относительный путь ../HexCellData.cginc.

		#include "../HexCellData.cginc"

		UNITY_DECLARE_TEX2DARRAY(_MainTex);

В вершинной программе получим данные ячеек для всех трёх индексов ячейки, хранящихся в данных вершины. Затем присвоим data.terrain их индексы рельефа.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
//			data.terrain = v.texcoord2.xyz;

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);

			data.terrain.x = cell0.w;
			data.terrain.y = cell1.w;
			data.terrain.z = cell2.w;
		}

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

unitypackage

Видимость


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

Шейдер


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

		struct Input {
			float4 color : COLOR;
			float3 worldPos;
			float3 terrain;
			float3 visibility;
		};

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

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);

			data.terrain.x = cell0.w;
			data.terrain.y = cell1.w;
			data.terrain.z = cell2.w;

			data.visibility.x = cell0.x;
			data.visibility.y = cell1.x;
			data.visibility.z = cell2.x;
		}

Видимость, равная 0, означает, что в данный момент ячейка невидима. Если бы она была видима, то имела бы значение видимости 1. Поэтому мы можем затемнить рельеф, умножив результат GetTerrainColor на соответствующий вектор видимости. Таким образом мы по отдельности модулируем цвет рельефа каждой смешанной ячейки.

		float4 GetTerrainColor (Input IN, int index) {
			float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]);
			float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw);
			return c * (IN.color[index] * IN.visibility[index]);
		}


Ячейки стали чёрными.

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

Полная темнота — это перебор для временно невидимых ячеек. Чтобы мы всё-таки могли видеть рельеф, нужно увеличить показатель, используемый для скрытых ячеек. Давайте перейдём от 0–1 к ?–1, что можно сделать, воспользовавшись функцией lerp в конце вершинной программы.

		void vert (inout appdata_full v, out Input data) {
			…

			data.visibility.x = cell0.x;
			data.visibility.y = cell1.x;
			data.visibility.z = cell2.x;
			data.visibility = lerp(0.25, 1, data.visibility);
		}


Затемнённые ячейки.

Отслеживание видимости ячеек


Чтобы видимость работала, ячейки должны отслеживать свою видимость. Но как ячейка определит, видима ли она? Мы можем сделать это, отслеживая количество видящих её сущностей. Когда кто-то начинает видеть ячейку, он должен сообщить об этом ячейке. А когда кто-то перестаёт видеть ячейку, он тоже должен уведомить её об этом. Ячейка просто отслеживает количество смотрящих, какими бы ни были эти сущности. Если ячейка имеет величину видимости не менее 1, то она видима, в противном случае — невидима. Чтобы реализовать такое поведение, добавим в HexCell переменную, два метода и свойство.

	public bool IsVisible {
		get {
			return visibility > 0;
		}
	}

	…

	int visibility;

	…

	public void IncreaseVisibility () {
		visibility += 1;
	}

	public void DecreaseVisibility () {
		visibility -= 1;
	}

Далее добавим в HexCellShaderData метод RefreshVisibility, который делает то же самое, что и RefreshTerrain, только для видимости. Сохраним данные в компоненте R данных ячеек. Так как мы работаем с байтами, которые преобразуются в значения 0–1, для обозначения видимости используем (byte)255.

	public void RefreshVisibility (HexCell cell) {
		cellTextureData[cell.Index].r = cell.IsVisible ? (byte)255 : (byte)0;
		enabled = true;
	}

Будем вызывать этот метод при увеличении и уменьшении видимости, меняя значение между 0 и 1.

	public void IncreaseVisibility () {
		visibility += 1;
		if (visibility == 1) {
			ShaderData.RefreshVisibility(this);
		}
	}

	public void DecreaseVisibility () {
		visibility -= 1;
		if (visibility == 0) {
			ShaderData.RefreshVisibility(this);
		}
	}

Создание области видимости отрядов


Давайте сделаем так, чтобы отряды могли видеть занимаемую ими ячейку. Это реализуется с помощью вызова IncreaseVisibility для нового местоположения отряда при задании HexUnit.Location. Также вызовем для старого местоположения (если оно существует) DecreaseVisibility.

	public HexCell Location {
		get {
			return location;
		}
		set {
			if (location) {
				location.DecreaseVisibility();
				location.Unit = null;
			}
			location = value;
			value.Unit = this;
			value.IncreaseVisibility();
			transform.localPosition = value.Position;
		}
	}


Отряды могут видеть, где они находятся.

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

	public void Die () {
		if (location) {
			location.DecreaseVisibility();
		}
		location.Unit = null;
		Destroy(gameObject);
	}

Дальность видимости


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

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

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

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

	List<HexCell> GetVisibleCells (HexCell fromCell, int range) {
		List<HexCell> visibleCells = ListPool<HexCell>.Get();

		searchFrontierPhase += 2;
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		else {
			searchFrontier.Clear();
		}

		fromCell.SearchPhase = searchFrontierPhase;
		fromCell.Distance = 0;
		searchFrontier.Enqueue(fromCell);
		while (searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.SearchPhase += 1;
			visibleCells.Add(current);
//			if (current == toCell) {
//				return true;
//			}

//			int currentTurn = (current.Distance - 1) / speed;

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase
				) {
					continue;
				}
//				…
//				int moveCost;
//				…

				int distance = current.Distance + 1;
				if (distance > range) {
					continue;
				}
//				int turn = (distance - 1) / speed;
//				if (turn > currentTurn) {
//					distance = turn * speed + moveCost;
//				}

				if (neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = distance;
//					neighbor.PathFrom = current;
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
				else if (distance < neighbor.Distance) {
					int oldPriority = neighbor.SearchPriority;
					neighbor.Distance = distance;
//					neighbor.PathFrom = current;
					searchFrontier.Change(neighbor, oldPriority);
				}
			}
		}
		return visibleCells;
	}

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

Также добавим HexGrid методы IncreaseVisibility и DecreaseVisibility. Они получают ячейку и дальность, берут список соответствующих ячеек и увеличивают/уменьшают их видимость. Закончив с этим, они должны вернуть список обратно в его пул.

	public void IncreaseVisibility (HexCell fromCell, int range) {
		List<HexCell> cells = GetVisibleCells(fromCell, range);
		for (int i = 0; i < cells.Count; i++) {
			cells[i].IncreaseVisibility();
		}
		ListPool<HexCell>.Add(cells);
	}

	public void DecreaseVisibility (HexCell fromCell, int range) {
		List<HexCell> cells = GetVisibleCells(fromCell, range);
		for (int i = 0; i < cells.Count; i++) {
			cells[i].DecreaseVisibility();
		}
		ListPool<HexCell>.Add(cells);
	}

Чтобы использовать эти методы, HexUnit требует доступа к сетке, поэтому добавим ему свойство Grid.

	public HexGrid Grid { get; set; }

При добавлении отряда к сетке будет присваивать этому свойству сетку в HexGrid.AddUnit.

	public void AddUnit (HexUnit unit, HexCell location, float orientation) {
		units.Add(unit);
		unit.Grid = this;
		unit.transform.SetParent(transform, false);
		unit.Location = location;
		unit.Orientation = orientation;
	}

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

	const int visionRange = 3;

	…

	public HexCell Location {
		get {
			return location;
		}
		set {
			if (location) {
//				location.DecreaseVisibility();
				Grid.DecreaseVisibility(location, visionRange);
				location.Unit = null;
			}
			location = value;
			value.Unit = this;
//			value.IncreaseVisibility();
			Grid.IncreaseVisibility(value, visionRange);
			transform.localPosition = value.Position;
		}
	}

	…

	public void Die () {
		if (location) {
//			location.DecreaseVisibility();
			Grid.DecreaseVisibility(location, visionRange);
		}
		location.Unit = null;
		Destroy(gameObject);
	}


Отряды с дальностью видимости, которая может накладываться.

Видимость при перемещении


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

	public void Travel (List<HexCell> path) {
//		Location = path[path.Count - 1];
		location.Unit = null;
		location = path[path.Count - 1];
		location.Unit = this;
		pathToTravel = path;
		StopAllCoroutines();
		StartCoroutine(TravelPath());
	}

Внутри корутины TravelPath будем уменьшать видимость первой ячейки только после завершения LookAt. После этого, перед перемещением в новую ячейку, будем увеличивать видимость из этой ячейки. Закончив с этим, снова уменьшим видимость из ней. Наконец, увеличим видимость из последней ячейки.

	IEnumerator TravelPath () {
		Vector3 a, b, c = pathToTravel[0].Position;
//		transform.localPosition = c;
		yield return LookAt(pathToTravel[1].Position);
		Grid.DecreaseVisibility(pathToTravel[0], visionRange);

		float t = Time.deltaTime * travelSpeed;
		for (int i = 1; i < pathToTravel.Count; i++) {
			a = c;
			b = pathToTravel[i - 1].Position;
			c = (b + pathToTravel[i].Position) * 0.5f;
			Grid.IncreaseVisibility(pathToTravel[i], visionRange);
			for (; t < 1f; t += Time.deltaTime * travelSpeed) {
				…
			}
			Grid.DecreaseVisibility(pathToTravel[i], visionRange);
			t -= 1f;
		}

		a = c;
		b = location.Position; // We can simply use the destination here.
		c = b;
		Grid.IncreaseVisibility(location, visionRange);
		for (; t < 1f; t += Time.deltaTime * travelSpeed) {
			…
		}

		…
	}


Видимость при движении.

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

	HexCell location, currentTravelLocation;

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

	IEnumerator TravelPath () {
		…
		
		for (int i = 1; i < pathToTravel.Count; i++) {
			currentTravelLocation = pathToTravel[i];
			a = c;
			b = pathToTravel[i - 1].Position;
			c = (b + currentTravelLocation.Position) * 0.5f;
			Grid.IncreaseVisibility(pathToTravel[i], visionRange);
			for (; t < 1f; t += Time.deltaTime * travelSpeed) {
				transform.localPosition = Bezier.GetPoint(a, b, c, t);
				Vector3 d = Bezier.GetDerivative(a, b, c, t);
				d.y = 0f;
				transform.localRotation = Quaternion.LookRotation(d);
				yield return null;
			}
			Grid.DecreaseVisibility(pathToTravel[i], visionRange);
			t -= 1f;
		}
		currentTravelLocation = null;
		
		…
	}

Теперь после завершения поворота в TravelPath мы можем проверять, известно ли старое промежуточное местоположение пути. Если да, то нужно уменьшить видимость в этой ячейке, а не в начале пути.

	IEnumerator TravelPath () {
		Vector3 a, b, c = pathToTravel[0].Position;
		yield return LookAt(pathToTravel[1].Position);
		Grid.DecreaseVisibility(
			currentTravelLocation ? currentTravelLocation : pathToTravel[0],
			visionRange
		);

		…
	}

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

	void OnEnable () {
		if (location) {
			transform.localPosition = location.Position;
			if (currentTravelLocation) {
				Grid.IncreaseVisibility(location, visionRange);
				Grid.DecreaseVisibility(currentTravelLocation, visionRange);
				currentTravelLocation = null;
			}
		}
	}

unitypackage

Видимость дорог и воды


Хотя изменения цвета рельефа основаны на видимости, дорог и воды это не затрагивает. Они выглядят слишком яркими для невидимых ячеек. Чтобы применить видимость к дорогам и воде, нам нужно добавить индексы ячеек и веса смешения и к их данным мешей. Поэтому проверим дочерние элементы Use Cell Data for the Rivers, Roads, Water, Water Shore и Estuaries префаба фрагмента.

Дороги


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

	void TriangulateRoadEdge (
		Vector3 center, Vector3 mL, Vector3 mR, float index
	) {
		roads.AddTriangle(center, mL, mR);
		roads.AddTriangleUV(
			new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		Vector3 indices;
		indices.x = indices.y = indices.z = index;
		roads.AddTriangleCellData(indices, weights1);
	}

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

	void TriangulateRoadSegment (
		Vector3 v1, Vector3 v2, Vector3 v3,
		Vector3 v4, Vector3 v5, Vector3 v6,
		Color w1, Color w2, Vector3 indices
	) {
		roads.AddQuad(v1, v2, v4, v5);
		roads.AddQuad(v2, v3, v5, v6);
		roads.AddQuadUV(0f, 1f, 0f, 0f);
		roads.AddQuadUV(1f, 0f, 0f, 0f);
		roads.AddQuadCellData(indices, w1, w2);
		roads.AddQuadCellData(indices, w1, w2);
	}

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

	void TriangulateRoad (
		Vector3 center, Vector3 mL, Vector3 mR,
		EdgeVertices e, bool hasRoadThroughCellEdge, float index
	) {
		if (hasRoadThroughCellEdge) {
			Vector3 indices;
			indices.x = indices.y = indices.z = index;
			Vector3 mC = Vector3.Lerp(mL, mR, 0.5f);
			TriangulateRoadSegment(
				mL, mC, mR, e.v2, e.v3, e.v4,
				weights1, weights1, indices
			);
			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)
			);
			roads.AddTriangleCellData(indices, weights1);
			roads.AddTriangleCellData(indices, weights1);
		}
		else {
			TriangulateRoadEdge(center, mL, mR, index);
		}
	}

Осталось добавить требуемые аргументы методов в TriangulateRoad, TriangulateRoadEdge и TriangulateRoadSegment, чтобы исправить все ошибки компилятора.

	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, cell.Index);

		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), cell.Index
			);
		}
	}
	
	…
	
	void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge, cell.Index);
		if (previousHasRiver) {
			TriangulateRoadEdge(roadCenter, center, mL, cell.Index);
		}
		if (nextHasRiver) {
			TriangulateRoadEdge(roadCenter, mR, center, cell.Index);
		}
	}
	
	…
	
	void TriangulateEdgeStrip (
		…
	) {
		…

		if (hasRoad) {
			TriangulateRoadSegment(
				e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4, w1, w2, indices
			);
		}
	}

Теперь данные мешей верны, и мы перейдём к шейдеру Road. Ему нужна вершинная программа и он должен содержать HexCellData.

		#pragma surface surf Standard fullforwardshadows decal:blend vertex:vert
		#pragma target 3.0

		#include "HexCellData.cginc"

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

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};

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

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);

			data.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility = lerp(0.25, 1, data.visibility);
		}

Во фрагментной программе нам достаточно только добавить видимость к цвету.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025);
			fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility);
			…
		}


Дороги с видимостью.

Открытая вода


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

	void TriangulateOpenWater (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…

		water.AddTriangle(center, c1, c2);
		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		water.AddTriangleCellData(indices, weights1);

		if (direction <= HexDirection.SE && neighbor != null) {
			…

			water.AddQuad(c1, c2, e1, e2);
			indices.y = neighbor.Index;
			water.AddQuadCellData(indices, weights1, weights2);

			if (direction <= HexDirection.E) {
				…
				water.AddTriangle(
					c2, e2, c2 + HexMetrics.GetWaterBridge(direction.Next())
				);
				indices.z = nextNeighbor.Index;
				water.AddTriangleCellData(
					indices, weights1, weights2, weights3
				);
			}
		}
	}

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

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…
		water.AddTriangle(center, e1.v1, e1.v2);
		water.AddTriangle(center, e1.v2, e1.v3);
		water.AddTriangle(center, e1.v3, e1.v4);
		water.AddTriangle(center, e1.v4, e1.v5);
		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		
		…
	}

Шейдер Water нужно изменить таким же образом, как и шейдер Road, но в нём нужно сочетать видимость не двух, а трёх ячеек.

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0

		#include "Water.cginc"
		#include "HexCellData.cginc"

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};

		…

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);

			data.visibility =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility = lerp(0.25, 1, data.visibility);
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float waves = Waves(IN.worldPos.xz, _MainTex);

			fixed4 c = saturate(_Color + waves);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Открытая вода с видимостью.

Побережья и устья


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

		Vector3 indices;
//		indices.x = indices.y = indices.z = cell.Index;
		indices.x = indices.z = cell.Index;
		indices.y = neighbor.Index;

Добавим данные ячеек к quad-ам и треугольнику побережья. Также передадим индексы при вызове TriangulateEstuary.

		if (cell.HasRiverThroughEdge(direction)) {
			TriangulateEstuary(
				e1, e2, cell.IncomingRiver == direction, indices
			);
		}
		else {
			…
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
		}

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			…
			waterShore.AddTriangleUV(
				…
			);
			indices.z = nextNeighbor.Index;
			waterShore.AddTriangleCellData(
				indices, weights1, weights2, weights3
			);
		}

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

	void TriangulateEstuary (
		EdgeVertices e1, EdgeVertices e2, bool incomingRiver, Vector3 indices
	) {
		waterShore.AddTriangle(e2.v1, e1.v2, e1.v1);
		waterShore.AddTriangle(e2.v5, e1.v5, e1.v4);
		waterShore.AddTriangleUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		waterShore.AddTriangleUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		waterShore.AddTriangleCellData(indices, weights2, weights1, weights1);
		waterShore.AddTriangleCellData(indices, weights2, weights1, weights1);

		estuaries.AddQuad(e2.v1, e1.v2, e2.v2, e1.v3);
		estuaries.AddTriangle(e1.v3, e2.v2, e2.v4);
		estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5);

		estuaries.AddQuadUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f),
			new Vector2(1f, 1f), new Vector2(0f, 0f)
		);
		estuaries.AddTriangleUV(
			new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(1f, 1f)
		);
		estuaries.AddQuadUV(
			new Vector2(0f, 0f), new Vector2(0f, 0f),
			new Vector2(1f, 1f), new Vector2(0f, 1f)
		);
		estuaries.AddQuadCellData(
			indices, weights2, weights1, weights2, weights1
		);
		estuaries.AddTriangleCellData(indices, weights1, weights2, weights2);
		estuaries.AddQuadCellData(indices, weights1, weights2);
		
		…
	}

В шейдер WaterShore нужно внести такие же изменения, что и в шейдер Water, смешав видимость трёх ячеек.

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0

		#include "Water.cginc"
		#include "HexCellData.cginc"

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};

		…

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);

			data.visibility =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility = lerp(0.25, 1, data.visibility);
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			…

			fixed4 c = saturate(_Color + max(foam, waves));
			o.Albedo = c.rgb * IN.visibility;
			…
		}

Шайдер Estuary смешивает видимость двух ячеек, как и шейдер Road. У него уже есть вершинная программа, потому что нам нужно, чтобы он передавал UV-координаты рек.

		#include "Water.cginc"
		#include "HexCellData.cginc"

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
			float2 riverUV;
			float3 worldPos;
			float visibility;
		};

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;

		void vert (inout appdata_full v, out Input o) {
			UNITY_INITIALIZE_OUTPUT(Input, o);
			o.riverUV = v.texcoord1.xy;

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);

			o.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			o.visibility = lerp(0.25, 1, o.visibility);
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			…

			fixed4 c = saturate(_Color + water);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Побережья и устья с видимостью.

Реки


Последние водные регионы, с которыми нужно поработать — это реки. Добавим в HexGridChunk.TriangulateRiverQuad параметр вектора индексов и добавим его к мешу, чтобы он мог поддерживать видимость двух ячеек.

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

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float v, bool reversed, Vector3 indices
	) {
		…
		rivers.AddQuadCellData(indices, weights1, weights2);
	}

TriangulateWithRiverBeginOrEnd создаёт конечные точки рек с quad-ом и треугольником в центре ячейки. Добавим для этого необходимые данные ячеек.

	void TriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		if (!cell.IsUnderwater) {
			bool reversed = cell.HasIncomingRiver;
			Vector3 indices;
			indices.x = indices.y = indices.z = cell.Index;
			TriangulateRiverQuad(
				m.v2, m.v4, e.v2, e.v4,
				cell.RiverSurfaceY, 0.6f, reversed, indices
			);
			center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY;
			rivers.AddTriangle(center, m.v2, m.v4);
			…
			rivers.AddTriangleCellData(indices, weights1);
		}
	}

У нас уже есть эти индексы ячеек в TriangulateWithRiver, поэтому просто передадим их при вызове TriangulateRiverQuad.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		if (!cell.IsUnderwater) {
			bool reversed = cell.IncomingRiver == direction;
			TriangulateRiverQuad(
				centerL, centerR, m.v2, m.v4,
				cell.RiverSurfaceY, 0.4f, reversed, indices
			);
			TriangulateRiverQuad(
				m.v2, m.v4, e.v2, e.v4,
				cell.RiverSurfaceY, 0.6f, reversed, indices
			);
		}
	}

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

	void TriangulateWaterfallInWater (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float waterY, Vector3 indices
	) {
		…
		rivers.AddQuadCellData(indices, weights1, weights2);
	}

И, наконец, изменим TriangulateConnection так, чтобы он передавал необходимые индексы методам рек и водопадов.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		…

		if (hasRiver) {
			e2.v3.y = neighbor.StreamBedY;
			Vector3 indices;
			indices.x = indices.z = cell.Index;
			indices.y = neighbor.Index;

			if (!cell.IsUnderwater) {
				if (!neighbor.IsUnderwater) {
					TriangulateRiverQuad(
						e1.v2, e1.v4, e2.v2, e2.v4,
						cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f,
						cell.HasIncomingRiver && cell.IncomingRiver == direction,
						indices
					);
				}
				else if (cell.Elevation > neighbor.WaterLevel) {
					TriangulateWaterfallInWater(
						e1.v2, e1.v4, e2.v2, e2.v4,
						cell.RiverSurfaceY, neighbor.RiverSurfaceY,
						neighbor.WaterSurfaceY, indices
					);
				}
			}
			else if (
				!neighbor.IsUnderwater &&
				neighbor.Elevation > cell.WaterLevel
			) {
				TriangulateWaterfallInWater(
					e2.v4, e2.v2, e1.v4, e1.v2,
					neighbor.RiverSurfaceY, cell.RiverSurfaceY,
					cell.WaterSurfaceY, indices
				);
			}
		}

		…
	}

В шейдер River нужно внести те же изменения, что и в шейдер Road.

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0

		#include "Water.cginc"
		#include "HexCellData.cginc"

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
			float visibility;
		};

		…

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);

			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);

			data.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility = lerp(0.25, 1, data.visibility);
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float river = River(IN.uv_MainTex, _MainTex);
			
			fixed4 c = saturate(_Color + river);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Реки с видимостью.

unitypackage

Объекты и видимость


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

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

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

Вот подобная текстура. Смещение по X хранится в её красном канале, а смещение по Z — в зелёном канале. Так как она покрывает область ячеек 2?2, то нам нужны смещения от 0 и 2. Такие данные нельзя хранить в цветовом канале, поэтому смещения уменьшаются наполовину. Нам не нужны чёткие края ячеек, поэтому вполне достаточно маленькой текстуры.


Текстура координат сетки.

Добавим текстуру в проект. Зададим для её Wrap Mode значение Repeat, как и у другой текстуры сетки. Нам не нужно никакого смешивания, поэтому для Blend Mode выберем значение Point. Также отключим Compression, чтобы данные не искажались. Отключим режим sRGB, чтобы при рендеринге в линейном режиме не выполнялось никаких преобразований цветовых пространств. И, наконец, нам не нужны mip-текстуры.


Параметры импорта текстуры.

Шейдер объектов с видимостью


Создадим новый шейдер Feature, чтобы добавить объектам поддержку видимости. Это простой поверхностный шейдер с вершинной программой. Добавим в него HexCellData и передадим показатель видимости во фрагментную программу, и как обычно учтём его в цвете. Отличие здесь заключается в том, что мы не можем использовать GetCellData, потому что требуемых данных мешей не существует. Вместо них у нас есть позиция в мире. Но пока оставим видимость равной 1.

Shader "Custom/Feature" {
	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
		[NoTilingOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {}
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.0

		#include "../HexCellData.cginc"

		sampler2D _MainTex, _GridCoordinates;

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;

		struct Input {
			float2 uv_MainTex;
			float visibility;
		};

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float3 pos = mul(unity_ObjectToWorld, v.vertex);

			data.visibility = 1;
		}

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

Изменим все материалы объектов так, чтобы они использовали новый шейдер, и назначим им текстуру координат сетки.


Урбанистический с текстурой сетки.

Доступ к данным ячеек


Чтобы сэмплировать текстуру координат сетки в вершинной программе, нам снова потребуется tex2Dlod с четырёхкомпонентным вектором координат текстуры. Первые две координаты — это позиция XZ мира. Две другие как и раньше равны нулю.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float3 pos = mul(unity_ObjectToWorld, v.vertex);

			float4 gridUV = float4(pos.xz, 0, 0);

			data.visibility = 1;
		}

Как и в шейдере Terrain, растянем UV-координаты, чтобы текстура имела правильное соотношение сторон, соответствующее сетке шестиугольников.

			float4 gridUV = float4(pos.xz, 0, 0);
			gridUV.x *= 1 / (4 * 8.66025404);
			gridUV.y *= 1 / (2 * 15.0);

Мы можем выяснить, в какой части ячеек 2?2 находимся, взяв округлённое вниз значение UV-координат. Это формирует базис координат ячеек.

			float4 gridUV = float4(pos.xz, 0, 0);
			gridUV.x *= 1 / (4 * 8.66025404);
			gridUV.y *= 1 / (2 * 15.0);
			float2 cellDataCoordinates = floor(gridUV.xy);

Чтобы найти координаты ячейки, в которой мы находимся, прибавим хранящиеся в текстуре смещения.

			float2 cellDataCoordinates =
				floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg;

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

			float2 cellDataCoordinates =
				floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg;
			cellDataCoordinates *= 2;

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

float4 GetCellData (float2 cellDataCoordinates) {
	float2 uv = cellDataCoordinates + 0.5;
	uv.x *= _HexCellData_TexelSize.x;
	uv.y *= _HexCellData_TexelSize.y;
	return tex2Dlod(_HexCellData, float4(uv, 0, 0));
}

Теперь мы можем использовать эту функцию в вершинной программе шейдера Feature.

			cellDataCoordinates *= 2;

			data.visibility = GetCellData(cellDataCoordinates).x;
			data.visibility = lerp(0.25, 1, data.visibility);


Объекты с видимостью.

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


Стены с меняющейся видимостью.

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

unitypackage

Часть 21: исследование карты


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

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


Мы готовы исследовать мир.

Отображение всей карты в режиме редактирования


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

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


Мы можем управлять тем, применяют ли шейдеры видимость, при помощи ключевого слова, как это делалось с наложением на сетку. Давайте используем ключевое слово HEX_MAP_EDIT_MODE, обозначающее состояние режима редактирования. Так как об этом ключевом слове должно знать несколько шейдеров, мы определим его глобально, с помощью статических методов Shader.EnableKeyWord и Shader.DisableKeyword. Будем вызывать соответствующий метод в HexGameUI.SetEditMode при изменении режима редактирования.

	public void SetEditMode (bool toggle) {
		enabled = !toggle;
		grid.ShowUI(!toggle);
		grid.ClearPath();
		if (toggle) {
			Shader.EnableKeyword("HEX_MAP_EDIT_MODE");
		}
		else {
			Shader.DisableKeyword("HEX_MAP_EDIT_MODE");
		}
	}

Шейдеры режима редактирования


Когда HEX_MAP_EDIT_MODE определено, шейдеры будут игнорировать видимость. Это сводится к тому, что видимость ячейки всегда будет считаться равной 1. Давайте добавим в начало include-файла HexCellData функцию для фильтрации данных ячеек в зависимости от ключевого слова.

sampler2D _HexCellData;
float4 _HexCellData_TexelSize;

float4 FilterCellData (float4 data) {
	#if defined(HEX_MAP_EDIT_MODE)
		data.x = 1;
	#endif
	return data;
}

Передадим через эту функцию результат обеих функций GetCellData перед его возвратом.

float4 GetCellData (appdata_full v, int index) {
	…
	return FilterCellData(data);
}

float4 GetCellData (float2 cellDataCoordinates) {
	…
	return FilterCellData(tex2Dlod(_HexCellData, float4(uv, 0, 0)));
}

Чтобы всё заработало, все соответствующие шейдеры должны получить директиву multi_compile для создания вариантов на случай, когда ключевое слово HEX_MAP_EDIT_MODE определено. Добавим соответствующую строку в шейдеры Estuary, Feature, River, Road, Terrain, Water и Water Shore, между директивой target и первой директивой include.

		#pragma multi_compile _ HEX_MAP_EDIT_MODE

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

unitypackage

Исследование ячеек


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

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


Чтобы добавить поддержку отслеживания состояния исследования добавим в HexCell общее свойство IsExplored.

	public bool IsExplored { get; set; }

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

	public bool IsExplored { get; private set; }

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

	public void IncreaseVisibility () {
		visibility += 1;
		if (visibility == 1) {
			IsExplored = true;
			ShaderData.RefreshVisibility(this);
		}
	}

Передача состояния исследования шейдерам


Как и в случае с видимостью ячеек, мы передаём их состояние исследования шейдерам через данные шейдера. В конце концов, это просто ещё один тип видимости. HexCellShaderData.RefreshVisibility хранит состояние видимости в канале R данных. Давайте будем хранить состояние исследования в канале G данных.

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
		cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		enabled = true;
	}

Чёрный неисследованный рельеф


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

float4 FilterCellData (float4 data) {
	#if defined(HEX_MAP_EDIT_MODE)
		data.xy = 1;
	#endif
	return data;
}

Шейдер Terrain передаёт данные видимости всех трёх возможных ячеек во фрагментную программу. В случае состояния исследования мы комбинируем их в вершинной программе и передаём во фрагментную программу единственное значение. Добавим во входящие данные visibility четвёртый компонент, чтобы у нас было для этого место.

		struct Input {
			float4 color : COLOR;
			float3 worldPos;
			float3 terrain;
			float4 visibility;
		};

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

		void vert (inout appdata_full v, out Input data) {
			…
			data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz);
		}

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

			data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz);
			data.visibility.w =
				cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z;

Состояние исследования теперь доступно во фрагментной программе через IN.visibility.w. Учтём его в вычислении albedo.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			…

			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Неисследованный рельеф теперь чёрный.

Рельеф неисследованных ячеек теперь имеет чёрный цвет. Но на объекты, дороги и воду это пока не повлияло. Однако этого достаточно, чтобы убедиться в том, что исследование работает.

Сохранение и загрузка состояния исследования


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

	const int mapFileVersion = 3;

Будем использовать эту константу при записи версии файла в Save и при проверке поддержки файла в Load.

	void Save (string path) {
		using (
			BinaryWriter writer =
			new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(mapFileVersion);
			hexGrid.Save(writer);
		}
	}

	void Load (string path) {
		if (!File.Exists(path)) {
			Debug.LogError("File does not exist " + path);
			return;
		}
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header <= mapFileVersion) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

В качестве последнего шага HexCell.Save мы запишем состояние исследования.

	public void Save (BinaryWriter writer) {
		…
		writer.Write(IsExplored);
	}

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

	public void Load (BinaryReader reader) {
		…

		IsExplored = reader.ReadBoolean();
		ShaderData.RefreshVisibility(this);
	}

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

	public void Load (BinaryReader reader, int header) {
		…

		IsExplored = header >= 3 ? reader.ReadBoolean() : false;
		ShaderData.RefreshVisibility(this);
	}

Теперь HexGrid.Load должен будет передавать в HexCell.Load данные заголовка.

	public void Load (BinaryReader reader, int header) {
		…

		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader, header);
		}
		…
	}

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

unitypackage

Скрываем неизвестные ячейки


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

Делаем рельеф по-настоящему чёрным


Хотя исследованный рельеф имеет чёрный цвет, мы всё равно можем распознать его, потому что он до сих пор имеет specular lighting. Чтобы избавиться от освещения, нам нужно сделать его идеально матово-чёрным. Чтобы не затрагивать при этом другие свойства поверхности, проще всего изменить specular color на чёрный. Это возможно, если использовать поверхностный шейдер, работающий со specular, но сейчас мы используем стандартный metallic. Поэтому начнём с переключения шейдера Terrain на specular.

Заменим цветовое свойство _Metallic на свойство _Specular. По умолчанию его значение цвета должно быть равно (0.2, 0.2, 0.2). Так мы гарантируем, что он будет соответствовать внешнему виду версии с metallic.

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_GridTex ("Grid Texture", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
//		_Metallic ("Metallic", Range(0,1)) = 0.0
		_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
	}

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

		half _Glossiness;
//		half _Metallic;
		fixed3 _Specular;
		fixed4 _Color;

Изменим pragma surface surf со Standard на StandardSpecular. Это заставит Unity генерировать шейдеры с использованием specular.

		#pragma surface surf StandardSpecular fullforwardshadows vertex:vert

Теперь функции surf нужно, чтобы второй параметр имел тип SurfaceOutputStandardSpecular. Кроме того, теперь нужно присваивать значение не o.Metallic, а o.Specular.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…

			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
//			o.Metallic = _Metallic;
			o.Specular = _Specular;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}

Теперь мы можем затенить отсветы, учтя explored в цвете specular.

			o.Specular = _Specular * explored;


Неисследованный рельеф без отражённого освещения.

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

Почему поверхности становятся зеркалами?
Это называется эффектом Френеля. Подробнее см. в серии туториалов Rendering.


Неисследованные области всё равно отражают окружение.

Чтобы избавиться от этих отражений, будем считать неисследованный рельеф полностью затенённым. Это реализуется присвоением значения explored параметру occlusion, который мы используем как маску отражений.

			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a;


Неисследованное без отражений.

Соответствие фону


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

	Properties {
		…
		_BackgroundColor ("Background Color", Color) = (0,0,0)
	}
	
	…
	
		half _Glossiness;
		fixed3 _Specular;
		fixed4 _Color;
		half3 _BackgroundColor;

Чтобы использовать этот цвет, мы добавим его как emissive light. Это реализуется присвоением o.Emission значения цвета фона, умноженного на единицу минус explored.

			o.Occlusion = explored;
			o.Emission = _BackgroundColor * (1 -  explored);

Так как мы используем скайбокс по умолчанию, видимый цвет фона на самом деле не одинаков. В целом наилучшим цветом будет немного красноватый серый. При настройке материала рельефа можно использовать для Hex Color код 68615BFF.


Материал рельефа с серым фоновым цветом.

В целом это работает, хотя если знать, куда смотреть, можно заметить очень слабые силуэты. Чтобы их не мог увидеть игрок, можно назначить для камеры вместо скайбокса однородный цвет фона 68615BFF.


Камера со однородным цветом фона.

Почему бы не удалить скайбокс?
Это можно сделать, но не забывайте, что он используется для environmental lighting карты. Если переключиться на однородный цвет, то изменится и освещение карты.

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


Неисследованных ячеек больше не видно.

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

Скрываем объекты рельефа


Сейчас у нас скрыт только меш рельефа. На всё остальное состояние исследования пока не действует.


Пока скрыт только рельеф.

Давайте теперь изменим шейдер Feature, который является непрозрачным шейдером наподобие Terrain. Превратим его в specular-шейдер и добавим в него цвет фона. Начнём со свойств.

	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
		_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
		_BackgroundColor ("Background Color", Color) = (0,0,0)
		[NoScaleOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {}
	}

Далее pragma surface и переменные, как и раньше.

		#pragma surface surf StandardSpecular fullforwardshadows vertex:vert
			
		…

		half _Glossiness;
//		half _Metallic;
		fixed3 _Specular;
		fixed4 _Color;
		half3 _BackgroundColor;

visibility тоже потребуется ещё один компонент. Так как Feature комбинирует видимость для каждой вершины, ему требовалось только одно значение float. Теперь нам нужны два.

		struct Input {
			float2 uv_MainTex;
			float2 visibility;
		};

Изменим vert так, чтобы он явным образом использовал для данных видимости data.visibility.x, а затем присвоим data.visibility.y значение данных исследования.

		void vert (inout appdata_full v, out Input data) {
			…

			float4 cellData = GetCellData(cellDataCoordinates);
			data.visibility.x = cellData.x;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y = cellData.y;
		}

Изменим surf так, чтобы он использовал новые данные, как и Terrain.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			float explored = IN.visibility.y;
			o.Albedo = c.rgb * (IN.visibility.x * explored);
//			o.Metallic = _Metallic;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Emission = _BackgroundColor * (1 -  explored);
			o.Alpha = c.a;
		}


Скрытые объекты рельефа.

Скрываем воду


Следующими идут шейдеры Water и Water Shore. Начнём с преобразования их в specular-шейдеры. Однако им не нужен цвет фона, потому что это прозрачные шейдеры.

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

		struct Input {
			…
			float2 visibility;
		};

		…

		void vert (inout appdata_full v, out Input data) {
			…

			data.visibility.x =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y =
				cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z;
		}

Water и Water Shore выполняют в surf разные операции, но задают свои свойства поверхности одинаковым образом. Так как они прозрачны, учтём explore в альфа-канале, а не будем задавать emission.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…

			float explored = IN.visibility.y;
			o.Albedo = c.rgb * IN.visibility.x;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a * explored;
		}


Скрытая вода.

Скрываем устья, реки и дороги


У нас остались шейдеры Estuary, River и Road. Все три являются прозрачными и комбинируют данные двух ячеек. Переключим их все на specular, а затем добавим в visibility данные исследования.

		struct Input {
			…
			float2 visibility;
		};

		…
		
		void vert (inout appdata_full v, out Input data) {
			…

			data.visibility.x = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y = cell0.y * v.color.x + cell1.y * v.color.y;
		}

Изменим функцию surf шейдеров Estuary и River так, чтобы она использовала новые данные. В оба нужно внести одинаковые изменения.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…

			float explored = IN.visibility.y;
			fixed4 c = saturate(_Color + water);
			o.Albedo = c.rgb * IN.visibility.x;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a * explored;
		}

Шейдер Road немного отличается, потому что он использует дополнительный показатель смешения.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025);
			fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility.x);
			float blend = IN.uv_MainTex.x;
			blend *= noise.x + 0.5;
			blend = smoothstep(0.4, 0.7, blend);

			float explored = IN.visibility.y;
			o.Albedo = c.rgb;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = blend * explored;
		}


Скрыто всё.

unitypackage

Избегаем неисследованные ячейки


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


Перемещение по неисследованным ячейкам.

Отряды определяют затраты на перемещение


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

Добавим в HexUnit общий метод GetMoveCost для определения затрат на перемещение. Ему нужно знать, между какими ячейками происходит перемещение, а также направление. Скопируем соответствующий код затрат на перемещение из HexGrid.Search в этот метод и изменим имена переменных.

	public int GetMoveCost (
		HexCell fromCell, HexCell toCell, HexDirection direction)
	{
		HexEdgeType edgeType = fromCell.GetEdgeType(toCell);
		if (edgeType == HexEdgeType.Cliff) {
			continue;
		}
		int moveCost;
		if (fromCell.HasRoadThroughEdge(direction)) {
			moveCost = 1;
		}
		else if (fromCell.Walled != toCell.Walled) {
			continue;
		}
		else {
			moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
			moveCost +=
				toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel;
		}
	}

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

	public int GetMoveCost (
		HexCell fromCell, HexCell toCell, HexDirection direction)
	{
		HexEdgeType edgeType = fromCell.GetEdgeType(toCell);
		if (edgeType == HexEdgeType.Cliff) {
			return -1;
		}
		int moveCost;
		if (fromCell.HasRoadThroughEdge(direction)) {
			moveCost = 1;
		}
		else if (fromCell.Walled != toCell.Walled) {
			return -1;
		}
		else {
			moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
			moveCost +=
				toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel;
		}
		return moveCost;
	}

Теперь нам нужно знать при нахождении пути не только скорость, но и выбранный отряд. Соответствующим образом изменим HexGameUI.DoPathFinding.

	void DoPathfinding () {
		if (UpdateCurrentCell()) {
			if (currentCell && selectedUnit.IsValidDestination(currentCell)) {
				grid.FindPath(selectedUnit.Location, currentCell, selectedUnit);
			}
			else {
				grid.ClearPath();
			}
		}
	}

Так как нам по-прежнему нужен доступ к скорости отряда, добавим в HexUnit свойство Speed. Пока оно будет возвращать постоянное значение 24.

	public int Speed {
		get {
			return 24;
		}
	}

В HexGrid изменим FindPath и Search так, чтобы они могли работать при нашем новом подходе.

	public void FindPath (HexCell fromCell, HexCell toCell, HexUnit unit) {
		ClearPath();
		currentPathFrom = fromCell;
		currentPathTo = toCell;
		currentPathExists = Search(fromCell, toCell, unit);
		ShowPath(unit.Speed);
	}
	
	bool Search (HexCell fromCell, HexCell toCell, HexUnit unit) {
		int speed = unit.Speed;
		…
	}

Теперь удалим из Search старый код, который определял, можно ли переместиться в соседнюю ячейку и каковы затраты на перемещение. Вместо это будем вызывать HexUnit.IsValidDestination и HexUnit.GetMoveCost. Будем пропускать ячейку, если затраты на перемещение оказываются отрицательными.

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase
				) {
					continue;
				}
//				if (neighbor.IsUnderwater || neighbor.Unit) {
//					continue;
//				}
//				HexEdgeType edgeType = current.GetEdgeType(neighbor);
//				if (edgeType == HexEdgeType.Cliff) {
//					continue;
//				}
//				int moveCost;
//				if (current.HasRoadThroughEdge(d)) {
//					moveCost = 1;
//				}
//				else if (current.Walled != neighbor.Walled) {
//					continue;
//				}
//				else {
//					moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
//					moveCost += neighbor.UrbanLevel + neighbor.FarmLevel +
//						neighbor.PlantLevel;
//				}
				if (!unit.IsValidDestination(neighbor)) {
					continue;
				}
				int moveCost = unit.GetMoveCost(current, neighbor, d);
				if (moveCost < 0) {
					continue;
				}

				int distance = current.Distance + moveCost;
				int turn = (distance - 1) / speed;
				if (turn > currentTurn) {
					distance = turn * speed + moveCost;
				}

				…
			}

Обход неисследованных областей


Чтобы избегать неисследованных ячеек, нам достаточно сделать так, чтобы HexUnit.IsValidDestination проверял, исследована ли ячейка.

	public bool IsValidDestination (HexCell cell) {
		return cell.IsExplored && !cell.IsUnderwater && !cell.Unit;
	}


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

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

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

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

unitypackage

Часть 22: усовершенствованная видимость


  • Плавно изменяем видимость.
  • Используем высоту ячейки для определения области видимости.
  • Скрываем край карты.

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


Чтобы увидеть дальше, взбирайся выше.

Переходы видимости


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

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

	public bool ImmediateMode { get; set; }

Отслеживание переходных ячеек


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

using System.Collections.Generic;
using UnityEngine;

public class HexCellShaderData : MonoBehaviour {

	Texture2D cellTexture;
	Color32[] cellTextureData;

	List<HexCell> transitioningCells = new List<HexCell>();

	public bool ImmediateMode { get; set; }

	public void Initialize (int x, int z) {
		…

		transitioningCells.Clear();
		enabled = true;
	}
	
	…
}

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

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		if (ImmediateMode) {
			cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
			cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		}
		else {
			transitioningCells.Add(cell);
		}
		enabled = true;
	}

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

Обход переходных ячеек в цикле


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

	const float transitionSpeed = 255f;

Теперь в LateUpdate мы можем определять дельту, применяемую к значениям. Для этого нужно умножить дельту времени на скорость. Она должна иметь значение integer, потому что мы не знаем, насколько большой она может оказаться. Резкое падение частоты кадров может сделать дельту больше 255.

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

	void LateUpdate () {
		int delta = (int)(Time.deltaTime * transitionSpeed);

		cellTexture.SetPixels32(cellTextureData);
		cellTexture.Apply();
		enabled = transitioningCells.Count > 0;
	}

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

		int delta = (int)(Time.deltaTime * transitionSpeed);
		if (delta == 0) {
			delta = 1;
		}

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

		int delta = (int)(Time.deltaTime * transitionSpeed);
		if (delta == 0) {
			delta = 1;
		}
		for (int i = 0; i < transitioningCells.Count; i++) {
			UpdateCellData(transitioningCells[i], delta);
		}

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

		for (int i = 0; i < transitioningCells.Count; i++) {
			if (!UpdateCellData(transitioningCells[i], delta)) {
				transitioningCells.RemoveAt(i--);
			}
		}

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

			if (!UpdateCellData(transitioningCells[i], delta)) {
				transitioningCells[i--] =
					transitioningCells[transitioningCells.Count - 1];
				transitioningCells.RemoveAt(transitioningCells.Count - 1);
			}

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

	bool UpdateCellData (HexCell cell, int delta) {
		int index = cell.Index;
		Color32 data = cellTextureData[index];
		bool stillUpdating = false;

		cellTextureData[index] = data;
		return stillUpdating;
	}

Обновление данных ячеек


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

		bool stillUpdating = false;

		if (cell.IsExplored && data.g < 255) {
			stillUpdating = true;
		}

		cellTextureData[index] = data;

Чтобы выполнять переход, будем прибавлять к значению G ячейки дельту. Арифметические операции не работают с байтами, они сначала преобразуются в integer. Поэтому сумма будет иметь формат integer, который нужно преобразовать в byte.

		if (cell.IsExplored && data.g < 255) {
			stillUpdating = true;
			int t = data.g + delta;
			data.g = (byte)t;
		}

Но перед преобразованием нужно убедиться, что значение не превышает 255.

			int t = data.g + delta;
			data.g = t >= 255 ? (byte)255 : (byte)t;

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

		if (cell.IsExplored && data.g < 255) {
			…
		}

		if (cell.IsVisible && data.r < 255) {
			stillUpdating = true;
			int t = data.r + delta;
			data.r = t >= 255 ? (byte)255 : (byte)t;
		}

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

		if (cell.IsVisible) {
			if (data.r < 255) {
				stillUpdating = true;
				int t = data.r + delta;
				data.r = t >= 255 ? (byte)255 : (byte)t;
			}
		}
		else if (data.r > 0) {
			stillUpdating = true;
			int t = data.r - delta;
			data.r = t < 0 ? (byte)0 : (byte)t;
		}

Теперь UpdateCellData готов и переходы видимости выполняются верно.


Переходы видимости.

Защита от дублированных элементов перехода


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

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

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		if (ImmediateMode) {
			cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
			cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		}
		else if (cellTextureData[index].b != 255) {
			cellTextureData[index].b = 255;
			transitioningCells.Add(cell);
		}
		enabled = true;
	}

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

	bool UpdateCellData (HexCell cell, int delta) {
		…

		if (!stillUpdating) {
			data.b = 0;
		}
		cellTextureData[index] = data;
		return stillUpdating;
	}


Переходы без дубликатов.

Мгновенно загружающаяся видимость


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

	public void Load (BinaryReader reader, int header) {
		…

		cellShaderData.ImmediateMode = true;

		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader, header);
		}
		…
	}

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

	public void Load (BinaryReader reader, int header) {
		…
		
		bool originalImmediateMode = cellShaderData.ImmediateMode;
		cellShaderData.ImmediateMode = true;

		…

		cellShaderData.ImmediateMode = originalImmediateMode;
	}

unitypackage

Область видимости, зависящая от высоты


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

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


Горизонт зависит от высоты точки обзора.

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

Высота для обзора


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

	public int ViewElevation {
		get {
			return elevation >= waterLevel ? elevation : waterLevel;
		}
	}

Но если на область видимости влияет высота, то при изменении высоты обзора ячейки может меняться и ситуация с видимостью. Так как ячейка блокировала или теперь блокирует область видимости нескольких отрядов, то не так просто определить, что нужно изменить. Эту задачу не сможет решить сама ячейка, поэтому пусть она сообщает об изменении ситуации HexCellShaderData. Допустим, у HexCellShaderData есть для этого метод ViewElevationChanged. Будем вызывать его при задании HexCell.Elevation, если это необходимо.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			if (elevation == value) {
				return;
			}
			int originalViewElevation = ViewElevation;
			elevation = value;
			if (ViewElevation != originalViewElevation) {
				ShaderData.ViewElevationChanged();
			}
			…
		}
	}

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

	public int WaterLevel {
		get {
			return waterLevel;
		}
		set {
			if (waterLevel == value) {
				return;
			}
			int originalViewElevation = ViewElevation;
			waterLevel = value;
			if (ViewElevation != originalViewElevation) {
				ShaderData.ViewElevationChanged();
			}
			ValidateRivers();
			Refresh();
		}
	}

Сбрасываем видимость


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

	bool needsVisibilityReset;

	…

	public void ViewElevationChanged () {
		needsVisibilityReset = true;
		enabled = true;
	}

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

	public HexGrid Grid { get; set; }
						
	…

	void LateUpdate () {
		if (needsVisibilityReset) {
			needsVisibilityReset = false;
			Grid.ResetVisibility();
		}

		…
	}

Перейдём к HexGrid: зададим ссылку на сетку в HexGrid.Awake после создания данных шейдера.

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexUnit.unitPrefab = unitPrefab;
		cellShaderData = gameObject.AddComponent<HexCellShaderData>();
		cellShaderData.Grid = this;
		CreateMap(cellCountX, cellCountZ);
	}

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

	public void ResetVisibility () {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].ResetVisibility();
		}
	}

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

	public void ResetVisibility () {
		if (visibility > 0) {
			visibility = 0;
			ShaderData.RefreshVisibility(this);
		}
	}

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

	public void ResetVisibility () {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].ResetVisibility();
		}
		for (int i = 0; i < units.Count; i++) {
			HexUnit unit = units[i];
			IncreaseVisibility(unit.Location, unit.VisionRange);
		}
	}

Чтобы это сработало, выполним рефакторинг-переименование HexUnit.visionRange в HexUnit.VisionRange и превратим его в свойство. Пока оно будет получать постоянное значение 3, но в будущем это изменится.

	public int VisionRange {
		get {
			return 3;
		}
	}

Благодаря этому данные видимости будут сбрасываться и оставаться правильными после изменения высоты обзора ячеек. Но вероятно, что мы изменим правила определения области видимости и запустим рекомпиляцию в режиме Play. Чтобы область видимости изменялась самостоятельно, давайте запускать сброс HexGrid.OnEnable при обнаружении рекомпиляции.

	void OnEnable () {
		if (!HexMetrics.noiseSource) {
			…
			ResetVisibility();
		}
	}

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

Расширяем горизонт


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

	List<HexCell> GetVisibleCells (HexCell fromCell, int range) {
		…

		range = fromCell.ViewElevation;
		fromCell.SearchPhase = searchFrontierPhase;
		fromCell.Distance = 0;
		searchFrontier.Enqueue(fromCell);
		…
	}


Используем высоту как область видимости.

Препятствия видимости


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


Области видимости ничего не препятствует.

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

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

				int distance = current.Distance + 1;
				if (distance + neighbor.ViewElevation > range) {
					continue;
				}


Высокие ячейки блокируют обзор.

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

Не заглядываем за углы


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

		HexCoordinates fromCoordinates = fromCell.coordinates;
		while (searchFrontier.Count > 0) {
			…

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…

				int distance = current.Distance + 1;
				if (distance + neighbor.ViewElevation > range ||
					distance > fromCoordinates.DistanceTo(neighbor.coordinates)
				) {
					continue;
				}

				…
			}
		}


Используем только кратчайшие пути.

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

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

		range += fromCell.ViewElevation;


Обзор с полной областью видимости на низкой точке обзора.

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

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

unitypackage

Ячейки, которые нельзя исследовать


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


Заметный край карты.

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

Помечаем ячейки как исследуемые


Чтобы обозначить, что ячейку можно исследовать, добавим в HexCell свойство Explorable.

	public bool Explorable { get; set; }

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

	public bool IsVisible {
		get {
			return visibility > 0 && Explorable;
		}
	}

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

	public bool IsExplored {
		get {
			return explored && Explorable;
		}
		private set {
			explored = value;
		}
	}

	…

	bool explored;

Скрываем край карты


Скрыть край прямоугольной карты можно в методе HexGrid.CreateCell. Ячейки, не находящиеся на краю, являются исследуемыми, все остальные — неисследуемыми.

	void CreateCell (int x, int z, int i) {
		…

		HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.Index = i;
		cell.ShaderData = cellShaderData;

		cell.Explorable =
			x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1;
		
		…
	}

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


Неисследуемый край карты.

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

Неисследуемые ячейки препятствуют видимости


Наконец, если ячейку невозможно исследовать, то она должна мешать видимости. Изменим HexGrid.GetVisibleCells, чтобы учесть это.

				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase ||
					!neighbor.Explorable
				) {
					continue;
				}

unitypackage

Часть 23: генерируем сушу


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

Эта часть туториала будет началом серии о процедурной генерации карт.

Данная часть создана в Unity 2017.1.0.


Одна из множества сгенерированных карт.

Генерация карт


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

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

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

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

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

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

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

Начинаем в режиме редактирования


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

	void Awake () {
		terrainMaterial.DisableKeyword("GRID_ON");
		Shader.EnableKeyword("HEX_MAP_EDIT_MODE");
		SetEditMode(true);
	}

Генератор карты


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

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

using System.Collections.Generic;
using UnityEngine;

public class HexMapGenerator : MonoBehaviour {

	public HexGrid grid;

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
	}
}

Добавим в сцену объект с компонентом HexMapGenerator и подключим его к сетке.


Объект генератора карт.

Изменение меню новой карты


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

	bool generateMaps = true;

	public void ToggleMapGeneration (bool toggle) {
		generateMaps = toggle;
	}


Меню новой карты с переключателем.

Дадим меню ссылку на генератор карт. Затем заставим его при необходимости вызывать метод GenerateMap генератора, а не просто выполнять CreateMap сетки.

	public HexMapGenerator mapGenerator;

	…

	void CreateMap (int x, int z) {
		if (generateMaps) {
			mapGenerator.GenerateMap(x, z);
		}
		else {
			hexGrid.CreateMap(x, z);
		}
		HexMapCamera.ValidatePosition();
		Close();
	}


Подключение к генератору.

Доступ к ячейкам


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

	public HexCell GetCell (int xOffset, int zOffset) {
		return cells[xOffset + zOffset * cellCountX];
	}
	
	public HexCell GetCell (int cellIndex) {
		return cells[cellIndex];
	}

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

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
		for (int i = 0; i < z; i++) {
			grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
		}
	}


Столбец из травы на маленькой карте.

unitypackage

Создание суши


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

Поднимаем рельеф


Начнём с малого — поднимем один участок суши над водой. Создадим для этого метод RaiseTerrain с параметром для управления размером участка. Вызовем этот метод в GenerateMap, заменив прошлый тестовый код. Давайте начнём с небольшого участка земли, состоящего из семи ячеек.

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
//		for (int i = 0; i < z; i++) {
//			grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
//		}
		RaiseTerrain(7);
	}

	void RaiseTerrain (int chunkSize) {}

Пока для обозначения поднятой суши мы используем просто тип рельефа «трава», а изначальный рельеф «песок» обозначает океан. Заставим RaiseTerrain брать случайную ячейку и изменять тип её рельефа, пока не получим нужное количество суши.

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

	void RaiseTerrain (int chunkSize) {
		for (int i = 0; i < chunkSize; i++) {
			GetRandomCell().TerrainTypeIndex = 1;
		}
	}

	HexCell GetRandomCell () {
		return grid.GetCell(Random.Range(0, grid.cellCountX * grid.cellCountZ));
	}


Семь случайных ячеек суши.

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

	int cellCount;

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		…
	}

	…

	HexCell GetRandomCell () {
		return grid.GetCell(Random.Range(0, cellCount));
	}

Создание одного участка


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

Добавим HexMapGenerator собственное свойство и счётчик фазы границы поиска, как это было в HexGrid.

	HexCellPriorityQueue searchFrontier;

	int searchFrontierPhase;

Проверяем, что очередь с приоритетом существует, прежде чем она нам понадобится.

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		grid.CreateMap(x, z);
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		RaiseTerrain(7);
	}

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

		RaiseTerrain(7);
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).SearchPhase = 0;
		}

Теперь RaiseTerrain должен искать соответствующие ячейки, а не выбирать их случайно. Этот процесс очень похож на способ поиска в HexGrid. Однако мы не будем посещать ячейки больше одного раза, поэтому нам достаточно будет увеличивать фазу границы поиска на 1 вместо 2. Затем инициализируем границу с первой ячейкой, которая выбирается случайно. Как обычно, кроме задания её фазы поиска, присвоим её расстоянию и эвристике нулевое значение.

	void RaiseTerrain (int chunkSize) {
//		for (int i = 0; i < chunkSize; i++) {
//			GetRandomCell().TerrainTypeIndex = 1;
//		}
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell();
		firstCell.SearchPhase = searchFrontierPhase;
		firstCell.Distance = 0;
		firstCell.SearchHeuristic = 0;
		searchFrontier.Enqueue(firstCell);
	}

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

		searchFrontier.Enqueue(firstCell);

		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.TerrainTypeIndex = 1;
			size += 1;

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor && neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = 0;
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
			}
		}
		searchFrontier.Clear();


Линия из ячеек.

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

Соединяем ячейки


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

		searchFrontier.Enqueue(firstCell);
		HexCoordinates center = firstCell.coordinates;

		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.TerrainTypeIndex = 1;
			size += 1;

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor && neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = neighbor.coordinates.DistanceTo(center);
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
			}
		}


Скопление ячеек.

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

		RaiseTerrain(30);


Масса суши в 30 ячеек.

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

Рандомизация формы суши


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

					neighbor.Distance = neighbor.coordinates.DistanceTo(center);
					neighbor.SearchHeuristic = Random.value < 0.5f ? 1: 0;
					searchFrontier.Enqueue(neighbor);


Искажённый участок.

Увеличив поисковую эвристику ячейки, мы сделали так, чтобы она посещалась позже ожидаемого. При этом другие ячейки, находящиеся на один шаг дальше от центра, будут посещаться раньше, если только им тоже не увеличили эвристику. Это значит, что если мы увеличим эвристику всех ячеек на одну величину, то это никак не повлияет на карту. То есть порог 1 не будет иметь влияния, как и порог 0. А порог 0.8 будет эквивалентен 0.2. То есть вероятность 0.5 делает процесс поиска наиболее «дрожащим».

Подходящая величина колебаний зависит от необходимого вида рельефа, поэтому давайте сделаем её настраиваемой. Добавим в генератор общее поле float jitterProbability с атрибутом Range, ограниченным в интервале 0–0.5. Дадим ему значение по умолчанию, равное среднему этого интервала, то есть 0.25. Это позволит нам настраивать генератор в окне инспектора Unity.

	[Range(0f, 0.5f)]
	public float jitterProbability = 0.25f;


Вероятность колебаний.

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

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

					neighbor.SearchHeuristic =
						Random.value < jitterProbability ? 1: 0;

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

Поднимаем несколько участков суши


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

		for (int i = 0; i < 5; i++) {
			RaiseTerrain(30);
		}


Пять участков суши.

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

Чтобы повысить вариативность суши, мы также можем изменять размер каждого участка. Добавим два поля integer для управления минимальным и максимальным размерами участков. Присвоим им достаточно большой интервал, например 20–200. Я сделаю стандартный минимум равным 30, а стандартный максимум — 100.

	[Range(20, 200)]
	public int chunkSizeMin = 30;

	[Range(20, 200)]
	public int chunkSizeMax = 100;


Интервал размеров участков.

Используем эти поля для случайного определения размера участка при вызове RaiseTerrain.

			RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));


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

Создаём достаточно суши


Пока мы не можем особо контролировать количество генерируемой суши. Хоть мы и можем добавить опцию конфигурации количества участков, сами размеры участков случайны и могут немного или сильно пересекаться. Поэтому количество участков не гарантирует получение на карте нужного количества суши. Давайте добавим опцию для прямого управления процентом суши, выраженную как integer. Так как 100% суши или воды — это не очень интересно, ограничим его интервалом 5–95, со значением 50 по умолчанию.

	[Range(5, 95)]
	public int landPercentage = 50;


Процент суши.

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

	public void GenerateMap (int x, int z) {
		…
//		for (int i = 0; i < 5; i++) {
//			RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));
//		}
		CreateLand();
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).SearchPhase = 0;
		}
	}

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
	}

CreateLand будет вызвать RaiseTerrain, пока у нас не потратится вся сумма ячеек. Чтобы не превысить сумму, изменим RaiseTerrain так, чтобы он получал сумму в качестве дополнительного параметра. Закончив работу, он должен возвращать оставшуюся сумму.

//	void RaiseTerrain (int chunkSize) {
	int RaiseTerrain (int chunkSize, int budget) {
		…
		return budget;
	}

Сумма должна уменьшаться каждый раз, когда ячейка удаляется из границы и превращается в сушу. Если после этого оказывается потраченной вся сумма, то мы должны прекратит поиск и завершить участок. Кроме того, это нужно делать только тогда, когда текущая ячейка пока не является сушей.

		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			if (current.TerrainTypeIndex == 0) {
				current.TerrainTypeIndex = 1;
				if (--budget == 0) {
					break;
				}
			}
			size += 1;
			
			…
		}

Теперь CreateLand может поднимать сушу, пока не потратит всю сумму ячеек.

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			landBudget = RaiseTerrain(
				Random.Range(chunkSizeMin, chunkSizeMax + 1), landBudget
			);
		}
	}


Ровно половина карты стала сушей.

unitypackage

Учитываем высоту


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

Выталкиваем сушу наверх


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

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.TerrainTypeIndex == 0) {
				…
			}


Суша с высотами.

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

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

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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			cell.TerrainTypeIndex = cell.Elevation;
		}
	}

Будем вызывать этот метод после создания суши.

	public void GenerateMap (int x, int z) {
		…
		CreateLand();
		SetTerrainType();
		…
	}

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

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.Elevation == 1 && --budget == 0) {
				break;
			}
			
//			if (current.TerrainTypeIndex == 0) {
//				current.TerrainTypeIndex = 1;
//				if (--budget == 0) {
//					break;
//				}
//			}


Наслоения пластов.

Добавляем воду


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

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		grid.CreateMap(x, z);
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = 1;
		}
		CreateLand();
		…
	}

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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (!cell.IsUnderwater) {
				cell.TerrainTypeIndex = cell.Elevation - cell.WaterLevel;
			}
		}
	}


Суша и вода.

Повышаем уровень воды


Мы не ограничены одним уровнем воды. Давайте сделаем его настраиваемым с помощью общего поля с интервалом 1–5 и значением 3 по умолчанию. Используем этот уровень при инициализации ячеек.

	[Range(1, 5)]
	public int waterLevel = 3;
	
	…
	
	public void GenerateMap (int x, int z) {
		…
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = waterLevel;
		}
		…
	}



Уровень воды 3.

Когда уровень воды равен 3, у нас получается меньше суши, чем мы рассчитывали. Так происходит, потому что RaiseTerrain по-прежнему считает, что уровень воды равен 1. Давайте это исправим.

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.Elevation == waterLevel && --budget == 0) {
				break;
			}

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





Уровни воды 2–5, суши всегда 50%.

unitypackage

Вертикальное перемещение


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

Поднятые высоко участки


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

	[Range(0f, 1f)]
	public float highRiseProbability = 0.25f;


Вероятность сильного поднятия ячеек.

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

		int rise = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation + rise;
			if (
				originalElevation < waterLevel &&
				current.Elevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;
			
			…
		}





Вероятности сильного повышения высоты 0.25, 0.50, 0.75 и 1.

Опускаем сушу


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


Большая карта без затонувшей суши.

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

	[Range(0f, 0.4f)]
	public float sinkProbability = 0.2f;


Вероятность опускания.

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

	int SinkTerrain (int chunkSize, int budget) {
		…

		int sink = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation - sink;
			if (
				originalElevation >= waterLevel &&
				current.Elevation < waterLevel
//				&& --budget == 0
			) {
//				break;
				budget += 1;
			}
			size += 1;

			…
		}
		searchFrontier.Clear();
		return budget;
	}

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

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
			if (Random.value < sinkProbability) {
				landBudget = SinkTerrain(chunkSize, landBudget);
			}
			else {
				landBudget = RaiseTerrain(chunkSize, landBudget);
			}
		}
	}





Вероятность опускания 0.1, 0.2, 0.3 и 0.4.

Ограничиваем высоту


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


Огромные высоты при 90% суши.

Чтобы ограничивать высоту, давайте добавим настраиваемый минимум и максимум. Разумный минимум будет где-то пределах от ?4 до 0, а приемлемый максимум может находиться в интервале 6–10. Пусть значения по умолчанию будут равны ?2 и 8. При ручном редактировании карты они будут находиться вне допустимого предела, так что можно изменить ползунок UI редактора, или оставить как есть.

	[Range(-4, 0)]
	public int elevationMinimum = -2;

	[Range(6, 10)]
	public int elevationMaximum = 8;


Минимум и максимум высоты.

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

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = originalElevation + rise;
			if (newElevation > elevationMaximum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation < waterLevel &&
				newElevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;

Сделаем то же самое в SinkTerrain, но для минимальной высоты.

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = current.Elevation - sink;
			if (newElevation < elevationMinimum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation >= waterLevel &&
				newElevation < waterLevel
			) {
				budget += 1;
			}
			size += 1;


Ограниченная высота с 90% суши.

Сохранение отрицательной высоты


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

Мы можем добавить поддержку отрицательной высоты, сохраняя её как integer, а не byte. Однако нам по-прежнему не нужно обеспечивать поддержку множества уровней высоты. Кроме того, мы можем смещать сохраняемое значение, прибавляя 127. Это позволит корректно сохранять высоты в интервале ?127–128 в пределах одного байта. Изменим HexCell.Save соответствующим образом.

	public void Save (BinaryWriter writer) {
		writer.Write((byte)terrainTypeIndex);
		writer.Write((byte)(elevation + 127));
		…
	}

Так как мы изменили способ сохранения данных карт, увеличим SaveLoadMenu.mapFileVersion до 4.

	const int mapFileVersion = 4;

И, наконец, изменим HexCell.Load так, чтобы он вычитал 127 из высот, загружаемых из файлов версии 4.

	public void Load (BinaryReader reader, int header) {
		terrainTypeIndex = reader.ReadByte();
		ShaderData.RefreshTerrain(this);
		elevation = reader.ReadByte();
		if (header >= 4) {
			elevation -= 127;
		}
		…
	}

unitypackage

Воссоздание той же карты


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

Использование Seed


Чтобы сделать процесс генерации карты непредсказуемым, мы используем Random.Range и Random.value. Чтобы снова получить ту же псевдослучайную последовательность чисел, нужно использовать то же значение seed. Мы уже применяли подобный подход раньше, в HexMetrics.InitializeHashGrid. Он сначала сохраняет текущее состояние генератора чисел, инициализированного с определённым значением seed, а затем восстанавливает своё исходное состояние. Тот же подход мы можем использовать для HexMapGenerator.GenerateMap. Мы снова можем запоминать старое состояние и восстанавливать его после завершения, чтобы не мешать ничему другому, использующему Random.

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		…
		Random.state = originalRandomState;
	}

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

	public int seed;


Отображение seed.

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

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		seed = Random.Range(0, int.MaxValue);
		Random.InitState(seed);
		
		…
	}

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

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

		seed = Random.Range(0, int.MaxValue);
		seed ^= (int)System.DateTime.Now.Ticks;
		seed ^= (int)Time.unscaledTime;
		Random.InitState(seed);

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

		seed ^= (int)Time.unscaledTime;
		seed &= int.MaxValue;
		Random.InitState(seed);

Многократное использование Seed


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

	public bool useFixedSeed;


Опция использования постоянного seed.

Если выбран постоянный seed, то мы просто пропускаем генерацию нового seed в GenerateMap. Если мы не изменим поле seed вручную, то в результате снова сгенерируется та же карта.

		Random.State originalRandomState = Random.state;
		if (!useFixedSeed) {
			seed = Random.Range(0, int.MaxValue);
			seed ^= (int)System.DateTime.Now.Ticks;
			seed ^= (int)Time.time;
			seed &= int.MaxValue;
		}
		Random.InitState(seed);

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



Большие карты со значениями seed 0 и 929396788, параметры стандартные.

unitypackage

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