keywords: UE4, Loading Assets Asynchronously, Memory Persistence

为什么需要异步加载资源,因为当一次性加载的资源较多或者单个资源较大时,普通的LoadObject()方式会阻塞引擎的主线程。

假设测试工程叫TestTD4,自定义Character叫ATestTD4Character(头文件为TestTD4Character.h)

假设在Content/Assets/目录下放了三个动画文件(AnimSequence)。

异步加载

通过DefaultGame.ini配置文件生成FSoftObjectPath

DefaultGame.ini

[/Script/TestTD4.TestTD4Character]
+TestAssets=/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump
+TestAssets=/Game/Assets/ThirdPersonRun.ThirdPersonRun
+TestAssets=/Game/Assets/ThirdPersonWalk.ThirdPersonWalk

TestTD4Character.h

UPROPERTY(Config)
TArray<FSoftObjectPath> TestAssets;

这个属性的意思是:加Config标签表示从DefaultGame.ini读取,TestAssets就是DefaultGame.ini中配置的属性名,当游戏启动时,这个数组会被自动填充3个元素,即资源的路径。

注:4.18版本中FStringAssetReference、TAssetPtr两个变量被重命名为:FSoftObjectPath、TSoftObjectPtr.

TestTD4Character.cpp

void ATestTD4Character::BeginPlay()
{
    Super::BeginPlay();

    for (FSoftObjectPath& Asset : TestAssets)
    {
        GEngine->AddOnScreenDebugMessage(-1, 3, FColor::Cyan, Asset.ToString());
    }

    FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
    AssetLoader.RequestAsyncLoad(TestAssets, FStreamableDelegate::CreateUObject(this, &ATestTD4Character::AnimAssetsDeferred));
}

void ATestTD4Character::AnimAssetsDeferred()
{
    for (FSoftObjectPath SoftObj : TestAssets)
    {
        TAssetPtr<UAnimSequence> AnimAsset(SoftObj);

        UAnimSequence* AnimObj = AnimAsset.Get();
        if (AnimObj)
        {
            GEngine->AddOnScreenDebugMessage(-1, 3, FColor::Red, AnimObj->GetName());
        }
    }
}

打印结果:

ThirdPersonWalk
ThirdPersonRun
ThirdPerson_Jump
/Game/Assets/ThirdPersonWalk.ThirdPersonWalk
/Game/Assets/ThirdPersonRun.ThirdPersonRun
/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump

如果注掉RequestAsyncLoad,直接执行AnimAssetsDeferred(),则打印结果为:

/Game/Assets/ThirdPersonWalk.ThirdPersonWalk
/Game/Assets/ThirdPersonRun.ThirdPersonRun
/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump

说明这三个资源确实是运行时异步加载,而不是游戏启动时就被自动加载。

通过路径字符串生成FSoftObjectPath

头文件中定义一个变量

FSoftObjectPath SoftObj;

在cpp函数中通过路径赋值(比如在BeginPlay()函数中)

SoftObj = FSoftObjectPath(TEXT("/Game/Mannequin/Animations/ThirdPersonWalk.ThirdPersonWalk"));
FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
AssetLoader.RequestAsyncLoad(SoftObj, FStreamableDelegate::CreateUObject(this, &AUMGTestGameModeBase::AnimAssetsDeferred));

回调函数中获取对象

void ATestTD4Character::AssetsDeferred()
{
    TSoftObjectPtr<UAnimSequence> AnimAsset(SoftObj);
    UAnimSequence* AnimObj= AnimAsset.Get();
    if (AnimObj)
    {
        GEngine->AddOnScreenDebugMessage(-1, 3, FColor::Red, AnimObj->GetName());
    }
}

同步加载

加载资产

同步加载有两种API:

  • FStreamableManager::LoadSynchronous

      UAnimSequence* AimObj = AssetLoader.LoadSynchronous<UAnimSequence>(FSoftObjectPath(TEXT("/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump")));
    
  • FStreamableManager::RequestSyncLoad

      TSharedPtr<FStreamableHandle> Handle = AssetLoader.RequestSyncLoad(FSoftObjectPath(TEXT("/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump")));
      if (Handle.IsValid())
      {
          UAnimSequence* AnimiObj = Cast<UAnimSequence>(Handle->GetLoadedAsset());
      }
    

