Асинхронная (и не очень) загрузка данных в Unreal Engine 4

8c501411faad4feb9bc36451bab47585.jpg

Содержание:


  • Шаг 1. Использование специальных указателей на ассеты
  • Шаг 2. Загрузка ресурсов в память по требованию
  • Примеры
    • Пример 1. Выбор персонажа
      • Структура данных
      • Загрузка ассетов
    • Пример 2. Экраны интерфейса
    • Пример 3. Таблицы данных без кода
  • Заключение

Всем привет!

Сегодня я расскажу о том, как обращаться с ассетами на Unreal Engine 4 так, чтобы не было мучительно больно за бесцельно занятую память и стоны игроков за время загрузки вашей игры.

Одной из неочевидных особенностей работы движка является то, что для всех как-либо затронутых через систему ссылок объектов в памяти хранится так называемый Class Default Object (CDO). Более того, для полноценного функционирования объектов в память загружаются и все упомянутые в них ресурсы — меши, текстуры, шейдеры и другие.

Как следствие, в такой системе необходимо очень внимательно следить за тем, как «разворачивается» дерево связей ваших игровых объектов в памяти. Легко привести пример, когда введение простейшего условия из разряда — если игрок в данный момент управляет яблоком, ему будет показана кнопка «Купи Больше Яблок Прямо Сейчас!» — потянет за собой загрузку половины текстур всего интерфейса, даже если пользователь играет только за персонажа-грушу.

Почему? Схема предельно проста:

  1. HUD проверяет какого класса игрок, тем самым загружая в память класс Яблоко (и все, что упомянуто в Яблоке);
  2. Если проверка была успешна — создается виджет КупиЯблоки (он упомянут напрямую → загружается сразу);
  3. КупиЯблоки по нажатию должны открывать окно ПремиумМагазина;
  4. ПремиумМагазин в зависимости от некоторых условий умеет показывать экран ОдежкиДляПерсонажа, где используются 146 иконок одежек и по 20 моделек разных косточек и бочков фруктов на каждый класс.

Дерево продолжит разворачиваться вплоть до всех своих листиков, и таким путем, казалось бы, совершенно безобидных проверок и упоминаний других классов (даже на уровне Cast«а!) — у вас в памяти будут сидеть целые группы объектов, которые никогда игроку не понадобятся в данный момент игрового процесса.

750f641467b442efbef59cfa050298a6.png

В какой-то момент при разработке это станет критичным для вашей игры, но далеко не сразу (пороги памяти даже у современных мобильных устройств очень высоки). При этом ошибки проектирования такого рода очень сложно и неприятно исправлять.

Я хочу привести несколько практических решений, которыми я пользуюсь постоянно сам, и которые могут служить примером разрешения таких ситуаций, и могут быть легко расширены для нужд вашего проекта.

Шаг 1. Использование специальных указателей на ассеты
Чтобы прервать порочную практику загрузки всего дерева зависимостей в память, господа из Epic Games предоставили нам возможность использования двух хитрых типов ссылок на ассеты, это TAssetPtr и TAssetSubclassOf (единственное их отличие друг от друга, что в TAssetSubclassOf не сможет попасть ассет класса A, только дочерние от него, что удобно, когда класс А — абстрактный).

Особенность использования данных типов в том, что они не загружают ресурсы в память автоматически, лишь только хранят ссылки на них. Тем самым, ресурсы попадают в собранный проект (чего не произошло, например, при хранении библиотеки персонажей в виде массива текстовых ссылок на ассеты), но загрузка в память происходит только тогда, когда об этом скажет разработчик.

Шаг 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 в качестве родителя для нашей структуры данных. Этот подход позволяет нам создать таблицу для удобного редактирования прямо в блюпринтах:

5b9e6d1134a343489a271ce0e8e42e1d.pngb001eee276be4c0b90846a744ac30f38.png

Для заметки — вы можете спросить, зачем же AssetId, если есть некий Row Name? Я использую дополнительный ключ для сквозной идентификации сущностей внутри игры, правила именования которых отличаются от тех ограничений, которые налагаются на Row Name авторами движка, хотя это и не обязательно.

Загрузка ассетов


Функционал для работы с таблицами в блюпринтах небогатый, но его достаточно:

0448fa03bba44e3e9c3b3aad87f83e6e.png

После получения ссылки на ассет персонажа используется нода 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);

Управление происходящим в блюпринтах будет выглядеть так:

052146a8949e44f798fffd79c0be4ea1.png

734b2699976b46ae9aae65f5061bfd94.png

Собственно, все. Используя такой подход, можно спавнить экторов асинхронно, минимально нагружая память игры.

Пример 2. Экраны интерфейса


Помните пример о кнопке НужноБольшеЯблок, и как она потянула за собой загрузку в память других экранов, которые даже не видит игрок на текущий момент?

Не всегда получится этого избежать на все 100%, но самая критичная зависимость между окнами интерфейса — это их открытие (создание) по какому-нибудь событию. В нашем случае кнопка ничего не знает о том окне, которое она порождает, кроме того, какое собственно окно нужно будет показать пользователю при клике.

Воспользуемся полученными ранее знаниями и создадим таблицу экранов интерфейса:

USTRUCT(Blueprintable)
 struct FMyWidgetTableRow : public FTableRowBase
 {
 	GENERATED_USTRUCT_BODY()
 
 	UPROPERTY(EditAnywhere, BlueprintReadWrite)
 	TAssetSubclassOf WidgetClass;
 	
 	FMyWidgetTableRow() :
 		WidgetClass(nullptr)
 	{
 	}
 };

Будет выглядеть она так:

fe3392d9b8c5402893b8fbbee6913acb.png

Создание интерфейса отличается от спавна экторов, поэтому создадим дополнительную функцию создания виджетов из асинхронно загружаемых ассетов:

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(OwningPlayer, WidgetType);

Третье, если в случае эктора он рождался в мире и становился частью его, то виджет, остается обычным подвешенным «голым» указателем, на который с радостью поохотился бы анриловский сборщик мусора. Чтобы дать ему шанс, мы включаем ему защиту от пожирания со стороны 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 в блюпринтах, и на основе него создавать свои кастомные структуры данных, которые будут использоваться в таблицах:

6e8b0f1eb7f54d72b52c9fd91c9f46ec.png

Во всем остальном работа с этим типом данных отличаться от сказанного выше не будет.

Заключение
Использование ссылок на ассеты через TAssetPtr может здорово сократить потребление памяти вашей игрой и значительно ускорить время загрузки. Я постарался привести наиболее практичные примеры использования такого подхода, и надеюсь, они будут вам полезны.

Полный исходный код всех примеров доступен здесь.

Комментарии и вопросы приветствуются.

Комментарии (0)

© Habrahabr.ru