Pull to refresh

Карты из шестиугольников в Unity: круговорот воды, эрозия, биомы, цилиндрическая карта

Reading time80 min
Views13K
Original author: Jasper Flick
Части 1-3: сетка, цвета и высоты ячеек

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

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

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

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

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

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

Часть 24: регионы и эрозия


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

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

Этот туториал создан в Unity 2017.1.0.


Разделяем и сглаживаем сушу.

Граница карты


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

Размер границы


Насколько близко суша должна подбираться к краю карты? На этот вопрос нет правильного ответа, поэтому сделаем этот параметр настраиваемым. Мы добавим два ползунка к компоненту HexMapGenerator, один для границ вдоль краёв по оси X, другой для границ вдоль оси Z. Так мы сможем использовать более широкую границу в одном из измерений, или вообще создавать границу только в одном измерении. Давайте используем интервал от 0 до 10 со значением по умолчанию 5.

	[Range(0, 10)]
	public int mapBorderX = 5;

	[Range(0, 10)]
	public int mapBorderZ = 5;


Ползунки границ карты.

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


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

	int xMin, xMax, zMin, zMax;

Инициализируем ограничения в GenerateMap перед созданием суши. Мы используем эти значения как параметры для вызовов Random.Range, поэтому максимумы на самом деле являются исключительными. Без границы они равны количеству ячеек измерения, поэтому не минус 1.

	public void GenerateMap (int x, int z) {
		…
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = waterLevel;
		}
		xMin = mapBorderX;
		xMax = x - mapBorderX;
		zMin = mapBorderZ;
		zMax = z - mapBorderZ;
		CreateLand();
		…
	}

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

	HexCell GetRandomCell () {
//		return grid.GetCell(Random.Range(0, cellCount));
		return grid.GetCell(Random.Range(xMin, xMax), Random.Range(zMin, zMax));
	}





Границы карты размерами 0×0, 5×5, 10×10 и 0×10.

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

Вероятность того, что суша пересечёт всю границу, зависит и от размера границы, и от максимального размера участка. Без колебаний участки остаются шестиугольниками. Полный шестиугольник с радиусом $r$ содержит $3r^2+3r+1$ ячеек. Если существуют шестиугольники с радиусом, равным размеру границы, то они смогут её пересечь. Полный шестиугольник с радиусом 5 содержит 91 ячейку. Так как по умолчанию максимум равен 100 ячейкам на участок, то это значит, что суша сможет проложить проложить мост через 5 ячеек, особенно если присутствуют колебания. Чтобы этого точно не происходило, или уменьшим максимальный размер участка, или увеличим размер границы.

Как выведена формула количества ячеек в шестиугольной области?
При радиусе 0 мы имеем дело с одной ячейкой. Отсюда взялась 1. При радиусе 1 вокруг центра есть шесть дополнительных ячеек, то есть $6+1$. Можно считать эти шесть ячеек концами шести треугольников, касающихся центра. При радиусе 2 к этим треугольникам добавляется второй ряд, то есть на треугольник получается ещё две ячейки, и всего $6(1+2)+1$. При радиусе 3 добавляется третий ряд, то есть ещё три ячейки на треугольник, и всего $6(1+2+3)+1$. И так далее. То есть в общем виде формула выглядит как $6(sum_(i=1)^r i)+1 = 6((r(r+1))/2)+1 = 3r(r+1)+1=3r^2+3r+1$.

Чтобы чётче это увидеть, мы можем присвоить размеру границы значение 200. Так как полный шестиугольник с радиусом 8 содержит 217 ячеек, суша с большой вероятностью коснётся края карты. По крайней мере, если использовать значение размера границы по умолчанию (5). При увеличении границы до 10 вероятность сильно понизится.



Участок суши имеет постоянный размер 200, границы карты равны 5 и 10.

Пангея


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


40% суши с границей карты 10.

Откуда взялось название Пангея?
Так назывался последний известный сверхконтинент, существовавший на Земле много лет назад. Название составлено из греческих слов pan и Gaia, означающих что-то вроде «вся природа» или «вся суша».


Защищаемся от невозможных карт


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

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

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

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
//		while (landBudget > 0) {
		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
			…
		}
	}

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

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

	void CreateLand () {
		…
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
		}
	}


95% суши с границей карты 10 не смогли потратить всю сумму.

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

unitypackage

Разбиение карты на части


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

Регион карты


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

//	int xMin, xMax, zMin, zMax;
	struct MapRegion {
		public int xMin, xMax, zMin, zMax;
	}

	MapRegion region;

Чтобы всё работало, нам нужно в GenerateMap добавить полям минимума-максимума префикс region..

		region.xMin = mapBorderX;
		region.xMax = x - mapBorderX;
		region.zMin = mapBorderZ;
		region.zMax = z - mapBorderZ;

А также в GetRandomCell.

	HexCell GetRandomCell () {
		return grid.GetCell(
			Random.Range(region.xMin, region.xMax),
			Random.Range(region.zMin, region.zMax)
		);
	}

Несколько регионов


Для поддержки нескольких регионов заменим одно поле MapRegion списком регионов.

//	MapRegion region;
	List<MapRegion> regions;

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

	void CreateRegions () {
		if (regions == null) {
			regions = new List<MapRegion>();
		}
		else {
			regions.Clear();
		}

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX - mapBorderX;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
	}

Вызовем этот метод в GenerateMap, а не будем создавать регион напрямую.

//		region.xMin = mapBorderX;
//		region.xMax = x - mapBorderX;
//		region.zMin = mapBorderZ;
//		region.zMax = z - mapBorderZ;
		CreateRegions();
		CreateLand();

Чтобы GetRandomCell мог работать с произвольным регионом, дадим ему параметр MapRegion.

	HexCell GetRandomCell (MapRegion region) {
		return grid.GetCell(
			Random.Range(region.xMin, region.xMax),
			Random.Range(region.zMin, region.zMax)
		);
	}

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

	int RaiseTerrain (int chunkSize, int budget, MapRegion region) {
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell(region);
		…
	}

	int SinkTerrain (int chunkSize, int budget, MapRegion region) {
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell(region);
		…
	}

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

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
				if (Random.value < sinkProbability) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
				}
			}
		}
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
		}
	}

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

		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			bool sink = Random.value < sinkProbability;
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
//				if (Random.value < sinkProbability) {
				if (sink) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
				}
			}
		}

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

//		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
		for (int guard = 0; guard < 10000; guard++) {
			bool sink = Random.value < sinkProbability;
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
					if (sink) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
					if (landBudget == 0) {
						return;
					}
				}
			}
		}

Два региона


Хоть у нас теперь и есть поддержка нескольких регионов, мы по-прежнему задаём только один. Давайте изменим CreateRegions так, чтобы он делил карту пополам по вертикали. Для этого вдвое уменьшим значение xMax добавляемого региона. Затем используем то же значение для xMin и снова используем исходное значение для xMax, использовав его как второй регион.

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX / 2;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
		region.xMin = grid.cellCountX / 2;
		region.xMax = grid.cellCountX - mapBorderX;
		regions.Add(region);

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

	[Range(0, 10)]
	public int regionBorder = 5;


Ползунок границы региона.

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

Чтобы применить эту границу региона, вычтем её из xMax первого региона и прибавим к xMin второго региона.

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX / 2 - regionBorder;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
		region.xMin = grid.cellCountX / 2 + regionBorder;
		region.xMax = grid.cellCountX - mapBorderX;
		regions.Add(region);


Карта разделена по вертикали на два региона.

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

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

		MapRegion region;
		if (Random.value < 0.5f) {
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = grid.cellCountX / 2 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
		}
		else {
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX - mapBorderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ / 2 - regionBorder;
			regions.Add(region);
			region.zMin = grid.cellCountZ / 2 + regionBorder;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
		}


Карта, горизонтально разделённая на два региона.

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

Четыре региона


Давайте сделаем количество регионов настраиваемым, создадим поддержку от 1 до 4 регионов.

	[Range(1, 4)]
	public int regionCount = 1;


