Бесплатный левел-дизайн, или как строить ландшафт в реальном времени на 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<UAsyncTaskDownloadImage>();
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<IHttpRequest> 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<FJsonObject> JsonObject;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);
FJsonSerializer::Deserialize(Reader, JsonObject); TSharedPtr<FJsonObject> Data = JsonObject->GetObjectField(L"data");
TArray<TSharedPtr<FJsonValue>> BaseCarpet = Data->GetArrayField(L"carpet"); TSharedPtr <FJsonObject> Bounds = Data->GetObjectField(L"bounds");
TArray<TSharedPtr<FJsonValue>> BoundsSW = Bounds->GetArrayField(L"sw");
TArray<TSharedPtr<FJsonValue>> 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<FJsonValue> Line : BaseCarpet)
{ TArray<TSharedPtr<FJsonValue>> JsonHeights = Line->AsArray; for (const TSharedPtr<FJsonValue> 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<FVector> Vertices, Normals;
TArray<FVector2D> UVs;
TArray<FLinearColor> VertexColors;
TArray<FProcMeshTangent> Tangets;
TArray<int32> 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. 

Результаты

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

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

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


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

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

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

  • Мини-курс по CI/CD, статьи и акцияМини-курс по CI/CD, статьи и акция Релиз видеокурса это ещё не конец, это новое начало работы. Расскажем об обновлении видеокурса «CI/CD на примере Gitlab CI» в формате дневника:2021 год, 14 январяШёл месяц шват, но мы не садили деревья, у нас всё-таки зима. Мы дополняли курс:  • Задание для самостоятельной […]
  • Услуги наполнение каталога сайтаУслуги наполнение каталога сайта 09/27/2020 3 минуты на чтение В этой статье Как настроить удаленный сетевой ресурс Как создать виртуальный каталог Как протестировать виртуальный каталог Как удалить виртуальный каталог В этой статье описывается, как создать. Протестировать и удалить виртуальный каталог на […]
  • Яндекс подал иск к ФАС из-за решения по обогащенным ответамЯндекс подал иск к ФАС из-за решения по обогащенным ответам Яндекс подал иск к Федеральной антимонопольной службе (ФАС) о признании ее решений и действий незаконными. Сведения об этом содержатся в базе арбитража. Дата рассмотрения иска пока не назначена.  В Яндексе пояснили, что компания подала заявление, чтобы обжаловать одно из решений […]
  • Как работает крупнейший маркетплейс: что у него под капотомКак работает крупнейший маркетплейс: что у него под капотом Всем привет, я — Сергей Бобрецов, CTO в Wildberries. Сегодня Wildberries — самый большой маркетплейс в России и мы так часто заняты повседневным хайлоадом, что не всегда успеваем рассказать что за всем этим стоит: какие технологии и решения под капотом, как мы справляемся с адом […]