FStreamableManager的源码注释已经写明:RequestAsyncLoad、RequestSyncLoad、LoadSynchronous等待延迟时间可能长达数秒。LoadSynchronous和RequestSyncLoad的内部实现是对异步加载的封装:调用FStreamableHandle::WaitUntilComplete()阻塞等待。RequestSyncLoad函数内部要么会进行异步载入并且调用WaitUntilComplete函数,要么直接调用LoadObject函数 —— 哪个更快就调哪个。

加载蓝图(角色蓝图、UMG蓝图等)

StreamManager加载蓝图时,不能使用默认路径(即使后缀加_C),否则加载出来的Class无法使用,虽然不为空。
正确方式:前缀统一用Class,然后后缀再加_C。

示例:

FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
UClass* WidgetClass = AssetLoader.LoadSynchronous<UClass>(FSoftObjectPath("Class'/Game/TopDownCPP/Blueprints/NewWidgetBlueprint.NewWidgetBlueprint_C'"));
if (WidgetClass)
{
    UUserWidget* Widget = CreateWidget<UUserWidget>(this, WidgetClass);
    if (Widget)
    {
        Widget->AddToViewport();
    }
}
TSoftObjectPtr 同步加载

header:

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Test")
TSoftObjectPtr<UParticleSystem> Particle;

cpp:

if (!Particle.IsValid())
{
    Particle.LoadSynchronous();
}
UParticleSystem* DestParticle = Particle.Get();
UGameplayStatics::SpawnEmitterAtLocation(this, DestParticle, Location, Rotation, Scale);

若不使用TSoftObjectPtr,而是UParticleSystem* Particle;,则游戏启动时就会强制加载资产,大量使用会导致内存暴涨。

资源内存释放

用上述方式加载资源后(包括同步加载和异步加载),如何再释放资源并从内存中销毁?两种情况:

自动回收

只要对象失去引用后就会被自动释放,无需手动Destroy。如果是异步加载,对象只在回调函数中有效,回调函数执行完毕后,就会被标记为可收回状态,如果此时ForceGC,则对象会立即销毁。

手动回收 - ManageActiveHandle

在执行加载时,将bManageActiveHandle标记为true,默认为false:表示是否手动管理FStreamableHandle。如果设置为true,则对象会一直常驻内存直到手动释放。

FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
UParticleSystem* AimObj = AssetLoader.LoadSynchronous<UParticleSystem>(FSoftObjectPath(AssetPath), true);

当对象不再需要时,再手动执行执行Unload。之后对象就会被自动回收:

FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
AssetLoader.Unload(FSoftObjectPath(AssetPath));

Unload之后如果需要立即回收,可以执行ForceGC:

GEngine->ForceGarbageCollection(true);

在编辑器模式下,上述两种回收方式都不起效,会一直常驻内存,只有在打包运行版本中才会生效。如果在编辑器运行模式下强制Destroy()或者MarkPendingKill(),则对象可以从内存中销毁,但是无法再次Load,除非重启编辑器。

手动回收 - TSharedPtr

FStreamableManager::RequestAsyncLoad()会返回一个共享指针TSharedPtr<FStreamableHandle>,逻辑上去控制这个智能指针的生命周期,当返回值HandleTSharedPtr引用关系全部失效时,就会自动销毁当前Asset。

TSharedPtr<FStreamableHandle> Handle = FStreamableManager::RequestAsyncLoad();

前述的ManageActiveHandle控制内存回收方式,其实是FStreamableManager内部帮你做好了智能指针的对象引用管理。

加载资源中的引用关系

2017年用4.18版本测试的结果如下:

加载角色蓝图时,蓝图中引用的SkeletalMesh等资源并不会被加载,而是在SpawnActor时才会去加载。在移动端上,如果角色蓝图引用的资源(SkeletalMeshes、Materials、Textures等)体积较大时,首次SpawnActor时会出现hitch。这种问题在PC上的SSD硬盘上很难察觉。

2019年用4.23.preview7版本测试的结果如下:

  • 加载角色蓝图时,蓝图中引用的SkeletalMesh会被自动加载。
  • 加载材质蓝图(Material)时,材质中纹理样本(Texture Sample)引用的贴图会自动被加载,即使这个节点悬空没有连到材质节点上。
  • 加载特效(ParticleSystem)时,特效中粒子发射器(Emitter)引用的材质蓝图会自动被加载,包括材质(Material)与材质实例(MaterialInstanceConstant),并且该材质引用的所有贴图也会自动被加载。