Ползунок для количества регионов.

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

		MapRegion region;
		switch (regionCount) {
		default:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX - mapBorderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			break;
		case 2:
			if (Random.value < 0.5f) {
				region.xMin = mapBorderX;
				region.xMax = grid.cellCountX / 2 - regionBorder;
				region.zMin = mapBorderZ;
				region.zMax = grid.cellCountZ - mapBorderZ;
				regions.Add(region);
				region.xMin = grid.cellCountX / 2 + regionBorder;
				region.xMax = grid.cellCountX - mapBorderX;
				regions.Add(region);
			}
			else {
				region.xMin = mapBorderX;
				region.xMax = grid.cellCountX - mapBorderX;
				region.zMin = mapBorderZ;
				region.zMax = grid.cellCountZ / 2 - regionBorder;
				regions.Add(region);
				region.zMin = grid.cellCountZ / 2 + regionBorder;
				region.zMax = grid.cellCountZ - mapBorderZ;
				regions.Add(region);
			}
			break;
		}

Что за оператор switch?
Это альтернатива написанию последовательности операторов if-else-if-else. switch применяется к переменной, а метки используются для обозначения того, какой код нужно выполнять. Существует также метка default, которая используется как последний блок else. Каждый вариант должен завершаться или оператором break, или оператором return.

Чтобы блок switch оставался удобочитаемым, обычно лучше делать все case короткими, в идеале — одним оператором или вызовом метода. Я не буду делать это для примера кода региона, но если вы хотите создать более интересные регионы, то рекомендую вам использовать отдельные методы. Например:

		switch (regionCount) {
			default: CreateOneRegion(); break;
			case 2: CreateTwoRegions(); break;
			case 3: CreateThreeRegions(); break;
			case 4: CreateFourRegions(); break;
		}

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

		switch (regionCount) {
		default:
			…
			break;
		case 2:
			…
			break;
		case 3:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 3 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = grid.cellCountX / 3 + regionBorder;
			region.xMax = grid.cellCountX * 2 / 3 - regionBorder;
			regions.Add(region);
			region.xMin = grid.cellCountX * 2 / 3 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
			break;
		}


Три региона.

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

		switch (regionCount) {
		…
		case 4:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ / 2 - regionBorder;
			regions.Add(region);
			region.xMin = grid.cellCountX / 2 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
			region.zMin = grid.cellCountZ / 2 + regionBorder;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			regions.Add(region);
			break;
		}
	}


Четыре региона.

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

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

unitypackage

Эрозия


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

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

Процент эрозии


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

	[Range(0, 100)]
	public int erosionPercentage = 50;


Ползунок эрозии.

Поиск разрушаемых эрозией ячеек


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

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

	bool IsErodible (HexCell cell) {
		int erodibleElevation = cell.Elevation - 2;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (neighbor && neighbor.Elevation <= erodibleElevation) {
				return true;
			}
		}
		return false;
	}

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

	void ErodeLand () {
		List<HexCell> erodibleCells = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (IsErodible(cell)) {
				erodibleCells.Add(cell);
			}
		}

		ListPool<HexCell>.Add(erodibleCells);
	}

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

	void ErodeLand () {
		List<HexCell> erodibleCells = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			…
		}

		int targetErodibleCount =
			(int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);

		ListPool<HexCell>.Add(erodibleCells);
	}

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

Снижение ячеек


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

		int targetErodibleCount =
			(int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);
		
		while (erodibleCells.Count > targetErodibleCount) {
			int index = Random.Range(0, erodibleCells.Count);
			HexCell cell = erodibleCells[index];

			cell.Elevation -= 1;

			erodibleCells.Remove(cell);
		}

		ListPool<HexCell>.Add(erodibleCells);

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

//			erodibleCells.Remove(cell);
			erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
			erodibleCells.RemoveAt(erodibleCells.Count - 1);



Наивное понижение 0% и 100% подверженных эрозии ячеек, seed карты 1957632474.

Отслеживание эрозии


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

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}


100% эрозии при сохранении подверженных эрозии ячеек в списке.

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

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

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}
			
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				if (
					neighbor && IsErodible(neighbor) &&
					!erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Add(neighbor);
				}
			}


Все подверженные эрозии ячейки опущены.

Сохраняем массу суши


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

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

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

	HexCell GetErosionTarget (HexCell cell) {
		List<HexCell> candidates = ListPool<HexCell>.Get();
		int erodibleElevation = cell.Elevation - 2;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (neighbor && neighbor.Elevation <= erodibleElevation) {
				candidates.Add(neighbor);
			}
		}
		HexCell target = candidates[Random.Range(0, candidates.Count)];
		ListPool<HexCell>.Add(candidates);
		return target;
	}

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

			HexCell cell = erodibleCells[index];
			HexCell targetCell = GetErosionTarget(cell);

			cell.Elevation -= 1;
			targetCell.Elevation += 1;

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}

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

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				…
			}

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && !IsErodible(neighbor) &&
					erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}
			}


100% эрозии с сохранением массы суши.

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

Ускоренная эрозия


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

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && neighbor != cell && !IsErodible(neighbor) &&
					erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}
			}

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

				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && neighbor != cell &&
					neighbor.Elevation == targetCell.Elevation + 1 &&
					!IsErodible(neighbor)
//					&& erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}

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

				HexCell neighbor = cell.GetNeighbor(d);
				if (
					neighbor && neighbor.Elevation == cell.Elevation + 2 &&
//					IsErodible(neighbor) &&
					!erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Add(neighbor);
				}

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

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}

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

			if (IsErodible(targetCell) && !erodibleCells.Contains(targetCell)) {
				erodibleCells.Add(targetCell);
			}

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

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





25%, 50%, 75% и 100% эрозии.

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


Четыре полностью подвергнутых эрозии региона всё равно остаются раздельными.

unitypackage

Часть 25: круговорот воды


  • Отображаем сырые данные карты.
  • Формируем климат ячеек.
  • Создаём частичную симуляцию круговорота воды.

В этой части мы добавим на суше влажность.

Этот туториал создан в Unity 2017.3.0.


Используем круговорот воды для определения биомов.

Облака


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

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

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

Визуализация данных


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

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_GridTex ("Grid Texture", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
		_BackgroundColor ("Background Color", Color) = (0,0,0)
		[Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0
	}


Переключатель для отображения данных карты.

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

		#pragma multi_compile _ GRID_ON
		#pragma multi_compile _ HEX_MAP_EDIT_MODE

		#pragma shader_feature SHOW_MAP_DATA

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

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

			#if defined(SHOW_MAP_DATA)
				float mapData;
			#endif
		};

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

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

			#if defined(SHOW_MAP_DATA)
				data.mapData = cell0.z * v.color.x + cell1.z * v.color.y +
					cell2.z * v.color.z;
			#endif
		}

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

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…
			o.Albedo = c.rgb * grid * _Color * explored;
			#if defined(SHOW_MAP_DATA)
				o.Albedo = IN.mapData * grid;
			#endif
			…
		}

Чтобы на самом деле передать данные в шейдер. нам нужно добавить в HexCellShaderData метод, записывающий что-то в синий канал данных текстур. Данные являются одним значением float, ограниченным в интервале 0–1.

	public void SetMapData (HexCell cell, float data) {
		cellTextureData[cell.Index].b =
			data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255);
		enabled = true;
	}

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

		cellTextureData[cell.Index].b =
			data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);

Добавим метод с тем же именем и в HexCell. Он будет передавать запрос данным его шейдера.

	public void SetMapData (float data) {
		ShaderData.SetMapData(this, data);
	}

Чтобы проверить работу кода, изменим HexMapGenerator.SetTerrainType так, чтобы он задавал данные каждой ячейки карты. Давайте визуализируем высоту, преобразованную из integer во float в интервале 0–1. Это делается вычитанием минимума высоты из высоты ячейки с последующим делением на максимум высоты минус минимум. Сделаем так, чтобы деление было с плавающей запятой.

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
			cell.SetMapData(
				(cell.Elevation - elevationMinimum) /
				(float)(elevationMaximum - elevationMinimum)
			);
		}
	}

Теперь мы сможем переключаться между обычным рельефом и визуализацией данных с помощью флажка Show Map Data ассета материала Terrain.



