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

https://catlikecoding.com/unity/tutorials/hex-map/part-8/
  • Перевод
Части 1-3: сетка, цвета и высоты ячеек.

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

Часть 8: вода


  • Добавляем в ячейки воду.
  • Триангулируем поверхность воды.
  • Создаём прибой с пеной.
  • Объединяем воду и реки.

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


Вода прибывает.

Уровень воды


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

	public int WaterLevel {
		get {
			return waterLevel;
		}
		set {
			if (waterLevel == value) {
				return;
			}
			waterLevel = value;
			Refresh();
		}
	}
	
	int waterLevel;

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

Затопление ячеек


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

	public bool IsUnderwater {
		get {
			return waterLevel > elevation;
		}
	}

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

//	public const float riverSurfaceElevationOffset = -0.5f;
	public const float waterElevationOffset = -0.5f;

Изменим HexCell.RiverSurfaceY так, чтобы он использовал новое имя. Затем добавим похожее свойство к поверхности воды затопленной ячейки.

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

Редактирование воды


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

	int activeElevation;
	int activeWaterLevel;

	…
	
	bool applyElevation = true;
	bool applyWaterLevel = true;
	
	

Добавим методы для соединения этих параметров с UI.

	public void SetApplyWaterLevel (bool toggle) {
		applyWaterLevel = toggle;
	}
	
	public void SetWaterLevel (float level) {
		activeWaterLevel = (int)level;
	}

И добавим уровень воды в EditCell.

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

Чтобы добавить в UI уровень воды, дублируем метку и ползунок высоты, а потом изменим их. Не забудьте прикрепить их события к соответствующим методам.


Ползунок уровня воды.

unitypackage

Триангуляция воды


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

Shader "Custom/Water" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Transparent" "Queue"="Transparent" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard alpha
		#pragma target 3.0

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
		};

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;

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

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


Материал Water.

Добавим к префабу новый дочерний объект, дублировав дочерний объект Rivers. Ему не нужны UV-координаты, и он должен использовать материал Water. Как обычно, сделаем это, создав экземпляр префаба, изменив его, а затем применив изменения к префабу. После этого избавимся от экземпляра.



Дочерний объект Water.

Далее добавим в HexGridChunk поддержку меша воды.

	public HexMesh terrain, rivers, roads, water;

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

И соединим его с дочерним объектом префаба.


Объект Water соединён.

Шестиугольники воды


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

	void Triangulate (HexDirection direction, HexCell cell) {
		…

		if (cell.IsUnderwater) {
			TriangulateWater(direction, cell, center);
		}
	}

	void TriangulateWater (
		HexDirection direction, HexCell cell, Vector3 center
	) {
	}

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

	void TriangulateWater (
		HexDirection direction, HexCell cell, Vector3 center
	) {
		center.y = cell.WaterSurfaceY;
		Vector3 c1 = center + HexMetrics.GetFirstSolidCorner(direction);
		Vector3 c2 = center + HexMetrics.GetSecondSolidCorner(direction);

		water.AddTriangle(center, c1, c2);
	}


Шестиугольники воды.

Соединения воды


Мы можем соединить соседние клетки с водой одним четырёхугольником.

		water.AddTriangle(center, c1, c2);

		if (direction <= HexDirection.SE) {
			HexCell neighbor = cell.GetNeighbor(direction);
			if (neighbor == null || !neighbor.IsUnderwater) {
				return;
			}

			Vector3 bridge = HexMetrics.GetBridge(direction);
			Vector3 e1 = c1 + bridge;
			Vector3 e2 = c2 + bridge;

			water.AddQuad(c1, c2, e1, e2);
		}


Соединения краёв воды.

И заполним углы одним треугольником.

		if (direction <= HexDirection.SE) {
			…

			water.AddQuad(c1, c2, e1, e2);

			if (direction <= HexDirection.E) {
				HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
				if (nextNeighbor == null || !nextNeighbor.IsUnderwater) {
					return;
				}
				water.AddTriangle(
					c2, e2, c2 + HexMetrics.GetBridge(direction.Next())
				);
			}
		}


Соединения углов воды.

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

Согласованные уровни воды


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


Несогласованные уровни воды.

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

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

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

unitypackage

Анимирование воды


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


Идеально плоская вода.

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

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
		};

		…

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float2 uv = IN.worldPos.xz;
			uv.y += _Time.y;
			float4 noise = tex2D(_MainTex, uv * 0.025);
			float waves = noise.z;

			fixed4 c = saturate(_Color + waves);
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Скроллинг воды, время ×10.

Два направления


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

			float2 uv1 = IN.worldPos.xz;
			uv1.y += _Time.y;
			float4 noise1 = tex2D(_MainTex, uv1 * 0.025);

			float2 uv2 = IN.worldPos.xz;
			uv2.x += _Time.y;
			float4 noise2 = tex2D(_MainTex, uv2 * 0.025);

			float waves = noise1.z + noise2.x;

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

			float waves = noise1.z + noise2.x;
			waves = smoothstep(0.75, 2, waves);


Два направления, время ×10.

Волны смешения


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

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

			float blendWave =
				sin((IN.worldPos.x + IN.worldPos.z) * 0.1 + _Time.y);

Синусоиды колеблются в интервале от -1 и 1, а нам нужен интервал 0–1. Можно получить его, возведя волну в квадрат. Чтобы увидеть изолированный результат, используем его вместо изменённого цвета в качестве выходного значения.

			sin((IN.worldPos.x + IN.worldPos.z) * 0.1 + _Time.y);
			blendWave *= blendWave;

			float waves = noise1.z + noise2.x;
			waves = smoothstep(0.75, 2, waves);

			fixed4 c = blendWave; //saturate(_Color + waves);


Волны смешения.

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

			float blendWave = sin(
				(IN.worldPos.x + IN.worldPos.z) * 0.1 +
				(noise1.y + noise2.z) + _Time.y
			);
			blendWave *= blendWave;


Искажённые волны смешения.

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

			float waves =
				lerp(noise1.z, noise1.w, blendWave) +
				lerp(noise2.x, noise2.y, blendWave);
			waves = smoothstep(0.75, 2, waves);

			fixed4 c = saturate(_Color + waves);


Смешение волн, время ×2.

unitypackage

Побережье


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

	void TriangulateWater (
		HexDirection direction, HexCell cell, Vector3 center
	) {
		center.y = cell.WaterSurfaceY;

		HexCell neighbor = cell.GetNeighbor(direction);
		if (neighbor != null && !neighbor.IsUnderwater) {
			TriangulateWaterShore(direction, cell, neighbor, center);
		}
		else {
			TriangulateOpenWater(direction, cell, neighbor, center);
		}
	}

	void TriangulateOpenWater (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		Vector3 c1 = center + HexMetrics.GetFirstSolidCorner(direction);
		Vector3 c2 = center + HexMetrics.GetSecondSolidCorner(direction);

		water.AddTriangle(center, c1, c2);

		if (direction <= HexDirection.SE && neighbor != null) {
//			HexCell neighbor = cell.GetNeighbor(direction);
//			if (neighbor == null || !neighbor.IsUnderwater) {
//				return;
//			}
			
			Vector3 bridge = HexMetrics.GetBridge(direction);
			…
		}
	}

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		
	}


Триангуляции вдоль побережья нет.

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

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		EdgeVertices e1 = new EdgeVertices(
			center + HexMetrics.GetFirstSolidCorner(direction),
			center + HexMetrics.GetSecondSolidCorner(direction)
		);
		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);
	}


Вееры треугольников вдоль побережья.

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

		water.AddTriangle(center, e1.v4, e1.v5);
		
		Vector3 bridge = HexMetrics.GetBridge(direction);
		EdgeVertices e2 = new EdgeVertices(
			e1.v1 + bridge,
			e1.v5 + bridge
		);
		water.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		water.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		water.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		water.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);


Полосы рёбер вдоль побережья.

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

		water.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			water.AddTriangle(
				e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next())
			);
		}


Углы рёбер вдоль побережья.

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

UV побережья


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

У открытой воды нет UV-координат, и ей не нужна пена. Она нужна только для воды рядом с побережьем. Поэтому требования к обоим типам воды довольно сильно отличаются. Логично будет создать для каждого типа собственный меш. Поэтому добавим в HexGridChunk поддержку ещё одного объекта меша.

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

Этот новый меш будет использовать TriangulateWaterShore.

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…
		waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			waterShore.AddTriangle(
				e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next())
			);
		}
	}

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


Объект Water shore и материал с UV.

Изменим шейдер Water Shore так, чтобы вместо воды он отображал UV-координаты.

			fixed4 c = fixed4(IN.uv_MainTex, 1, 1);

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


Отдельный меш для побережья.

Давайте поместим информацию о побережье в координату V. Со стороны воды присвоим ей значение 0, со стороны суши — значение 1. Так как больше нам передавать ничего не нужно, все координаты U будут просто равны 0.

		waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);
		waterShore.AddQuadUV(0f, 0f, 0f, 1f);
		waterShore.AddQuadUV(0f, 0f, 0f, 1f);
		waterShore.AddQuadUV(0f, 0f, 0f, 1f);
		waterShore.AddQuadUV(0f, 0f, 0f, 1f);

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			waterShore.AddTriangle(
				e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next())
			);
			waterShore.AddTriangleUV(
				new Vector2(0f, 0f),
				new Vector2(0f, 1f),
				new Vector2(0f, 0f)
			);
		}


Переходы к побережьям, неправильные.

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

			waterShore.AddTriangleUV(
				new Vector2(0f, 0f),
				new Vector2(0f, 1f),
				new Vector2(0f, nextNeighbor.IsUnderwater ? 0f : 1f)
			);


Переходы к побережьям, правильные.

Пена на побережье


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

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float shore = IN.uv_MainTex.y;
			
			float foam = shore;

			fixed4 c = saturate(_Color + foam);
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Линейная пена.

Чтобы пена была интереснее, умножим её на квадрат синусоиды.

			float foam = sin(shore * 10);
			foam *= foam * shore;


Затухающая пена квадрата синусоиды.

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

			float shore = IN.uv_MainTex.y;
			shore = sqrt(shore);


Пена становится гуще у берега.

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

			float2 noiseUV = IN.worldPos.xz;
			float4 noise = tex2D(_MainTex, noiseUV * 0.015);

			float distortion = noise.x * (1 - shore);
			float foam = sin((shore + distortion) * 10);
			foam *= foam * shore;


Пена с искажениями.

И, разумеется, всё это анимируем: и синусоиду, и искажения.

			float2 noiseUV = IN.worldPos.xz + _Time.y * 0.25;
			float4 noise = tex2D(_MainTex, noiseUV * 0.015);

			float distortion = noise.x * (1 - shore);
			float foam = sin((shore + distortion) * 10 - _Time.y);
			foam *= foam * shore;


