Содержание:



Всем привет!

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

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

Комментарии и вопросы приветствуются.
Поделиться с друзьями
-->

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


  1. gdt
    05.09.2016 19:05

    Спасибо, познавательная статья. А есть опыт работы или примеры подгрузки ассетов из произвольных pak-файлов, по типу DLC? Пару месяцев назад по работе пришлось вплотную заняться этой темой, и приемлемого решения так и не нашёл.


    1. ufna
      05.09.2016 19:17

      Около года назад делал proof of concept на кастомной сборке движка, но на продакшне к сожалению пока не доводилось с этим работать.


  1. AllexIn
    05.09.2016 19:36

    Может быть можете подсказать, что почитать на тему разработки сетевых игр с выделенным сервером на С++?
    Документацию не предлагать. :)
    ИНтересуют в первую очередь туториалы, которые рассказывают о том, как делать не надо.
    Я заметил за собой(и за другими) желание ваять велосипед, вместо того, чтобы пользоваться инструментами предоставленными UE. И вот хочется это желание победить. Но не хватает грамотных примеров.
    Туториалы гуглятся посредственные, объясняющие совсем уж основы, 90% которых занимает создание виджетов для хоста, да еще и на блюпринтах. :((


    1. ufna
      05.09.2016 19:47

      По урокам не подскажу, т.к. сам использовал всегда только документацию. Но если вы конкретизируете что вас интересует, может подскажу предметно (или может послужить материалом для следующей статьи :) )


      1. AllexIn
        06.09.2016 17:24

        Конкретизировать не получится, ибо пока только въезжаю в сеть.
        Я вообще в проекте сетью не занимаюсь, но с сетевиками беда и, похоже, придется всё самому осваивать.


  1. slonopotamus
    05.09.2016 22:15

    Всё хорошо кроме одного — не используйте TAssetPtr, он упоротый. Используйте вместо этого FStringAssetReference/FStringClassReference.


    1. ufna
      05.09.2016 22:33

      А что с ним не так? Он же не более чем шаблонизированная обвязка над FStringAssetReference, что позволяет не мудрствуя лукаво отсекать ассеты по классу, вместо опасной возможности «вставить всё куда угодно».


      1. slonopotamus
        05.09.2016 22:41
        -1

        Ну например он не умеет передаваться по сети.


        1. ufna
          05.09.2016 22:47
          +3

          Мне сложно представить ситуацию, при которой потребовалось бы кидаться с сервака таким типом данных, но даже если так — из TAssetPtr прекрасно достается FStringAssetReference, который можно отправить RPC'шкой на клиент в нужный момент :)