Всем привет! Меня зовут Влад Маркелов, и сегодня я расскажу, как можно бесплатно и быстро создавать огромные игровые уровни и как в реальном времени строить ландшафт по информации из Интернета.
Но прежде, чем перейти к сухим техническим деталям, небольшое лирическое отступление, о чем вообще эта статья и как я до этого докатился.
Немного предыстории
В геймдев меня затянуло в 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:
Сначала мы сериализуем полученную строку в FJsonObject с помощью TJsonReader и FJsonSerializer;
Далее идем по древу JSON и получаем из него нужные значения в формате JSON;
После этого переводим их в удобные нам типы данных.
В чем хранить высоты, в целом не столь важно: можно даже в 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.
Результаты
Как можно заметить, на карте есть некоторые артефакты, а именно — разрывы ландшафта. Они возникают из-за несовершенного алгоритма вычисление высоты в каждом вертексе на границах тайла, по которому мы загружали высоту. Сейчас они загружаются отдельно для каждого тайла и интерполируются каждый внутри себя независимо от соседних. По-хорошему же стоит либо строить глобальную карту высот, которая содержит в себе всю информацию о загруженных тайлах, и высчитывать уже по ней, либо как-то учитывать высоты в соседних тайлах при построении нового, и в таком случае точки на границах тайла будут точно совпадать, не порождая такие трещины.
Однако, если не обращать внимание на такие мелкие недочеты, смотрите, что у нас получается. Вот, например, Эверест:
А это — Большой каньон:
Ну вот и все: с этой темой разобрались. В комментариях буду рад ответить на вопросы и вообще подискутировать на тему.
Напоследок оставлю ссылку на свой сайт — на нем можно ознакомиться с другими моими статьями.
Комментарии (5)
Vitter
21.01.2022 18:23+1Какого %%Зачем вы выбрали равноугольную проекцию (а проекция Меркатора (1569 г.) - равноугольная)? Равноугольные - это с сохранением углов.
В таких случаях очень желательно во-первых, брать равновеликие проекции (с сохраненением площадей). Во-вторых, желательно с сохранением пропроций.
Например, Гиперэллиптическая проекция Тоблера (1973 г.)Или РавноЗемельная проекция (2018 г.) ( https://en.wikipedia.org/wiki/Equal_Earth_projection )
lucasfalse Автор
21.01.2022 18:45+1Ну тут все просто, проекция Меркатора является стандартом. И поскольку нам нужно соотнести координаты в игре с координатами на гугл картах, логично использовать такой же метод проекции
panteleymonov
21.01.2022 20:51Я бы переформулировал вопрос. Не важно в каком формате беруться данные, а вот как они ложаться на сферу будет значимым. Особенно для полюсов, если у вас для построение высот у меша сохраняется подобие сетки меркатора. Можно воспользоваться уже имеющимся у вас аналогом интерполяции и строить сам меш детализируя икосаэдр
А вообще, проект Outerra я думаю эту тему достаточно хорошо раскрыл.
fire64
22.01.2022 12:18+1Отличная статья, тоже всегда было желание сделать полноценный мир, на основе процедурной генерации. Только в качестве геоданных использовал OSM.
websoda
ого