Анимированная пена.

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

			float distortion1 = noise.x * (1 - shore);
			float foam1 = sin((shore + distortion1) * 10 - _Time.y);
			foam1 *= foam1;

			float distortion2 = noise.y * (1 - shore);
			float foam2 = sin((shore + distortion2) * 10 + _Time.y + 2);
			foam2 *= foam2 * 0.7;

			float foam = max(foam1, foam2) * shore;


Прибывающая и отступающая пена.

Смешение волн и пены


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

Вместо того, чтобы копировать код волн, давайте вставим его в include-файл Water.cginc. Фактически мы вставляем в него код и для пены, и для волн, каждый как отдельную функцию.

Как работают include-файлы шейдера?
Создание собственных include-файлов шейдеров рассматривается в туториале Rendering 5, Multiple Lights.

float Foam (float shore, float2 worldXZ, sampler2D noiseTex) {
//	float shore = IN.uv_MainTex.y;
	shore = sqrt(shore);

	float2 noiseUV = worldXZ + _Time.y * 0.25;
	float4 noise = tex2D(noiseTex, noiseUV * 0.015);

	float distortion1 = noise.x * (1 - shore);
	float foam1 = sin((shore + distortion1) * 10 - _Time.y);
	foam1 *= foam1;

	float distortion2 = noise.y * (1 - shore);
	float foam2 = sin((shore + distortion2) * 10 + _Time.y + 2);
	foam2 *= foam2 * 0.7;

	return max(foam1, foam2) * shore;
}

float Waves (float2 worldXZ, sampler2D noiseTex) {
	float2 uv1 = worldXZ;
	uv1.y += _Time.y;
	float4 noise1 = tex2D(noiseTex, uv1 * 0.025);

	float2 uv2 = worldXZ;
	uv2.x += _Time.y;
	float4 noise2 = tex2D(noiseTex, uv2 * 0.025);

	float blendWave = sin(
		(worldXZ.x + worldXZ.y) * 0.1 +
		(noise1.y + noise2.z) + _Time.y
	);
	blendWave *= blendWave;

	float waves =
		lerp(noise1.z, noise1.w, blendWave) +
		lerp(noise2.x, noise2.y, blendWave);
	return smoothstep(0.75, 2, waves);
}

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

		#include "Water.cginc"

		sampler2D _MainTex;

		…

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

			fixed4 c = saturate(_Color + waves);
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}

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

		#include "Water.cginc"

		sampler2D _MainTex;

		…

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float shore = IN.uv_MainTex.y;
			float foam = Foam(shore, IN.worldPos.xz, _MainTex);
			float waves = Waves(IN.worldPos.xz, _MainTex);
			waves *= 1 - shore;

			fixed4 c = saturate(_Color + max(foam, waves));
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Смешение пены и волн.

unitypackage

Снова о прибрежной воде


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


Почти скрытая прибрежная вода.

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

Коэффициент целостности равен 0.8. Чтобы удвоить размер соединений воды нам нужно присвоить коэффициенту воды значение 0.6.

	public const float waterFactor = 0.6f;
	
	public static Vector3 GetFirstWaterCorner (HexDirection direction) {
		return corners[(int)direction] * waterFactor;
	}

	public static Vector3 GetSecondWaterCorner (HexDirection direction) {
		return corners[(int)direction + 1] * waterFactor;
	}

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

	void TriangulateOpenWater (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		Vector3 c1 = center + HexMetrics.GetFirstWaterCorner(direction);
		Vector3 c2 = center + HexMetrics.GetSecondWaterCorner(direction);

		…
	}
	
	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		EdgeVertices e1 = new EdgeVertices(
			center + HexMetrics.GetFirstWaterCorner(direction),
			center + HexMetrics.GetSecondWaterCorner(direction)
		);
		…
	}


Использование углов воды.

Расстояние между шестиугольниками воды и в самом деле удвоилось. Теперь HexMetrics также должен иметь метод создания мостов в воде.

	public const float waterBlendFactor = 1f - waterFactor;
	
	public static Vector3 GetWaterBridge (HexDirection direction) {
		return (corners[(int)direction] + corners[(int)direction + 1]) *
			waterBlendFactor;
	}

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

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

		if (direction <= HexDirection.SE && neighbor != null) {
			Vector3 bridge = HexMetrics.GetWaterBridge(direction);
			…

			if (direction <= HexDirection.E) {
				…
				water.AddTriangle(
					c2, e2, c2 + HexMetrics.GetWaterBridge(direction.Next())
				);
			}
		}
	}

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…
		
		Vector3 bridge = HexMetrics.GetWaterBridge(direction);
		…

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			waterShore.AddTriangle(
				e1.v5, e2.v5, e1.v5 +
					HexMetrics.GetWaterBridge(direction.Next())
			);
			…
		}
	}


Длинные мосты в воде.

Между рёбрами воды и суши


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

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

//		Vector3 bridge = HexMetrics.GetWaterBridge(direction);
		Vector3 center2 = neighbor.Position;
		center2.y = center.y;
		EdgeVertices e2 = new EdgeVertices(
			center2 + HexMetrics.GetSecondSolidCorner(direction.Opposite()),
			center2 + HexMetrics.GetFirstSolidCorner(direction.Opposite())
		);
		…

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			Vector3 center3 = nextNeighbor.Position;
			center3.y = center.y;
			waterShore.AddTriangle(
				e1.v5, e2.v5, center3 +
					HexMetrics.GetFirstSolidCorner(direction.Previous())
			);
			…
		}


Неправильные углы рёбер.

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

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
//			Vector3 center3 = nextNeighbor.Position;
//			center3.y = center.y;
			Vector3 v3 = nextNeighbor.Position + (nextNeighbor.IsUnderwater ?
				HexMetrics.GetFirstWaterCorner(direction.Previous()) :
				HexMetrics.GetFirstSolidCorner(direction.Previous()));
			v3.y = center.y;
			waterShore.AddTriangle(e1.v5, e2.v5, v3);
			waterShore.AddTriangleUV(
				new Vector2(0f, 0f),
				new Vector2(0f, 1f),
				new Vector2(0f, nextNeighbor.IsUnderwater ? 0f : 1f)
			);
		}


Правильные углы рёбер.

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

	shore = sqrt(shore) * 0.9;


Готовая пена.

unitypackage

Подводные реки


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


Реки, текущие в воде.

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

		Tags { "RenderType"="Transparent" "Queue"="Transparent+1" }


Отрисовываем реки последними.

Прячем подводные реки


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

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

		if (!cell.IsUnderwater) {
			bool reversed = cell.HasIncomingRiver;
			…
		}
	}
	
	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		if (!cell.IsUnderwater) {
			bool reversed = cell.IncomingRiver == direction;
			…
		}
	}

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

		if (cell.HasRiverThroughEdge(direction)) {
			e2.v3.y = neighbor.StreamBedY;

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


Больше никаких подводных рек.

Водопады


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

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

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

	void TriangulateWaterfallInWater (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float waterY
	) {
		v1.y = v2.y = y1;
		v3.y = v4.y = y2;
		rivers.AddQuad(v1, v2, v3, v4);
		rivers.AddQuadUV(0f, 1f, 0.8f, 1f);
	}

Вызовем этот метод в TriangulateConnection, когда сосед оказывается под водой и у нас создаётся водопад.

			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
					);
				}
				else if (cell.Elevation > neighbor.WaterLevel) {
					TriangulateWaterfallInWater(
						e1.v2, e1.v4, e2.v2, e2.v4,
						cell.RiverSurfaceY, neighbor.RiverSurfaceY,
						neighbor.WaterSurfaceY
					);
				}
			}

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

			if (!cell.IsUnderwater) {
				…
			}
			else if (
				!neighbor.IsUnderwater &&
				neighbor.Elevation > cell.WaterLevel
			) {
				TriangulateWaterfallInWater(
					e2.v4, e2.v2, e1.v4, e1.v2,
					neighbor.RiverSurfaceY, cell.RiverSurfaceY,
					cell.WaterSurfaceY
				);
			}

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


Интерполируем.

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

		v1.y = v2.y = y1;
		v3.y = v4.y = y2;
		float t = (waterY - y2) / (y1 - y2);
		v3 = Vector3.Lerp(v3, v1, t);
		v4 = Vector3.Lerp(v4, v2, t);
		rivers.AddQuad(v1, v2, v3, v4);
		rivers.AddQuadUV(0f, 1f, 0.8f, 1f);

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

		v1.y = v2.y = y1;
		v3.y = v4.y = y2;
		v1 = HexMetrics.Perturb(v1);
		v2 = HexMetrics.Perturb(v2);
		v3 = HexMetrics.Perturb(v3);
		v4 = HexMetrics.Perturb(v4);
		float t = (waterY - y2) / (y1 - y2);
		v3 = Vector3.Lerp(v3, v1, t);
		v4 = Vector3.Lerp(v4, v2, t);
		rivers.AddQuadUnperturbed(v1, v2, v3, v4);
		rivers.AddQuadUV(0f, 1f, 0.8f, 1f);

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

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


Водопады заканчиваются на поверхности воды.

unitypackage

Устья


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


Река встречается с побережьем без искажения вершин.

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

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

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

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

		if (cell.HasRiverThroughEdge(direction)) {
			TriangulateEstuary(e1, e2);
		}
		else {
			waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
			waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
			waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
			waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
		}

		…
	}

	void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) {
	}

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

	void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) {
		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)
		);
	}


Трапецоидная дыра для области смешения.

UV2-координаты


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

	public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates;

	[NonSerialized] List<Vector2> uvs, uv2s;
	
	public void Clear () {
		…
		if (useUVCoordinates) {
			uvs = ListPool<Vector2>.Get();
		}
		if (useUV2Coordinates) {
			uv2s = ListPool<Vector2>.Get();
		}
		triangles = ListPool<int>.Get();
	}

	public void Apply () {
		…
		if (useUVCoordinates) {
			hexMesh.SetUVs(0, uvs);
			ListPool<Vector2>.Add(uvs);
		}
		if (useUV2Coordinates) {
			hexMesh.SetUVs(1, uv2s);
			ListPool<Vector2>.Add(uv2s);
		}
		…
	}

Чтобы добавить второй набор UV, мы дублируем методы работы с UV и изменим нужным нам образом.

	public void AddTriangleUV2 (Vector2 uv1, Vector2 uv2, Vector3 uv3) {
		uv2s.Add(uv1);
		uv2s.Add(uv2);
		uv2s.Add(uv3);
	}
	
	public void AddQuadUV2 (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) {
		uv2s.Add(uv1);
		uv2s.Add(uv2);
		uv2s.Add(uv3);
		uv2s.Add(uv4);
	}

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

Функция шейдера реки


Так как мы будем использовать эффект реки в двух шейдерах, переместим код из шейдера River в новую функцию include-файла Water.

