Бесплатный левел-дизайн, или как строить ландшафт в реальном времени на UE4

Всем привет! Меня зовут Влад Маркелов, и сегодня я расскажу, как можно бесплатно и быстро создавать огромные игровые уровни и как в реальном времени строить ландшафт по информации из Интернета.

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

Немного предыстории

В геймдев меня затянуло в 2013 году — с тех пор так оттуда и не выбрался. Если конкретнее, изначально меня занесло в модостроение: с 2016 года я работал с командой мода над стартапом, как раз на UE4. Тогда версия движка была еще 4.12 — как будто вечность прошла с того момента. Параллельно я фрилансил, по большей части тоже на Unreal Engine. Таким образом, уже 5 лет я не расстаюсь с ним. В прошлом году ушел во «взрослый» геймдев: сначала в 1C Entertaiment, ну а сейчас я занимаю должность старшего программиста в MY.GAMES. 

С детства я любил видеоигры — особенно с большим открытым миром. Skyrim, «Ведьмак», GTA. Иди, куда хочешь, смотри, на что хочешь, — полная свобода. Ну, или почти полная. 

И я всегда мечтал об игре, где будет доступен весь мир. Прямо весь. Мечтал создать свою «GTA на Android», где можно было бы сесть на самолет «Лос-Анджелес-Москва» и выпрыгнуть с парашютом где-то над Парижем. Ну, знаете, эти детские фантазии. Разумеется, тогда я не понимал ни масштабов нашей планеты, ни масштабов работы, требующейся для ее детального отображения в игре.

Гораздо позже пришло осознание, что если это и возможно, то в не той детализации. Яркий пример тому — игра Microsoft Flight Simulator. Хоть карта в ней была сделана и не полностью процедурно, детализация при ближайшем рассмотрении оставляет желать лучшего — за исключением городов и особо красивых природных мест, да и те хорошо выглядят только с высоты низкого полета, порядка нескольких сотен метров. Но и эти масштабы работы уже впечатляют. 

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

Однако, если мы готовы пожертвовать качеством на некотором масштабе — пусть даже на той же высоте полета, — мы можем воспроизвести любую местность на планете: хоть всю планету целиком, благо данных в Интернете более чем достаточно. Отличным примером может послужить и тот же Microsoft Flight Simulator, либо Google Earth, которая строит 3D-ландшафт из открытых данных. Как правило, они захватываются со спутника и практически не подвергаются ручной обработке. И раз эти данные есть, мы можем их получить и построить свой ландшафт, с блэкджеком и лодами, ограниченный в масштабах лишь мощностью компьютера. 

Вводные данные: что будем делать

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

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

И тут и возникла идея: почему бы не скачать ландшафт из Интернета? 

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

Но! При небольшой доработке этот метод можно использовать не только для загрузки ландшафта в реальном времени, но и для построения обычного ландшафта в редакторе Unreal Engine 4. 

Примеры генерации ландшафта через математический шум
Примеры генерации ландшафта через математический шум

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

Представление данных о планете Земля — как оно бывает

Итак, мы знаем, что все данные о планете хранятся в радиальных координатах. А точнее — в системе мировых геодезических координат WGS 84.

Казалось бы, у нас есть два угла. Мы знаем радиус  Земли. И, как в 9-ом классе, умножив синусы углов на радиус, мы получим координату в привычных XYZ-координатах. Но не все так просто: 

  • во-первых, радиус Земли сильно различается в разных точках планеты;

  • во-вторых, таким образом мы получим поверхность шара.

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

Плоский ландшафт
Плоский ландшафт

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

Пример проекции Меркатора
Пример проекции Меркатора

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

Тут и появляется новая проблема — искривление. 

Для наглядности приведу довольно известный пример с расстояниями на Google Maps. На рисунке ниже расстояние, проведенное по кратчайшему пути на 2D-проекции, равно примерно 10 000 км, а вот кратчайшее расстояние, которое Google Maps строит автоматически, составляет уже 9 000 км. То есть, расстояние, проведенное по прямой на глобусе, отличается более чем на 10% от расстояния, проведенного по 2D-карте.

Если бы размер стран на плоской карте совпадал с реальным, Гренландия оказалась бы в три раза больше Австралии, а крошечная Новая Зеландия поравнялась бы с Германией. Ну а размер Антарктиды просто поражает воображение! На ней могла бы уместиться вся остальная суша целиком. А как вам острова Канады в Северном Ледовитом океане? Суммарно они по площади примерно как Колумбия, но на плоской карте готовы потягаться со всей Южной Америкой.