注意事项

  • FStreamableManager::Unload()会Release掉和当前资源相关的所有FStreamableHandle。比如在三处位置加载了同一个资源,即使bManageActiveHandle设置为true,那么只要调用Unload一次,就可以将这三个FStreamableHandle对象全部Release掉,即从内存中释放该对象;如果对这3个FStreamableHandle对象分别执行Release,那么只有当最后一个Handle被Release之后,该资源才会从内存中释放。

  • 异步加载时,谁先请求则谁的回调函数先执行,不会存在回调函数执行顺序乱序的问题(除非修改TAsyncLoadPriority),因为引擎内部接收回调请求的容器使用的是TArray,且每次执行索引为0的回调,然后RemoveAt(0)。

  • 异步加载时,如果资源还没加载完成就执行ReleaseHandle()(假设加载时bManageActiveHandle为true),比如在执行回调函数之前执行ReleaseHandle,那么当资源加载完成后(回调函数执行之后),会自动从内存中回收。不过该对象在回调函数中仍然有效,除非在回调函数内ForceGC。

      void AMyPlayerController::TestAsyncLoadAndRelease()
      {
          FStreamableDelegate Call;
          Call.BindUFunction(this, FName("AsyncLoadCallback"));
    
          FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
          Handle = AssetLoader.RequestAsyncLoad(FSoftObjectPath(AssetPath), Call, 0, true);
          Handle->ReleaseHandle();
      }
    
  • UPROPERTY()修饰的成员变量,可以让其保持的资源对象常驻内存,如果不再需要驻留内存,将该成员变量值为NULL,等到下次GC时就会被自动回收

  • GEngine->ForceGarbageCollection();执行后,内存回收至少要等到下一帧才会执行。在当前帧内,即使一个对象执行ConditionalBeginDestroy()且执行了ForceGarbageCollection,当前帧内该对象仍然有效。

  • ConditionalBeginDestroy()是所有UObject都有的API,其对象销毁是异步执行且对象在当前帧内持续有效;AActor::Destroy()是AActor特有的API,其对象回收发生在当前帧结束时。

  • bManageActiveHandle为true时,即使切换关卡,该资源也不会被销毁,类似AddToRoot()的效果。

常见错误

【2018-05-21】
如果在场景A中去加载场景B(SyncLoad或者AsyncLoad,不是UGameplayStatics::OpenLevel方式),那么加载场景B时,bManageActiveHandle参数不能设置为false,而要设置为true。

现象:
如果bManageActiveHandle设置为默认值false,那么当执行OpenLevel切换到场景B时,若此时场景B的内存已经被回收掉,则会导致程序无响应卡死(20秒左右后闪退,但是没有崩溃日志),而且在移动端还会引起各种莫名其妙的问题。

解决办法:
如果场景A中需要Load场景B,bManageActiveHandle设置为true;等到切换到场景B时,再手动ReleaseHandle。

【2018-08-16】
使用TCHAR作为路径名时编译会报错:

FString Path("/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump");
TSoftObjectPtr<UParticleSystem> Asset(FSoftObjectPath(*Path));
UParticleSystem* Part = Asset.Get();

错误日志:

error C2228: left of '.Get' must have class/struct/union

解决办法:
使用TCHAR_TO_ANSI转换:

FString Path("/Game/Assets/ThirdPerson_Jump.ThirdPerson_Jump");
TSoftObjectPtr<UParticleSystem> Asset(FSoftObjectPath(TCHAR_TO_ANSI(*Path)));

参考

《Fortnite》开发经验分享之运行时资源管理:Runtime Asset Management
https://answers.unrealengine.com/storage/temp/136465-runtimeassetmanagementin416.pdf

Referencing Assets
https://docs.unrealengine.com/en-US/Programming/Assets/ReferencingAssets/index.html

Asset Manager Explained | Inside Unreal
https://www.youtube.com/watch?v=9MGHBU5eNu0

ChunkDownloader Explained | Inside Unreal
https://www.youtube.com/watch?v=h3A8qVb2VFk


如果热爱与希望,背道而驰,那我选择热爱。 ----井上雄彦