Карта 1208905299, обычный рельеф и визуализация высот.

Создание климата


Для симуляции климата нам нужно отслеживать данные климата. Так как карта состоит из дискретных ячеек, у каждой из них есть свой локальный климат. Создадим структуру ClimateData для хранения всех соответствующих данных. Конечно, можно добавить данные и к самим ячейкам, но мы будем использовать их только при генерации карты. Поэтому сохраним их отдельно. Это значит, что мы можем определить эту struct внутри HexMapGenerator, как и MapRegion. Мы начнём с того, что будем отслеживать только облака, что можно реализовать с помощью одного поля float.

	struct ClimateData {
		public float clouds;
	}

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

	List<ClimateData> climate = new List<ClimateData>();

Теперь нам нужен метод для создания климата карты. Он должен начинаться с очистки списка климатических зон, а затем добавлять по одному элементу для каждой ячейки. Исходные данные климата просто равны нулю, этого можно добиться с помощью стандартного конструктора ClimateData.
	void CreateClimate () {
		climate.Clear();
		ClimateData initialData = new ClimateData();
		for (int i = 0; i < cellCount; i++) {
			climate.Add(initialData);
		}
	}

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

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

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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
			cell.SetMapData(climate[i].clouds);
		}
	}

Меняющийся климат


Первый этап симуляции климата — это испарение. Какое количество воды должно испаряться? Давайте управлять этой величиной с помощью ползунка. Значение 0 означает отсутствие испарения, 1 — максимальное испарение. По умолчанию мы используем 0.5.

	[Range(0f, 1f)]
	public float evaporation = 0.5f;


Ползунок испарения.

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

	void EvolveClimate (int cellIndex) {
		HexCell cell = grid.GetCell(cellIndex);
		ClimateData cellClimate = climate[cellIndex];
		
		if (cell.IsUnderwater) {
			cellClimate.clouds += evaporation;
		}

		climate[cellIndex] = cellClimate;
	}

Вызовем этот метод для каждой ячейки в CreateClimate.

	void CreateClimate () {
		…

		for (int i = 0; i < cellCount; i++) {
			EvolveClimate(i);
		}
	}

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

		for (int cycle = 0; cycle < 40; cycle++) {
			for (int i = 0; i < cellCount; i++) {
				EvolveClimate(i);
			}
		}

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


Испарение над водой.

Рассеяние облаков


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

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

		if (cell.IsUnderwater) {
			cellClimate.clouds += evaporation;
		}

		float cloudDispersal = cellClimate.clouds * (1f / 6f);
		cellClimate.clouds = 0f;

		climate[cellIndex] = cellClimate;

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

		float cloudDispersal = cellClimate.clouds * (1f / 6f);
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (!neighbor) {
				continue;
			}
			ClimateData neighborClimate = climate[neighbor.Index];
			neighborClimate.clouds += cloudDispersal;
			climate[neighbor.Index] = neighborClimate;
		}
		cellClimate.clouds = 0f;


Рассеивающиеся облака.

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

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

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

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

Осадки


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

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


Ползунок коэффициента осадков.

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

		if (cell.IsUnderwater) {
			cellClimate.clouds += evaporation;
		}

		float precipitation = cellClimate.clouds * precipitationFactor;
		cellClimate.clouds -= precipitation;

		float cloudDispersal = cellClimate.clouds * (1f / 6f);


Исчезающие облака.

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

unitypackage

Влажность


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

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


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

	struct ClimateData {
		public float clouds, moisture;
	}

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

	[Range(0f, 1f)]
	public float evaporationFactor = 0.5f;

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

		if (cell.IsUnderwater) {
			cellClimate.moisture = 1f;
			cellClimate.clouds += evaporationFactor;
		}
		else {
			float evaporation = cellClimate.moisture * evaporationFactor;
			cellClimate.moisture -= evaporation;
			cellClimate.clouds += evaporation;
		}
		
		float precipitation = cellClimate.clouds * precipitationFactor;
		cellClimate.clouds -= precipitation;
		cellClimate.moisture += precipitation;

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


Облака с испарением влажности.

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

			cell.SetMapData(climate[i].moisture);


Отображение влажности.

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

Сток осадков


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

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


Ползунок стока.

Мы не будем генерировать реки?
Мы добавим их в будущем туториале на основании сгенерированного климата.

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

		float cloudDispersal = cellClimate.clouds * (1f / 6f);
		float runoff = cellClimate.moisture * runoffFactor * (1f / 6f);
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (!neighbor) {
				continue;
			}
			ClimateData neighborClimate = climate[neighbor.Index];
			neighborClimate.clouds += cloudDispersal;

			int elevationDelta = neighbor.Elevation - cell.Elevation;
			if (elevationDelta < 0) {
				cellClimate.moisture -= runoff;
				neighborClimate.moisture += runoff;
			}
			
			climate[neighbor.Index] = neighborClimate;
		}


Вода, стекающая на более низкую высоту.

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

			int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;


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

Просачивание


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

	[Range(0f, 1f)]
	public float seepageFactor = 0.125f;


Ползунок просачивания.

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

		float runoff = cellClimate.moisture * runoffFactor * (1f / 6f);
		float seepage = cellClimate.moisture * seepageFactor * (1f / 6f);
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			…

			int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
			if (elevationDelta < 0) {
				cellClimate.moisture -= runoff;
				neighborClimate.moisture += runoff;
			}
			else if (elevationDelta == 0) {
				cellClimate.moisture -= seepage;
				neighborClimate.moisture += seepage;
			}

			climate[neighbor.Index] = neighborClimate;
		}


Добавили немного просачивания.

unitypackage

Дождевые тени


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

Ветер


Давайте начнём с добавления в симуляцию доминирующего направления ветра. Хоть на поверхности Земли доминирующие направления ветра сильно варьируются, мы обойдёмся настраиваемым глобальным направлением ветра. Давайте по умолчанию используем северо-западный. Кроме того, давайте сделаем силу ветра настраиваемой в интервале от 1 до 10 со значением по умолчанию 4.

	public HexDirection windDirection = HexDirection.NW;
	
	[Range(1f, 10f)]
	public float windStrength = 4f;


Направление и сила ветра.

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

		float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));

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

		HexDirection mainDispersalDirection = windDirection.Opposite();
		float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));

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

			ClimateData neighborClimate = climate[neighbor.Index];
			if (d == mainDispersalDirection) {
				neighborClimate.clouds += cloudDispersal * windStrength;
			}
			else {
				neighborClimate.clouds += cloudDispersal;
			}


Северо-западный ветер, сила 4.

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

Абсолютная высота


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

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

		float precipitation = cellClimate.clouds * precipitationFactor;
		cellClimate.clouds -= precipitation;
		cellClimate.moisture += precipitation;

		float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f);
		
		HexDirection mainDispersalDirection = windDirection.Opposite();

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

		float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f);
		if (cellClimate.clouds > cloudMaximum) {
			cellClimate.moisture += cellClimate.clouds - cloudMaximum;
			cellClimate.clouds = cloudMaximum;
		}


Дождевые тени, вызванные большой высотой.

unitypackage

Завершаем симуляцию


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

Параллельное вычисление


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

	List<ClimateData> climate = new List<ClimateData>();
	List<ClimateData> nextClimate = new List<ClimateData>();

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

	void CreateClimate () {
		climate.Clear();
		nextClimate.Clear();
		ClimateData initialData = new ClimateData();
		for (int i = 0; i < cellCount; i++) {
			climate.Add(initialData);
			nextClimate.Add(initialData);
		}

		for (int cycle = 0; cycle < 40; cycle++) {
			for (int i = 0; i < cellCount; i++) {
				EvolveClimate(i);
			}
			List<ClimateData> swap = climate;
			climate = nextClimate;
			nextClimate = swap;
		}
	}

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

		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (!neighbor) {
				continue;
			}
			ClimateData neighborClimate = nextClimate[neighbor.Index];
			…

			nextClimate[neighbor.Index] = neighborClimate;
		}

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