Может показаться, что эти изменения видно только в масштабах целой планеты, но на деле погрешность в десятки сантиметров заметна уже после пары километров — да и погрешность float тоже никуда не исчезает. Думаю, если вы сталкивались с большими игровыми мирами, вам это очень знакомо. В таком случае каждые 2-3 километра передвижения игрока нужно менять опорную точку и, соответственно, центр мира, таким образом повышая точность вокруг текущей игровой зоны, доступной игроку — благо в Unreal Engine 4 это делается парой строчек кода. 

Искажение размеров при проекции
Искажение размеров при проекции

Однако, с помощью Меркатора мы можем перевести координаты из WGS84 в XY-координаты вокруг точки опоры и с этим работать дальше. 

Загрузка данных о ландшафте в Unreal Engine 4

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

В качестве источника текстур я выбрал открытые, быстрые и гибкие Google-карты. Они хранятся в так называемых тайлах в разном масштабе. Вычисление координат тайлов также не является секретом: в Интернете можно найти и документацию, и реализации на разных языках программирования. 

Код
#define M_PI 3.1415926

#define TileSize 256.
#define InitRes (2. * M_PI * 6378137. / TileSize)
#define OriginShift (2. * M_PI * 6378137. / 2.0)

static inline void PixelsToTile(const double px, const double py, int& tx, int &ty)
{
	tx = ceil(px / TileSize) - 1.;
	ty = ceil(py / TileSize) - 1.;
}

static inline void WGS84ToMeters(const double Lat, const double Lon, double &mx, double &my)
{
	mx = Lon * OriginShift / 180.;
	my = log(tan((90. + Lat) * M_PI / 360.)) / (M_PI / 180.);
	
	my = my * OriginShift / 180.;
}

static inline double Resolution(const int Zoom)
{
	return InitRes / pow(2., Zoom);
}

static inline void MetersToPixels(const double mx, const double my, double &px, double &py, const int Zoom)
{
	const double res = Resolution( Zoom );
	px = (mx + OriginShift) / res;
	py = (my + OriginShift) / res;
}

static inline void MetersToWGS84(const double mx, const double my, double &Lat, double &Lon)
{
	Lon = (mx / OriginShift) * 180.;
	Lat = (my / OriginShift) * 180.;

	Lat = 180. / M_PI * (2. * atan( exp( Lat * M_PI / 180.)) - M_PI / 2.);
}

static inline void PixelsToMeters(const double px, const double py, double &mx, double &my, const int Zoom)
{
	const double res = Resolution( Zoom );
	mx = px * res - OriginShift;
	my = py * res - OriginShift;
}

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

Код
static inline void TileBounds(const int tx, const int ty, double &MinX, double &MinY, double &MaxX, double &MaxY, const int Zoom)
{
	PixelsToMeters(double(tx) * TileSize,		double(ty) * TileSize,		MinX, MinY, Zoom);
	PixelsToMeters(double(tx + 1) * TileSize,	double(ty + 1) * TileSize,	MaxX, MaxY, Zoom);
}

static inline void WGS84Bounds(const int tx, const int ty, double &MinLat, double &MinLon, double &MaxLat, double &MaxLon, const int Zoom)
{
	double MinX, MinY, MaxX, MaxY;
	TileBounds( tx, ty, MinX, MinY, MaxX, MaxY, Zoom);
	MetersToWGS84(MinX, MinY, MinLat, MinLon);
	MetersToWGS84(MaxX, MaxY, MaxLat, MaxLon);
}

static inline void TileTMSToGoogle(const int tx, const int ty, int &GoogleX, int &GoogleY, const int Zoom)
{
	GoogleX = tx;
	GoogleY = (pow(2.,Zoom) - 1.) - ty;
}

static inline void WGS84ToTile(const double Lat, const double Lon, int& tx, int &ty, const int Zoom)
{
	double mx, my;
	double px, py;
	WGS84ToMeters(Lat,Lon,mx,my);
	MetersToPixels(mx,my,px,py,Zoom);
	PixelsToTile(px, py, tx, ty);
}

Но большую часть этого кода в конечном итоге мы использовать не будем — сочтем его просто компьютерной магией. В конце концов, нас интересуют лишь две функции: WGS84Bounds и WGS84ToTile.

Теперь, зная широту и долготу, мы можем вычислить нужный тайл. Далее через API Google Maps мы можем его загрузить:

const double Lat = 41.85; 
const double Lon = -87.65; 
const int32 Zoom = 3;

int32 TileX, TileY;
TileTMSToGoogle(TileX, TileY, TileX, TileY, Zoom);
WGS84ToTile(Lat, Lon, TileXx, TileY, Zoom)

FString Path(L"https://mt1.google.com/vt/lyrs=s&x=" + FString::FromInt(TileX) + L"&y=" + FString::FromInt(TileY) + L"&z=" + FString::FromInt(Zoom));

// no lyrs for default
// lyrs=s for satellite
// lyrs=y for hybrid

Кроме того, в зависимости от наших нужд мы можем загрузить разные слои карты: схему, спутник или гибрид. В дальнейших примерах мы будем загружать именно снимки со спутника. Ну а для загрузки большего куска карты просто итерируем номера тайлов, пока не загрузим достаточное их количество. То есть, если вокруг некой широты и долготы нам нужно загрузить два тайла в каждую сторону, мы вычисляем центральный тайл и проходимся по двойному циклу от X–2 и Y–2 до X+2 и Y+2. 

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

Через UAsyncTaskDownloadImage мы загружаем картинку:

UAsyncTaskDownloadImage* DownloadTask = NewObject();
DownloadTask->OnSuccess.AddDynamic(this, &ThisClass::OnWebSuccess);
DownloadTask->OnFail.AddDynamic(this, &ThisClass::OnWebFail);
DownloadTask->Start(Path); // Web or local path to tile image

Хотя в теории Unreal Engine 4 позволяет нам отправить сразу все тайлы на загрузку одновременно, скорость соединения и ограничения API не дадут нам этого сделать. Только если запустить сразу несколько загрузок — тогда может прокатить. 

Также для удобства можно написать наследника этого таска, который будет содержать больше информации о тайле. Однако, для универсальности мы сейчас говорим о базовых возможностях UE4. Движок сразу преобразует загруженный таском тайл в нужный нам texture 2D dynamic — наследника UTexture, который мы можем применить к динамическому материалу. Эту текстуру мы получим из делегата OnSuccess.

Асинхронно загружаем нужный тайл с диска или из интернета, применяем его к динамическому материалу

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

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

Пример кода приведен ниже: как вы видите, пришлось несколько ухищряться через render target, поскольку Unreal Engine 4 не поддерживает прямой экспорт динамической текстуры, в отличие от обычной.

//UTexture2DDynamic* Texture; // Got from OnWebSuccess
UTextureRenderTarget2D* RenderTarget = UKismetRenderingLibrary::CreateRenderTarget2D(this, Texture->SizeX, Texture->SizeY, RTF_RGBA8);

UCanvas* Canvas;
FVector2D Size;
FDrawToRenderTargetContext Context;
UKismetRenderingLibrary::BeginDrawCanvasToRenderTarget(this, RenderTarget, Canvas, Size, Context);

FCanvasTileItem TileItem(FVector2D::ZeroVector, Texture->Resource, Size, FColor::White);
TileItem.PivotPoint = FVector2D(0.5f);

Canvas->DrawItem(TileItem);

UKismetRenderingLibrary::EndDrawCanvasToRenderTarget(this, Context);
UKismetRenderingLibrary::ExportRenderTarget(this, RenderTarget, TEXT("SomePath/"), TEXT("SomTile.png"));

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

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

Пример такого материала можно посмотреть ниже. В нем как раз используется один тайл с маленьким зумом на сетку 8×8 из маленьких тайлов — то есть, одна картинка на 64 тайла.

Рисунок ниже показывает смену тайла с разрешением 19 на тайл с разрешением 16 при удалении от объекта:

А вот, кстати, и площадь Европы, загруженная в реальном времени из движка лишь по двум числам:

Но плоскую карту мы можем увидеть и в Google Maps — такое нам не интересно. Поэтому пора загружать карту высот. 

Загружаем информацию о высоте

Воспользуемся сервисом Airmap. Это довольно глобальная платформа для получения информации об объектах в воздухе, опасных зонах и многом другом. Но нам интересно именно elevation API, которое и поможет нам получить информацию о высоте любой точки Земли. У Google есть свой аналог, и, возможно, он даже лучше, но он платный. Им я не пользовался, так что ни рекомендовать, ни предостерегать не буду. 