float River (float2 riverUV, sampler2D noiseTex) {
	float2 uv = riverUV;
	uv.x = uv.x * 0.0625 + _Time.y * 0.005;
	uv.y -= _Time.y * 0.25;
	float4 noise = tex2D(noiseTex, uv);

	float2 uv2 = riverUV;
	uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052;
	uv2.y -= _Time.y * 0.23;
	float4 noise2 = tex2D(noiseTex, uv2);
	
	return noise.x * noise2.w;
}

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

		#include "Water.cginc"

		sampler2D _MainTex;

		…

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

Объекты устья


Добавим в HexGridChunk поддержку объекта меша устья.

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

Создадим шейдер, материал и объект устья, продублировав побережье и изменив его. Соединим его с фрагментом, и сделаем так, чтобы он использовал координаты UV и UV2.


Объект Estuarties.

Триангуляция устья


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

	void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) {
		…

		estuaries.AddTriangle(e1.v3, e2.v2, e2.v4);
		estuaries.AddTriangleUV(
			new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 1f)
		);
	}


Средний треугольник.

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

		estuaries.AddQuad(e1.v2, e1.v3, e2.v1, e2.v2);
		estuaries.AddTriangle(e1.v3, e2.v2, e2.v4);
		estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5);
		
		estuaries.AddQuadUV(0f, 0f, 0f, 1f);
		estuaries.AddTriangleUV(
			new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 1f)
		);
		estuaries.AddQuadUV(0f, 0f, 0f, 1f);


Готовый трапецоид.

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

		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(0f, 1f), new Vector2(0f, 0f)
		);
//		estuaries.AddQuadUV(0f, 0f, 0f, 1f);


Повёрнутый quad, симметричная геометрия

Течение реки


Для поддержки эффекта реки нам нужно добавить UV2-координаты. Низ среднего треугольника находится в середине реки, поэтому его координата U должна быть равна 0.5. Так как река течёт по направлению к воде, лева точка получает координату U, равную 1, а правая — координату U со значением 0. Зададим координатам Y значения 0 и 1, соответствующие направлению течения.

		estuaries.AddTriangleUV2(
			new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f)
		);

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

		estuaries.AddQuadUV2(
			new Vector2(1f, 0f), new Vector2(1f, 1f),
			new Vector2(1f, 0f), new Vector2(0.5f, 1f)
		);
		estuaries.AddTriangleUV2(
			new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f)
		);
		estuaries.AddQuadUV2(
			new Vector2(0.5f, 1f), new Vector2(0f, 1f),
			new Vector2(0f, 0f), new Vector2(0f, 0f)
		);


UV2 трапецоида.

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

		struct Input {
			float2 uv_MainTex;
			float2 uv2_MainTex;
			float3 worldPos;
		};

		…

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float shore = IN.uv_MainTex.y;
			float foam = Foam(shore, IN.worldPos.xz, _MainTex);
			float waves = Waves(IN.worldPos.xz, _MainTex);
			waves *= 1 - shore;

			fixed4 c = fixed4(IN.uv2_MainTex, 1, 1);
			…
		}


UV2-координаты.

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

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

			float river = River(IN.uv2_MainTex, _MainTex);

			fixed4 c = saturate(_Color + river);
			…
		}


Используем UV2 для создания эффекта реки.

Мы создали реки таким образом, что при триангуляции соединений между ячейками координаты V реки изменяются от 0.8 до 1. Поэтому здесь мы тоже должны использовать этот интервал, а не значения от 0 до 1. Однако соединение побережья на 50% больше, чем обычные соединения ячеек. Поэтому для наилучшего соответствия с течением реки мы должны изменять значения от 0.8 до 1.1.

		estuaries.AddQuadUV2(
			new Vector2(1f, 0.8f), new Vector2(1f, 1.1f),
			new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f)
		);
		estuaries.AddTriangleUV2(
			new Vector2(0.5f, 1.1f),
			new Vector2(1f, 0.8f),
			new Vector2(0f, 0.8f)
		);
		estuaries.AddQuadUV2(
			new Vector2(0.5f, 1.1f), new Vector2(0f, 1.1f),
			new Vector2(0f, 0.8f), new Vector2(0f, 0.8f)
		);



Синхронизированное течение реки и устья.

Настройка течения


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

Вместо того, чтобы сохранять верхние координаты U постоянными за пределами ширины реки, сдвинем их на 0.5. Самая левая точка принимает значение 1.5, самая правая — значение −0.5.

В то же время, расширим течение, сдвинув координаты U левой и правой точек низа. Изменим левую с 1 на 0.7, а правую — с 0 на 0.3.

		estuaries.AddQuadUV2(
			new Vector2(1.5f, 0.8f), new Vector2(0.7f, 1.1f),
			new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f)
		);
		…
		estuaries.AddQuadUV2(
			new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.1f),
			new Vector2(0f, 0.8f), new Vector2(-0.5f, 0.8f)
		);



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

Чтобы завершить эффект искривления, изменим координаты V тех же четырёх точек. Так как вода течёт в сторону от конца реки, увеличим координаты V верхних точек до 1. И чтобы создать более качественную кривую, увеличим координаты V двух нижних точек до 1.15.

		estuaries.AddQuadUV2(
			new Vector2(1.5f, 1f), new Vector2(0.7f, 1.15f),
			new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f)
		);
		estuaries.AddTriangleUV2(
			new Vector2(0.5f, 1.1f),
			new Vector2(1f, 0.8f),
			new Vector2(0f, 0.8f)
		);
		estuaries.AddQuadUV2(
			new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.15f),
			new Vector2(0f, 0.8f), new Vector2(-0.5f, 1f)
		);



Искривлённое течение реки.

Смешение реки и побережья


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

			float shoreWater = max(foam, waves);

			float river = River(IN.uv2_MainTex, _MainTex);

			float water = lerp(shoreWater, river, IN.uv_MainTex.x);

			fixed4 c = saturate(_Color + water);

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

Вместо использования uv2_MainTex нам придётся передавать вторичные UV-координаты вручную. Для этого переименуем uv2_MainTex в riverUV. Затем добавим в шейдер вершинную функцию, которая назначает ему координаты.

		#pragma surface surf Standard alpha vertex:vert
		…
		
		struct Input {
			float2 uv_MainTex;
			float2 riverUV;
			float3 worldPos;
		};

		…

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

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

			float river = River(IN.riverUV, _MainTex);

			…
		}


Интерполяция на основании значения побережья.

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

		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.AddQuadUV(0f, 0f, 0f, 1f);


Правильное смешение.

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


Устья в действии

unitypackage

Реки, вытекающие из водоёмов


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

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

	bool IsValidRiverDestination (HexCell neighbor) {
		return neighbor && (
			elevation >= neighbor.elevation || waterLevel == neighbor.elevation
		);
	}

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

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

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

		RemoveOutgoingRiver();
		…
	}

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

	void ValidateRivers () {
		if (
			hasOutgoingRiver &&
			!IsValidRiverDestination(GetNeighbor(outgoingRiver))
		) {
			RemoveOutgoingRiver();
		}
		if (
			hasIncomingRiver &&
			!GetNeighbor(incomingRiver).IsValidRiverDestination(this)
		) {
			RemoveIncomingRiver();
		}
	}

Воспользуемся этим новым методом в свойствах Elevation и WaterLevel.

	public int Elevation {
		…
		set {
			…

//			if (
//				hasOutgoingRiver &&
//				elevation < GetNeighbor(outgoingRiver).elevation
//			) {
//				RemoveOutgoingRiver();
//			}
//			if (
//				hasIncomingRiver &&
//				elevation > GetNeighbor(incomingRiver).elevation
//			) {
//				RemoveIncomingRiver();
//			}
			ValidateRivers();

			…
		}
	}

	public int WaterLevel {
		…
		set {
			if (waterLevel == value) {
				return;
			}
			waterLevel = value;
			ValidateRivers();
			Refresh();
		}
	}


Исходящие и входящие в озёра реки.

Разворачиваем течение


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

	void TriangulateEstuary (
		EdgeVertices e1, EdgeVertices e2, bool incomingRiver
	) {
	…
}

Будем передавать эту информацию при вызове этого метода из TriangulateWaterShore.

		if (cell.HasRiverThroughEdge(direction)) {
			TriangulateEstuary(e1, e2, cell.IncomingRiver == direction);
		}

Теперь нам нужно развернуть течение реки, изменив координаты UV2. Координаты U для исходящих рек нужно отзеркалить: −0.5 становится 1.5, 0 становится 1, 1 становится 0, а 1.5 становится −0.5.

С координатами V всё немного сложнее. Если посмотреть на то, как мы работали с перевёрнутыми соединениями рек, то 0.8 должно стать 0, а 1 должна стать −0.2. Это значит, что 1.1 становится −0.3, а 1.15 становится −0.35.

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

	void TriangulateEstuary (
		EdgeVertices e1, EdgeVertices e2, bool incomingRiver
	) {
		…

		if (incomingRiver) {
			estuaries.AddQuadUV2(
				new Vector2(1.5f, 1f), new Vector2(0.7f, 1.15f),
				new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f)
			);
			estuaries.AddTriangleUV2(
				new Vector2(0.5f, 1.1f),
				new Vector2(1f, 0.8f),
				new Vector2(0f, 0.8f)
			);
			estuaries.AddQuadUV2(
				new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.15f),
				new Vector2(0f, 0.8f), new Vector2(-0.5f, 1f)
			);
		}
		else {
			estuaries.AddQuadUV2(
				new Vector2(-0.5f, -0.2f), new Vector2(0.3f, -0.35f),
				new Vector2(0f, 0f), new Vector2(0.5f, -0.3f)
			);
			estuaries.AddTriangleUV2(
				new Vector2(0.5f, -0.3f),
				new Vector2(0f, 0f),
				new Vector2(1f, 0f)
			);
			estuaries.AddQuadUV2(
				new Vector2(0.5f, -0.3f), new Vector2(0.7f, -0.35f),
				new Vector2(1f, 0f), new Vector2(1.5f, -0.2f)
			);
		}
	}


Правильное течение рек.

unitypackage

Часть 9: объекты рельефа


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

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


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

Добавляем поддержку объектов


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

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

Менеджер объектов


Давайте создадим компонент HexFeatureManager, который займётся объектами в пределах одного фрагмента. Воспользуемся той же схемой, что и в HexMesh — дадим ему методы Clear, Apply и AddFeature. Так как объект нужно где-то размещать, метод AddFeature получает параметр позиции.

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

using UnityEngine;

public class HexFeatureManager : MonoBehaviour {

	public void Clear () {}

	public void Apply () {}

	public void AddFeature (Vector3 position) {}
}

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

	public HexFeatureManager features;

	public void Triangulate () {
		terrain.Clear();
		rivers.Clear();
		roads.Clear();
		water.Clear();
		waterShore.Clear();
		estuaries.Clear();
		features.Clear();
		for (int i = 0; i < cells.Length; i++) {
			Triangulate(cells[i]);
		}
		terrain.Apply();
		rivers.Apply();
		roads.Apply();
		water.Apply();
		waterShore.Apply();
		estuaries.Apply();
		features.Apply();
	}