//		cellClimate.clouds = 0f;

		ClimateData nextCellClimate = nextClimate[cellIndex];
		nextCellClimate.moisture += cellClimate.moisture;
		nextClimate[cellIndex] = nextCellClimate;
		climate[cellIndex] = new ClimateData();

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

		nextCellClimate.moisture += cellClimate.moisture;
		if (nextCellClimate.moisture > 1f) {
			nextCellClimate.moisture = 1f;
		}
		nextClimate[cellIndex] = nextCellClimate;


Параллельные вычисления.

Исходная влажность


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

	[Range(0f, 1f)]
	public float startingMoisture = 0.1f;


Вверху ползунок исходной влажности.

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

		ClimateData initialData = new ClimateData();
		initialData.moisture = startingMoisture;
		ClimateData clearData = new ClimateData();
		for (int i = 0; i < cellCount; i++) {
			climate.Add(initialData);
			nextClimate.Add(clearData);
		}


С исходной влажностью.

Задаём биомы


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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			float moisture = climate[i].moisture;
			if (!cell.IsUnderwater) {
				if (moisture < 0.2f) {
					cell.TerrainTypeIndex = 4;
				}
				else if (moisture < 0.4f) {
					cell.TerrainTypeIndex = 0;
				}
				else if (moisture < 0.6f) {
					cell.TerrainTypeIndex = 3;
				}
				else if (moisture < 0.8f) {
					cell.TerrainTypeIndex = 1;
				}
				else {
					cell.TerrainTypeIndex = 2;
				}
			}
			else {
				cell.TerrainTypeIndex = 2;
			}
			cell.SetMapData(moisture);
		}
	}


Биомы.

При использовании равномерного распределения результат получается не очень хорошим, и выглядит неестественно. Лучше использовать другие пороговые значения, например 0.05, 0.12, 0.28 и 0.85.

				if (moisture < 0.05f) {
					cell.TerrainTypeIndex = 4;
				}
				else if (moisture < 0.12f) {
					cell.TerrainTypeIndex = 0;
				}
				else if (moisture < 0.28f) {
					cell.TerrainTypeIndex = 3;
				}
				else if (moisture < 0.85f) {
					cell.TerrainTypeIndex = 1;
				}


Изменённые биомы.

unitypackage

Часть 26: биомы и реки


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

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

Туториал был создан с помощью Unity 2017.3.0p3.


Тепло и вода оживляют карту.

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


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

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

Почему иногда течение реки неверно?
В нашем методе TriangulateWaterShore есть ошибка, которая проявляется очень редко. Она возникает в конечной точке реки, после переворота направления течения. Проблема в том, что мы проверяем только, соответствует ли текущее направление направлению входящей реки. Когда мы работаем с началом реки, происходит ошибка. Решение заключается в том, чтобы проверять, действительно ли в ячейке есть входящая река. Я добавил это исправление в шестую часть туториала («Реки»).

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

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

Высокие ячейки с влажностью


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

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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
			float data =
				(float)(cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			cell.SetMapData(data);
		}
	}



Влажность и высота над уровнем воды. Большая карта номер 1208905299 с параметрами по умолчанию.

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

			float data =
				moisture * (cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			cell.SetMapData(data);


Веса для истоков рек.

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

			float data =
				moisture * (cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			if (data > 0.75f) {
				cell.SetMapData(1f);
			}
			else if (data > 0.5f) {
				cell.SetMapData(0.5f);
			}
			else if (data > 0.25f) {
				cell.SetMapData(0.25f);
			}
//			cell.SetMapData(data);


Категории весов истоков рек.

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

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

	void CreateRivers () {
		List<HexCell> riverOrigins = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (cell.IsUnderwater) {
				continue;
			}
			ClimateData data = climate[i];
			float weight =
				data.moisture * (cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			if (weight > 0.75f) {
				riverOrigins.Add(cell);
				riverOrigins.Add(cell);
			}
			if (weight > 0.5f) {
				riverOrigins.Add(cell);
			}
			if (weight > 0.25f) {
				riverOrigins.Add(cell);
			}
		}

		ListPool<HexCell>.Add(riverOrigins);
	}

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

	public void GenerateMap (int x, int z) {
		…
		CreateRegions();
		CreateLand();
		ErodeLand();
		CreateClimate();
		CreateRivers();
		SetTerrainType();
		…
	}

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

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
//			float data =
//				moisture * (cell.Elevation - waterLevel) /
//				(elevationMaximum - waterLevel);
//			if (data > 0.6f) {
//				cell.SetMapData(1f);
//			}
//			else if (data > 0.4f) {
//				cell.SetMapData(0.5f);
//			}
//			else if (data > 0.2f) {
//				cell.SetMapData(0.25f);
//			}
		}
	}

Очки рек


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

	[Range(0, 20)]
	public int riverPercentage = 10;


Ползунок процента рек.

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

	int cellCount, landCells;
	…
	
	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		landCells = landBudget;
		for (int guard = 0; guard < 10000; guard++) {
			…
		}
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
			landCells -= landBudget;
		}
	}

Внутри CreateRivers количество очков рек теперь можно вычислить так же, как мы это делаем в CreateLand.

	void CreateRivers () {
		List<HexCell> riverOrigins = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			…
		}

		int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f);
		
		ListPool<HexCell>.Add(riverOrigins);
	}

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

		int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f);
		while (riverBudget > 0 && riverOrigins.Count > 0) {
			int index = Random.Range(0, riverOrigins.Count);
			int lastIndex = riverOrigins.Count - 1;
			HexCell origin = riverOrigins[index];
			riverOrigins[index] = riverOrigins[lastIndex];
			riverOrigins.RemoveAt(lastIndex);
		}
		
		if (riverBudget > 0) {
			Debug.LogWarning("Failed to use up river budget.");
		}

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

	int CreateRiver (HexCell origin) {
		int length = 0;
		return length;
	}

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

		while (riverBudget > 0 && riverOrigins.Count > 0) {
			…

			if (!origin.HasRiver) {
				riverBudget -= CreateRiver(origin);
			}
		}

Текущие реки


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

	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		while (!cell.IsUnderwater) {
			HexDirection direction = (HexDirection)Random.Range(0, 6);
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}


Случайные реки.

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

	List<HexDirection> flowDirections = new List<HexDirection>();
	
	…
	
	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		while (!cell.IsUnderwater) {
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				if (!neighbor || neighbor.HasRiver) {
					continue;
				}
				flowDirections.Add(d);
			}

			HexDirection direction =
//				(HexDirection)Random.Range(0, 6);
				flowDirections[Random.Range(0, flowDirections.Count)];
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}

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

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

			if (flowDirections.Count == 0) {
				return length > 1 ? length : 0;
			}


Сохранённые реки.

Стекание вниз


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

				if (!neighbor || neighbor.HasRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}
				
				flowDirections.Add(d);


Реки, текущие вниз.

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

				if (delta > 0) {
					continue;
				}

				if (delta < 0) {
					flowDirections.Add(d);
					flowDirections.Add(d);
					flowDirections.Add(d);
				}
				flowDirections.Add(d);

Избегаем резких поворотов


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

	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		HexDirection direction = HexDirection.NE;
		while (!cell.IsUnderwater) {
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…

				if (delta < 0) {
					flowDirections.Add(d);
					flowDirections.Add(d);
					flowDirections.Add(d);
				}
				if (
					length == 1 ||
					(d != direction.Next2() && d != direction.Previous2())
				) {
					flowDirections.Add(d);
				}
				flowDirections.Add(d);
			}

			if (flowDirections.Count == 0) {
				return length > 1 ? length : 0;
			}

//			HexDirection direction =
			direction = flowDirections[Random.Range(0, flowDirections.Count)];
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}

Это значительно снижает вероятность появления рек-зигзагов, которые выглядят некрасиво.


Меньшее количество резких поворотов.

Слияние рек


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

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

				HexCell neighbor = cell.GetNeighbor(d);
//				if (!neighbor || neighbor.HasRiver) {
//					continue;
//				}
				if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}

				if (neighbor.HasOutgoingRiver) {
					cell.SetOutgoingRiver(d);
					return length;
				}



Реки до и после объединения.