У выбранного API довольно скромный набор запросов, но для ваших целей хватает:

  • Запрос для получения массива высот по массиву координат — в нашем случае с ним придется перебрать все точки на карте, так что он разрастется непомерно, такое нам не подходит;

  • Запрос высоты по направлению от A до B — чуть лучше, но тоже не то. 

Код
// Get array of specified points

"https://api.airmap.com/elevation/v1/ele?points={Array of LatLng Points}"

"https://api.airmap.com/elevation/v1/ele/?points=49.97609502353998,14.129133478483624"

{
  "status": "success",
  "data": [
    347
  ]
}

// Get array of points along path

"https://api.airmap.com/elevation/v1/ele/path?points=[sw, ne]"

"https://api.airmap.com/elevation/v1/ele/path?points=49.9760329253738,14.128741875966284,49.97493584462879,14.130635515530571"

{
  "status": "success",
  "data": [
    {
      "from": [
        49.9760329253738,
        14.128741875966284
      ],
      "to": [
        49.97493584462879,
        14.130635515530571
      ],
      "step": [
        -0.00018284679083535593,
        0.00031560659404779773
      ],
      "profile": [360,346,337,329,323,315,304]
    }
  ]
}

Но есть и вариант для нас идеальный: мы можем запросить 2D-массив, покрывающий всю площадь от угла A до угла B. Координаты передаются просто через запятую: самая южная широта, самая западная долгота, самая северная широта и самая восточная долгота. 

// Get 2D array of points from one corner to another

"https://api.airmap.com/elevation/v1/ele/carpet?points=[sw, ne]"

"https://api.airmap.com/elevation/v1/ele/carpet?points=49.9760329253738,14.128741875966284,49.97493584462879,14.130635515530571"

// All request return height in meters
// Limit for all request is 10000 height points

const FString South = TEXT("49.9760329253738");
const FString West = TEXT("14.128741875966284");
const FString North = TEXT("49.97493584462879");
const FString East = TEXT("14.130635515530571");

const FString URL = L"https://api.airmap.com/elevation/v1/ele/carpet?points=" + South + L"," + West + L"," + North + L"," + East;

Тут важно понимать, что у API есть свои ограничения, а именно — максимальное число точек, которое нам могут прислать (10 000). Такое количество приходится на площадь где-то между 15 и 14 зумом тайлов Google Maps. Так что самый большой тайл, высоты которого мы можем загрузить одним запросом, — это тайл с зумом 15 и небольшим запасом с каждой стороны. Поскольку плотность сетки высот никак не связана с Google Maps, а API возвращает высоту по меньшей площади, если на углах нет точного совпадения, стоит запрашивать площадь на 3-5% больше реальной площади тайла, чтобы все его высоты наверняка попали в полученный результат. 

Составим такой запрос: 

TSharedRef Request = FHttpModule::Get().CreateRequest();

	Request->OnProcessRequestComplete().BindUObject(this, &UMapperLoadHeighmap::OnRequestComplete);
	Request->SetURL(URL);
	Request->SetVerb(L"GET");

	Request->SetHeader(L"Content-Type", L"application/json; charset=utf-8");

	Request->ProcessRequest();

И получим на него примерно такой ответ:

Код
// response

{
  "status": "success",
  "data": {
    "bounds": {
      "sw": [
        49.97472222222222,
        14.12861111111111
      ],
      "ne": [
        49.97611111111111,
        14.130833333333333
      ]
    },
    "stats": {
      "max": 360,
      "min": 288,
      "avg": 330.8301886792453
    },
    "carpet": [
      [335,328,323,320,318,316,309,301,293],
      [349,342,334,331,327,322,315,304,292],
      [356,350,341,334,329,323,315,305,292],
      [358,351,345,337,329,320,312,301,289],
      [357,352,346,337,327,319,310,298,288],
      [360,354,347,337,329,321,311,302,293]
    ]
  }
}

// south-west ------------
// |                     | 
// |                     |
// ---------- north-east |

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

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

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

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

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

Черной пунктирной линией на графике показана кубическая интерполяция по трем точкам — но это в 2D-плоскости, учитывая высоту и одну из оставшихся координат: либо X, либо Y. В 3D-пространстве картинка будет сложнее — в виде хитрой искривленной поверхности, которая уже ближе подходит к действительности, хоть и не идеально совпадает. Но стоит помнить, что мы ограничены во вводных данных. 