Давайте начнём с размещения одного объекта в центре каждой ячейки

	void Triangulate (HexCell cell) {
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			Triangulate(d, cell);
		}
		features.AddFeature(cell.Position);
	}

Теперь нам понадобится настоящий менеджер объектов. Добавим в префаб Hex Grid Chunk ещё один дочерний объект и дадим ему компонент HexFeatureManager. Затем можно соединить с ним фрагмент.




Менеджер объектов, добавленный в префаб фрагмента.

Префаб объектов


Какой объект рельефа мы создадим? Для первого теста вполне подойдёт куб. Создадим достаточно большой куб, допустим, с масштабом (3, 3, 3), и превратим его в префаб. Также создадим для него материал. Я использовал материал по умолчанию с красным цветом. Удалим его коллайдер, потому что он нам не понадобится.


Префаб куба.

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

	public Transform featurePrefab;


Менеджер объектов с префабом.

Создание экземпляров объектов


Структура готова, и мы можем приступить к добавлению объектов рельефа! Достаточно просто создать экземпляр префаба в HexFeatureManager.AddFeature и задать его позицию.

	public void AddFeature (Vector3 position) {
		Transform instance = Instantiate(featurePrefab);
		instance.localPosition = position;
	}


Экземпляры объектов рельефа.

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

	public void AddFeature (Vector3 position) {
		Transform instance = Instantiate(featurePrefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = position;
	}


Кубы на поверхности рельефа.

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

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

		instance.localPosition = HexMetrics.Perturb(position);


Искажённые позиции объектов.

Уничтожение объектов рельефа


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

Быстрее всего это сделать, создав игровой объект-контейнер и превратив все объекты рельефа в его дочерние элементы. Тогда при вызове Clear мы будем уничтожать этот контейнер и создавать новый. Сам контейнер будет дочерним элементом своего менеджера.

	Transform container;

	public void Clear () {
		if (container) {
			Destroy(container.gameObject);
		}
		container = new GameObject("Features Container").transform;
		container.SetParent(transform, false);
	}
	
	…
	
	public void AddFeature (Vector3 position) {
		Transform instance = Instantiate(featurePrefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.SetParent(container, false);
	}

Наверно, неэффективно каждый раз создавать и уничтожать объекты рельефа.
Да, кажется, что это так. Но пока нас это волновать не должно. Сначала нам нужно правильно разместить объекты. Разобравшись с этим, мы увидим, что такие действия являются узким местом, поэтому подумаем об эффективности. Именно тогда мы можем прийти и к использованию метода HexFeatureManager.Apply. Но оставим это для будущего туториала. К счастью, всё не так плохо, потому что мы разделили рельеф на фрагменты.

unitypackage

Размещение объектов рельефа


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


Объекты расположены повсюду.

Поэтому давайте перед размещением объекта проверять в HexGridChunk.Triangulate, является ли ячейка пустой.

		if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) {
			features.AddFeature(cell.Position);
		}


Ограниченное размещение.

По одному объекту на направление


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

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

	void Triangulate (HexDirection direction, HexCell cell) {
		…

		if (cell.HasRiver) {
			…
		}
		else {
			TriangulateWithoutRiver(direction, cell, center, e);

			if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) {
				features.AddFeature((center + e.v1 + e.v5) * (1f / 3f));
			}
		}

		…
	}


Много объектов, но нет по соседству с реками.

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

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

		if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) {
			features.AddFeature((center + e.v1 + e.v5) * (1f / 3f));
		}
	}


Объекты появились рядом с реками.

Можно ли рендерить такое количество объектов?
Большое количество объектов создаёт множество вызовов отрисовки, но здесь помогает dynamic batching движка Unity. Так как объекты малы, их меши должны иметь всего несколько вершин. Это позволяет объединить многие из них в один batch. Но если это окажется «узким местом», то придётся поработать с ними в будущем. Также можно использовать instancing, который при работе со множеством мелких мешей сравним с dynamic batching.

unitypackage

Разнообразие объектов


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

	public void AddFeature (Vector3 position) {
		Transform instance = Instantiate(featurePrefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * Random.value, 0f);
		instance.SetParent(container, false);
	}


Случайные повороты.

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

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

Создание таблицы хешей


Мы можем создать таблицу хешей из массива значений float и заполнить её один раз случайными значениями. Благодаря этому текстура нам вообще не понадобится. Давайте добавим её в HexMetrics. Размера 256 на 256 хватит для достаточной вариативности.

	public const int hashGridSize = 256;

	static float[] hashGrid;

	public static void InitializeHashGrid () {
		hashGrid = new float[hashGridSize * hashGridSize];
		for (int i = 0; i < hashGrid.Length; i++) {
			hashGrid[i] = Random.value;
		}
	}

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

Чтобы обеспечить воссоздание всегда одинаковых объектов, нам нужно добавить параметр seed в метод инициализации.

	public static void InitializeHashGrid (int seed) {
		hashGrid = new float[hashGridSize * hashGridSize];
		Random.InitState(seed);
		for (int i = 0; i < hashGrid.Length; i++) {
			hashGrid[i] = Random.value;
		}
	}

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

		Random.State currentState = Random.state;
		Random.InitState(seed);
		for (int i = 0; i < hashGrid.Length; i++) {
			hashGrid[i] = Random.value;
		}
		Random.state = currentState;

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

	public int seed;

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);

		…
	}

	void OnEnable () {
		if (!HexMetrics.noiseSource) {
			HexMetrics.noiseSource = noiseSource;
			HexMetrics.InitializeHashGrid(seed);
		}
	}

Общая переменная seed позволяет нам выбирать значение seed для карты. Подойдёт любое значение. Я выбрал 1234.


Выбор seed.

Использование таблицы хешей


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

	public static float SampleHashGrid (Vector3 position) {
		int x = (int)position.x % hashGridSize;
		int z = (int)position.z % hashGridSize;
		return hashGrid[x + z * hashGridSize];
	}

Что делает %?
Это оператор модуля, он вычисляет остаток от деления, в нашем случае — целочисленного деления. Например, ряд −4, −3, −2, −1, 0, 1, 2, 3, 4 modulo 3 превращается в −1, 0, −2, −1, 0, 1, 2, 0, 1.

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

		int x = (int)position.x % hashGridSize;
		if (x < 0) {
			x += hashGridSize;
		}
		int z = (int)position.z % hashGridSize;
		if (z < 0) {
			z += hashGridSize;
		}

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

	public const float hashGridScale = 0.25f;

	public static float SampleHashGrid (Vector3 position) {
		int x = (int)(position.x * hashGridScale) % hashGridSize;
		if (x < 0) {
			x += hashGridSize;
		}
		int z = (int)(position.z * hashGridScale) % hashGridSize;
		if (z < 0) {
			z += hashGridSize;
		}
		return hashGrid[x + z * hashGridSize];
	}

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

	public void AddFeature (Vector3 position) {
		float hash = HexMetrics.SampleHashGrid(position);
		Transform instance = Instantiate(featurePrefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * hash, 0f);
		instance.SetParent(container, false);
	}

Порог размещения


Хотя объекты имеют разный поворот, в их размещении всё равно заметен паттерн. В каждой ячейке есть по семь объектов. Мы можем добавить в эту схему хаос, произвольным образом пропуская некоторые из объектов. Как нам решать, добавлять ли объект, или нет? Разумеется, проверяя ещё одно случайное значение!

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

using UnityEngine;

public struct HexHash {

	public float a, b;

	public static HexHash Create () {
		HexHash hash;
		hash.a = Random.value;
		hash.b = Random.value;
		return hash;
	}
}

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

Изменим HexMetrics так, чтобы он использовал новую структуру.

	static HexHash[] hashGrid;

	public static void InitializeHashGrid (int seed) {
		hashGrid = new HexHash[hashGridSize * hashGridSize];
		Random.State currentState = Random.state;
		Random.InitState(seed);
		for (int i = 0; i < hashGrid.Length; i++) {
			hashGrid[i] = HexHash.Create();
		}
		Random.state = currentState;
	}

	public static HexHash SampleHashGrid (Vector3 position) {
		…
	}

Теперь HexFeatureManager.AddFeature имеет доступ к двум значениям хешей. Давайте используем первое, чтобы решать, добавлять ли объект, или пропустить его. Если значение равно или больше 0.5, то пропускаем. При этом мы избавимся примерно от половины объектов. Второе значение как обычно будет использоваться для определения поворота.

	public void AddFeature (Vector3 position) {
		HexHash hash = HexMetrics.SampleHashGrid(position);
		if (hash.a >= 0.5f) {
			return;
		}
		Transform instance = Instantiate(featurePrefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * hash.b, 0f);
		instance.SetParent(container, false);
	}


Плотность объектов снижена на 50%.

unitypackage

Рисование объектов


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

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

	public int UrbanLevel {
		get {
			return urbanLevel;
		}
		set {
			if (urbanLevel != value) {
				urbanLevel = value;
				RefreshSelfOnly();
			}
		}
	}

	int urbanLevel;

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

Ползунок плотности


Для изменения уровня урбанизации добавим в HexMapEditor поддержку ещё одного ползунка.

	int activeUrbanLevel;
	
	…
	
	bool applyUrbanLevel;
	
	…
	
	public void SetApplyUrbanLevel (bool toggle) {
		applyUrbanLevel = toggle;
	}
	
	public void SetUrbanLevel (float level) {
		activeUrbanLevel = (int)level;
	}

	void EditCell (HexCell cell) {
		if (cell) {
			…
			if (applyWaterLevel) {
				cell.WaterLevel = activeWaterLevel;
			}
			if (applyUrbanLevel) {
				cell.UrbanLevel = activeUrbanLevel;
			}
			if (riverMode == OptionalToggle.No) {
				cell.RemoveRiver();
			}
			…
		}
	}

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

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



Ползунок урбанизации.

Изменение порога


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

Быстрее всего воспользоваться уровнем урбанизации можно, умножив его на 0.25 и воспользовавшись значением в качестве нового порога пропуска объектов. Благодаря этому вероятность появления объекта будет увеличиваться с каждым уровнем на 25%.

	public void AddFeature (HexCell cell, Vector3 position) {
		HexHash hash = HexMetrics.SampleHashGrid(position);
		if (hash.a >= cell.UrbanLevel * 0.25f) {
			return;
		}
		…
	}

Чтобы это заработало, передадим ячейки в HexGridChunk.

	void Triangulate (HexCell cell) {
		…
		if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) {
			features.AddFeature(cell,  cell.Position);
		}
	}

	void Triangulate (HexDirection direction, HexCell cell) {
		…
			if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) {
				features.AddFeature(cell, (center + e.v1 + e.v5) * (1f / 3f));
			}
		…
	}
	
	…
	
	void TriangulateAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…

		if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) {
			features.AddFeature(cell, (center + e.v1 + e.v5) * (1f / 3f));
		}
	}


