Pull to refresh
VK
Building the Internet

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

Reading time8 min
Views25K


Содержание:



Всем привет!

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

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

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

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

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

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



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

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

Шаг 1. Использование специальных указателей на ассеты


Чтобы прервать порочную практику загрузки всего дерева зависимостей в память, господа из Epic Games предоставили нам возможность использования двух хитрых типов ссылок на ассеты, это TAssetPtr и TAssetSubclassOf (единственное их отличие друг от друга, что в TAssetSubclassOf<class A> не сможет попасть ассет класса 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<AActor> ActorClass;
 
 	FMyActorTableRow() :
 		AssetId(TEXT("")),
 		ActorClass(nullptr)
 	{
 	}
 };

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



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

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


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



После получения ссылки на ассет персонажа используется нода Spawn Actor (Async). Это кастомная нода, для нее был написан такой код:

void UMyAssetLibrary::AsyncSpawnActor(UObject* WorldContextObject, TAssetSubclassOf<AActor> 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<UClass>(StaticLoadObject(UClass::StaticClass(), nullptr, *(Reference.ToString())));
 	if (ActorClass != nullptr)
 	{
 		// Спавним эктора в мир
 		SpawnedActor = WorldContextObject->GetWorld()->SpawnActor<AActor>(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<UUserWidget> WidgetClass;
 	
 	FMyWidgetTableRow() :
 		WidgetClass(nullptr)
 	{
 	}
 };

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



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

UUserWidget* UMyAssetLibrary::SyncCreateWidget(UObject* WorldContextObject, TAssetSubclassOf<UUserWidget> 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<UClass>(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<UUserWidget>(World, WidgetType);
 	}
 	else
 	{
 		UserWidget = CreateWidget<UUserWidget>(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<UUserWidget>(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<UMaterialInstanceConstant> MaterialInstance;
 
 	FMyMaterialInstanceAsset() :
 		MaterialInstance(nullptr)
 	{
 	}
 };

Теперь вы можете использовать тип FMyMaterialInstanceAsset в блюпринтах, и на основе него создавать свои кастомные структуры данных, которые будут использоваться в таблицах:



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

Заключение


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

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

Комментарии и вопросы приветствуются.
Tags:
Hubs:
+31
Comments9

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен