[Перевод] Процедурно генерируемые карты мира на Unity C#, часть 4 (трафик)
Это последняя статья из цикла о процедурно генерируемых с помощью Unity и C# картах мира. Осторожно, под катом 7 МБ картинок.
Содержание
Часть 1:
Введение
Генерирование шума
Начало работы
Генерирование карты высот
Часть 2:
Свертывание карты на одной оси
Свертывание карты на обеих осях
Поиск соседних элементов
Битовые маски
Заливка
Часть 3:
Генерирование тепловой карты
Генерирование карты влажности
Генерирование рек
Часть 4 (эта статья):
Генерирование биомов
Генерирование сферических карт
Генерирование биомов
Биомы — это способ классификации типов земной поверхности. Наш генератор биомов будет основан на популярной модели Уиттекера, в которой биомы классифицируются по количеству осадков и температуре. Мы уже сгенерировали тепловую карту и карту влажности для нашего мира, поэтому определение биомов будет довольно просто выполнить. Схема классификации Уиттекера представлена на следующей иллюстрации:
Мы можем разделить различные типы биомов по заданной температуре и уровню влажности. Сначала создадим новое перечисление, в котором будут храниться эти типы биомов:
public enum BiomeType
{
Desert,
Savanna,
TropicalRainforest,
Grassland,
Woodland,
SeasonalForest,
TemperateRainforest,
BorealForest,
Tundra,
Ice
}
Затем нужно создать таблицу, которая поможет нам определить тип биома на основании температуры и влажности. У нас уже есть HeatType и MoistureType. Каждое из этих перечислений содержит 6 определенных типов. Для сопоставления каждого из этих типов со схемой Уиттекера создана следующая таблица:
Для удобства поиска этих данных в коде преобразуем таблицу в двухмерный массив. Он будет таким:
BiomeType[,] BiomeTable = new BiomeType[6,6] {
//COLDEST //COLDER //COLD //HOT //HOTTER //HOTTEST
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.Grassland, BiomeType.Desert, BiomeType.Desert, BiomeType.Desert }, //DRYEST
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.Grassland, BiomeType.Desert, BiomeType.Desert, BiomeType.Desert }, //DRYER
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.Woodland, BiomeType.Woodland, BiomeType.Savanna, BiomeType.Savanna }, //DRY
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.Woodland, BiomeType.Savanna, BiomeType.Savanna }, //WET
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.SeasonalForest, BiomeType.TropicalRainforest, BiomeType.TropicalRainforest }, //WETTER
{ BiomeType.Ice, BiomeType.Tundra, BiomeType.BorealForest, BiomeType.TemperateRainforest, BiomeType.TropicalRainforest, BiomeType.TropicalRainforest } //WETTEST
};
Чтобы еще больше упростить поиск, добавим новую функцию, возвращающую тип биома любого тайла. Эта часть довольно проста, ведь каждому тайлу уже назначен тип тепла и влажности.
public BiomeType GetBiomeType(Tile tile)
{
return BiomeTable [(int)tile.MoistureType, (int)tile.HeatType];
}
Эта проверка выполняется для каждого тайла и устанавливает области биомов для всей карты.
private void GenerateBiomeMap()
{
for (var x = 0; x < Width; x++) {
for (var y = 0; y < Height; y++) {
if (!Tiles[x, y].Collidable) continue;
Tile t = Tiles[x,y];
t.BiomeType = GetBiomeType(t);
}
}
}
Отлично, мы определили все биомы. Однако пока мы не можем их визуализировать. Следующим шагом будет назначение цвета каждому типу. Это позволит нам наглядно отобразить область каждого биома на изображении. Я выбрал следующие цвета:
Значения цветов вставлены в класс TextureGenerator вместе с кодом генерирования текстуры биомов:
//карта биомов
private static Color Ice = Color.white;
private static Color Desert = new Color(238/255f, 218/255f, 130/255f, 1);
private static Color Savanna = new Color(177/255f, 209/255f, 110/255f, 1);
private static Color TropicalRainforest = new Color(66/255f, 123/255f, 25/255f, 1);
private static Color Tundra = new Color(96/255f, 131/255f, 112/255f, 1);
private static Color TemperateRainforest = new Color(29/255f, 73/255f, 40/255f, 1);
private static Color Grassland = new Color(164/255f, 225/255f, 99/255f, 1);
private static Color SeasonalForest = new Color(73/255f, 100/255f, 35/255f, 1);
private static Color BorealForest = new Color(95/255f, 115/255f, 62/255f, 1);
private static Color Woodland = new Color(139/255f, 175/255f, 90/255f, 1);
public static Texture2D GetBiomeMapTexture(int width, int height, Tile[,] tiles, float coldest, float colder, float cold)
{
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++)
{
BiomeType value = tiles[x, y].BiomeType;
switch(value){
case BiomeType.Ice:
pixels[x + y * width] = Ice;
break;
case BiomeType.BorealForest:
pixels[x + y * width] = BorealForest;
break;
case BiomeType.Desert:
pixels[x + y * width] = Desert;
break;
case BiomeType.Grassland:
pixels[x + y * width] = Grassland;
break;
case BiomeType.SeasonalForest:
pixels[x + y * width] = SeasonalForest;
break;
case BiomeType.Tundra:
pixels[x + y * width] = Tundra;
break;
case BiomeType.Savanna:
pixels[x + y * width] = Savanna;
break;
case BiomeType.TemperateRainforest:
pixels[x + y * width] = TemperateRainforest;
break;
case BiomeType.TropicalRainforest:
pixels[x + y * width] = TropicalRainforest;
break;
case BiomeType.Woodland:
pixels[x + y * width] = Woodland;
break;
}
// Тайлы воды
if (tiles[x,y].HeightType == HeightType.DeepWater) {
pixels[x + y * width] = DeepColor;
}
else if (tiles[x,y].HeightType == HeightType.ShallowWater) {
pixels[x + y * width] = ShallowColor;
}
// рисуем реки
if (tiles[x,y].HeightType == HeightType.River)
{
float heatValue = tiles[x,y].HeatValue;
if (tiles[x,y].HeatType == HeatType.Coldest)
pixels[x + y * width] = Color.Lerp (IceWater, ColdWater, (heatValue) / (coldest));
else if (tiles[x,y].HeatType == HeatType.Colder)
pixels[x + y * width] = Color.Lerp (ColdWater, RiverWater, (heatValue - coldest) / (colder - coldest));
else if (tiles[x,y].HeatType == HeatType.Cold)
pixels[x + y * width] = Color.Lerp (RiverWater, ShallowColor, (heatValue - colder) / (cold - colder));
else
pixels[x + y * width] = ShallowColor;
}
// добавляем контур
if (tiles[x,y].HeightType >= HeightType.Shore && tiles[x,y].HeightType != HeightType.River)
{
if (tiles[x,y].BiomeBitmask != 15)
pixels[x + y * width] = Color.Lerp (pixels[x + y * width], Color.black, 0.35f);
}
}
}
texture.SetPixels(pixels);
texture.wrapMode = TextureWrapMode.Clamp;
texture.Apply();
return texture;
}
При рендеринге карт биомов получаются красивые сворачиваемые карты мира.
Генерирование сферических карт
До этого момента мы создавали миры, сворачиваемые по оси X и Y. Такие карты отлично подходят для игр, потому что данные легко рендерятся в игровую карту.
Если попытаться спроектировать такие сворачиваемые текстуры на сферу, они будут выглядеть странно. Чтобы наш мир мог накладываться на сферу, необходимо написать генератор сферических текстур. В этой части мы добавим такую функцию для сгенерированных нами миров.
Сферическое генерирование немного отличается от генерирования свертываемых карт, потому что требует других шумовых схем и наложения текстур. По этой причине мы разделим класс генератора на две ветви подклассов: WrappableWorldGenerator и SphericalWorldGenerator. Каждый из них будет наследовать базовый класс Generator.
Это позволит нам иметь общее функциональное ядро, предоставляющее расширенные возможности каждому типу генератора.
Исходный класс Generator, а также некоторые его функции станут абстрактными:
protected abstract void Initialize();
protected abstract void GetData();
protected abstract Tile GetTop(Tile tile);
protected abstract Tile GetBottom(Tile tile);
protected abstract Tile GetLeft(Tile tile);
protected abstract Tile GetRight(Tile tile);
Имеющиеся у нас функции Initialize () и GetData () были созданы для сворачиваемых миров, поэтому для сферического генератора нужно написать новые. Также мы создадим новые классы получения тайлов, потому что свертывание будет происходит на оси X со сферическим проецированием.
Инициализация шума происходит способом, похожим на ранее описанный, за исключением одного главного отличия. Тепловая карта в новом генераторе не будет свертываться на оси Y. Поэтому мы не можем создать правильный градиент для умножения. Нам придется делать это вручную во время генерирования данных.
protected override void Initialize()
{
HeightMap = new ImplicitFractal (FractalType.MULTI,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
TerrainOctaves,
TerrainFrequency,
Seed);
HeatMap = new ImplicitFractal(FractalType.MULTI,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
HeatOctaves,
HeatFrequency,
Seed);
MoistureMap = new ImplicitFractal (FractalType.MULTI,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
MoistureOctaves,
MoistureFrequency,
Seed);
}
Функция GetData изменится значительно. Мы вернемся к сэмплированию трехмерного шума. Шум будет сэмплироваться на основании системы координат с широтой и долготой.
Я посмотрел, как выполнили сферическое проецирование в libnoise, и использовал ту же концепцию. Основной код, преобразующий координаты широты и долготы в декартовы координаты трехмерной сферической карты, будет следующим:
void LatLonToXYZ(float lat, float lon, ref float x, ref float y, ref float z)
{
float r = Mathf.Cos (Mathf.Deg2Rad * lon);
x = r * Mathf.Cos (Mathf.Deg2Rad * lat);
y = Mathf.Sin (Mathf.Deg2Rad * lon);
z = r * Mathf.Sin (Mathf.Deg2Rad * lat);
}
Функция GetData циклически переберет все координаты, используя этот способ преобразования для генерирования данных карты. С помощью этого способа мы создаем данные тепла, высоты и влажности. Карта биомов генерируется так же, как и раньше — из конечных тепловой карты и карты влажности.
protected override void GetData()
{
HeightData = new MapData (Width, Height);
HeatData = new MapData (Width, Height);
MoistureData = new MapData (Width, Height);
// Указываем область нашей карты по широте/долготе
float southLatBound = -180;
float northLatBound = 180;
float westLonBound = -90;
float eastLonBound = 90;
float lonExtent = eastLonBound - westLonBound;
float latExtent = northLatBound - southLatBound;
float xDelta = lonExtent / (float)Width;
float yDelta = latExtent / (float)Height;
float curLon = westLonBound;
float curLat = southLatBound;
// Циклически перебираем все тайлы с помощью их координат широты/долготы
for (var x = 0; x < Width; x++) {
curLon = westLonBound;
for (var y = 0; y < Height; y++) {
float x1 = 0, y1 = 0, z1 = 0;
// Преобразуем широту и долготу в x, y, z
LatLonToXYZ (curLat, curLon, ref x1, ref y1, ref z1);
// Тепловые данные
float sphereValue = (float)HeatMap.Get (x1, y1, z1);
if (sphereValue > HeatData.Max)
HeatData.Max = sphereValue;
if (sphereValue < HeatData.Min)
HeatData.Min = sphereValue;
HeatData.Data [x, y] = sphereValue;
// Настройка тепла на основании широты
float coldness = Mathf.Abs (curLon) / 90f;
float heat = 1 - Mathf.Abs (curLon) / 90f;
HeatData.Data [x, y] += heat;
HeatData.Data [x, y] -= coldness;
// Данные высоты
float heightValue = (float)HeightMap.Get (x1, y1, z1);
if (heightValue > HeightData.Max)
HeightData.Max = heightValue;
if (heightValue < HeightData.Min)
HeightData.Min = heightValue;
HeightData.Data [x, y] = heightValue;
// Данные влажности
float moistureValue = (float)MoistureMap.Get (x1, y1, z1);
if (moistureValue > MoistureData.Max)
MoistureData.Max = moistureValue;
if (moistureValue < MoistureData.Min)
MoistureData.Min = moistureValue;
MoistureData.Data [x, y] = moistureValue;
curLon += xDelta;
}
curLat += yDelta;
}
}
Мы получаем, соответственно, карту высот, тепловую карту, карту влажности и карту биомов:
Заметьте, что карты изгибаются возле углов. Это сделано специально, так работает сферическое проецирование. Давайте применим текстуру биомов для сферы и посмотрим, что получится:
Неплохое начало. Обратите внимание, наша карта высот стала черно-белой. Мы сделали это для того, чтобы использовать карту высот в качестве шейдера сферы. Для лучшего эффекта нам необходимо рельефная текстура, поэтому мы сначала отрендерим черно-белую текстуру, отображающую нужные нам смещения. Эта текстура затем будет преобразована в рельефную текстуру с помощью следующего кода:
public static Texture2D CalculateBumpMap(Texture2D source, float strength)
{
Texture2D result;
float xLeft, xRight;
float yUp, yDown;
float yDelta, xDelta;
var pixels = new Color[source.width * source.height];
strength = Mathf.Clamp(strength, 0.0F, 10.0F);
result = new Texture2D(source.width, source.height, TextureFormat.ARGB32, true);
for (int by = 0; by < result.height; by++)
{
for (int bx = 0; bx < result.width; bx++)
{
xLeft = source.GetPixel(bx - 1, by).grayscale * strength;
xRight = source.GetPixel(bx + 1, by).grayscale * strength;
yUp = source.GetPixel(bx, by - 1).grayscale * strength;
yDown = source.GetPixel(bx, by + 1).grayscale * strength;
xDelta = ((xLeft - xRight) + 1) * 0.5f;
yDelta = ((yUp - yDown) + 1) * 0.5f;
pixels[bx + by * source.width] = new Color(xDelta, yDelta, 1.0f, yDelta);
}
}
result.SetPixels(pixels);
result.wrapMode = TextureWrapMode.Clamp;
result.Apply();
return result;
}
Передав этой функции левую текстуру, мы получим рельефную текстуру, изображенную справа:
Теперь если мы применим эту рельефную карту вместе с картой высот через стандартный шейдер к нашей сфере, мы получим следующее:
Чтобы еще улучшить изображение, мы добавим пару слоев облаков. Сгенерировать облака с помощью шума очень просто, так почему бы и нет. Мы используем модуль волнового (billow) шума для создания облаков.
Добавим два слоя облаков, чтобы придать им глубины. Код генератора облачного шума представлен ниже:
Cloud1Map = new ImplicitFractal(FractalType.BILLOW,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
5,
1.65f,
Seed);
Cloud2Map = new ImplicitFractal (FractalType.BILLOW,
BasisType.SIMPLEX,
InterpolationType.QUINTIC,
6,
1.75f,
Seed);
Мы используем данные таким же способом. Генератор текстур облаков — это простой линейный интерполятор (lerp) от белого до прозрачного белого. Мы отсекаем облака до установленного значения, делая все остальное прозрачным. Код генератора текстур облаков имеет следующий вид:
public static Texture2D GetCloudTexture(int width, int height, Tile[,] tiles, float cutoff)
{
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++)
{
if (tiles[x,y].CloudValue > cutoff)
pixels[x + y * width] = Color.Lerp(new Color(1f, 1f, 1f, 0), Color.white, tiles[x,y].CloudValue);
else
pixels[x + y * width] = new Color(0,0,0,0);
}
}
texture.SetPixels(pixels);
texture.wrapMode = TextureWrapMode.Clamp;
texture.Apply();
return texture;
}
Создадим с его помощью две различные текстуры облаков. Эти текстуры тоже создаются для сферического проецирования, поэтому имеют изгибы по краям:
Теперь добавим два сферических меша немного большего размера, чем исходная сфера. Применив текстуры облаков к стандартному шейдеру с эффектом затухания (fade), мы получим красиво выглядящую облачность:
В конце я привожу скриншот всех сгенерированных текстур, использованных для создания финального рендера планеты:
На этом серия статей заканчивается. Исходный код всего проекта на github: World Generator Final.