Рисование уровней плотности урбанизации.

unitypackage

Несколько префабов объектов рельефа


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

Избавимся от поля featurePrefab в HexFeatureManager и заменим его на массив для префабов урбанизации. Для получения соответствующего префаба будем вычитать из уровня урбанизации единицу и использовать значение как индекс.

<del>//	public Transform featurePrefab;</del>
	public Transform[] urbanPrefabs;
	
	public void AddFeature (HexCell cell, Vector3 position) {
		…
		Transform instance = Instantiate(urbanPrefabs[cell.UrbanLevel - 1]);
		…
	}

Создадим два дубликата префаба объекта, переименуем и изменим их так, чтобы они обозначали три разных уровня урбанизации. Уровень 1 — это низкая плотность, поэтому используем куб с единичной длиной ребра, обозначающий лачугу. Я изменю масштаб префаба уровня 2 на (1.5, 2, 1.5), чтобы это походило на двухэтажное здание. Для высоких зданий уровня 3 я использовал масштаб (2, 5, 2).



Использование разных префабов для каждого уровня урбанизации.

Смешение префабов


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

На уровне 1 используем размещения лачуг в 40% случаев. Других зданий тут не будет вообще. Для уровня используем тройку значений (0.4, 0, 0).

На уровне 2 заменим лачуги зданиями побольше, и добавим вероятность 20% для дополнительных лачуг. Высоких зданий делать не будем. То есть используем пороговую тройку значений (0.2, 0.4, 0).

На уровне 3 заменим средние здания на высокие, снова заменим лачуги и добавим ещё одну вероятность 20% лачуг. Пороговые значения будут равны (0.2, 0.2, 0.4).

То есть идея заключается в том, что при повышении уровня урбанизации мы будем апгрейдить существующие здания и добавлять новые в пустые места. Для удаления существующего здания нам нужно использовать те же интервалы значений хешей. Если хеши между 0 и 0.4 на уровне 1 были лачугами, то на уровне 3 тот же интервал будет создавать высокие здания. На уровне 3 высокие здания должны создаваться при значениях хешей в интервале 0–0.4, двухэтажные здания — в интервале 0.4–0.6, а лачуги — в интервале 0.6–0.8. Если проверять их с наибольших до наименьших, то это можно сделать с помощью тройки порогов (0.4, 0.6, 0.8). Пороги уровня 2 тогда станут (0, 0.4, 0.6), а пороги уровня 1 станут (0, 0, 0.4).

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

	static float[][] featureThresholds = {
		new float[] {0.0f, 0.0f, 0.4f},
		new float[] {0.0f, 0.4f, 0.6f},
		new float[] {0.4f, 0.6f, 0.8f}
	};
						
	public static float[] GetFeatureThresholds (int level) {
		return featureThresholds[level];
	}

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

	Transform PickPrefab (int level, float hash) {
		if (level > 0) {
			float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1);
			for (int i = 0; i < thresholds.Length; i++) {
				if (hash < thresholds[i]) {
					return urbanPrefabs[i];
				}
			}
		}
		return null;
	}

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


Перевёрнутый порядок префабов.

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

	public void AddFeature (HexCell cell, Vector3 position) {
		HexHash hash = HexMetrics.SampleHashGrid(position);
//		if (hash.a >= cell.UrbanLevel * 0.25f) {
//			return;
//		}
//		Transform instance = Instantiate(urbanPrefabs[cell.UrbanLevel - 1]);
		Transform prefab = PickPrefab(cell.UrbanLevel, hash.a);
		if (!prefab) {
			return;
		}
		Transform instance = Instantiate(prefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * hash.b, 0f);
		instance.SetParent(container, false);
	}


Смешиваем префабы.

Вариации на уровне


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

	public float a, b, c;

	public static HexHash Create () {
		HexHash hash;
		hash.a = Random.value;
		hash.b = Random.value;
		hash.c = Random.value;
		return hash;
	}

Превратим HexFeatureManager.urbanPrefabs в массив массивов, и добавим к методу PickPrefab параметр choice. Используем его для выбора индекса встроенного массива, умножив его на длину этого массива и преобразовав в integer.

	public Transform[][] urbanPrefabs;
	
	…

	Transform PickPrefab (int level, float hash, float choice) {
		if (level > 0) {
			float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1);
			for (int i = 0; i < thresholds.Length; i++) {
				if (hash < thresholds[i]) {
					return urbanPrefabs[i][(int)(choice * urbanPrefabs[i].Length)];
				}
			}
		}
		return null;
	}

Давайте обоснуем наш выбор на значении второго хеша (B). Тогда потребуется, чтобы поворот сменился с B на C.

	public void AddFeature (HexCell cell, Vector3 position) {
		HexHash hash = HexMetrics.SampleHashGrid(position);
		Transform prefab = PickPrefab(cell.UrbanLevel, hash.a, hash.b);
		if (!prefab) {
			return;
		}
		Transform instance = Instantiate(prefab);
		position.y += instance.localScale.y * 0.5f;
		instance.localPosition = HexMetrics.Perturb(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * hash.c, 0f);
		instance.SetParent(container, false);
	}

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

	public static HexHash Create () {
		HexHash hash;
		hash.a = Random.value * 0.999f;
		hash.b = Random.value * 0.999f;
		hash.c = Random.value * 0.999f;
		return hash;
	}

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

using UnityEngine;

[System.Serializable]
public struct HexFeatureCollection {

	public Transform[] prefabs;

	public Transform Pick (float choice) {
		return prefabs[(int)(choice * prefabs.Length)];
	}
}

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

//	public Transform[][] urbanPrefabs;
	public HexFeatureCollection[] urbanCollections;

	…

	Transform PickPrefab (int level, float hash, float choice) {
		if (level > 0) {
			float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1);
			for (int i = 0; i < thresholds.Length; i++) {
				if (hash < thresholds[i]) {
					return urbanCollections[i].Pick(choice);
				}
			}
		}
		return null;
	}

Теперь мы можем назначать каждому уровню плотности несколько зданий. Так как они независимы, нам не обязательно использовать одинаковое количество на уровень. Я просто использовал по два варианта на уровень, добавив к каждому более длинный нижний вариант. Я выбрал для них масштабы (3.5, 3, 2), (2.75, 1.5, 1.5) и (1.75, 1, 1).



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

unitypackage

Несколько типов объектов


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

	public int FarmLevel {
		get {
			return farmLevel;
		}
		set {
			if (farmLevel != value) {
				farmLevel = value;
				RefreshSelfOnly();
			}
		}
	}

	public int PlantLevel {
		get {
			return plantLevel;
		}
		set {
			if (plantLevel != value) {
				plantLevel = value;
				RefreshSelfOnly();
			}
		}
	}

	int urbanLevel, farmLevel, plantLevel;

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

	int activeUrbanLevel, activeFarmLevel, activePlantLevel;

	bool applyUrbanLevel, applyFarmLevel, applyPlantLevel;

	…

	public void SetApplyFarmLevel (bool toggle) {
		applyFarmLevel = toggle;
	}

	public void SetFarmLevel (float level) {
		activeFarmLevel = (int)level;
	}

	public void SetApplyPlantLevel (bool toggle) {
		applyPlantLevel = toggle;
	}

	public void SetPlantLevel (float level) {
		activePlantLevel = (int)level;
	}

	…

	void EditCell (HexCell cell) {
		if (cell) {
			…
			if (applyUrbanLevel) {
				cell.UrbanLevel = activeUrbanLevel;
			}
			if (applyFarmLevel) {
				cell.FarmLevel = activeFarmLevel;
			}
			if (applyPlantLevel) {
				cell.PlantLevel = activePlantLevel;
			}
			…
		}
	}

Добавим их в UI.


Три ползунка.

Также дополнительные коллекции понадобятся HexFeatureManager.

	public HexFeatureCollection[]
		urbanCollections, farmCollections, plantCollections;


Три коллекции объектов рельефа.

Я создал и для ферм, и для растений по два префаба на уровень плотности, как и для коллекций зданий. Для всех них я использовал кубы. Фермы имеют светло-зелёный материал, растения — тёмно-зелёный.

Я сделал кубы ферм высотой в 0.1 единицы, чтобы обозначит квадратные наделы сельскохозяйственных земель. В качестве масштабов высокой плотности я выбрал (2.5, 0.1, 2.5) и (3.5, 0.1, 2). В среднем площадки имеют площадь 1.75 и размер 2.5 на 1.25. Низкий уровень плотности получил площадь 1 и размер 1.5 на 0.75.

Префабы растений обозначают высокие деревья и большие кустарники. Префабы высокой плотности самые большие, (1.25, 4.5, 1.25) и (1.5, 3, 1.5). Средние масштабы — (0.75, 3, 0.75) и (1, 1.5, 1). Самые маленькие растения имеют размеры (0.5, 1.5, 0.5) и (0.75, 1, 0.75).

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


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

	public float a, b, c, d, e;

	public static HexHash Create () {
		HexHash hash;
		hash.a = Random.value * 0.999f;
		hash.b = Random.value * 0.999f;
		hash.c = Random.value * 0.999f;
		hash.d = Random.value * 0.999f;
		hash.e = Random.value * 0.999f;
		return hash;
	}

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

	Transform PickPrefab (
		HexFeatureCollection[] collection,
		int level, float hash, float choice
	) {
		if (level > 0) {
			float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1);
			for (int i = 0; i < thresholds.Length; i++) {
				if (hash < thresholds[i]) {
					return collection[i].Pick(choice);
				}
			}
		}
		return null;
	}

	public void AddFeature (HexCell cell, Vector3 position) {
		HexHash hash = HexMetrics.SampleHashGrid(position);
		Transform prefab = PickPrefab(
			urbanCollections, cell.UrbanLevel, hash.a, hash.d
		);
		…
		instance.localRotation = Quaternion.Euler(0f, 360f * hash.e, 0f);
		instance.SetParent(container, false);
	}

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

		Transform prefab = PickPrefab(
			urbanCollections, cell.UrbanLevel, hash.a, hash.d
		);
		Transform otherPrefab = PickPrefab(
			farmCollections, cell.FarmLevel, hash.b, hash.d
		);
		if (!prefab) {
			return;
		}

Какой же экземпляр префаб мы в результате создадим? Если один из них окажется null, то выбор очевиден. Однако если существуют оба, то нам нужно принять решение. Давайте просто будем добавлять префаб с наименьшим значением хеша.

		Transform otherPrefab = PickPrefab(
			farmCollections, cell.FarmLevel, hash.b, hash.d
		);
		if (prefab) {
			if (otherPrefab && hash.b < hash.a) {
				prefab = otherPrefab;
			}
		}
		else if (otherPrefab) {
			prefab = otherPrefab;
		}
		else {
			return;
		}


