Асинхронная (и не очень) загрузка данных в Unreal Engine 4
Содержание:
- Шаг 1. Использование специальных указателей на ассеты
- Шаг 2. Загрузка ресурсов в память по требованию
- Примеры
- Пример 1. Выбор персонажа
- Структура данных
- Загрузка ассетов
- Пример 2. Экраны интерфейса
- Пример 3. Таблицы данных без кода
- Пример 1. Выбор персонажа
- Заключение
Всем привет!
Сегодня я расскажу о том, как обращаться с ассетами на Unreal Engine 4 так, чтобы не было мучительно больно за бесцельно занятую память и стоны игроков за время загрузки вашей игры.
Одной из неочевидных особенностей работы движка является то, что для всех как-либо затронутых через систему ссылок объектов в памяти хранится так называемый Class Default Object (CDO). Более того, для полноценного функционирования объектов в память загружаются и все упомянутые в них ресурсы — меши, текстуры, шейдеры и другие.
Как следствие, в такой системе необходимо очень внимательно следить за тем, как «разворачивается» дерево связей ваших игровых объектов в памяти. Легко привести пример, когда введение простейшего условия из разряда — если игрок в данный момент управляет яблоком, ему будет показана кнопка «Купи Больше Яблок Прямо Сейчас!» — потянет за собой загрузку половины текстур всего интерфейса, даже если пользователь играет только за персонажа-грушу.
Почему? Схема предельно проста:
- HUD проверяет какого класса игрок, тем самым загружая в память класс Яблоко (и все, что упомянуто в Яблоке);
- Если проверка была успешна — создается виджет КупиЯблоки (он упомянут напрямую → загружается сразу);
- КупиЯблоки по нажатию должны открывать окно ПремиумМагазина;
- ПремиумМагазин в зависимости от некоторых условий умеет показывать экран ОдежкиДляПерсонажа, где используются 146 иконок одежек и по 20 моделек разных косточек и бочков фруктов на каждый класс.
Дерево продолжит разворачиваться вплоть до всех своих листиков, и таким путем, казалось бы, совершенно безобидных проверок и упоминаний других классов (даже на уровне Cast«а!) — у вас в памяти будут сидеть целые группы объектов, которые никогда игроку не понадобятся в данный момент игрового процесса.
В какой-то момент при разработке это станет критичным для вашей игры, но далеко не сразу (пороги памяти даже у современных мобильных устройств очень высоки). При этом ошибки проектирования такого рода очень сложно и неприятно исправлять.
Я хочу привести несколько практических решений, которыми я пользуюсь постоянно сам, и которые могут служить примером разрешения таких ситуаций, и могут быть легко расширены для нужд вашего проекта.
Шаг 1. Использование специальных указателей на ассетыЧтобы прервать порочную практику загрузки всего дерева зависимостей в память, господа из Epic Games предоставили нам возможность использования двух хитрых типов ссылок на ассеты, это TAssetPtr и TAssetSubclassOf (единственное их отличие друг от друга, что в TAssetSubclassOf
Особенность использования данных типов в том, что они не загружают ресурсы в память автоматически, лишь только хранят ссылки на них. Тем самым, ресурсы попадают в собранный проект (чего не произошло, например, при хранении библиотеки персонажей в виде массива текстовых ссылок на ассеты), но загрузка в память происходит только тогда, когда об этом скажет разработчик.
Шаг 2. Загрузка ресурсов в память по требованиюДля этого нам понадобится такая штука, как FStreamableManager. Более подробно я расскажу об этом ниже в рамках примеров, пока лишь достаточно сказать, что загрузка ассетов может быть как асинхронной, так и синхронной, тем самым может полностью заменить «обычные» ссылки на ассеты.Примеры
Основная цель статьи — это дать практические ответы на вопросы «Кто виноват?» (прямые ссылки на ассеты) и «Что делать?» (загружать их через TAssetPtr), поэтому я не буду повторять то, что вы и так можете прочитать в официальной документации движка, а приведу примеры реализации таких подходов на практике.
Пример 1. Выбор персонажа
Во многих играх, будь то DOTA 2 или World of Tanks — есть возможность посмотреть персонажа вне боя. Клик по карусели — и вот уже на экране отображается новая моделька. Если на все доступные модели будут прямые ссылки, то, как мы уже знаем, все они попадут в память еще на этапе загрузки. Только представьте — все сто двенадцать персонажей доты и сразу в память! :)
Структура данных
Чтобы было удобно загружать персонажей, мы заведем табличку, в которой по айдишнику персонажа сможем получить ссылку на его ассет.
/**
* Example #1. Table for dynamic actor creation (not defined in advance)
*/
USTRUCT(Blueprintable)
struct FMyActorTableRow : public FTableRowBase
{
GENERATED_USTRUCT_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FString AssetId;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TAssetSubclassOf ActorClass;
FMyActorTableRow() :
AssetId(TEXT("")),
ActorClass(nullptr)
{
}
};
Обратите внимание, я использовал класс FTableRowBase в качестве родителя для нашей структуры данных. Этот подход позволяет нам создать таблицу для удобного редактирования прямо в блюпринтах:
Для заметки — вы можете спросить, зачем же AssetId, если есть некий Row Name? Я использую дополнительный ключ для сквозной идентификации сущностей внутри игры, правила именования которых отличаются от тех ограничений, которые налагаются на Row Name авторами движка, хотя это и не обязательно.
Загрузка ассетов
Функционал для работы с таблицами в блюпринтах небогатый, но его достаточно:
После получения ссылки на ассет персонажа используется нода Spawn Actor (Async). Это кастомная нода, для нее был написан такой код:
void UMyAssetLibrary::AsyncSpawnActor(UObject* WorldContextObject, TAssetSubclassOf AssetPtr, FTransform SpawnTransform, const FMyAsyncSpawnActorDelegate& Callback)
{
// Асинхронно загружаем ассет в память
FStreamableManager& AssetLoader = UMyGameSingleton::Get().AssetLoader;
FStringAssetReference Reference = AssetPtr.ToStringReference();
AssetLoader.RequestAsyncLoad(Reference, FStreamableDelegate::CreateStatic(&UMyAssetLibrary::OnAsyncSpawnActorComplete, WorldContextObject, Reference, SpawnTransform, Callback));
}
void UMyAssetLibrary::OnAsyncSpawnActorComplete(UObject* WorldContextObject, FStringAssetReference Reference, FTransform SpawnTransform, FMyAsyncSpawnActorDelegate Callback)
{
AActor* SpawnedActor = nullptr;
// Ассет теперь должен быть в памяти, пытаемся загрузить объект класса
UClass* ActorClass = Cast(StaticLoadObject(UClass::StaticClass(), nullptr, *(Reference.ToString())));
if (ActorClass != nullptr)
{
// Спавним эктора в мир
SpawnedActor = WorldContextObject->GetWorld()->SpawnActor(ActorClass, SpawnTransform);
}
else
{
UE_LOG(LogMyAssetLibrary, Warning, TEXT("UMyAssetLibrary::OnAsyncSpawnActorComplete -- Failed to load object: $"), *Reference.ToString());
}
// Вызываем событие о спавне в блюпринты
Callback.ExecuteIfBound(SpawnedActor != nullptr, Reference, SpawnedActor);
}
Главная магия процесса загрузки происходит здесь:
FStreamableManager& AssetLoader = UMyGameSingleton::Get().AssetLoader;
FStringAssetReference Reference = AssetPtr.ToStringReference();
AssetLoader.RequestAsyncLoad(Reference, FStreamableDelegate::CreateStatic(&UMyAssetLibrary::OnAsyncSpawnActorComplete, WorldContextObject, Reference, SpawnTransform, Callback));
Мы используем FStreamableManager для того, чтобы загрузить в память ассет, переданный через TAssetPtr. После загрузки ассета будет вызвана функция UMyAssetLibrary: OnAsyncSpawnActorComplete, в которой мы уже попробуем создать экземпляр класса, и если все ОК, предпримем попытку спавна эктора в мир.
Асинхронное выполнение операций предполагает уведомление об их выполнении_=B8, поэтому в конце мы вызываем блюпринтовое событие:
Callback.ExecuteIfBound(SpawnedActor != nullptr, Reference, SpawnedActor);
Управление происходящим в блюпринтах будет выглядеть так:
Собственно, все. Используя такой подход, можно спавнить экторов асинхронно, минимально нагружая память игры.
Пример 2. Экраны интерфейса
Помните пример о кнопке НужноБольшеЯблок, и как она потянула за собой загрузку в память других экранов, которые даже не видит игрок на текущий момент?
Не всегда получится этого избежать на все 100%, но самая критичная зависимость между окнами интерфейса — это их открытие (создание) по какому-нибудь событию. В нашем случае кнопка ничего не знает о том окне, которое она порождает, кроме того, какое собственно окно нужно будет показать пользователю при клике.
Воспользуемся полученными ранее знаниями и создадим таблицу экранов интерфейса:
USTRUCT(Blueprintable)
struct FMyWidgetTableRow : public FTableRowBase
{
GENERATED_USTRUCT_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TAssetSubclassOf WidgetClass;
FMyWidgetTableRow() :
WidgetClass(nullptr)
{
}
};
Будет выглядеть она так:
Создание интерфейса отличается от спавна экторов, поэтому создадим дополнительную функцию создания виджетов из асинхронно загружаемых ассетов:
UUserWidget* UMyAssetLibrary::SyncCreateWidget(UObject* WorldContextObject, TAssetSubclassOf Asset, APlayerController* OwningPlayer)
{
// Check we're trying to load not null asset
if (Asset.IsNull())
{
FString InstigatorName = (WorldContextObject != nullptr) ? WorldContextObject->GetFullName() : TEXT("Unknown");
UE_LOG(LogMyAssetLibrary, Warning, TEXT("UMyAssetLibrary::SyncCreateWidget -- Asset ptr is null for: %s"), *InstigatorName);
return nullptr;
}
// Load asset into memory first (sync)
FStreamableManager& AssetLoader = UMyGameSingleton::Get().AssetLoader;
FStringAssetReference Reference = Asset.ToStringReference();
AssetLoader.SynchronousLoad(Reference);
// Now load object and check that it has desired class
UClass* WidgetType = Cast(StaticLoadObject(UClass::StaticClass(), NULL, *(Reference.ToString())));
if (WidgetType == nullptr)
{
return nullptr;
}
// Create widget from loaded object
UUserWidget* UserWidget = nullptr;
if (OwningPlayer == nullptr)
{
UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject);
UserWidget = CreateWidget(World, WidgetType);
}
else
{
UserWidget = CreateWidget(OwningPlayer, WidgetType);
}
// Be sure that it won't be killed by GC on this frame
if (UserWidget)
{
UserWidget->SetFlags(RF_StrongRefOnFrame);
}
return UserWidget;
}
Здесь есть несколько вещей, на которые стоит обратить внимание.
Первое, это то, что мы добавили проверку на валидность ассета, переданного нам по ссылке:
// Check we're trying to load not null asset
if (Asset.IsNull())
{
FString InstigatorName = (WorldContextObject != nullptr) ? WorldContextObject->GetFullName() : TEXT("Unknown");
UE_LOG(LogMyAssetLibrary, Warning, TEXT("UMyAssetLibrary::SyncCreateWidget -- Asset ptr is null for: %s"), *InstigatorName);
return nullptr;
}
Все может быть в нашем нелегком деле разработчиков игр, поэтому такие случаи предусмотреть будет не лишним.
Второе, виджеты не спавнятся в мир, для них используется функция CreateWidget:
UserWidget = CreateWidget
Третье, если в случае эктора он рождался в мире и становился частью его, то виджет, остается обычным подвешенным «голым» указателем, на который с радостью поохотился бы анриловский сборщик мусора. Чтобы дать ему шанс, мы включаем ему защиту от пожирания со стороны GC на текущий фрейм:
UserWidget->SetFlags(RF_StrongRefOnFrame);
Тем самым, если никто не возьмет эстафету на себя (окно не показано пользователю, а только создано), то сборщик мусора его удалит.
И четвертое, на сладкое — мы загружаем виджет синхронно, в рамках одного тика:
AssetLoader.SynchronousLoad(Reference);
Как показывает практика, это отлично подходит даже для мобилок, при этом обращаться с синхронной функцией легче — не требуется заводить дополнительные события загрузки и как-либо обрабатывать их. Конечно, при такой практике, не надо делать все длительные операции в Construct«е виджета — если это необходимо, дайте в начале ему появиться для игрока, и потом уже пишите «загрузка», пока все 100500 айтемов игрока и модельки персонажа загружаются на экран.
Пример 3. Таблицы данных без кода
Что делать, если вам нужно создавать много структур данных с использованием TAssetPtr, но не хочется для каждой заводить класс в коде и наследоваться от FTableRowBase? В блюпринтах нет такого типа данных, поэтому совсем без кода обойтись не получится, но можно создать прокси-класс со ссылкой на конкретный тип ассетов. Например, для текстурных атласов я использую такую структуру:
USTRUCT(Blueprintable)
struct FMyMaterialInstanceAsset
{
GENERATED_USTRUCT_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TAssetPtr MaterialInstance;
FMyMaterialInstanceAsset() :
MaterialInstance(nullptr)
{
}
};
Теперь вы можете использовать тип FMyMaterialInstanceAsset в блюпринтах, и на основе него создавать свои кастомные структуры данных, которые будут использоваться в таблицах:
Во всем остальном работа с этим типом данных отличаться от сказанного выше не будет.
ЗаключениеИспользование ссылок на ассеты через TAssetPtr может здорово сократить потребление памяти вашей игрой и значительно ускорить время загрузки. Я постарался привести наиболее практичные примеры использования такого подхода, и надеюсь, они будут вам полезны.
Полный исходный код всех примеров доступен здесь.
Комментарии и вопросы приветствуются.