虚幻引擎资产管理总结

759 阅读18分钟

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

一、前言

当我们打开游戏引擎后,做的第一件事应该就是导入或者手动创建某些开发中所需要的资源,它们可能是模型(Mesh)、贴图(Texture)或者场景(Map)。这些资源存在于引擎中显得再自然不过,然而引擎将这些资源进行打包,生成游戏包体,是如何确定互相之间的引用关系的,如何管理这些资源在内存中的加载和释放的,我们又要如何手动管理内存资产,如何优化内存和游戏包体大小。想要回答这些问题,首先要搞懂引擎对资产的管理方式。

本文将介绍虚幻引擎对资产的管理,手动管理游戏需要的资产,资源的加载和释放,资产引用关系以及打包游戏资产的设置等,全文大概1.2万字,如有不足,请指正。

二、何为资产

首先,理解资产的概念很重要,这是解决上述问题(手动管理游戏需要的资产,资产的加载和释放)的基础。那么,何为资产(Asset)?贴图算是资产么?算是。Mesh算是资产么?算是。蓝图算是资产么?算是。其实引擎早已通过文件后缀的方式告诉我们了:以uasset为后缀的文件在虚幻引擎眼里都是资产,但在content文件夹下除了uasset还存在另一种文件:umap,它也是资产,而且是“更高级”的主资产(Primary Asset),之后会解释其含义。

使用工具
以下关于代码的分析等使用的工具环境是:

  • Windows 10
  • UE 5.0.1
  • Visual Studio 2022

uasset的本质
首先,uasset在磁盘中的表现形式是文件,当我们在引擎里对某个uasset资产做引用的时候,会通过路径将它加载到内存当中,而每个uasset会和UPackage类对象一一对应,然后存储一个UPackage对象时,会将该包下的所有对象都存到uasset中。而他们之间的关联方式则是我们经常在C++中见到的“Outer”对象,下面是在编辑器模式下打印的一个空Actor蓝图资产的UPackage中关联的其他UObject,包括对应的Class、CDO、蓝图节点等:

这里需要注意的是在编辑器模式下而非Runtime时,因为在不同模式下对应的“Outer”是不同的,编辑器中蓝图UObject的“Outer”是UPackage,而在Runtime是Actor的“Outer”一般是所在的Level。

那么,什么是“Outer”?其实就是英文单词意思直译过来就好了,一个Object被另外一个包含,如Level中存在很多Actor,Actor中存在很多个Component,这里Level和Actor就是对应的Outer,也可以通过在C++中获取最外面的“Outer”对象。

这里要区分Outer和Owner,Component的Outer和Owner一般都是Actor,而Actor一般没有Owner,Outer是所在的Level,编辑器下是UPackage。

打印逻辑如下:

加载uasset的过程,填写路径的相关细节说明,大钊老师已做出详细解释和说明,视频链接放在最后供参考。

至于文件后缀(uasset或者umap),由EPackageExtension枚举定义,也可以自定义资产后缀做类型去区分:

/**
 * Enum for the extensions that a package payload can be stored under.
 * Each extension can be used by only one EPackageSegment
 * EPackageSegment::Header segment has multiple possible extensions
 * Use LexToString to convert an EPackageExtension to the extension string to append to a file's basename
 * (e.g. LexToString(EPackageExtension::Asset) -> ".uasset"
 * Exceptions:
 * Unspecified -> <emptystring>
 *  Custom -> ".CustomExtension"; the actual string to use is stored as a separate field on an FPackagePath
 */
enum class EPackageExtension : uint8
{
// Header Segments
/**
* A PackageResourceManager will search for any of the other header extensions
* when receiving a PackagePath with Unspecified
*/
Unspecified=0,
/** A binary-format header that does not contain a UWorld or ULevel */
Asset,
/** A binary-format header that contains a UWorld or ULevel */
Map,
/**
* Used when the owner of an EPackageExtension has a specific extension that does not match
* one of the enumerated possibilies, e.g. a custom extension on a temp file
*/
Custom,
// Other Segments

};

现在,我们知道了在加载uasset的过程其实是加载与该Object相关的一系列Object,那么,引擎又是如何管理所有需要用到的uasset的呢?答案是AssetManager。

三、AssetManager