Смешение городских и сельских объектов.
Далее сделаем то же самое с растениями, воспользовавшись значением хеша C.

		if (prefab) {
			if (otherPrefab && hash.b < hash.a) {
				prefab = otherPrefab;
			}
		}
		else if (otherPrefab) {
			prefab = otherPrefab;
		}
		otherPrefab = PickPrefab(
			plantCollections, cell.PlantLevel, hash.c, hash.d
		);
		if (prefab) {
			if (otherPrefab && hash.c < hash.a) {
				prefab = otherPrefab;
			}
		}
		else if (otherPrefab) {
			prefab = otherPrefab;
		}
		else {
			return;
		}

Однако мы не можем просто скопировать код. Когда мы выберем вместо городского объекта сельский, то нужно сравнить хеш растений с хешем ферм, а не с городским. Поэтому нам нужно отслеживать хеш, который мы решили выбрать, и сравнивать с ним.

		float usedHash = hash.a;
		if (prefab) {
			if (otherPrefab && hash.b < hash.a) {
				prefab = otherPrefab;
				usedHash = hash.b;
			}
		}
		else if (otherPrefab) {
			prefab = otherPrefab;
			usedHash = hash.b;
		}
		otherPrefab = PickPrefab(
			plantCollections, cell.PlantLevel, hash.c, hash.d
		);
		if (prefab) {
			if (otherPrefab && hash.c < usedHash) {
				prefab = otherPrefab;
			}
		}
		else if (otherPrefab) {
			prefab = otherPrefab;
		}
		else {
			return;
		}


Смешение городских, сельских и растительных объектов.

unitypackage

Часть 10: стены


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

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


Нет ничего более гостеприимного, чем высокая стена.

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


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


Стены, расположенные вдоль рёбер.

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

Свойство Walled


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

	public bool Walled {
		get {
			return walled;
		}
		set {
			if (walled != value) {
				walled = value;
				Refresh();
			}
		}
	}
	
	bool walled;

Переключатель редактора


Для переключения состояния «огороженности» ячеек нам нужно добавить в HexMapEditor поддержку переключателя. Поэтому добавим ещё одно поле OptionalToggle и метод для его задания.

	OptionalToggle riverMode, roadMode, walledMode;
	
	…
	
	public void SetWalledMode (int mode) {
		walledMode = (OptionalToggle)mode;
	}

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

	void EditCell (HexCell cell) {
		if (cell) {
			…
			if (roadMode == OptionalToggle.No) {
				cell.RemoveRoads();
			}
			if (walledMode != OptionalToggle.Ignore) {
				cell.Walled = walledMode == OptionalToggle.Yes;
			}
			if (isDrag) {
				…
			}
		}
	}

Продублируем один из предыдущих элементов UI переключателей и изменим их так, чтобы они управляли состоянием «огороженности». Я помещу их в панель UI вместе с другими объектами.


Переключатель «огороженности».

unitypackage

Создание стен


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



Дочерний префаб Walls.

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

Управление стенами


Так как стены являются объектами рельефа, ими должен заниматься HexFeatureManager. Поэтому дадим менеджеру объектов рельефа ссылку на объект Walls, и сделаем так, чтобы он вызывал методы Clear и Apply.

	public HexMesh walls;

	…

	public void Clear () {
		…
		walls.Clear();
	}

	public void Apply () {
		walls.Apply();
	}


Стены, соединённые с менеджером объектов рельефа.

Разве Walls не должен быть дочерним элементом Features?
Мы можем упорядочить объекты и таким образом, но это необязательно. Так как в окне иерархии отображаются только непосредственные дочерние элементы корневых объектов префабов, я предпочитаю оставить Walls непосредственным дочерним элементом Hex Grid Chunk.

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

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell
	) {
	}

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

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

		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			…
		}
		else {
			…
		}

		features.AddWall(e1, cell, e2, neighbor);

		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (direction <= HexDirection.E && nextNeighbor != null) {
			…
		}
	}

Построение сегмента стены


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

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight
	) {
	}


Ближняя и дальняя стороны.

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

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell
	) {
		if (nearCell.Walled != farCell.Walled) {
			AddWallSegment(near.v1, far.v1, near.v5, far.v5);
		}
	}

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

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight
	) {
		Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f);
		Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f);
	}

Какой высоты должна быть стена? Давайте зададим её высоту в HexMetrics. Я сделал их размером с один уровень высоты ячеек.

	public const float wallHeight = 3f;

HexFeatureManager.AddWallSegment может использовать эту высоту для позиционирования третьей и четвёртой вершины quad-а, а также добавить её в меш walls.

		Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f);
		Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f);

		Vector3 v1, v2, v3, v4;
		v1 = v3 = left;
		v2 = v4 = right;
		v3.y = v4.y = left.y + HexMetrics.wallHeight;
		walls.AddQuad(v1, v2, v3, v4);

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


Односторонние quad-ы стен.

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

		walls.AddQuad(v1, v2, v3, v4);
		walls.AddQuad(v2, v1, v4, v3);


Двусторонние стены.

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

Толстые стены


Хотя стены уже видимы с обеих сторон, у них нет толщины. По сути, стены тонкие, как бумага, и почти невидимы под определённым углом. Поэтому давайте сделаем их цельными, добавив толщину. Зададим их толщину в HexMetrics. Я выбрал значение 0.75 единицы, мне оно показалось подходящим.

	public const float wallThickness = 0.75f;

Чтобы сделать две стены толстыми, нужно развести два quad-а в стороны. Они должны двигаться в противоположных направлениях. Одна сторона должна сдвинуться к ближнему ребру, другая — к дальнему. Вектор смещения для этого равен far - near, но чтобы оставить верхнюю часть стены плоской, нам нужно присвоить его компоненту Y значение 0.

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

	public static Vector3 WallThicknessOffset (Vector3 near, Vector3 far) {
		Vector3 offset;
		offset.x = far.x - near.x;
		offset.y = 0f;
		offset.z = far.z - near.z;
		return offset;
	}

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

		return offset.normalized * (wallThickness * 0.5f);

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

		Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f);
		Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f);
		
		Vector3 leftThicknessOffset =
			HexMetrics.WallThicknessOffset(nearLeft, farLeft);
		Vector3 rightThicknessOffset =
			HexMetrics.WallThicknessOffset(nearRight, farRight);

		Vector3 v1, v2, v3, v4;
		v1 = v3 = left - leftThicknessOffset;
		v2 = v4 = right - rightThicknessOffset;
		v3.y = v4.y = left.y + HexMetrics.wallHeight;
		walls.AddQuad(v1, v2, v3, v4);

		v1 = v3 = left + leftThicknessOffset;
		v2 = v4 = right + rightThicknessOffset;
		v3.y = v4.y = left.y + HexMetrics.wallHeight;
		walls.AddQuad(v2, v1, v4, v3);


Стены со смещениями.

Quad-ы теперь смещены, хотя это и не совсем заметно.

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

Верхушки стен


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

		Vector3 v1, v2, v3, v4;
		v1 = v3 = left - leftThicknessOffset;
		v2 = v4 = right - rightThicknessOffset;
		v3.y = v4.y = left.y + HexMetrics.wallHeight;
		walls.AddQuad(v1, v2, v3, v4);

		Vector3 t1 = v3, t2 = v4;

		v1 = v3 = left + leftThicknessOffset;
		v2 = v4 = right + rightThicknessOffset;
		v3.y = v4.y = left.y + HexMetrics.wallHeight;
		walls.AddQuad(v2, v1, v4, v3);

		walls.AddQuad(t1, t2, v3, v4);


Стены с верхушками.

Повороты на углах


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


Конфигурации углов.

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


Роли ячеек.

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

	void AddWallSegment (
		Vector3 pivot, HexCell pivotCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		AddWallSegment(pivot, left, pivot, right);
	}

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

	public void AddWall (
		Vector3 c1, HexCell cell1,
		Vector3 c2, HexCell cell2,
		Vector3 c3, HexCell cell3
	) {
		if (cell1.Walled) {
			if (cell2.Walled) {
				if (!cell3.Walled) {
					AddWallSegment(c3, cell3, c1, cell1, c2, cell2);
				}
			}
			else if (cell3.Walled) {
				AddWallSegment(c2, cell2, c3, cell3, c1, cell1);
			}
			else {
				AddWallSegment(c1, cell1, c2, cell2, c3, cell3);
			}
		}
		else if (cell2.Walled) {
			if (cell3.Walled) {
				AddWallSegment(c1, cell1, c2, cell2, c3, cell3);
			}
			else {
				AddWallSegment(c2, cell2, c3, cell3, c1, cell1);
			}
		}
		else if (cell3.Walled) {
			AddWallSegment(c3, cell3, c1, cell1, c2, cell2);
		}
	}

Для добавления угловых сегментов вызовем этот метод в конце HexGridChunk.TriangulateCorner.

	void TriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		
		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}


Стены с углами, но дыры всё ещё есть.

Закрываем дыры


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

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

		float leftTop = left.y + HexMetrics.wallHeight;
		float rightTop = right.y + HexMetrics.wallHeight;

		Vector3 v1, v2, v3, v4;
		v1 = v3 = left - leftThicknessOffset;
		v2 = v4 = right - rightThicknessOffset;
		v3.y = leftTop;
		v4.y = rightTop;
		walls.AddQuad(v1, v2, v3, v4);

		Vector3 t1 = v3, t2 = v4;

		v1 = v3 = left + leftThicknessOffset;
		v2 = v4 = right + rightThicknessOffset;
		v3.y = leftTop;
		v4.y = rightTop;
		walls.AddQuad(v2, v1, v4, v3);


Замкнутые стены.

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

Можно избавиться от этих артефактов теней, снизив normal bias до нуля. Или же изменить режим Cast Shadows mesh renderer стены на Two Sided. Это заставит отбрасывающий тень объект передавать на рендеринг обе стороны каждого треугольника стены, что закроет все дыры.


Больше дыр в тенях нет.

unitypackage

Стены на уступах


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


Прямые стены на уступах.

Следуем за ребром


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

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell
	) {
		if (nearCell.Walled != farCell.Walled) {
			AddWallSegment(near.v1, far.v1, near.v2, far.v2);
			AddWallSegment(near.v2, far.v2, near.v3, far.v3);
			AddWallSegment(near.v3, far.v3, near.v4, far.v4);
			AddWallSegment(near.v4, far.v4, near.v5, far.v5);
		}
	}


Изгибающиеся стены.

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

Размещение стен на земле


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


Висящие в воздухе стены.

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

Чтобы опустить стену, нам нужно определить, какая из сторон нижняя — ближняя или дальняя. Мы можем просто использовать высоту самой низкой стороны, но так низко нам опускаться не нужно. Можно интерполировать координату Y от низкой к высокой со смещением чуть меньше 0.5. Так как стены только изредка становятся выше нижней ступеньки уступа, мы можем использовать в качестве смещения вертикальный шаг уступа. Другая толщина стен конфигурации уступов может потребовать другого смещения.


Опущенная стена.

Давайте добавим в HexMetrics метод WallLerp, который займётся этой интерполяцией, в дополнение к усреднению координат X и Z ближних и дальних вершин. Он основан на методе TerraceLerp.

	public const float wallElevationOffset = verticalTerraceStepSize;
						
	…
						
	public static Vector3 WallLerp (Vector3 near, Vector3 far) {
		near.x += (far.x - near.x) * 0.5f;
		near.z += (far.z - near.z) * 0.5f;
		float v =
			near.y < far.y ? wallElevationOffset : (1f - wallElevationOffset);
		near.y += (far.y - near.y) * v;
		return near;
	}

Заставим HexFeatureManager использовать этот метод для определения левых и правых вершин.

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight
	) {
		Vector3 left = HexMetrics.WallLerp(nearLeft, farLeft);
		Vector3 right = HexMetrics.WallLerp(nearRight, farRight);

		…
	}


Стоящие на земле стены.

Изменение искажения стен


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

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

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight
	) {
		nearLeft = HexMetrics.Perturb(nearLeft);
		farLeft = HexMetrics.Perturb(farLeft);
		nearRight = HexMetrics.Perturb(nearRight);
		farRight = HexMetrics.Perturb(farRight);

		…
		walls.AddQuadUnperturbed(v1, v2, v3, v4);

		…
		walls.AddQuadUnperturbed(v2, v1, v4, v3);

		walls.AddQuadUnperturbed(t1, t2, v3, v4);
	}


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

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


Более постоянная толщина стен.

unitypackage

Отверстия в стенах


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

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

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell,
		bool hasRiver, bool hasRoad
	) {
		if (nearCell.Walled != farCell.Walled) {
			AddWallSegment(near.v1, far.v1, near.v2, far.v2);
			if (hasRiver || hasRoad) {
				// Leave a gap.
			}
			else {
				AddWallSegment(near.v2, far.v2, near.v3, far.v3);
				AddWallSegment(near.v3, far.v3, near.v4, far.v4);
			}
			AddWallSegment(near.v4, far.v4, near.v5, far.v5);
		}
	}

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

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

		bool hasRiver = cell.HasRiverThroughEdge(direction);
		bool hasRoad = cell.HasRoadThroughEdge(direction);

		if (hasRiver) {
			…
		}

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

		features.AddWall(e1, cell, e2, neighbor, hasRiver, hasRoad);
		…
	}


Отверстия в стенах для прохода рек и дорог.

Накрываем стены


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

	void AddWallCap (Vector3 near, Vector3 far) {
		near = HexMetrics.Perturb(near);
		far = HexMetrics.Perturb(far);

		Vector3 center = HexMetrics.WallLerp(near, far);
		Vector3 thickness = HexMetrics.WallThicknessOffset(near, far);

		Vector3 v1, v2, v3, v4;

		v1 = v3 = center - thickness;
		v2 = v4 = center + thickness;
		v3.y = v4.y = center.y + HexMetrics.wallHeight;
		walls.AddQuadUnperturbed(v1, v2, v3, v4);
	}

Когда AddWall обнаруживает, что нам нужно отверстие, мы добавляем крышку между второй и четвёртой парами вершин рёбер. Для четвёртой пары вершин нужно переключить ориентацию, иначе грань quad-а будет смотреть внутрь.

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell,
		bool hasRiver, bool hasRoad
	) {
		if (nearCell.Walled != farCell.Walled) {
			AddWallSegment(near.v1, far.v1, near.v2, far.v2);
			if (hasRiver || hasRoad) {
				AddWallCap(near.v2, far.v2);
				AddWallCap(far.v4, near.v4);
			}
			…
		}
	}


Закрытые отверстия в стенах.

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

unitypackage

Избегаем обрывов и воды


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


Стены на обрывах и в воде.

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

	public void AddWall (
		EdgeVertices near, HexCell nearCell,
		EdgeVertices far, HexCell farCell,
		bool hasRiver, bool hasRoad
	) {
		if (
			nearCell.Walled != farCell.Walled &&
			!nearCell.IsUnderwater && !farCell.IsUnderwater &&
			nearCell.GetEdgeType(farCell) != HexEdgeType.Cliff
		) {
			…
		}
	}


Удалили мешающие стены вдоль рёбер, но углы остались на месте.

Удаление углов стен


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

	void AddWallSegment (
		Vector3 pivot, HexCell pivotCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		if (pivotCell.IsUnderwater) {
			return;
		}

		AddWallSegment(pivot, left, pivot, right);
	}


Подводных опорных ячеек больше нет.

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

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

		if (pivotCell.IsUnderwater) {
			return;
		}

		bool hasLeftWall = !leftCell.IsUnderwater &&
			pivotCell.GetEdgeType(leftCell) != HexEdgeType.Cliff;
		bool hasRighWall = !rightCell.IsUnderwater &&
			pivotCell.GetEdgeType(rightCell) != HexEdgeType.Cliff;

		if (hasLeftWall && hasRighWall) {
			AddWallSegment(pivot, left, pivot, right);
		}


Удалили все мешающие углы.

Закрываем углы


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

		if (hasLeftWall) {
			if (hasRighWall) {
				AddWallSegment(pivot, left, pivot, right);
			}
			else {
				AddWallCap(pivot, left);
			}
		}
		else if (hasRighWall) {
			AddWallCap(right, pivot);
		}


Закрываем стены.

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


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


Дыры между стенами и гранями обрывов.

Было бы гораздо лучше, если бы стена продолжалась до самой грани обрыва. Мы можем сделать это, добавив ещё один сегмент стены между текущим концом стены и угловой вершиной обрыва. Так как большая часть этого сегмента окажется скрытой внутри обрыва, мы можем обойтись снижением до нуля толщины стены внутри обрыва. Таким образом, нам достаточно создать клин: два идущих в точку quad-а и треугольник поверх них. Создадим для этой цели метод AddWallWedge. Это можно сделать, скопировав AddWallCap и добавив точку клина.

	void AddWallWedge (Vector3 near, Vector3 far, Vector3 point) {
		near = HexMetrics.Perturb(near);
		far = HexMetrics.Perturb(far);
		point = HexMetrics.Perturb(point);

		Vector3 center = HexMetrics.WallLerp(near, far);
		Vector3 thickness = HexMetrics.WallThicknessOffset(near, far);

		Vector3 v1, v2, v3, v4;
		Vector3 pointTop = point;
		point.y = center.y;

		v1 = v3 = center - thickness;
		v2 = v4 = center + thickness;
		v3.y = v4.y = pointTop.y = center.y + HexMetrics.wallHeight;

//		walls.AddQuadUnperturbed(v1, v2, v3, v4);
		walls.AddQuadUnperturbed(v1, point, v3, pointTop);
		walls.AddQuadUnperturbed(point, v2, pointTop, v4);
		walls.AddTriangleUnperturbed(pointTop, v3, v4);
	}

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

		if (hasLeftWall) {
			if (hasRighWall) {
				AddWallSegment(pivot, left, pivot, right);
			}
			else if (leftCell.Elevation < rightCell.Elevation) {
				AddWallWedge(pivot, left, right);
			}
			else {
				AddWallCap(pivot, left);
			}
		}
		else if (hasRighWall) {
			if (rightCell.Elevation < leftCell.Elevation) {
				AddWallWedge(right, pivot, left);
			}
			else {
				AddWallCap(right, pivot);
			}
		}


Клиновидные стены, соединяющиеся с обрывами.

unitypackage

Часть 11: новые объекты рельефа


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


Заполненный объектами ландшафт.

Башни на стенах


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

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

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

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


Префаб башни.

Добавим ссылку на этот префаб в HexFeatureManager и подключим его.

	public Transform wallTower;


Ссылка на префаб башни.

Строим башни


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

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight
	) {
		…

		Transform towerInstance = Instantiate(wallTower);
		towerInstance.transform.localPosition = (left + right) * 0.5f;
		towerInstance.SetParent(container, false);
	}


По одной башне на каждый сегмент стены.

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

Вместо того, чтобы вычислять поворот самостоятельно, мы просто присвоим свойству Transform.right вектор. Код Unity займётся изменением поворота объекта так, чтобы его локальное направление right соответствовало переданному вектору.

		Transform towerInstance = Instantiate(wallTower);
		towerInstance.transform.localPosition = (left + right) * 0.5f;
		Vector3 rightDirection = right - left;
		rightDirection.y = 0f;
		towerInstance.transform.right = rightDirection;
		towerInstance.SetParent(container, false);


Башни выровнены со стеной.

Как работает присвоение Transform.right?
В нём используется метод Quaternion.FromToRotation для вычисления поворота. Вот код свойства.

public Vector3 right {
	get {
		return rotation * Vector3.right;
	}
	set {
		rotation = Quaternion.FromToRotation(Vector3.right, value);
	}
}

Уменьшаем количество башен


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

	void AddWallSegment (
		Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight,
		bool addTower = false
	) {
		…

		if (addTower) {
			Transform towerInstance = Instantiate(wallTower);
			towerInstance.transform.localPosition = (left + right) * 0.5f;
			Vector3 rightDirection = right - left;
			rightDirection.y = 0f;
			towerInstance.transform.right = rightDirection;
			towerInstance.SetParent(container, false);
		}
	}

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

	void AddWallSegment (
		Vector3 pivot, HexCell pivotCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
				AddWallSegment(pivot, left, pivot, right, true);
		…
	}


Башни находятся только в углах.

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

				HexHash hash = HexMetrics.SampleHashGrid(
					(pivot + left + right) * (1f / 3f)
				);
				bool hasTower = hash.e < HexMetrics.wallTowerThreshold;
				AddWallSegment(pivot, left, pivot, right, hasTower);

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

	public const float wallTowerThreshold = 0.5f;


Случайные башни.

Убираем башни со склонов


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


Башни на склонах.

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

				bool hasTower = false;
				if (leftCell.Elevation == rightCell.Elevation) {
					HexHash hash = HexMetrics.SampleHashGrid(
						(pivot + left + right) * (1f / 3f)
					);
					hasTower = hash.e < HexMetrics.wallTowerThreshold;
				}
				AddWallSegment(pivot, left, pivot, right, hasTower);


Больше на стенах склонов башен нет.

Ставим стены и башни на землю


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


Башни в воздухе.

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


Стены в воздухе.

Это можно исправить, растянув основание стен и башен до земли. Для этого добавим смещение по Y для стен в HexMetrics. На одну единицу вниз будет достаточно. Увеличим высоту башен на ту же величину.

	public const float wallHeight = 4f;
						
	public const float wallYOffset = -1f;