Сохраняем расстояние


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

		while (riverBudget > 0 && riverOrigins.Count > 0) {
			int index = Random.Range(0, riverOrigins.Count);
			int lastIndex = riverOrigins.Count - 1;
			HexCell origin = riverOrigins[index];
			riverOrigins[index] = riverOrigins[lastIndex];
			riverOrigins.RemoveAt(lastIndex);

			if (!origin.HasRiver) {
				bool isValidOrigin = true;
				for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
					HexCell neighbor = origin.GetNeighbor(d);
					if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) {
						isValidOrigin = false;
						break;
					}
				}
				if (isValidOrigin) {
					riverBudget -= CreateRiver(origin);
				}
			}

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



Без сохраняемого расстояния и с ним.

Заканчиваем реку озером


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

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

		while (!cell.IsUnderwater) {
			int minNeighborElevation = int.MaxValue;
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
//				if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) {
//					continue;
//				}
				if (!neighbor) {
					continue;
				}

				if (neighbor.Elevation < minNeighborElevation) {
					minNeighborElevation = neighbor.Elevation;
				}

				if (neighbor == origin || neighbor.HasIncomingRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}

				…
			}

			…
		}

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

			if (flowDirections.Count == 0) {
//				return length > 1 ? length : 0;
				if (length == 1) {
					return 0;
				}

				if (minNeighborElevation >= cell.Elevation) {
					cell.WaterLevel = minNeighborElevation;
					if (minNeighborElevation == cell.Elevation) {
						cell.Elevation = minNeighborElevation - 1;
					}
				}
				break;
			}



Концы рек без озёр и с озёрами. В этом случае процент рек равен 20.

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

Дополнительные озёра


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

		while (!cell.IsUnderwater) {
			…
			
			if (minNeighborElevation >= cell.Elevation) {
				cell.WaterLevel = cell.Elevation;
				cell.Elevation -= 1;
			}
			
			cell = cell.GetNeighbor(direction);
		}



Без дополнительных озёр и с ними.

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

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

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

			if (
				minNeighborElevation >= cell.Elevation &&
				Random.value < extraLakeProbability
			) {
				cell.WaterLevel = cell.Elevation;
				cell.Elevation -= 1;
			}



Дополнительные озёра.

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

unitypackage

Температура


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

Температура и широта


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

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		return latitude;
	}

Определим температуру в SetTerrainType и используем её как данные карты.

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			float temperature = DetermineTemperature(cell);
			cell.SetMapData(temperature);
			float moisture = climate[i].moisture;
			…
		}
	}


Широта как температура, южное полушарие.

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

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

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


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

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

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);
		return temperature;
	}

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

Полушария


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

	public enum HemisphereMode {
		Both, North, South
	}

	public HemisphereMode hemisphere;


Выбор полушария.

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

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		if (hemisphere == HemisphereMode.Both) {
			latitude *= 2f;
			if (latitude > 1f) {
				latitude = 2f - latitude;
			}
		}
		else if (hemisphere == HemisphereMode.North) {
			latitude = 1f - latitude;
		}

		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);
		return temperature;
	}


Оба полушария.

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

Чем выше, тем холоднее


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

		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);

		temperature *= 1f - (cell.ViewElevation - waterLevel) /
			(elevationMaximum - waterLevel + 1f);

		return temperature;


Высота влияет на температуру.

Температурные колебания


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

	[Range(0f, 1f)]
	public float temperatureJitter = 0.1f;


Ползунок колебаний температуры.

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

		temperature *= 1f - (cell.ViewElevation - waterLevel) /
			(elevationMaximum - waterLevel + 1f);

		temperature +=
			(HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) *
			temperatureJitter;

		return temperature;



Колебания температуры со значениями 0.1 и 1.

Мы можем добавить на каждой карте небольшую вариативность к колебаниям, выбирая из четырёх каналов шума случайным образом. Зададим канал один раз в SetTerrainType, а затем индексируем каналы цвета в DetermineTemperature.

	int temperatureJitterChannel;
	
	…
	
	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		for (int i = 0; i < cellCount; i++) {
			…
		}
	}
	
	float DetermineTemperature (HexCell cell) {
		…

		float jitter =
			HexMetrics.SampleNoise(cell.Position * 0.1f)[temperatureJitterChannel];

		temperature += (jitter * 2f - 1f) * temperatureJitter;

		return temperature;
	}


Разные колебания температуры с максимальной силой.

unitypackage

Биомы


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

Матрица биомов


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

Ранее мы назначали типы рельефа на основании пяти интервалов влажности. Мы просто опустим самую сухую полосу до 0.05, а остальные сохраним. Для полос температуры мы используем 0.1, 0.3, 0.6 и выше. Для удобства зададим эти значения в статических массивах.

	static float[] temperatureBands = { 0.1f, 0.3f, 0.6f };
					
	static float[] moistureBands = { 0.12f, 0.28f, 0.85f };

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

	struct Biome {
		public int terrain;
		
		public Biome (int terrain) {
			this.terrain = terrain;
		}
	}

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

	static Biome[] biomes = {
		new Biome(0), new Biome(4), new Biome(4), new Biome(4),
		new Biome(0), new Biome(2), new Biome(2), new Biome(2),
		new Biome(0), new Biome(1), new Biome(1), new Biome(1),
		new Biome(0), new Biome(1), new Biome(1), new Biome(1)
	};


Матрица биомов с индексами одномерного массива.

Определение биома


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

	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			float temperature = DetermineTemperature(cell);
//			cell.SetMapData(temperature);
			float moisture = climate[i].moisture;
			if (!cell.IsUnderwater) {
//				if (moisture < 0.05f) {
//					cell.TerrainTypeIndex = 4;
//				}
//				…
//				else {
//					cell.TerrainTypeIndex = 2;
//				}
				int t = 0;
				for (; t < temperatureBands.Length; t++) {
					if (temperature < temperatureBands[t]) {
						break;
					}
				}
				int m = 0;
				for (; m < moistureBands.Length; m++) {
					if (moisture < moistureBands[m]) {
						break;
					}
				}
				Biome cellBiome = biomes[t * 4 + m];
				cell.TerrainTypeIndex = cellBiome.terrain;
			}
			else {
				cell.TerrainTypeIndex = 2;
			}
		}
	}


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

Настройка биомов


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

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

	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		int rockDesertElevation =
			elevationMaximum - (elevationMaximum - waterLevel) / 2;
		
		for (int i = 0; i < cellCount; i++) {
			…
			if (!cell.IsUnderwater) {
				…
				Biome cellBiome = biomes[t * 4 + m];

				if (cellBiome.terrain == 0) {
					if (cell.Elevation >= rockDesertElevation) {
						cellBiome.terrain = 3;
					}
				}

				cell.TerrainTypeIndex = cellBiome.terrain;
			}
			else {
				cell.TerrainTypeIndex = 2;
			}
		}
	}


Песчаные и каменистые пустыни.

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

				if (cellBiome.terrain == 0) {
					if (cell.Elevation >= rockDesertElevation) {
						cellBiome.terrain = 3;
					}
				}
				else if (cell.Elevation == elevationMaximum) {
					cellBiome.terrain = 4;
				}


Снежные шапки на максимальной высоте.

Растения


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

	struct Biome {
		public int terrain, plant;

		public Biome (int terrain, int plant) {
			this.terrain = terrain;
			this.plant = plant;
		}
	}

В самых холодных и сухих биомах вообще не будет растений. Во всём остальном, чем теплее и влажнее климат, тем больше растений. Второй столбец влажности получает всего первый уровень растений для самой жаркой строки, поэтому [0, 0, 0, 1]. Третий столбец увеличивает уровни на один, за исключением снега, то есть [0, 1, 1, 2]. А самый влажный столбец снова увеличивает их, то есть получается [0, 2, 2, 3]. Изменим массив biomes, добавив в него конфигурацию растений.

	static Biome[] biomes = {
		new Biome(0, 0), new Biome(4, 0), new Biome(4, 0), new Biome(4, 0),
		new Biome(0, 0), new Biome(2, 0), new Biome(2, 1), new Biome(2, 2),
		new Biome(0, 0), new Biome(1, 0), new Biome(1, 1), new Biome(1, 2),
		new Biome(0, 0), new Biome(1, 1), new Biome(1, 2), new Biome(1, 3)
	};


