Бесплатный левел-дизайн, или как строить ландшафт в реальном времени на 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-карты. Они хранятся в так называемых тайлах в разном масштабе. Вычисление координат тайлов также не является секретом: в Интернете можно найти и документацию, и реализации на разных языках программирования.
Также нам нужно уметь вычислять границы тайла для загрузки высот. В простейшей реализации все это выглядит примерно так:
Но большую часть этого кода в конечном итоге мы использовать не будем — сочтем его просто компьютерной магией. В конце концов, нас интересуют лишь две функции: WGS84Bounds и WGS84ToTile.
Теперь, зная широту и долготу, мы можем вычислить нужный тайл. Далее через API Google Maps мы можем его загрузить:
Кроме того, в зависимости от наших нужд мы можем загрузить разные слои карты: схему, спутник или гибрид. В дальнейших примерах мы будем загружать именно снимки со спутника. Ну а для загрузки большего куска карты просто итерируем номера тайлов, пока не загрузим достаточное их количество. То есть, если вокруг некой широты и долготы нам нужно загрузить два тайла в каждую сторону, мы вычисляем центральный тайл и проходимся по двойному циклу от X–2 и Y–2 до X+2 и Y+2.
Важно помнить, что API работает не моментально: однозначно не стоит загружать текстуру синхронно. Еще лучше — предусмотреть сценарий, когда тайлы будут загружаться приличное количество времени, потому что, скорее всего, так и будет. Добавить экран загрузки или что-то в этом роде. Тайлы загружаются за одинаковое время практически вне зависимости от размера, и в среднем это 0.4-0.5 секунды на тайл.
Через UAsyncTaskDownloadImage мы загружаем картинку:
Хотя в теории Unreal Engine 4 позволяет нам отправить сразу все тайлы на загрузку одновременно, скорость соединения и ограничения API не дадут нам этого сделать. Только если запустить сразу несколько загрузок — тогда может прокатить.
Также для удобства можно написать наследника этого таска, который будет содержать больше информации о тайле. Однако, для универсальности мы сейчас говорим о базовых возможностях UE4. Движок сразу преобразует загруженный таском тайл в нужный нам texture 2D dynamic — наследника UTexture, который мы можем применить к динамическому материалу. Эту текстуру мы получим из делегата OnSuccess.
Кроме того, нередко возникают случаи, когда одна и та же область загружается по многу раз. Допустим, игрок любит летать по одинаковому маршруту. Для таких случаев можно сделать кэширование тайлов локально на диске. Загрузить эти тайлы можно будет также с помощью UAsyncTaskDownloadImage и при нужде проверять, есть ли нужный тайл на диске, и только если его нет — загружать из Интернета.
Думаю, не стоит объяснять, что загрузка с диска происходит в разы быстрее. В случае, если ваше приложение распространяется через какой-то сервис — например, Steam, — можно сохранять все эти тайлы в облаке, перенося данные одного игрока на другие машины. Кроме того, благодаря этому методу мы можем хотя бы частично отвязать приложение от обязательного подключения к Интернету.
Пример кода приведен ниже: как вы видите, пришлось несколько ухищряться через render target, поскольку Unreal Engine 4 не поддерживает прямой экспорт динамической текстуры, в отличие от обычной.
Не будем забывать, что для оптимизации можно загружать тайлы и в высоком разрешении, и в низком, то есть — с большим и маленьким зумом. И позже склеить их в материале в одно целое, включая нужные текстуры в зависимости от дальности камеры.
Предположим, каждый наш физический тайл — то есть, геометрия тайла — соответствует по размеру тайлу с высоким разрешением. Тогда, чтобы применить изображение с низким зумом, придется провести небольшие математические операции. Но зато мы сможем использовать одну и ту же картинку сразу на 4, 16, а то и 64 физических тайлах.
Пример такого материала можно посмотреть ниже. В нем как раз используется один тайл с маленьким зумом на сетку 8×8 из маленьких тайлов — то есть, одна картинка на 64 тайла.
Рисунок ниже показывает смену тайла с разрешением 19 на тайл с разрешением 16 при удалении от объекта:
А вот, кстати, и площадь Европы, загруженная в реальном времени из движка лишь по двум числам:
Но плоскую карту мы можем увидеть и в Google Maps — такое нам не интересно. Поэтому пора загружать карту высот.
Загружаем информацию о высоте
Воспользуемся сервисом Airmap. Это довольно глобальная платформа для получения информации об объектах в воздухе, опасных зонах и многом другом. Но нам интересно именно elevation API, которое и поможет нам получить информацию о высоте любой точки Земли. У Google есть свой аналог, и, возможно, он даже лучше, но он платный. Им я не пользовался, так что ни рекомендовать, ни предостерегать не буду.
У выбранного API довольно скромный набор запросов, но для ваших целей хватает:
- Запрос для получения массива высот по массиву координат — в нашем случае с ним придется перебрать все точки на карте, так что он разрастется непомерно, такое нам не подходит;
- Запрос высоты по направлению от A до B — чуть лучше, но тоже не то.
Но есть и вариант для нас идеальный: мы можем запросить 2D-массив, покрывающий всю площадь от угла A до угла B. Координаты передаются просто через запятую: самая южная широта, самая западная долгота, самая северная широта и самая восточная долгота.
Тут важно понимать, что у API есть свои ограничения, а именно — максимальное число точек, которое нам могут прислать (10 000). Такое количество приходится на площадь где-то между 15 и 14 зумом тайлов Google Maps. Так что самый большой тайл, высоты которого мы можем загрузить одним запросом, — это тайл с зумом 15 и небольшим запасом с каждой стороны. Поскольку плотность сетки высот никак не связана с Google Maps, а API возвращает высоту по меньшей площади, если на углах нет точного совпадения, стоит запрашивать площадь на 3-5% больше реальной площади тайла, чтобы все его высоты наверняка попали в полученный результат.
Составим такой запрос:
И получим на него примерно такой ответ:
В нем содержится информация по реальным координатам, между которыми вернулись высоты. Как правило, они отличаются от запрошенных на сотые доли. Но на деле эта разница может быть в десятки метров. Как вы видите, в ответ на запрошенную площадь квадратного тайла мы получаем вообще не квадратный массив.
Ниже схематично можно увидеть разницу в площадях, а также в плотности полученных высот и реальных вертексов тайла.
Из-за этих различий нам нужно написать алгоритм интерполяции. В самом примитивном варианте нам нужно вычислить координаты каждого вертекса на полученной площади и найти четыре ближайшие точки к его позиции, а после этого вычислить высоту, спроецировав точку на плоскость. Это можно сделать, например, с помощью встроенной в UE4 функции PointPlaneProject.
На графике ниже видно расхождения между реальным ландшафтом (красной линией) и построенным в игре (синей линией). Изображение схематичное, но весьма наглядно отображает проблему.
Скорее всего, этого метода нам будет достаточно. А если нет, придется экспериментировать с интерполяцией по большему количеству точек, чтобы точнее обрабатывать нелинейные изменения ландшафта.
Черной пунктирной линией на графике показана кубическая интерполяция по трем точкам — но это в 2D-плоскости, учитывая высоту и одну из оставшихся координат: либо X, либо Y. В 3D-пространстве картинка будет сложнее — в виде хитрой искривленной поверхности, которая уже ближе подходит к действительности, хоть и не идеально совпадает. Но стоит помнить, что мы ограничены во вводных данных.
Перевести информацию из JSON в понятный движку формат можно с помощью встроенных в Unreal Engine 4 утилит для работы с JSON:
- Сначала мы сериализуем полученную строку в FJsonObject с помощью TJsonReader и FJsonSerializer;
- Далее идем по древу JSON и получаем из него нужные значения в формате JSON;
- После этого переводим их в удобные нам типы данных.
В чем хранить высоты, в целом не столь важно: можно даже в int, тут скорее вопрос удобства. А вот широту и долготу обязательно хранить в double: float катастрофически не хватает точности для описания всей планеты. Также мы могли бы воспользоваться встроенной функцией JsonObjectStringToUStruct, но, к сожалению, UE4 не поддерживает рефлексию для double и для вложенных массивов — а в ответе Airmap мы получили именно такой.
Далее с помощью procedural mesh component создаем сам меш из уже загруженных и посчитанных высот. Этот компонент, по сути, позволяет нам в режиме реального времени задать массив вертексов, треугольников, цветов вертексов, разметку UV-карты для правильного нанесения текстур и из всего этого собрать секцию меша.
Ниже — пример построения плейна 100×100 см из 25 вертексов. Конечно, предварительно надо не забыть обновить высоты вертексов.
С помощью dynamic material instance задаем одну или две текстуры — для большого и маленького зума: количество маленьких тайлов в большом зуме, индексы маленького тайла в большом зуме по X и Y — так для N тайлов. Таким образом, нам нужно получить от игрока всего одно значение широты и долготы. Более того — мы можем встроить в игру поиск координат по названию локации, используя API Google Maps.
Результаты
Как можно заметить, на карте есть некоторые артефакты, а именно — разрывы ландшафта. Они возникают из-за несовершенного алгоритма вычисление высоты в каждом вертексе на границах тайла, по которому мы загружали высоту. Сейчас они загружаются отдельно для каждого тайла и интерполируются каждый внутри себя независимо от соседних. По-хорошему же стоит либо строить глобальную карту высот, которая содержит в себе всю информацию о загруженных тайлах, и высчитывать уже по ней, либо как-то учитывать высоты в соседних тайлах при построении нового, и в таком случае точки на границах тайла будут точно совпадать, не порождая такие трещины.
Однако, если не обращать внимание на такие мелкие недочеты, смотрите, что у нас получается. Вот, например, Эверест:
А это — Большой каньон:
Ну вот и все: с этой темой разобрались. В комментариях буду рад ответить на вопросы и вообще подискутировать на тему.
Напоследок оставлю ссылку на свой сайт — на нем можно ознакомиться с другими моими статьями.
Спасибо за статью! От себя добавлю это видео:
https://youtu.be/sLqXFF8mlEU
Оно, правда, про Unity, но тоже много полезного материала содержит.
Хорошо, когда кроме живого классика Себастьяна, кто-то еще популярно раскрывает всякие интересные темы
Комментарий недоступен
Тут задачи тягаться с майками и не было, понятно что у них совсем другие ресурсы. А этой технологии при должной фантазии полно применений можно найти
У Вас есть собственные примеры или хотя бы других кто реализовал создание планеты Земля? Было бы интересно посмотреть. Заранее плюсану Вам.
Комментарий недоступен
И никогда больше не смотрим