Изменим HexMetrics.WallLerp так, чтобы при определении координаты Y он учитывал новое смещение.

		public static Vector3 WallLerp (Vector3 near, Vector3 far) {
		near.x += (far.x - near.x) * 0.5f;
		near.z += (far.z - near.z) * 0.5f;
		float v =
			near.y < far.y ? wallElevationOffset : (1f - wallElevationOffset);
		near.y += (far.y - near.y) * v + wallYOffset;
		return near;
	}

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



Стены и башни на земле.

unitypackage

Мосты


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

Начнём с простого отмасштабированного куба, который будет играть роль префаба моста. Ширина рек варьируется, но между центрами дорог с обеих сторон есть примерно семь единиц расстояния. Поэтому зададим ему приблизительный масштаб (3, 1, 7). Добавим префабу красный городской материал и избавимся от его коллайдера. Как и в случае с башнями, поместим куб внутрь корневого объекта с одинаковым масштабом. Благодаря этому сама геометрия моста будет не важна.

Добавим ссылку на префаб моста в HexFeatureManager и назначим ей префаб.

	public Transform wallTower, bridge;


Назначенный префаб моста.

Размещение мостов


Чтобы разместить мост, нам потребуется метод HexFeatureManager.AddBridge. Мост должен располагаться между центром реки и одной из сторон реки.

	public void AddBridge (Vector3 roadCenter1, Vector3 roadCenter2) {
		Transform instance = Instantiate(bridge);
		instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f;
		instance.SetParent(container, false);
	}

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

		roadCenter1 = HexMetrics.Perturb(roadCenter1);
		roadCenter2 = HexMetrics.Perturb(roadCenter2);
		Transform instance = Instantiate(bridge);

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

		Transform instance = Instantiate(bridge);
		instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f;
		instance.forward = roadCenter2 - roadCenter1;
		instance.SetParent(container, false);

Прокладываем мосты через прямые реки


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

Для начала давайте разберёмся с прямыми реками. Внутри HexGridChunk.TriangulateRoadAdjacentToRiver первый оператор else if занимается размещением дорог по соседству с такими реками. Поэтому здесь мы и будем добавлять мосты.

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

	void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
			…
			roadCenter += corner * 0.5f;
			features.AddBridge(roadCenter, center - corner * 0.5f);
			center += corner * 0.25f;
		}
		…
	}


Мосты через прямые реки.

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

			roadCenter += corner * 0.5f;
			if (cell.IncomingRiver == direction.Next()) {
				features.AddBridge(roadCenter, center - corner * 0.5f);
			}
			center += corner * 0.25f;

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

			if (cell.IncomingRiver == direction.Next() && (
				cell.HasRoadThroughEdge(direction.Next2()) ||
				cell.HasRoadThroughEdge(direction.Opposite())
			)) {
				features.AddBridge(roadCenter, center - corner * 0.5f);
			}


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

Мосты над искривлёнными реками


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

	void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		else {
			HexDirection middle;
			if (previousHasRiver) {
				middle = direction.Next();
			}
			else if (nextHasRiver) {
				middle = direction.Previous();
			}
			else {
				middle = direction;
			}
			if (
				!cell.HasRoadThroughEdge(middle) &&
				!cell.HasRoadThroughEdge(middle.Previous()) &&
				!cell.HasRoadThroughEdge(middle.Next())
			) {
				return;
			}
			Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle);
			roadCenter += offset * 0.25f;
		}

		…
	}

Масштаб смещения на внешней стороне кривой равен 0.25, а внутри HexMetrics.innerToOuter * 0.7f. Используем его для размещения моста.

			Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle);
			roadCenter += offset * 0.25f;
			features.AddBridge(
				roadCenter,
				center - offset * (HexMetrics.innerToOuter * 0.7f)
			);


Мосты над искривлёнными реками.

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

			Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle);
			roadCenter += offset * 0.25f;
			if (direction == middle) {
				features.AddBridge(
					roadCenter,
					center - offset * (HexMetrics.innerToOuter * 0.7f)
				);
			}

И опять нужно убедиться, что дорога есть и на противоположной стороне.

			if (
				direction == middle &&
				cell.HasRoadThroughEdge(direction.Opposite())
			) {
				features.AddBridge(
					roadCenter,
					center - offset * (HexMetrics.innerToOuter * 0.7f)
				);
			}


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

Масштабирование мостов


Так как мы искажаем рельеф, расстояние между центрами дорог и противоположными сторонами реки варьируются. Иногда мосты слишком короткие, иногда слишком длинные.


Варьирующиеся расстояния, но постоянная длина мостов.

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

Для выполнения правильного масштабирования нам нужно знать исходную длину префаба моста. Будем хранить эту длину в HexMetrics.

	public const float bridgeDesignLength = 7f;

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

	public void AddBridge (Vector3 roadCenter1, Vector3 roadCenter2) {
		roadCenter1 = HexMetrics.Perturb(roadCenter1);
		roadCenter2 = HexMetrics.Perturb(roadCenter2);
		Transform instance = Instantiate(bridge);
		instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f;
		instance.forward = roadCenter2 - roadCenter1;
		float length = Vector3.Distance(roadCenter1, roadCenter2);
		instance.localScale = new Vector3(
			1f,	1f, length * (1f / HexMetrics.bridgeDesignLength)
		);
		instance.SetParent(container, false);
	}


Изменяющаяся длина мостов.

Конструкция моста


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



Арочные мосты разной длины.

unitypackage

Особые объекты


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

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


Префаб замка.

Ещё одним специальным объектом может быть зиккурат, например, построенный из трёх поставленных друг на друга кубов. Для нижнего куба подойдёт масштаб (8, 2.5, 8).


Префаб зиккурата.

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


Префаб мегафлоры.

Добавим в HexFeatureManager массив, чтобы отслеживать эти префабы.

	public Transform[] special;

Сначала добавим в массив замок, затем зиккурат, а потом мегафлору.


Настройка особых объектов.

Делаем ячейки особыми


Теперь для HexCell требуется индекс особых объектов, определяющий тип особого объекта, если он там находится.

	int specialIndex;

Как и другим объектам рельефа, дадим ему свойство получать и задавать это значение.

	public int SpecialIndex {
		get {
			return specialIndex;
		}
		set {
			if (specialIndex != value) {
				specialIndex = value;
				RefreshSelfOnly();
			}
		}
	}

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

	public bool IsSpecial {
		get {
			return specialIndex > 0;
		}
	}

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

	int activeUrbanLevel, activeFarmLevel, activePlantLevel, activeSpecialIndex;

	…

	bool applyUrbanLevel, applyFarmLevel, applyPlantLevel, applySpecialIndex;
	
	…
	
	public void SetApplySpecialIndex (bool toggle) {
		applySpecialIndex = toggle;
	}

	public void SetSpecialIndex (float index) {
		activeSpecialIndex = (int)index;
	}
	
	…
	
	void EditCell (HexCell cell) {
		if (cell) {
			if (applyColor) {
				cell.Color = activeColor;
			}
			if (applyElevation) {
				cell.Elevation = activeElevation;
			}
			if (applyWaterLevel) {
				cell.WaterLevel = activeWaterLevel;
			}
			if (applySpecialIndex) {
				cell.SpecialIndex = activeSpecialIndex;
			}
			if (applyUrbanLevel) {
				cell.UrbanLevel = activeUrbanLevel;
			}
			…
		}
	}

Добавим в UI ползунок для управления особым объектом. Так как у нас три объекта, используем в ползунке интервал 0–3. Ноль будет означать отсутствие объекта, один — замок, два — зиккурат, три — мегафлору.


Ползунок особых объектов.

Добавление особых объектов


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

	public void AddSpecialFeature (HexCell cell, Vector3 position) {
		Transform instance = Instantiate(special[cell.SpecialIndex - 1]);
		instance.localPosition = HexMetrics.Perturb(position);
		instance.SetParent(container, false);
	}

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

	public void AddSpecialFeature (HexCell cell, Vector3 position) {
		Transform instance = Instantiate(special[cell.SpecialIndex - 1]);
		instance.localPosition = HexMetrics.Perturb(position);
		HexHash hash = HexMetrics.SampleHashGrid(position);
		instance.localRotation = Quaternion.Euler(0f, 360f * hash.e, 0f);
		instance.SetParent(container, false);
	}

При триангуляции ячейки в HexGridChunk.Triangulate проверим, содержит ли ячейка особый объект. Если да, то вызываем наш новый метод, точно так же, как AddFeature.

	void Triangulate (HexCell cell) {
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			Triangulate(d, cell);
		}
		if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) {
			features.AddFeature(cell, cell.Position);
		}
		if (cell.IsSpecial) {
			features.AddSpecialFeature(cell, cell.Position);
		}
	}


Особые объекты. Они намного больше обычных.

Избегаем рек


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


Объекты на реках.

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

	public int SpecialIndex {
		…
		set {
			if (specialIndex != value && !HasRiver) {
				specialIndex = value;
				RefreshSelfOnly();
			}
		}
	}

Кроме того, при добавлении реки нам нужно будет избавляться от всех особых объектов. Река должна смывать их. Это можно сделать, присваивая в методе HexCell.SetOutgoingRiver индексам особых объектов значение 0.

	public void SetOutgoingRiver (HexDirection direction) {
		…
		hasOutgoingRiver = true;
		outgoingRiver = direction;
		specialIndex = 0;

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

		SetRoad((int)direction, false);
	}

Избегаем дорог


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


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

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

	public int SpecialIndex {
		…
		set {
			if (specialIndex != value && !HasRiver) {
				specialIndex = value;
				RemoveRoads();
				RefreshSelfOnly();
			}
		}
	}

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

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

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

Избегаем других объектов


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


Объект, пересекающийся с другими объектами.

В этом случае мы будем подавлять меньшие объекты, как будто они оказались под водой. На этот раз проверку будем выполнять в HexFeatureManager.AddFeature.

	public void AddFeature (HexCell cell, Vector3 position) {
		if (cell.IsSpecial) {
			return;
		}

		…
	}

Избегаем воду


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


Объекты в воде.

В HexGridChunk.Triangulate будем выполнять одинаковую проверку затопленности и для особых, и для обычных объектов.

	void Triangulate (HexCell cell) {
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			Triangulate(d, cell);
		}
		if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) {
			features.AddFeature(cell, cell.Position);
		}
		if (!cell.IsUnderwater && cell.IsSpecial) {
			features.AddSpecialFeature(cell, cell.Position);
		}
	}

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

	void Triangulate (HexCell cell) {
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			Triangulate(d, cell);
		}
		if (!cell.IsUnderwater) {
			if (!cell.HasRiver && !cell.HasRoads) {
				features.AddFeature(cell, cell.Position);
			}
			if (cell.IsSpecial) {
				features.AddSpecialFeature(cell, cell.Position);
			}
		}
	}

Для экспериментов такого количества объектов нам будет достаточно.

unitypackage
  • +30
  • 2,8k
  • 2
Поделиться публикацией
Комментарии 2
    +2
    TL;DR
    Но материал классный!
      0
      магия.

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

      Самое читаемое