Матрица биомов с уровнями растений.

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

				cell.TerrainTypeIndex = cellBiome.terrain;
				cell.PlantLevel = cellBiome.plant;


Биомы с растениями.

Растения теперь выглядят иначе?
Я немного изменил масштаб большинства префабов растений, чтобы их было заметнее на расстоянии. Масштабы двух префабов низких растений равны (1, 2, 1) и (0.75, 1, 0.75). Для средних они равны (1.5, 3, 1.5) и (2, 1.5, 2). А для высоких — (2, 4.5, 2) и (2.5, 3, 2.5).

Также я сделал немного более тёмным цвет растений, чтобы он лучше сочетался с текстурами: (13, 114, 0).

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

				if (cellBiome.terrain == 4) {
					cellBiome.plant = 0;
				}
				else if (cellBiome.plant < 3 && cell.HasRiver) {
					cellBiome.plant += 1;
				}

				cell.TerrainTypeIndex = cellBiome.terrain;
				cell.PlantLevel = cellBiome.plant;


Изменённые растения.

Подводные биомы


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

	void SetTerrainType () {
			…
			if (!cell.IsUnderwater) {
				…
			}
			else {
				int terrain;
				if (cell.Elevation == waterLevel - 1) {
					terrain = 1;
				}
				else if (cell.Elevation >= waterLevel) {
					terrain = 1;
				}
				else if (cell.Elevation < 0) {
					terrain = 3;
				}
				else {
					terrain = 2;
				}
				cell.TerrainTypeIndex = terrain;
			}
		}
	}


Подводная вариативность.

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

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

				if (cell.Elevation == waterLevel - 1) {
					int cliffs = 0, slopes = 0;
					for (
						HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++
					) {
						HexCell neighbor = cell.GetNeighbor(d);
						if (!neighbor) {
							continue;
						}
						int delta = neighbor.Elevation - cell.WaterLevel;
						if (delta == 0) {
							slopes += 1;
						}
						else if (delta > 0) {
							cliffs += 1;
						}
					}
					terrain = 1;
				}

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

				if (cell.Elevation == waterLevel - 1) {
					int cliffs = 0, slopes = 0;
					for (
						HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++
					) {
						…
					}
					if (cliffs + slopes > 3) {
						terrain = 1;
					}
					else if (cliffs > 0) {
						terrain = 3;
					}
					else if (slopes > 0) {
						terrain = 0;
					}
					else {
						terrain = 1;
					}
				}



Вариативность побережья.

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

				if (terrain == 1 && temperature < temperatureBands[0]) {
					terrain = 2;
				}
				cell.TerrainTypeIndex = terrain;

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

unitypackage

Часть 27: сворачивание карты


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

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

Туториал создан с помощью Unity 2017.3.0p3.


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

Сворачивание карт


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

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

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

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

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

Опциональное сворачивание


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


Меню новой карты с опцией сворачивания.

Добавим в NewMapMenu поле для отслеживания выбора, а также метод для его изменения. Сделаем так, чтобы при изменении состояния переключателя вызывался этот метод.

	bool wrapping = true;

	…

	public void ToggleWrapping (bool toggle) {
		wrapping = toggle;
	}

Когда запрашивается новая карта, передаём значение опции сворачивания.

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

Изменим HexMapGenerator.GenerateMap так, чтобы он принимал этот новый аргумент, а затем передавал его в HexGrid.CreateMap.

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

code>HexGrid должен знать, выполняем ли мы сворачивание, поэтому добавим ему поле и заставим CreateMap задавать его. Другие классы должны изменять свою логику в зависимости от того, сворачивается ли сетка, поэтому сделаем поле общим. Кроме того, это позволяет задавать значение по умолчанию через инспектор.

	public int cellCountX = 20, cellCountZ = 15;
	
	public bool wrapping;

	…

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

		cellCountX = x;
		cellCountZ = z;
		this.wrapping = wrapping;
		…
	}

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

	void Awake () {
		…
		CreateMap(cellCountX, cellCountZ, wrapping);
	}
	
	…
	
	public void Load (BinaryReader reader, int header) {
		…
		if (x != cellCountX || z != cellCountZ) {
			if (!CreateMap(x, z, wrapping)) {
				return;
			}
		}

		…
	}


Переключатель сворачивания сетки, по умолчанию включен.

Сохранение и загрузка


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

	const int mapFileVersion = 5;

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

	public void Save (BinaryWriter writer) {
		writer.Write(cellCountX);
		writer.Write(cellCountZ);
		writer.Write(wrapping);

		…
	}

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

	public void Load (BinaryReader reader, int header) {
		ClearPath();
		ClearUnits();
		int x = 20, z = 15;
		if (header >= 1) {
			x = reader.ReadInt32();
			z = reader.ReadInt32();
		}
		bool wrapping = header >= 5 ? reader.ReadBoolean() : false;
		if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) {
			if (!CreateMap(x, z, wrapping)) {
				return;
			}
		}

		…
	}

Метрики сворачивания


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

	public static int wrapSize;

	public static bool Wrapping {
		get {
			return wrapSize > 0;
		}
	}

Нам нужно задавать размер сворачивания при каждом вызове HexGrid.CreateMap.

	public bool CreateMap (int x, int z, bool wrapping) {
		…
		this.wrapping = wrapping;
		HexMetrics.wrapSize = wrapping ? cellCountX : 0;
		…
	}

Так как эти данные не переживут рекомпиляции в режиме Play, зададим их и в OnEnable.

	void OnEnable () {
		if (!HexMetrics.noiseSource) {
			HexMetrics.noiseSource = noiseSource;
			HexMetrics.InitializeHashGrid(seed);
			HexUnit.unitPrefab = unitPrefab;
			HexMetrics.wrapSize = wrapping ? cellCountX : 0;
			ResetVisibility();
		}
	}

Ширина ячеек


При работе со сворачиваемыми картами нам часто придётся иметь дело с позициями по оси X, измеряемыми в ширине ячеек. Хоть для этого и можно использовать HexMetrics.innerRadius * 2f, удобнее было бы, если бы мы не должны были каждый раз добавлять умножение. Поэтому давайте добавим константу HexMetrics.innerDiameter.

	public const float innerRadius = outerRadius * outerToInner;

	public const float innerDiameter = innerRadius * 2f;

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

	void CreateCell (int x, int z, int i) {
		Vector3 position;
		position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter;
		…
	}

Во-вторых, в HexMapCamera при ограничении позиции камеры.

	Vector3 ClampPosition (Vector3 position) {
		float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
		position.x = Mathf.Clamp(position.x, 0f, xMax);

		…
	}

А также в HexCoordinates при преобразовании из позиции в координаты.

	public static HexCoordinates FromPosition (Vector3 position) {
		float x = position.x / HexMetrics.innerDiameter;
		…
	}

unitypackage

Центрирование карты


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

Столбцы фрагментов карты


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

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

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

	Transform[] columns;
	
	…
	
	void CreateChunks () {
		columns = new Transform[chunkCountX];
		for (int x = 0; x < chunkCountX; x++) {
			columns[x] = new GameObject("Column").transform;
			columns[x].SetParent(transform, false);
		}
		
		…
	}

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

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


Фрагменты, сгруппированные в столбцы.

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

	public bool CreateMap (int x, int z, bool wrapping) {
		…
		if (columns != null) {
			for (int i = 0; i < columns.Length; i++) {
				Destroy(columns[i].gameObject);
			}
		}

		…
	}

Телепортация столбцов


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

	public void CenterMap (float xPosition) {
		int centerColumnIndex = (int)
			(xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX));
	}

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

	int currentCenterColumnIndex = -1;
	
	…
	
	public bool CreateMap (int x, int z, bool wrapping) {
		…
		this.wrapping = wrapping;
		currentCenterColumnIndex = -1;
		…
	}
	
	…
	
	public void CenterMap (float xPosition) {
		int centerColumnIndex = (int)
			(xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX));
		
		if (centerColumnIndex == currentCenterColumnIndex) {
			return;
		}
		currentCenterColumnIndex = centerColumnIndex;
	}

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

		currentCenterColumnIndex = centerColumnIndex;

		int minColumnIndex = centerColumnIndex - chunkCountX / 2;
		int maxColumnIndex = centerColumnIndex + chunkCountX / 2;

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

		int minColumnIndex = centerColumnIndex - chunkCountX / 2;
		int maxColumnIndex = centerColumnIndex + chunkCountX / 2;

		Vector3 position;
		position.y = position.z = 0f;
		for (int i = 0; i < columns.Length; i++) {
			position.x = 0f;
			columns[i].localPosition = position;
		}