当点击运行键时,引擎会自动运行当前关卡的逻辑,在不做手动管理的情况下,当前Level资源的加载都是引擎自动帮你完成的,这可以减轻开发者的工作负担,但加载过程仿佛变成了黑盒子,这不是大多数开发者希望面对的事,我们有时需要手动管理资源的加载和释放、优化内存、获取资源加载进度等,引擎也提供了对应的接口,那就是AssetManager类。

AssetManager是一个单例类,在C++中继承自定义子类之后需要在引擎设置中进行覆盖:

AssetManager提供了一些资源加载和卸载,其中主要的有:

/** Gets the FAssetData for a primary asset with the specified type/name, will only work for once that have been scanned for already. Returns true if it found a valid data */
virtual bool GetPrimaryAssetData(const FPrimaryAssetId& PrimaryAssetId, FAssetData& AssetData) const;

/** Gets list of all FAssetData for a primary asset type, returns true if any were found */
virtual bool GetPrimaryAssetDataList(FPrimaryAssetType PrimaryAssetType, TArray<FAssetData>& AssetDataList) const;

/** Gets the in-memory UObject for a primary asset id, returning nullptr if it's not in memory. Will return blueprint class for blueprint assets. This works even if the asset wasn't loaded explicitly */
virtual UObject* GetPrimaryAssetObject(const FPrimaryAssetId& PrimaryAssetId) const;
virtual TSharedPtr<FStreamableHandle> LoadPrimaryAssets(const TArray<FPrimaryAssetId>& AssetsToLoad, const TArray<FName>& LoadBundles = TArray<FName>(), FStreamableDelegate DelegateToCall = FStreamableDelegate(), TAsyncLoadPriority Priority = FStreamableManager::DefaultAsyncLoadPriority);

/** Single asset wrapper */
virtual TSharedPtr<FStreamableHandle> LoadPrimaryAsset(const FPrimaryAssetId& AssetToLoad, const TArray<FName>& LoadBundles = TArray<FName>(), FStreamableDelegate DelegateToCall = FStreamableDelegate(), TAsyncLoadPriority Priority = FStreamableManager::DefaultAsyncLoadPriority);
virtual int32 UnloadPrimaryAssets(const TArray<FPrimaryAssetId>& AssetsToUnload);

/** Single asset wrapper */
virtual int32 UnloadPrimaryAsset(const FPrimaryAssetId& AssetToUnload);

公开到蓝图中的方法:

这里多次出现了三个重要的类型:PrimaryAsset、PrimaryAssetType和PrimaryAssetId。

  • PrimaryAsset:主资产,接下来会介绍。
  • PrimaryAssetType:主资产类型,对主资产进行分类的标签。
  • FPrimaryAssetId:主资产ID,用于对主资产做唯一标识,维护PrimaryAssetType和PrimaryAssetName信息,在蓝图中的获取方式:

struct FPrimaryAssetId
{
/** An FName describing the logical type of this object, usually the name of a base UClass. For example, any Blueprint derived from APawn will have a Primary Asset Type of "Pawn".
"PrimaryAssetType:PrimaryAssetName" should form a unique name across your project. */
FPrimaryAssetType PrimaryAssetType;
/** An FName describing this asset. This is usually the short name of the object, but could be a full asset path for things like maps, or objects with GetPrimaryId() overridden.
"PrimaryAssetType:PrimaryAssetName" should form a unique name across your project. */
FName PrimaryAssetName;
}
/**
 * A primary asset type, represented as an FName internally and implicitly convertible back and forth
 * This exists so the blueprint API can understand it's not a normal FName
 */
struct FPrimaryAssetType
{
/** Convert from FName */
FPrimaryAssetType() {}
FPrimaryAssetType(FName InName) : Name(InName) {}
FPrimaryAssetType(EName InName) : Name(FName(InName)) {}
FPrimaryAssetType(const WIDECHAR* InName) : Name(FName(InName)) {}
FPrimaryAssetType(const ANSICHAR* InName) : Name(FName(InName)) {}

/** Convert to FName */
operator FName&() { return Name; }
operator const FName&() const { return Name; }

/** Returns internal Name explicitly, not normally needed */
FName GetName() const
{
return Name;
}

private:
friend struct Z_Construct_UScriptStruct_FPrimaryAssetType_Statics;

/** The FName representing this type */
FName Name;
};