Перевести информацию из JSON в понятный движку формат можно с помощью встроенных в Unreal Engine 4 утилит для работы с JSON:

  1. Сначала мы сериализуем полученную строку в FJsonObject с помощью TJsonReader и FJsonSerializer;

  2. Далее идем по древу JSON и получаем из него нужные значения в формате JSON; 

  3. После этого переводим их в удобные нам типы данных. 

В чем хранить высоты, в целом не столь важно: можно даже в int, тут скорее вопрос удобства. А вот широту и долготу обязательно хранить в double: float катастрофически не хватает точности для описания всей планеты. Также мы могли бы воспользоваться встроенной функцией JsonObjectStringToUStruct, но, к сожалению, UE4 не поддерживает рефлексию для double и для вложенных массивов — а в ответе Airmap мы получили именно такой.

Код
TSharedPtr JsonObject;
TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString);
FJsonSerializer::Deserialize(Reader, JsonObject);

TSharedPtr Data = JsonObject->GetObjectField(L"data");
TArray> BaseCarpet = Data->GetArrayField(L"carpet");

TSharedPtr  Bounds = Data->GetObjectField(L"bounds");
TArray> BoundsSW = Bounds->GetArrayField(L"sw");
TArray> BoundsNE = Bounds->GetArrayField(L"ne");

float South = BoundsSW[0]->AsNumber();
float West = BoundsSW[1]->AsNumber();
float North = BoundsNE[0]->AsNumber();
float East = BoundsNE[1]->AsNumber();

for (const TSharedPtr Line : BaseCarpet)
{
	TArray> JsonHeights = Line->AsArray;
	for (const TSharedPtr JsonHeight : JsonHeights)
	{
		float Height = JsonHeight->AsNumber();
		
		// Do something with height
	}
}

Далее с помощью procedural mesh component создаем сам меш из уже загруженных и посчитанных высот. Этот компонент, по сути, позволяет нам в режиме реального времени задать массив вертексов, треугольников, цветов вертексов, разметку UV-карты для правильного нанесения текстур и из всего этого собрать секцию меша. 

Ниже — пример построения плейна 100×100 см из 25 вертексов. Конечно, предварительно надо не забыть обновить высоты вертексов. 

Код
constexpr int32 Height = 5;
	constexpr int32 Width = 5;
	constexpr float Spacing = 25.f;
	constexpr float HeightOffset = (Height - 1) * Spacing * 0.5f;
	constexpr float WidthOffset = (Width - 1) * Spacing * 0.5f;
	constexpr float UVSpacing = 1.0f / FMath::Max(Height - 1, Width - 1);
	
	TArray Vertices, Normals;
	TArray UVs;
	TArray VertexColors;
	TArray Tangets;
	TArray Triangles;
	
	for (int32 y = 0; y < Height - 1; y++)
	{
		for (int32 x = 0; x < Width - 1; x++)
		{
			Vertices.Add(FVector(-WidthOffset + x * Spacing, -HeightOffset + y * Spacing, 0.f));
			Normals.Add(FVector(0.0f, 0.0f, 1.0f));
			UVs.Add(FVector2D(x * UVSpacing, y * UVSpacing));
			VertexColors.Add(FLinearColor(0.0f, 0.0f, 0.0f, 0.0f));
			Tangents.Add(FProcMeshTangent(1.0f, 0.0f, 0.0f));
			
			Triangles.Add(x + (y * Width));					//current vertex
			Triangles.Add(x + (y * Width) + Width);			//current vertex + row
			Triangles.Add(x + (y * Width) + Width + 1);		//current vertex + row + one right

			Triangles.Add(x + (y * Width));					//current vertex
			Triangles.Add(x + (y * Width) + Width + 1);		//current vertex + row + one right
			Triangles.Add(x + (y * Width) + 1);				//current vertex + one right
		}
	}
	
	CreateMeshSection_LinearColor(0, Vertices, Triangles, Normals, UVs, VertexColors, Tangents, true);

С помощью dynamic material instance задаем одну или две текстуры — для большого и маленького зума: количество маленьких тайлов в большом зуме, индексы маленького тайла в большом зуме по X и Y — так для N тайлов. Таким образом, нам нужно получить от игрока всего одно значение широты и долготы. Более того — мы можем встроить в игру поиск координат по названию локации, используя API Google Maps. 

Результаты

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

Однако, если не обращать внимание на такие мелкие недочеты, смотрите, что у нас получается. Вот, например, Эверест:

А это — Большой каньон:


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

Напоследок оставлю ссылку на свой сайт — на нем можно ознакомиться с другими моими статьями.

 

Источник

Читайте также