Асинхронная (и не очень) загрузка данных в Unreal Engine 4
Сегодня я расскажу о том, как обращаться с ассетами на Unreal Engine 4 так, чтобы не было мучительно больно за бесцельно занятую память и стоны игроков за время загрузки вашей игры. Одной из неочевидных особенностей работы движка является то, что для всех как-либо затронутых через систему ссылок объектов в памяти хранится так называемый Class Default Object (CDO). Более того, для полноценного функционирования объектов в память загружаются и все упомянутые в них ресурсы – меши, текстуры, шейдеры и другие.
Как следствие, в такой системе необходимо очень внимательно следить за тем, как «разворачивается» дерево связей ваших игровых объектов в памяти. Легко привести пример, когда введение простейшего условия из разряда — если игрок в данный момент управляет яблоком, ему будет показана кнопка «Купи Больше Яблок Прямо Сейчас!» – потянет за собой загрузку половины текстур всего интерфейса, даже если пользователь играет только за персонажа-грушу.
Почему? Схема предельно проста:
- HUD проверяет какого класса игрок, тем самым загружая в память класс Яблоко (и все, что упомянуто в Яблоке);
- Если проверка была успешна — создается виджет КупиЯблоки (он упомянут напрямую -> загружается сразу);
- КупиЯблоки по нажатию должны открывать окно ПремиумМагазина;
- ПремиумМагазин в зависимости от некоторых условий умеет показывать экран ОдежкиДляПерсонажа, где используются 146 иконок одежек и по 20 моделек разных косточек и бочков фруктов на каждый класс.
В какой-то момент при разработке это станет критичным для вашей игры, но далеко не сразу (пороги памяти даже у современных мобильных устройств очень высоки). При этом ошибки проектирования такого рода очень сложно и неприятно исправлять.
Я хочу привести несколько практических решений, которыми я пользуюсь постоянно сам, и которые могут служить примером разрешения таких ситуаций, и могут быть легко расширены для нужд вашего проекта.
Шаг 1. Использование специальных указателей на ассеты
Чтобы прервать порочную практику загрузки всего дерева зависимостей в память, господа из Epic Games предоставили нам возможность использования двух хитрых типов ссылок на ассеты, это TAssetPtr и TAssetSubclassOf (единственное их отличие друг от друга, что в TAssetSubclassOf<class A> не сможет попасть ассет класса A, только дочерние от него, что удобно, когда класс А – абстрактный).
Особенность использования данных типов в том, что они не загружают ресурсы в память автоматически, лишь только хранят ссылки на них. Тем самым, ресурсы попадают в собранный проект (чего не произошло, например, при хранении библиотеки персонажей в виде массива текстовых ссылок на ассеты), но загрузка в память происходит только тогда, когда об этом скажет разработчик.
Шаг 2. Загрузка ресурсов в память по требованию
Для этого нам понадобится такая штука, как FStreamableManager. Более подробно я расскажу об этом ниже в рамках примеров, пока лишь достаточно сказать, что загрузка ассетов может быть как асинхронной, так и синхронной, тем самым может полностью заменить «обычные» ссылки на ассеты.
Примеры
Основная цель статьи – это дать практические ответы на вопросы «Кто виноват?» (прямые ссылки на ассеты) и «Что делать?» (загружать их через TAssetPtr), поэтому я не буду повторять то, что вы и так можете прочитать в официальной документации движка, а приведу примеры реализации таких подходов на практике.
Пример 1. Выбор персонажаВо многих играх, будь то DOTA 2 или World of Tanks – есть возможность посмотреть персонажа вне боя. Клик по карусели – и вот уже на экране отображается новая моделька. Если на все доступные модели будут прямые ссылки, то, как мы уже знаем, все они попадут в память еще на этапе загрузки. Только представьте – все сто двенадцать персонажей доты и сразу в память! :)
Структура данныхЧтобы было удобно загружать персонажей, мы заведем табличку, в которой по айдишнику персонажа сможем получить ссылку на его ассет.
Обратите внимание, я использовал класс FTableRowBase в качестве родителя для нашей структуры данных. Этот подход позволяет нам создать таблицу для удобного редактирования прямо в блюпринтах:
Для заметки – вы можете спросить, зачем же AssetId, если есть некий Row Name? Я использую дополнительный ключ для сквозной идентификации сущностей внутри игры, правила именования которых отличаются от тех ограничений, которые налагаются на Row Name авторами движка, хотя это и не обязательно.
Загрузка ассетовФункционал для работы с таблицами в блюпринтах небогатый, но его достаточно:
После получения ссылки на ассет персонажа используется нода Spawn Actor (Async). Это кастомная нода, для нее был написан такой код:
Главная магия процесса загрузки происходит здесь:
Мы используем FStreamableManager для того, чтобы загрузить в память ассет, переданный через TAssetPtr. После загрузки ассета будет вызвана функция UMyAssetLibrary::OnAsyncSpawnActorComplete, в которой мы уже попробуем создать экземпляр класса, и если все ОК, предпримем попытку спавна эктора в мир.
Асинхронное выполнение операций предполагает уведомление об их выполнении_=B8, поэтому в конце мы вызываем блюпринтовое событие:
Callback.ExecuteIfBound(SpawnedActor != nullptr, Reference, SpawnedActor);
Управление происходящим в блюпринтах будет выглядеть так:
Собственно, все. Используя такой подход, можно спавнить экторов асинхронно, минимально нагружая память игры.
Пример 2. Экраны интерфейсаПомните пример о кнопке НужноБольшеЯблок, и как она потянула за собой загрузку в память других экранов, которые даже не видит игрок на текущий момент?
Не всегда получится этого избежать на все 100%, но самая критичная зависимость между окнами интерфейса – это их открытие (создание) по какому-нибудь событию. В нашем случае кнопка ничего не знает о том окне, которое она порождает, кроме того, какое собственно окно нужно будет показать пользователю при клике.
Воспользуемся полученными ранее знаниями и создадим таблицу экранов интерфейса:
Будет выглядеть она так:
Создание интерфейса отличается от спавна экторов, поэтому создадим дополнительную функцию создания виджетов из асинхронно загружаемых ассетов:
Здесь есть несколько вещей, на которые стоит обратить внимание.
Первое, это то, что мы добавили проверку на валидность ассета, переданного нам по ссылке:
Все может быть в нашем нелегком деле разработчиков игр, поэтому такие случаи предусмотреть будет не лишним.
Второе, виджеты не спавнятся в мир, для них используется функция CreateWidget:
UserWidget = CreateWidget<UUserWidget>(OwningPlayer, WidgetType);
Третье, если в случае эктора он рождался в мире и становился частью его, то виджет, остается обычным подвешенным «голым» указателем, на который с радостью поохотился бы анриловский сборщик мусора. Чтобы дать ему шанс, мы включаем ему защиту от пожирания со стороны GC на текущий фрейм:
Тем самым, если никто не возьмет эстафету на себя (окно не показано пользователю, а только создано), то сборщик мусора его удалит.
И четвертое, на сладкое – мы загружаем виджет синхронно, в рамках одного тика:
Как показывает практика, это отлично подходит даже для мобилок, при этом обращаться с синхронной функцией легче – не требуется заводить дополнительные события загрузки и как-либо обрабатывать их. Конечно, при такой практике, не надо делать все длительные операции в Construct’е виджета – если это необходимо, дайте в начале ему появиться для игрока, и потом уже пишите «загрузка», пока все 100500 айтемов игрока и модельки персонажа загружаются на экран.
Пример 3. Таблицы данных без кодаЧто делать, если вам нужно создавать много структур данных с использованием TAssetPtr, но не хочется для каждой заводить класс в коде и наследоваться от FTableRowBase? В блюпринтах нет такого типа данных, поэтому совсем без кода обойтись не получится, но можно создать прокси-класс со ссылкой на конкретный тип ассетов. Например, для текстурных атласов я использую такую структуру:
Теперь вы можете использовать тип FMyMaterialInstanceAsset в блюпринтах, и на основе него создавать свои кастомные структуры данных, которые будут использоваться в таблицах:
Во всем остальном работа с этим типом данных отличаться от сказанного выше не будет.
Заключение
Использование ссылок на ассеты через TAssetPtr может здорово сократить потребление памяти вашей игрой и значительно ускорить время загрузки. Я постарался привести наиболее практичные примеры использования такого подхода, и надеюсь, они будут вам полезны.