AssetManager提供了很多加载卸载主资源的方法和AssetBundle相关的函数,那么什么是主资产呢?

Primary and Secondary Assets
某个打包出来的游戏开始运行,如果不手动指定一个“Default Map”,程序是不知道开始进入哪个场景的,也更不清楚该加载哪些资源,而一旦指定了某个Level作为主场景,程序就会加载该场景以及一切引用到的资源。

在这里,Primary Asset是要指定的Level,Secondary Assets是Level所包含的其它被引用到的资产,包括Mesh、Texture、Audio等等。

虚幻引擎中的资产管理系统将所有资产分成两种类型:Primary Asset和Secondary Assets。Primary Asset可以由AssetManager通过它们的主要PrimaryAssetId直接操作,它是通过调用GetPrimaryAssetId获得的。Secondary Assets不是由资产管理器直接处理的,而是由引擎自动加载,以响应被主要资产引用或使用。(希望解释清了)

Primary and Secondary Assets的区分
虽然大概分类理解了,但是具体到某个资产的时候,要如何做区分呢?其实有一个特别简单的方式,就是将鼠标移到对应资产下,资产详细信息就会有所显示:

Primary Asset的信息:

Secondary Assets的信息:

他们的区别就是,Primary Asset是会标注PrimaryAssetType和PrimaryAssetName信息的。

继承C++类的蓝图转变为Primary Asset
为了将来自特定UObject类的资产指定为Primary Assets,覆盖GetPrimaryAssetId以返回一个有效的FPrimaryAssetId结构体,如:

virtual FPrimaryAssetId GetPrimaryAssetId() const override;

FPrimaryAssetId AMyActor::GetPrimaryAssetId() const
{
UPackage* Package = GetOutermost();

if (!Package->HasAnyPackageFlags(PKG_PlayInEditor))
{
return FPrimaryAssetId(UAssetManager::PrimaryAssetLabelType, Package->GetFName());
}

return FPrimaryAssetId();
}

改为PrimaryAsset有什么用呢,当然是通过AssetManager对其加载做管理了。

目前为止,UWorld是引擎默认唯一的主资产,对应的C++也对其GetPrimaryAssetId方法做了重写:

刚刚说了如何将蓝图资产转变为主资产,那么其他类型如Mesh或者Texture应该如何转变为主资产呢,答案是使用Data Asset。

四、Data Asset

创建Data Asset的方式很简单,如下图:

不过需要提前选择一个继承UDataAsset的基类,当然如果想使用主资产,就得继承UPrimaryDataAsset,在类中添加对应需要的资产引用,并且这个基类需要重写GetPrimaryAssetId()方法,指定自定义的PrimaryAssetType(可以参考map的实现方式)。

需要注意的是上述的创建Data Asset的方式实际上是创建了一个选定基类的实例:

当创建了对应的Data Asset之后,就可以调用AssetManager中的方法获取并加载资源了,可以选择同步或者异步进行加载:

等一下,是不是感觉忽略了什么?没错。忽略了一项非常重要的设置:告诉引擎指定PrimaryAsset的位置。

因为之前提到过,如果不告诉引擎你需要加载PrimaryAsset,比如Default Map,引擎是不会去加载它的,除非你在某个位置对它进行了引用。那么,要在哪里设置呢?看下图:

需要填的信息:

  • Primary Asset Type:Data Asset基类中指定的PrimaryAssetType
  • Asset Base Class:指定的Data Asset基类
  • Has Blueprint Class:指这个类型是否有蓝图资产(Data Asset不是蓝图),一般是C++重载方法的才有
  • Directories:Data Asset所在的文件夹
  • Rules:后面讲打包分块的时候会说

其他的选项看Tip基本能理解就不说了。

做好设置之后就可以用AssetManager加载、卸载资源了。

注意事项:
虽然AssetManager提供了卸载Primary Asset的方法,但是只有在保证该Primary Asset没有引用,才会被垃圾回收在合适的时间进行回收。

五、Data Table,Curve Table,Data Registry

其实这三兄弟和Data Asset关系不大,它们主要作用是存储数据,然后在运行时加载,如果要讲可能要单独写一篇关于其他主题的内容了,所以这里不多介绍。

到目前为止,想讲的基础讲完了,接下来才是本文重点。

六、异步加载

