[Перевод] Процедурно генерируемые карты мира на Unity C#, часть 3
Это третья статья из цикла о процедурно генерируемых с помощью Unity и C# картах мира. Цикл будет состоять из четырех статей.
Содержание
Часть 1:
Введение
Генерирование шума
Начало работы
Генерирование карты высот
Часть 2:
Свертывание карты на одной оси
Свертывание карты на обеих осях
Поиск соседних элементов
Битовые маски
Заливка
Часть 3 (эта статья):
Генерирование тепловой карты
Генерирование карты влажности
Генерирование рек
Часть 4:
Генерирование биомов
Генерирование сферических карт
Генерирование тепловой карты
Тепловая карта определяет температуру сгенерированного мира. Создаваемая нами тепловая карта будет основана на данных высоты и широты. Данные широты могут быть получены простым градиентом шума. Библиотека Accidental Noise предоставляет следующую функцию:
ImplicitGradient gradient = new ImplicitGradient (1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1);
Поскольку мы сворачиваем мир, в качестве градиента тепла нам будет достаточно одного градиента по оси Y.
Для генерирования текстуры тепловой карты добавим в класс TextureGenerator новую функцию. Она позволит нам визуально отслеживать изменения, происходящие с тепловой картой:
public static Texture2D GetHeatMapTexture(int width, int height, Tile[,] tiles)
{
var texture = new Texture2D(width, height);
var pixels = new Color[width * height];
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
pixels[x + y * width] = Color.Lerp(Color.blue, Color.red, tiles[x,y].HeatValue);
//darken the color if a edge tile
if (tiles[x,y].Bitmask != 15)
pixels[x + y * width] = Color.Lerp(pixels[x + y * width], Color.black, 0.4f);
}
}
texture.SetPixels(pixels);
texture.wrapMode = TextureWrapMode.Clamp;
texture.Apply();
return texture;
}
Наш градиент температур будет выглядеть примерно так:
Эти данные — отличное начало, потому что нам нужна теплая полоса по центру карты, аналогичная экватору Земли. Это будет основой тепловой карты, над которой мы начнем работать.
Теперь нам нужно назначить области HeatType (типов тепла), похожие на области HeightType (типов высот) из предыдущей части статьи.
public enum HeatType
{
Coldest,
Colder,
Cold,
Warm,
Warmer,
Warmest
}
Эти типы тепла мы сделаем настраиваемыми из Unity inspector с помощью новых переменных:
float ColdestValue = 0.05f;
float ColderValue = 0.18f;
float ColdValue = 0.4f;
float WarmValue = 0.6f;
float WarmerValue = 0.8f;
В LoadTiles на основании значения тепла мы назначим HeatType для каждого тайла.
// назначаем тип тепла
if (heatValue < ColdestValue)
t.HeatType = HeatType.Coldest;
else if (heatValue < ColderValue)
t.HeatType = HeatType.Colder;
else if (heatValue < ColdValue)
t.HeatType = HeatType.Cold;
else if (heatValue < WarmValue)
t.HeatType = HeatType.Warm;
else if (heatValue < WarmerValue)
t.HeatType = HeatType.Warmer;
else
t.HeatType = HeatType.Warmest;
Теперь мы можем добавить в класс TextureGenerator новые цвета для каждого HeatType:
// Цвета карты высот
private static Color Coldest = new Color(0, 1, 1, 1);
private static Color Colder = new Color(170/255f, 1, 1, 1);
private static Color Cold = new Color(0, 229/255f, 133/255f, 1);
private static Color Warm = new Color(1, 1, 100/255f, 1);
private static Color Warmer = new Color(1, 100/255f, 0, 1);
private static Color Warmest = new Color(241/255f, 12/255f, 0, 1);
public static Texture2D GetHeatMapTexture(int width, int height, Tile[,] tiles)
{
var texture = new Texture2D(width, height);
var pixels = new Color[width * height];
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
switch (tiles[x,y].HeatType)
{
case HeatType.Coldest:
pixels[x + y * width] = Coldest;
break;
case HeatType.Colder:
pixels[x + y * width] = Colder;
break;
case HeatType.Cold:
pixels[x + y * width] = Cold;
break;
case HeatType.Warm:
pixels[x + y * width] = Warm;
break;
case HeatType.Warmer:
pixels[x + y * width] = Warmer;
break;
case HeatType.Warmest:
pixels[x + y * width] = Warmest;
break;
}
//затемняем цвет, если тайл является граничным
if (tiles[x,y].Bitmask != 15)
pixels[x + y * width] = Color.Lerp(pixels[x + y * width], Color.black, 0.4f);
}
}
texture.SetPixels(pixels);
texture.wrapMode = TextureWrapMode.Clamp;
texture.Apply();
return texture;
}
Генерируя эту тепловую карту, мы получим следующее изображение:
Сейчас мы можем четко видеть назначенные области HeatType. Однако эти данные пока являются только полосами. Они не сообщают нам ничего, кроме данных о температуре на основании широты. В реальности температура зависит от множества факторов, поэтому мы смешаем с этим градиентным шумом фрактальный шум.
Добавим пару новых переменных и новый фрактал в Generator:
int HeatOctaves = 4;
double HeatFrequency = 3.0;
private void Initialize()
{
// Инициализируем тепловую карту
ImplicitGradient gradient = new ImplicitGradient (1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1);
ImplicitFractal heatFractal = new ImplicitFractal(FractalType.MULTI,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
HeatOctaves,
HeatFrequency,
Seed);
// Комбинируем градиент с тепловым фракталом
HeatMap = new ImplicitCombiner (CombinerType.MULTIPLY);
HeatMap.AddSource (gradient);
HeatMap.AddSource (heatFractal);
}
При комбинировании фрактала с градиентом с помощью операции умножения (Multiply) конечный шум умножается на основании широты. Операция Multiply проиллюстрирована ниже:
Слева — градиентный шум, в середине — фрактальный шум, справа — результат операции Multiply. Как видите, у нас получилась гораздо более приятная тепловая карта.
Теперь займемся широтой. Нам нужно учесть карту высот: мы хотим, чтобы пики самых высоких гор были холодными. Настроить это можно в функции LoadTiles:
// Настройка тепловой карты на основании высоты. Выше = холоднее
if (t.HeightType == HeightType.Grass) {
HeatData.Data[t.X, t.Y] -= 0.1f * t.HeightValue;
}
else if (t.HeightType == HeightType.Forest) {
HeatData.Data[t.X, t.Y] -= 0.2f * t.HeightValue;
}
else if (t.HeightType == HeightType.Rock) {
HeatData.Data[t.X, t.Y] -= 0.3f * t.HeightValue;
}
else if (t.HeightType == HeightType.Snow) {
HeatData.Data[t.X, t.Y] -= 0.4f * t.HeightValue;
}
Такая настройка дает нам окончательную тепловую карту, в которой учитываются и широта, и высота:
Генерирование карты влажности
Карта влажности похожа на тепловую карту. Сначала сгенерируем фрактал для заполнения основой из случайных значений. Затем мы изменим эти данные на основании тепловой карты.
Мы рассмотрим код создания влажности вкратце, потому что он очень похож на код тепловой карты.
Во-первых, дополним класс Tile новым MoistureType:
public enum MoistureType
{
Wettest,
Wetter,
Wet,
Dry,
Dryer,
Dryest
}
Классу Generator потребуются новые переменные, видимые из Unity Inspector:
int MoistureOctaves = 4;
double MoistureFrequency = 3.0;
float DryerValue = 0.27f;
float DryValue = 0.4f;
float WetValue = 0.6f;
float WetterValue = 0.8f;
float WettestValue = 0.9f;
В TextureGenerator необходимы новая функция генерирования карты влажности (MoistureMap) и связанные с ней цвета:
//Карта влажности
private static Color Dryest = new Color(255/255f, 139/255f, 17/255f, 1);
private static Color Dryer = new Color(245/255f, 245/255f, 23/255f, 1);
private static Color Dry = new Color(80/255f, 255/255f, 0/255f, 1);
private static Color Wet = new Color(85/255f, 255/255f, 255/255f, 1);
private static Color Wetter = new Color(20/255f, 70/255f, 255/255f, 1);
private static Color Wettest = new Color(0/255f, 0/255f, 100/255f, 1);
public static Texture2D GetMoistureMapTexture(int width, int height, Tile[,] tiles)
{
var texture = new Texture2D(width, height);
var pixels = new Color[width * height];
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
Tile t = tiles[x,y];
if (t.MoistureType == MoistureType.Dryest)
pixels[x + y * width] = Dryest;
else if (t.MoistureType == MoistureType.Dryer)
pixels[x + y * width] = Dryer;
else if (t.MoistureType == MoistureType.Dry)
pixels[x + y * width] = Dry;
else if (t.MoistureType == MoistureType.Wet)
pixels[x + y * width] = Wet;
else if (t.MoistureType == MoistureType.Wetter)
pixels[x + y * width] = Wetter;
else
pixels[x + y * width] = Wettest;
}
}
texture.SetPixels(pixels);
texture.wrapMode = TextureWrapMode.Clamp;
texture.Apply();
return texture;
}
И, наконец, функция LoadTiles будет устанавливать тип влажности (MoistureType) на основании значения влажности (MoistureValue):
//Анализ карты влажности
float moistureValue = MoistureData.Data[x,y];
moistureValue = (moistureValue - MoistureData.Min) / (MoistureData.Max - MoistureData.Min);
t.MoistureValue = moistureValue;
//назначение типа влажности
if (moistureValue < DryerValue) t.MoistureType = MoistureType.Dryest;
else if (moistureValue < DryValue) t.MoistureType = MoistureType.Dryer;
else if (moistureValue < WetValue) t.MoistureType = MoistureType.Dry;
else if (moistureValue < WetterValue) t.MoistureType = MoistureType.Wet;
else if (moistureValue < WettestValue) t.MoistureType = MoistureType.Wetter;
else t.MoistureType = MoistureType.Wettest;
При рендеринге исходного шума для MoistureMap мы получим следующее:
Единственное, что нам осталось — настроить карту влажности согласно карте высот. Мы сделаем это в функции LoadTiles:
//настройка влажности согласно высоте
if (t.HeightType == HeightType.DeepWater) {
MoistureData.Data[t.X, t.Y] += 8f * t.HeightValue;
}
else if (t.HeightType == HeightType.ShallowWater) {
MoistureData.Data[t.X, t.Y] += 3f * t.HeightValue;
}
else if (t.HeightType == HeightType.Shore) {
MoistureData.Data[t.X, t.Y] += 1f * t.HeightValue;
}
else if (t.HeightType == HeightType.Sand) {
MoistureData.Data[t.X, t.Y] += 0.25f * t.HeightValue;
}
После настройки карты влажности соответственно высоте определенных тайлов обновленная карта влажности выглядит намного лучше:
Генерирование рек
Способ генерирования рек, который я опишу — это попытка решить проблему создания убедительно выглядящих рек перебором.
Первый шаг алгоритма — выбор случайного тайла на карте. Выбранный тайл должен быть сушей и иметь значение высоты выше определенной границы.
Начиная с этого тайла, мы определяем, какой соседний тайл расположен ниже всех, и перемещаемся к нему. Таким образом мы создаем путь, пока не будет достигнут тайл воды.
Если сгенерированный путь соответствует нашим критериям (длина реки, число изгибов, количество пересечений), мы сохраняем его для дальнейшего использования.
В противном случае мы отбрасываем его и пробуем еще раз. Приведенный ниже код позволит нам начать:
private void GenerateRivers()
{
int attempts = 0;
int rivercount = RiverCount;
Rivers = new List ();
// Генерируем реки
while (rivercount > 0 && attempts < MaxRiverAttempts) {
// Получаем случайный тайл
int x = UnityEngine.Random.Range (0, Width);
int y = UnityEngine.Random.Range (0, Height);
Tile tile = Tiles[x,y];
// проверяем тайл
if (!tile.Collidable) continue;
if (tile.Rivers.Count > 0) continue;
if (tile.HeightValue > MinRiverHeight)
{
// Тайл подходит для начала реки
River river = new River(rivercount);
// Выясняем, в каком направлении попытается течь эта река
river.CurrentDirection = tile.GetLowestNeighbor ();
// Рекурсивно находим путь к воде
FindPathToWater(tile, river.CurrentDirection, ref river);
// Проверяем правильность сгенерированной реки
if (river.TurnCount < MinRiverTurns || river.Tiles.Count < MinRiverLength || river.Intersections > MaxRiverIntersections)
{
//Проверка не пройдена - отбрасываем эту реку
for (int i = 0; i < river.Tiles.Count; i++)
{
Tile t = river.Tiles[i];
t.Rivers.Remove (river);
}
}
else if (river.Tiles.Count >= MinRiverLength)
{
//Проверка пройдена - добавляем реку в список
Rivers.Add (river);
tile.Rivers.Add (river);
rivercount--;
}
}
attempts++;
}
}
Рекурсивная функция FindPathToWater () определяет наилучший выбираемый путь на основании высоты суши, уже существующих рек и предпочтительного направления. Рано или поздно она найдет выход к тайлу воды. Функция вызывается рекурсивно, пока путь не будет завершен.
private void FindPathToWater(Tile tile, Direction direction, ref River river)
{
if (tile.Rivers.Contains (river))
return;
// проверяем, нет ли уже реки на этом тайле
if (tile.Rivers.Count > 0)
river.Intersections++;
river.AddTile (tile);
// получаем соседние тайлы
Tile left = GetLeft (tile);
Tile right = GetRight (tile);
Tile top = GetTop (tile);
Tile bottom = GetBottom (tile);
float leftValue = int.MaxValue;
float rightValue = int.MaxValue;
float topValue = int.MaxValue;
float bottomValue = int.MaxValue;
// запрашиваем значения высот соседей
if (left.GetRiverNeighborCount(river) < 2 && !river.Tiles.Contains(left))
leftValue = left.HeightValue;
if (right.GetRiverNeighborCount(river) < 2 && !river.Tiles.Contains(right))
rightValue = right.HeightValue;
if (top.GetRiverNeighborCount(river) < 2 && !river.Tiles.Contains(top))
topValue = top.HeightValue;
if (bottom.GetRiverNeighborCount(river) < 2 && !river.Tiles.Contains(bottom))
bottomValue = bottom.HeightValue;
// если соседний тайл является другой, уже существующей рекой, вливаемся в нее
if (bottom.Rivers.Count == 0 && !bottom.Collidable)
bottomValue = 0;
if (top.Rivers.Count == 0 && !top.Collidable)
topValue = 0;
if (left.Rivers.Count == 0 && !left.Collidable)
leftValue = 0;
if (right.Rivers.Count == 0 && !right.Collidable)
rightValue = 0;
// перенаправляем поток, если тайл значительно ниже
if (direction == Direction.Left)
if (Mathf.Abs (rightValue - leftValue) < 0.1f)
rightValue = int.MaxValue;
if (direction == Direction.Right)
if (Mathf.Abs (rightValue - leftValue) < 0.1f)
leftValue = int.MaxValue;
if (direction == Direction.Top)
if (Mathf.Abs (topValue - bottomValue) < 0.1f)
bottomValue = int.MaxValue;
if (direction == Direction.Bottom)
if (Mathf.Abs (topValue - bottomValue) < 0.1f)
topValue = int.MaxValue;
// находим минимум
float min = Mathf.Min (Mathf.Min (Mathf.Min (leftValue, rightValue), topValue), bottomValue);
// если минимум не найден - выход
if (min == int.MaxValue)
return;
//Переходим к следующему соседу
if (min == leftValue) {
if (left.Collidable)
{
if (river.CurrentDirection != Direction.Left){
river.TurnCount++;
river.CurrentDirection = Direction.Left;
}
FindPathToWater (left, direction, ref river);
}
} else if (min == rightValue) {
if (right.Collidable)
{
if (river.CurrentDirection != Direction.Right){
river.TurnCount++;
river.CurrentDirection = Direction.Right;
}
FindPathToWater (right, direction, ref river);
}
} else if (min == bottomValue) {
if (bottom.Collidable)
{
if (river.CurrentDirection != Direction.Bottom){
river.TurnCount++;
river.CurrentDirection = Direction.Bottom;
}
FindPathToWater (bottom, direction, ref river);
}
} else if (min == topValue) {
if (top.Collidable)
{
if (river.CurrentDirection != Direction.Top){
river.TurnCount++;
river.CurrentDirection = Direction.Top;
}
FindPathToWater (top, direction, ref river);
}
}
}
После выполнения процесса генерирования рек у нас появится несколько путей, ведущих к воде. Это будет выглядеть примерно так:
Многие пути пересекаются, и если бы мы вырыли эти реки сейчас, они бы выглядели немного странно, потому что их размеры не совпадали бы в точке пересечения. Поэтому нам необходимо определить, какие из рек пересекаются, и сгруппировать их.
Нам потребуется класс RiverGroup:
public class RiverGroup
{
public List Rivers = new List();
}
А также код, группирующий пересекающиеся реки:
private void BuildRiverGroups()
{
//циклом проверяем каждый тайл, принадлежит ли он нескольким рекам
for (var x = 0; x < Width; x++) {
for (var y = 0; y < Height; y++) {
Tile t = Tiles[x,y];
if (t.Rivers.Count > 1)
{
// несколько рек == пересечение
RiverGroup group = null;
// Существует ли уже для этой группы группа рек?
for (int n=0; n < t.Rivers.Count; n++)
{
River tileriver = t.Rivers[n];
for (int i = 0; i < RiverGroups.Count; i++)
{
for (int j = 0; j < RiverGroups[i].Rivers.Count; j++)
{
River river = RiverGroups[i].Rivers[j];
if (river.ID == tileriver.ID)
{
group = RiverGroups[i];
}
if (group != null) break;
}
if (group != null) break;
}
if (group != null) break;
}
// найдена существующая группа -- добавляем к ней
if (group != null)
{
for (int n=0; n < t.Rivers.Count; n++)
{
if (!group.Rivers.Contains(t.Rivers[n]))
group.Rivers.Add(t.Rivers[n]);
}
}
else //существующая группа не найдена -- создаем новую
{
group = new RiverGroup();
for (int n=0; n < t.Rivers.Count; n++)
{
group.Rivers.Add(t.Rivers[n]);
}
RiverGroups.Add (group);
}
}
}
}
}
Итак, у нас есть группы рек, которые пересекаются и текут к воде. При рендеринге этих групп получается следующее, каждая группа представлена своим случайным цветом:
Имея эту информацию, мы можем начинать «рыть» наши реки. Для каждой группы рек мы начинаем с рытья самой длинной реки в группе. Оставшиеся реки роются на основании этого самого длинного пути.
Код ниже показывает, как мы начинаем рыть группы рек:
private void DigRiverGroups()
{
for (int i = 0; i < RiverGroups.Count; i++) {
RiverGroup group = RiverGroups[i];
River longest = null;
//Поиск самой длинной реки в этой группе
for (int j = 0; j < group.Rivers.Count; j++)
{
River river = group.Rivers[j];
if (longest == null)
longest = river;
else if (longest.Tiles.Count < river.Tiles.Count)
longest = river;
}
if (longest != null)
{
//Сначала роем самый длинный путь
DigRiver (longest);
for (int j = 0; j < group.Rivers.Count; j++)
{
River river = group.Rivers[j];
if (river != longest)
{
DigRiver (river, longest);
}
}
}
}
}
Код рытья реки немного сложнее, поскольку он пытается рандомизировать как можно больше параметров.
Также важно, чтобы река расширялась при приближении к воде. Код DigRiver () не очень красив, но справляется с задачей:
private void DigRiver(River river)
{
int counter = 0;
// Насколько широка будет эта река?
int size = UnityEngine.Random.Range(1,5);
river.Length = river.Tiles.Count;
// рандомизируем изменение размера
int two = river.Length / 2;
int three = two / 2;
int four = three / 2;
int five = four / 2;
int twomin = two / 3;
int threemin = three / 3;
int fourmin = four / 3;
int fivemin = five / 3;
// рандомизируем длину каждого размера
int count1 = UnityEngine.Random.Range (fivemin, five);
if (size < 4) {
count1 = 0;
}
int count2 = count1 + UnityEngine.Random.Range(fourmin, four);
if (size < 3) {
count2 = 0;
count1 = 0;
}
int count3 = count2 + UnityEngine.Random.Range(threemin, three);
if (size < 2) {
count3 = 0;
count2 = 0;
count1 = 0;
}
int count4 = count3 + UnityEngine.Random.Range (twomin, two);
// Проверяем, не роем мы уже после завершения пути реки
if (count4 > river.Length) {
int extra = count4 - river.Length;
while (extra > 0)
{
if (count1 > 0) { count1--; count2--; count3--; count4--; extra--; }
else if (count2 > 0) { count2--; count3--; count4--; extra--; }
else if (count3 > 0) { count3--; count4--; extra--; }
else if (count4 > 0) { count4--; extra--; }
}
}
// Роем реку
for (int i = river.Tiles.Count - 1; i >= 0 ; i--)
{
Tile t = river.Tiles[i];
if (counter < count1) {
t.DigRiver (river, 4);
}
else if (counter < count2) {
t.DigRiver (river, 3);
}
else if (counter < count3) {
t.DigRiver (river, 2);
}
else if ( counter < count4) {
t.DigRiver (river, 1);
}
else {
t.DigRiver(river, 0);
}
counter++;
}
}
После рытья рек мы получим нечто подобное:
Мы получили убедительно выглядящие реки, однако нам нужно убедиться, что они обеспечивают влажность карты. Реки не появляются в пустынных областях, поэтому нужно проверить, не является ли область вокруг рек сухой.
Для упрощения этого процесса добавим новую функцию, которая будет настраивать карту влажности на основании данных о реках.
private void AdjustMoistureMap()
{
for (var x = 0; x < Width; x++) {
for (var y = 0; y < Height; y++) {
Tile t = Tiles[x,y];
if (t.HeightType == HeightType.River)
{
AddMoisture (t, (int)60);
}
}
}
}
Добавленная влажность изменяется на основании расстояния от исходного тайла. Чем дальше от реки, тем меньше влажности получает тайл.
private void AddMoisture(Tile t, int radius)
{
int startx = MathHelper.Mod (t.X - radius, Width);
int endx = MathHelper.Mod (t.X + radius, Width);
Vector2 center = new Vector2(t.X, t.Y);
int curr = radius;
while (curr > 0) {
int x1 = MathHelper.Mod (t.X - curr, Width);
int x2 = MathHelper.Mod (t.X + curr, Width);
int y = t.Y;
AddMoisture(Tiles[x1, y], 0.025f / (center - new Vector2(x1, y)).magnitude);
for (int i = 0; i < curr; i++)
{
AddMoisture (Tiles[x1, MathHelper.Mod (y + i + 1, Height)], 0.025f / (center - new Vector2(x1, MathHelper.Mod (y + i + 1, Height))).magnitude);
AddMoisture (Tiles[x1, MathHelper.Mod (y - (i + 1), Height)], 0.025f / (center - new Vector2(x1, MathHelper.Mod (y - (i + 1), Height))).magnitude);
AddMoisture (Tiles[x2, MathHelper.Mod (y + i + 1, Height)], 0.025f / (center - new Vector2(x2, MathHelper.Mod (y + i + 1, Height))).magnitude);
AddMoisture (Tiles[x2, MathHelper.Mod (y - (i + 1), Height)], 0.025f / (center - new Vector2(x2, MathHelper.Mod (y - (i + 1), Height))).magnitude);
}
curr--;
}
}
Такая настройка дает нам обновленную карту влажности, учитывающую наличие рек. Она пригодится в следующей части, в которой мы начнем генерировать биомы.
Обновленная карта влажности будет выглядеть примерно так:
Скоро будет готова четвертая часть статьи. Это будет лучшая часть, в которой мы используем все сгенерированные нами карты для создания мира.
Исходники кода третьей части на github: World Generator Part 3.