Для каждого столбца проверяем, меньше ли индекс минимального индекса. Если да, то он слишком далеко влево от центра. Он должен телепортироваться на другую сторону карты. Это можно выполнить, сделав его координату X равной ширине карты. Аналогично, если индекс столбца больше максимального индекса, то он слишком далеко вправо от центра, и должен телепортироваться в другую сторону.

		for (int i = 0; i < columns.Length; i++) {
			if (i < minColumnIndex) {
				position.x = chunkCountX *
					(HexMetrics.innerDiameter * HexMetrics.chunkSizeX);
			}
			else if (i > maxColumnIndex) {
				position.x = chunkCountX *
					-(HexMetrics.innerDiameter * HexMetrics.chunkSizeX);
			}
			else {
				position.x = 0f;
			}
			columns[i].localPosition = position;
		}

Перемещение камеры


Изменим HexMapCamera.AdjustPosition так, чтобы при работе с сворачиваемой картой он вместо ClampPosition вызывал WrapPosition. Сначала просто сделаем новый метод WrapPosition дубликатом ClampPosition, но с единственным отличием: в конце он будет вызывать CenterMap.

	void AdjustPosition (float xDelta, float zDelta) {
		…
		transform.localPosition =
			grid.wrapping ? WrapPosition(position) : ClampPosition(position);
	}

	…

	Vector3 WrapPosition (Vector3 position) {
		float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
		position.x = Mathf.Clamp(position.x, 0f, xMax);

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

		grid.CenterMap(position.x);
		return position;
	}

Чтобы карта сразу же была центрированной, вызовем в OnEnable метод ValidatePosition.

	void OnEnable () {
		instance = this;
		ValidatePosition();
	}


Перемещение влево и вправо при центрировании по камере.

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

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

	Vector3 WrapPosition (Vector3 position) {
//		float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
//		position.x = Mathf.Clamp(position.x, 0f, xMax);
		float width = grid.cellCountX * HexMetrics.innerDiameter;
		while (position.x < 0f) {
			position.x += width;
		}
		while (position.x > width) {
			position.x -= width;
		}

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

		grid.CenterMap(position.x);
		return position;
	}


Сворачиваемая камера движется вдоль карты.

Сворачиваемые текстуры шейдера


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

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

#define OUTER_TO_INNER 0.866025404
#define OUTER_RADIUS 10
#define CHUNK_SIZE_X 5
#define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER))

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

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

		#include "../HexMetrics.cginc"
		#include "../HexCellData.cginc"

		…

		float4 GetTerrainColor (Input IN, int index) {
			float3 uvw = float3(
				IN.worldPos.xz * (2 * TILING_SCALE),
				IN.terrain[index]
			);
			…
		}

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

		#include "HexMetrics.cginc"
		#include "HexCellData.cginc"

		…

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			float4 noise =
				tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE));
			…
		}

Наконец, в Water.cginc мы использовали 0.015 для пены и 0.025 для волн. Здесь мы снова можем заменить эти значения на удвоенный и утроенный масштаб тайлинга.

#include "HexMetrics.cginc"

float Foam (float shore, float2 worldXZ, sampler2D noiseTex) {
	shore = sqrt(shore) * 0.9;

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

	…
}

…

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

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

	…
}

unitypackage

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


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


Пробел на краю.

Сворачивание соседей


Чтобы триангулировать соединение восток-запад, нам нужно сделать ячейки на противоположных сторонах соседями друг друга. Пока мы этого не делаем, потому что в HexGrid.CreateCell связь E–W устанавливается с предыдущей ячейкой, только если её индекс по X больше нуля. Для сворачивания этой связи нам нужно соединить последнюю ячейку строки с первой ячейкой в той же строке при включенном сворачивании карты.

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

		if (x > 0) {
			cell.SetNeighbor(HexDirection.W, cells[i - 1]);
			if (wrapping && x == cellCountX - 1) {
				cell.SetNeighbor(HexDirection.E, cells[i - x]);
			}
		}
		…
	}

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


Соединения E–W.

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

		if (z > 0) {
			if ((z & 1) == 0) {
				cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]);
				if (x > 0) {
					cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]);
				}
				else if (wrapping) {
					cell.SetNeighbor(HexDirection.SW, cells[i - 1]);
				}
			}
			else {
				…
			}
		}


Соединения NE–SW.

Наконец, соединения SE–NW устанавливаются в конец каждой нечётной строки под первой. Эти ячейки должны соединяться с первой ячейкой предыдущей строки.

		if (z > 0) {
			if ((z & 1) == 0) {
				…
			}
			else {
				cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]);
				if (x < cellCountX - 1) {
					cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]);
				}
				else if (wrapping) {
					cell.SetNeighbor(
						HexDirection.SE, cells[i - cellCountX * 2 + 1]
					);
				}
			}
		}


Соединения SE–NW.

Сворачивание шума


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

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

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

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

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

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

		Vector4 sample = noiseSource.GetPixelBilinear(
			position.x * noiseScale,
			position.z * noiseScale
		);

		if (Wrapping && position.x < innerDiameter) {
			Vector4 sample2 = noiseSource.GetPixelBilinear(
				(position.x + wrapSize * innerDiameter) * noiseScale,
				position.z * noiseScale
			);
		}

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

		if (Wrapping && position.x < innerDiameter) {
			Vector4 sample2 = noiseSource.GetPixelBilinear(
				(position.x + wrapSize * innerDiameter) * noiseScale,
				position.z * noiseScale
			);
			sample = Vector4.Lerp(
				sample2, sample, position.x * (1f / innerDiameter)
			);
		}


Смешение шума, неидеальное решение

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

		if (Wrapping && position.x < innerDiameter * 1.5f) {
			Vector4 sample2 = noiseSource.GetPixelBilinear(
				(position.x + wrapSize * innerDiameter) * noiseScale,
				position.z * noiseScale
			);
			sample = Vector4.Lerp(
				sample2, sample, position.x * (1f / innerDiameter) - 0.5f
			);
		}


Правильное затухание.

Редактирование ячеек


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


Кисть урезается.

Чтобы исправить это, нам нужно сообщить HexCoordinates о сворачивании. Мы можем это сделать, согласовывая координату X в методе-конструкторе. Мы знаем, что осевая координата X получается из координаты X смещения вычитанием половины координаты Z. Можно воспользоваться этой информацией, чтобы выполнить обратное преобразование и проверить, меньше ли координата смещения нуля. Если это так, то мы имеем координату за восточной стороной несвёрнутой карты. Так как в каждом направлении мы телепортируем не больше половины карты, нам будет достаточно один раз прибавить к X размер сворачивания. А когда координата смещения больше размера сворачивания, нам нужно выполнять вычитание.

	public HexCoordinates (int x, int z) {
		if (HexMetrics.Wrapping) {
			int oX = x + z / 2;
			if (oX < 0) {
				x += HexMetrics.wrapSize;
			}
			else if (oX >= HexMetrics.wrapSize) {
				x -= HexMetrics.wrapSize;
			}
		}
		this.x = x;
		this.z = z;
	}

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

	public HexCell GetCell (Vector3 position) {
		position = transform.InverseTransformPoint(position);
		HexCoordinates coordinates = HexCoordinates.FromPosition(position);
//		int index =
//			coordinates.X + coordinates.Z * cellCountX + coordinates.Z / 2;
//		return cells[index];
		return GetCell(coordinates);
	}

Сворачивание побережья


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


Отсутствующий край воды.

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

	public int ColumnIndex { get; set; }