上面说过,引擎开始加载某个Level,是通过一层层引用加载对应的资产和Class的,如果某个Level关联着PlayerCharacter(基本上是必然的),然后PlayerCharacter身上又引用了非常多的其他类和资源,那就意味着引擎要花费相当长的时间去加载这些资产,而且很有可能大部分关联的资产在当下用不到,加载速度慢,耗用大量内存,很显然这不是我们想要的,那要怎么解决这种内存相关和引用相关的问题呢?除了上面所说的Assetmanager做资产管理,还需要涉及到另一个东西:异步加载资源。

为什么说异步加载资源可以解决上述问题,举个例子:PlayerCharacter身上由于不可避免的原因引用了目前不需要的类,这个类或者蓝图身上携带大量的Texture等占用内存资源的资产,如果不使用软引用或者异步加载这些资源,就意味着只要存在PlayerCharacter,这些占用的空间就不会被释放(因为垃圾回收只有在没有引用的时候才会回收空间),而如果使用软引用,在需要的时候通过异步加载的方式加载资产,这样就能防止所有被引用的资产在一开始全部一股地被加载进内存。那么在蓝图中哪些情况会视为存在引用呢?我总结了以下几种情况:

以下方式算作被引用:

1.创建某类型变量,不管是否为空,这个类以及它引用的类和资产会被引用,这是最直接的引用方式

2.CastTo(强转节点)
强转成对应类也会对该类进行引用,听起来有点难解引用,但是更难的还在后面。

3.Get All Actors/Widgets

此节点性能差,一般能存成数组就用数组了,但依然没法躲开引用关系。
你说用函数传参行不行,抱歉,也不行。

4.函数参数,参数类也会被视为存在引用

但最恶心的是下面这种:

5.已经废弃的,没有连接在蓝图中的节点变量,因为误删了一些变量,然后又没有连线,所以编译能通过,却存在对已废弃的变量的引用,所以一定不要认为只要把蓝图节点断开就算完事儿了。

例如:BP_Actor存在的引用:

BP_Character的引用来自这里:

那么,要如何尽量减少类与类之间的引用呢,请继续看。

解引用
解引用是一个复杂的过程,需要有好的项目架构作为基础,同时在写蓝图的时候要处理好变量与各种节点的使用,我们也不需要过分去解引用而破坏了项目结构或者复杂化实现简单逻辑,这样就得不偿失了,这里有一个合适的“度”需要根据项目去衡量。

提供几种方式解引用:
1.软引用合理

但是需要对比一下下面这几种情况:
Actor基类变量:

Texture2D变量:

蓝图类变量:

从提示中可以得出结论:以C++为类为变量的类型,会被视为软引用,而如果变量是蓝图类,则依然会被直接加载引用,这也是为什么Reference Viewer中蓝图类变量会被视为硬引用的原因:

事实上,一般情况都是将大资产(Texture,Mesh)之类的设置为Soft Object,将蓝图类变量设置为Soft Object意义不大。软引用的本质是存储的对应路径类名字,在需要的时候异步加载,但思考一件事:当我们将软引用对象加载出来后强转成对应类型或者将它保存为某个变量,软引用的作用还存在么?显然没啥意义,换做是Texture,就无伤大雅了,如果你希望类与类之间脱离引用关系,不妨试一下下面两种方式。

2.多继承和接口
如果说Soft Object主要是用来处理资产的引用关系,那么继承方式就是处理类与类之间解引用的方式了。有时候我们会看到这样一种设计:一个基类蓝图存在它所需要的大部分,只是所引用的资产全部为空,而它的子类也没有什么过多重载方法和扩展,不同之处在于子类引用的资产存在对应的资源(硬引用或者软引用),这么做有什么好处呢?其实好处就是避免了CastTo节点造成子类和子类之间的引用,只保留父类之间的引用关系就好,这是一种我认为比较好的解引用的方式。

3.委托
委托简直是解引用的神器,当我们希望连父类之间的引用关系都不存在时,那么委托是一个绝佳选择,只要做时间通知就好,至于委托怎么使用我就不多说了,很简单。

其实以上几种方式应该是能满足很多情况了,如有其他方式欢迎评论补充。

七、打包引用

引擎通过引用的方式从主资产开始加载所需资源,在编辑器中如此,打包项目也是如此,指定主资产并且根据引用关系遍历所有用到的资源,但是需要注意的是,当我们使用了上述的软引用方式,引擎就会认为你没有对该资产进行使用,也就不会将它打包进游戏包体中,或者如果所需资源在任意地方都不存在引用,但是却需要将该资源打包进项目中,就需要在引擎的设置中手动添加对应资产的路径,告诉引擎,这个文件夹下的资产也需要打包到游戏里面。当然你也可以选择哪些资产不要被打包进游戏:

不过我想说的重点是如何对包头做分块处理。为什么要分块包体?原因很简单,为了方便后续更新维护,开发者不希望因为后续做了一点点改动,就需要对整个游戏包体都做更新,玩家也不想因为开发者改了几个bug就重新下载一遍游戏,那么解决方式就是对包体进行分块,然后开发DLC也是同样到理,而且分块不应过多,过多就意味着压缩更小,包体更大,而过少就意味着更新会很麻烦,做好其中的权衡很重要。

PAK分块
游戏打包之后数据会被封到PAK文件当中,像这样:

想要对主资产进行分块,需要用到PrimaryAssetlabel,创建Data Asset并继承PrimaryAssetlabel。

打开之后就是这样:

参数说明:
Priority:优先级,值越大,对资产的管理权越高,换句话说,如果一个资产同时满足两个PrimaryAssetlabel条件,这个资产会分给Priority值大的。

Chunk ID:当前分块的ID,与PAK文件名字对应,-1默认是pakchunk0中。

Ccook Rule:下面介绍。

其他的比较简单,就是选择对应路径下的资产或者指定资产由此PrimaryAssetlabel进行管理。

设置好之后很有可能不同Chunk之中引入了相同的资源,那么我们要如何判断资产属于哪个块,以及每一个块的大小呢,后面介绍。先说一下Cook。

Cook
什么是Cook(烘焙)?

Cook简单而言就是对“没用”资产的提出过程,比如在日常开发过程中,我们需要调试,以及有些功能只有在编辑器下才可用,资产中也会携带与正式发布版无关的功能或者相关内容,Cook的过程就是对这一类我们不需要的东西做剔除,然后只保留游戏相关的部分,这个过程就是的Cook。

理解了Cook的概念再对上面做参数介绍的时候有关Cook的部分这里做一下说明:

  • Unknown:相当于自动Cook,如果有对此资源的引用就Cook,如果没有就不Cook
  • Never Cook:永不Cook,如果存在对它的引用会报错
  • Development Cook:在Development打包条件下有引用就会Cook
  • Development Always Cook:在Development打包条件下永远会Cook
  • Always Cook:在Development或者Ship打包条件下永远会Cook

总结:只要有对资产的引用,就应该做Cook操作。

Cook前后资产对比:

图片引擎中的资产大小

Cook之后的大小

从对比可知,Cook前后资产大小的变化,一般会减小,因为去掉了编辑器相关的部分。

八、可视化工具

上面提到了一些工具,这里做一下总结:

Reference Viewer
某个资产的引用关系,可以显示Soft Reference和Soft Reference,以及对应的主资产,用于解引用:

Audit Asset
资产分块工具,用于查看某个或者分块资产的“相对准确”的大小,并不代表最终打包后的大小,如果想要查看自己分块的大小,记得提前将内容进行Cook:

Cook完成Refresh会多Windows选项:

多创建几个然后打包,要注意资产的划分方式,防止相同的被放到两个Chunk中:

Size Map
用于查看资产占用大小,通过它优化某个大资产:

内存命令
Memreport会保存当前内存使用情况,也可进行调试。

九、总结

到目前为止,应该算是讲清楚了引擎如何打包找到需要的资产、资产之间的引用方式、如何手动管理主资产的加载和卸载、以及对内存的优化与调试,细节还有很多,包括UE5又出了Game Feature等新的开发模式,以及具体参数可以看官方文档,有时间再补充。

引用
Asset Management

[英文直播]Asset Manager 阐述 | Asset Manager Explained(官方字幕)_哔哩哔哩_bilibili

[中文直播]第33期 | UE4资产管理基础1 | Epic 大钊_哔哩哔哩_bilibili

打包项目

Cooking and Chunking


这是侑虎科技第1461篇文章,感谢作者雪流星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:www.zhihu.com/people/xuel…

再次感谢雪流星的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)