Присвоим этот индекс в HexGrid.CreateCell. Он просто равен координате смещения X, поделённой на размер фрагмента.

	void CreateCell (int x, int z, int i) {
		…
		cell.Index = i;
		cell.ColumnIndex = x / HexMetrics.chunkSizeX;
		…
	}

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

		Vector3 center2 = neighbor.Position;
		if (neighbor.ColumnIndex < cell.ColumnIndex - 1) {
			center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter;
		}
		else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) {
			center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter;
		}


Рёбра побережья, но нет углов.

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

		if (nextNeighbor != null) {
			Vector3 center3 = nextNeighbor.Position;
			if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) {
				center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter;
			}
			else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) {
				center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter;
			}
			Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ?
				HexMetrics.GetFirstWaterCorner(direction.Previous()) :
				HexMetrics.GetFirstSolidCorner(direction.Previous()));
			…
		}


Правильно сворачиваемое побережье.

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


Опция соединения восточной и западной сторон влияет и на генерацию карт. При сворачивании карты алгоритм генерации тоже должен сворачиваться. Это приведёт к созданию другой карты, но при использовании ненулевого Map Border X сворачивание неочевидно.



Большая карта 1208905299 с параметрами по умолчанию. Со сворачиванием и без него.

При сворачивании не имеет смысла использовать Map Border X. Но мы не можем просто отделаться от него, потому что при этом сольются регионы. При сворачивании вместо него мы можем просто использовать RegionBorder.

Изменим HexMapGenerator.CreateRegions, заменив во всех случаях mapBorderX на borderX. Эта новая переменная будет равна или regionBorder, или mapBorderX, в зависимости от значения опции сворачивания. Ниже я показал изменения только для первого случая.

		int borderX = grid.wrapping ? regionBorder : mapBorderX;
		MapRegion region;
		switch (regionCount) {
		default:
			region.xMin = borderX;
			region.xMax = grid.cellCountX - borderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			break;
		…
		}

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

		switch (regionCount) {
		default:
			if (grid.wrapping) {
				borderX = 0;
			}
			region.xMin = borderX;
			region.xMax = grid.cellCountX - borderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			break;
		case 2:
			if (Random.value < 0.5f) {
				…
			}
			else {
				if (grid.wrapping) {
					borderX = 0;
				}
				region.xMin = borderX;
				region.xMax = grid.cellCountX - borderX;
				region.zMin = mapBorderZ;
				region.zMax = grid.cellCountZ / 2 - regionBorder;
				regions.Add(region);
				region.zMin = grid.cellCountZ / 2 + regionBorder;
				region.zMax = grid.cellCountZ - mapBorderZ;
				regions.Add(region);
			}
			break;
		…
		}


Один регион сворачивания.

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



При отключении эрозии становится заметным шов на рельефе.

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

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

	public int DistanceTo (HexCoordinates other) {
//		return
//			((x < other.x ? other.x - x : x - other.x) +
//			(Y < other.Y ? other.Y - Y : Y - other.Y) +
//			(z < other.z ? other.z - z : z - other.z)) / 2;
		
		int xy =
			(x < other.x ? other.x - x : x - other.x) +
			(Y < other.Y ? other.Y - Y : Y - other.Y);

		return (xy + (z < other.z ? other.z - z : z - other.z)) / 2;
	}

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

		int xy =
			(x < other.x ? other.x - x : x - other.x) +
			(Y < other.Y ? other.Y - Y : Y - other.Y);

		if (HexMetrics.Wrapping) {
			other.x += HexMetrics.wrapSize;
			int xyWrapped =
				(x < other.x ? other.x - x : x - other.x) +
				(Y < other.Y ? other.Y - Y : Y - other.Y);
			if (xyWrapped < xy) {
				xy = xyWrapped;
			}
		}

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

		if (HexMetrics.Wrapping) {
			other.x += HexMetrics.wrapSize;
			int xyWrapped =
				(x < other.x ? other.x - x : x - other.x) +
				(Y < other.Y ? other.Y - Y : Y - other.Y);
			if (xyWrapped < xy) {
				xy = xyWrapped;
			}
			else {
				other.x -= 2 * HexMetrics.wrapSize;
				xyWrapped =
					(x < other.x ? other.x - x : x - other.x) +
					(Y < other.Y ? other.Y - Y : Y - other.Y);
				if (xyWrapped < xy) {
					xy = xyWrapped;
				}
			}
		}

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



Правильно сворачиваемый рельеф без эрозии и с эрозией.

unitypackage

Путешествуем по миру


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

Исследуемый шов


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


Шов карты нельзя исследовать.

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

		if (wrapping) {
			cell.Explorable = z > 0 && z < cellCountZ - 1;
		}
		else {
			cell.Explorable =
				x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1;
		}

Видимость объектов рельефа


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


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

Это происходит, потому что для режима сворачивания текстуры, использованной HexCellShaderData, задано ограничение (clamp). Чтобы решить проблему, достаточно изменить её clamp mode на repeat. Но нам это нужно сделать только для координат U, поэтому в Initialize зададим wrapModeU и wrapModeV по отдельности.

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

Отряды и столбцы


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


Отряд не переносится и находится на неверной стороне.

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

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

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

	public void MakeChildOfColumn (Transform child, int columnIndex) {
		child.SetParent(columns[columnIndex], false);
	}

Будем вызывать этот метод, когда задано свойство HexUnit.Location.

	public HexCell Location {
		…
		set {
			…
			Grid.MakeChildOfColumn(transform, value.ColumnIndex);
		}
	}

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

	IEnumerator TravelPath () {
		Vector3 a, b, c = pathToTravel[0].Position;
		yield return LookAt(pathToTravel[1].Position);

//		Grid.DecreaseVisibility(
//			currentTravelLocation ? currentTravelLocation : pathToTravel[0],
//			VisionRange
//		);
		if (!currentTravelLocation) {
			currentTravelLocation = pathToTravel[0];
		}
		Grid.DecreaseVisibility(currentTravelLocation, VisionRange);
		int currentColumn = currentTravelLocation.ColumnIndex;

		…
	}

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

		int currentColumn = currentTravelLocation.ColumnIndex;

		float t = Time.deltaTime * travelSpeed;
		for (int i = 1; i < pathToTravel.Count; i++) {
			…
			Grid.IncreaseVisibility(pathToTravel[i], VisionRange);

			int nextColumn = currentTravelLocation.ColumnIndex;
			if (currentColumn != nextColumn) {
				Grid.MakeChildOfColumn(transform, nextColumn);
				currentColumn = nextColumn;
			}

			…
		}

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


Скачки по через карту.

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

		for (int i = 1; i < pathToTravel.Count; i++) {
			currentTravelLocation = pathToTravel[i];
			a = c;
			b = pathToTravel[i - 1].Position;
//			c = (b + currentTravelLocation.Position) * 0.5f;
//			Grid.IncreaseVisibility(pathToTravel[i], VisionRange);

			int nextColumn = currentTravelLocation.ColumnIndex;
			if (currentColumn != nextColumn) {
				if (nextColumn < currentColumn - 1) {
					a.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
					b.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
				}
				else if (nextColumn > currentColumn + 1) {
					a.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
					b.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
				}
				Grid.MakeChildOfColumn(transform, nextColumn);
				currentColumn = nextColumn;
			}

			c = (b + currentTravelLocation.Position) * 0.5f;
			Grid.IncreaseVisibility(pathToTravel[i], VisionRange);

			…
		}


Движение со сворачиванием.

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

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

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

	IEnumerator LookAt (Vector3 point) {
		if (HexMetrics.Wrapping) {
			float xDistance = point.x - transform.localPosition.x;
			if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) {
				point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
			}
			else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) {
				point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
			}
		}

		…
	}

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

Я скачал последний пакет и получаю в режиме Play ошибки поворотов
Это происходит, потому что камера использует собственную ось Rotation. Необходимо добавить эту ось. Подробнее см. в части 5.

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

Я скачал последний пакет и он постоянно генерирует одну и ту же карту
Генератор настроен на использование одинакового seed (1208905299), который я использовал для большинства скриншотов. Чтобы он был случайным, отключите Use Fixed Seed.

unitypackage
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+17
Comments3

Articles

Change theme settings