UE4的资源管理
为什么要做资源管理?
开发一个大型游戏,因为美术资源和游戏数据都是海量的,不可能把所有的数据都放在内存里。本身玩家的游戏设备内存大小也是有限的,而大型游戏尤其是3A大作下载下来都几十G上百G,内存根本放不下。因此就需要有一个手段,可以在需要用的时候把他们加载进来,不需要的时候卸载掉,让内存尽可能的节省,把内存尽可能用到最合适的地方。同时又要避免内存和硬盘上的资源频繁加载卸载耗时影响用户体验。可想而知,要做好资源管理,是一件非常麻烦又非常有必要的事情。
UE4的资源文件和内存里对象的关系是什么?
UE4的资源,就是在工程文件夹下的那些非代码文件,比如Content下面的网格,材质,蓝图等这些文件,大部分资源是以uasset作为后缀的,也有其他后缀如地图关卡的umap等。在打包时,这些文件可能会根据平台需要,被cook成更小的平台专用文件,然后被放在后缀是pak的压缩包里。游戏运行时,程序就会挂载解压这些pak包,然后加载包中的资源文件来使用。打个不是很合适的比方,Pak包就类似于Unity的AssetBundle包,uasset文件就类似于unity的.meta管理的那些资源文件。
程序在用资源的时候,并不是直接在用这些文件。而是要把这些文件转化为UObject或其他程序可以用的内存对象。比如网格资源文件,程序用的实际是UStaticMesh对象。而把资源文件,转变为内存里的UObject对象,就是资源管理做的事情。
对于UE4来说,这个过程大概有这几个步骤:
- 读取资源文件的数据到内存
- 根据内存的二进制数据,把空壳对象反序列化成实际的对象
- 如果这个对象有依赖其他对象,就递归的去做1和2的操作,直到这个对象完整可用
- 调用对象的初始化函数,并将对象加入到引擎的对象管理中
UE4的资源是怎样索引的?
UE4是通过路径来关联索引资源的,就跟操作系统下面的文件一样,每个资源都有他的唯一路径。但是这个路径又和文件路径稍微有点区别。
比如像上图这样的一个静态网格资源,点击Copy Reference,再粘贴到文本,可以看到他们路径是这样的:
StaticMesh'/Game/Geometry/Meshes/1M_Cube.1M_Cube'
我把路径用颜色标了一下方便描述
- 红色部分:(StaticMesh)
是资源的类型,平时加载资源的时候也可以不要,加载函数内部会截掉这部分只留单引号里面的路径,所以其实前面这个StaticMesh或Blueprint可以不写,在编辑器Content Browser里右键点资源拷路径是有这部分的,我猜引擎留着这部分可能是为了用户清楚他的类型是什么,更方便识别一些,这个只是给人看的,程序不看这个。
- 绿色部分:(/Game)
是资源的分区。大部分资源的路径都是以/Game开头,这个其实表示这个资源是在游戏的Content目录下面,也可以是/Engine就表示引擎下面的,比如下图,或者对应插件目录
- 蓝色部分:(/Geometry/Meshes)
就跟操作系统文件管理器中的一样,表示资源在哪个文件夹下面
- 黄色部分:(1M_Cube)
资源的包(Package)名,也就是这个资源所在的真实物理资源文件(uasset/umap)的名字,包其实就是UE4将对象按照自己的规则序列化到磁盘上的文件,在Content Browser里看到的每一个文件都是一个包
- 紫色部分:(1M_Cube)
资源的对象名,因为物理的资源文件里面可能有多个对象,这个名字可以唯一标识包的内部每个对象的唯一名字。比如蓝图资源里有多个UObject,一个关卡文件里有多个Actor,一个UI蓝图里有多个控件。如果不写,UE4的某些接口会默认以包名补充到后面,也就是说默认使用和包名相同的对象名,但有的接口又可能不做处理,所以还是建议写。如果用的不是默认对象,而是资源对象的类,就要在后面加一个_C,如果是CDO对象,就要在前面加Default__
- 冒号后面的部分
有些资源路径后面会带冒号:接一个文件名,这种其实是对象的子对象名,有的资源对象内部有子对象,比如C++类里的子类,这个不常用,知道即可
业务逻辑要怎样加载资源?
先简单列一下UE4资源加载的API,可能也有别的,但这些最常用:
- 查找资源
- FindObject
- FindObjectFast
- FindObjectChecked
- FindObjectSafe
- FSoftObjectPath::ResolveObject
- 同步加载资源
- LoadObject
- LoadClass
- LoadPackage
- FSoftObjectPath::TryLoad
- FStreamableManager::RequestSyncLoad
- FStreamableManager::LoadSynchronous
- FlushAsyncLoading(异步转同步)
- 异步加载资源
- LoadPackageAsync
- FStreamableManager::RequestAsyncLoad
- 判断加载状态
- GIsSavingPackage
- IsGarbageCollectingOnGameThread
- IsLoading
- GetNumAsyncPackages
- GetAsyncLoadPercentage
- FStreamableManager::IsAsyncLoadComplete
大致介绍一下其中一些API的内部细节:
FindObject,FindObjectFast,FindObjectChecked,FindObjectSafe
查找资源的接口,会在内存中查找对象,找到就会返回,找不到会返回nullptr,不会触发加载。如果传入了Outer,就会在Outer所在的Package下面找对应的资源对象,如果没有Outer就会在全局找这个资源对象。Fast版本功能和FindObject相同,但是不会检查路径,明确知道完整路径时用这个可以避免检查路径开销,速度会快一些。Check版本功能也和FindObject相同,但是不会返回nullptr,找不到就会报Fatal,直接停止程序(Shipping和Test版不会)。Safe版本的函数功能和FindObject相同,但是在gc中或者在保存包中直接返回nullptr
FSoftObjectPath::ResolveObject
是对FindObject的封装函数,内部会根据FSoftObjectPath保存的FName路径查找资源,还会处理重定向。这里FSoftObjectPath是软引用,后面会具体说。
看内部源码实现的话,其实会发现这些函数不同版本,最终都会调用到 StaticFindObjectFastInternal 这个函数,下面具体来说下这个函数内部流程
可以看到这里传入的ObjectName(资源路径)会被GetObjectOuterHash转为hash值,通过Hash值在ThreadHash上取到一个对象列表,之后再根据条件取得要查找的对象(具体条件看上面红色框),这个ThreadHash结构如下
可以看到,UE4会把每个对象会根据Hash存到全局的HashOuter里,这是一个TMultiMap,也就是一个hash会对应多个Value。Hash是用GetObjectOuterHash这个函数计算出来的,内部实际是路径FName加Package名字取hash,这个计算结果本身就会冲突,多个路径有概率映射到同一个hash值,所以用MultiMap就可以将多个值存到同一个hash上。这样只要把加载好的对象存到这里,就可以保证即使多次查找也能找到同一个对象。
LoadObject,LoadClass,LoadPackage
这几个函数就是最常用的同步加载资源,内部会先调用FindObject在内存中找,找到了直接返回,没找到就会进入同步加载。如果看源码的话,会发现不管哪个同步加载函数,最终都会把路径转化为Package再进行LoadPackage,前面路径部分也提到过,一个包里如果有多个资源,他们在硬盘上对应的是同一个文件,那么只需要加载这个文件就好了。如下图,再深入底层可以看到,最终调用的是 LoadPackageAsync 函数,这就是异步加载的入口,并且最后FlushAsyncLoading,内部阻塞等待,将异步加载转为同步
- FSoftObjectPath::TryLoad
- FStreamableManager::RequestSyncLoad
- FStreamableManager::LoadSynchronous
- FStreamableManager::RequestAsyncLoad
这几个函数,其实都是更上层的封装,最终所有的加载都会走到 LoadPackageAsync 函数。这个函数就是UE4资源加载的大入口,后面整套资源加载都隐藏在了这个函数之后。其中最后一个FStreamableManager::RequestAsyncLoad是异步加载,可以提交要加载的资源路径和加载的回调函数,之后引擎在完成或失败时回调业务。
还有几个判断引擎资源加载状态的函数,对做业务逻辑和资源加载优化也很有用,下面简单介绍一下
- GIsSavingPackage
判断引擎当前是否在保存Package。平常可以不用关注,一般是保存资源的时候会为true,如果有运行时动态热更新资源的情况可能需要注意
- IsGarbageCollectingOnGameThread
判断引擎当前是否在gc,加载逻辑中有很多地方都有判断,如果当前帧处于gc中,这一帧就会跳过加载
- IsLoading
判断引擎当前是否正在加载,包括同步异步的情况,内部就是判断全局变量GGameThreadLoadCounter是否大于0,这个变量会在调用BeginLoad函数时侯加1,在调用EndLoad函数时候减1,具体细节后面说,如果为0说明引擎当前没有在做资源加载,也可以自己用这个数字判断当前有多少个资源在加载
- GetNumAsyncPackages
返回当前正在加载的包,名字虽然叫Async,但是包括同步异步的情况,因为无论是同步还是异步加载,最终调用的都是LoadPackageAsync函数
- GetAsyncLoadPercentage
获取指定资源加载进度,参数是FName包名,正在加载的资源返回值是0~100,如果找不到Package会返回-1
- FStreamableManager::IsAsyncLoadComplete
参数是FSoftObjectPath,判断指定的FSoftObjectPath对应的资源是否已经加载完成,加载完成后除了监听回调函数外,还可以用这个判断,如果完成就通过ResolveObject把资源取出来
UE4的资源加载内部是怎样做的?
首先看参数,有路径,加载完成的回调委托,以及返回一个加载的handle。那么可以大致想到这个流程就是我们业务提交一个路径进去,引擎在内部就默默的加载,等加载完成后调用回调函数,我们通过这个函数来处理加载好的资源对象。
下面来具体说明UE4引擎内部是怎样实现的
资源包文件的结构
简单来看,一个UPackage文件就是这样的,有很多UObject序列化的二进制数据
Summary: 这个是资源包的摘要信息,是加载资源时最早被加载进来的部分。里面最重要的就是保存有Import表和Export表,Import表描述了这个资源依赖哪些资源,Export表描述了这个包里面有哪些资源,这些资源可以被外面其他资源依赖这样的信息。
Export1,Export2...: 这个就是资源包内具体对象序列化后的二进制数据,依次排列着,前面导出表里有多少个资源,这里就会有多少个资源。等所有的Export都加载完成,这个包就加载完成了。
资源加载的主类
FAsyncLoadingThread
这个类是负责资源加载的主类,加载逻辑主要是在TickAsyncLoading中执行的,虽然类名以Thread为结尾,但其实加载这个工作根据不同模式也可以不在一个单独的线程来做。
加载的模式
引擎加载资源内部有两种模式,一个是Async,一个是EDL(EventDrivenLoader)
Async:会启动一个专门的加载线程负责Tick资源加载
EDL:在主线程Tick加载,加载的每一个步骤通过事件串联起来
因为他们本质做的事情没有区别,都是做上面这样的流程,EDL因为在主线程执行,优点是可以让资源加载完成后不用调度回主线程就可以使用,也不用额外开一个线程,因此比较适合移动端,缺点就是主线程要耗费额外的时间来处理资源加载流程。下面具体以EDL来详细说明。
资源加载的流程
- 加载Summary,这一步有IO
- 根据Import表信息,发起依赖资源的加载,并等待所有依赖资源完成
- 加载所有的Export对象,这一步有IO
- 执行PostLoad,并把对象加入引擎管理,完成加载
EDL流程
在EDL内部会把加载流程拆成多个Event,每个Event会做对应的工作,大部分Event结束时会通过入队操作直接开始下个Event,(Import的Event会通过计数等待前置都完成后才开始下个Event),下面对应加载的4个流程
因为其中有的步骤内部是会执行多次,比如Import和Export往往都是要处理多个资源加载,所以在EDL内部,又把加载拆成了多个节点,节点会组成一个有向图,从有向图的起点走到终点就完成了资源的加载
加载资源流程步骤如下:
Summary阶段 :保存了包内部信息,以及导入表和导出表。在EDL加载的时候,通过CreateLinker启动,会触发io异步读文件,完成时会回调到FinishLinker这一步。在这一步完成后,根据导入表和导出表就知道了当前包自己内部有哪些对象,需要依赖哪些外部对象。这部分在图中每个资源黄色部分只会有1个Node
Import阶段 :等待不包含在包内,但自己包内强引用了一些外部对象,需要等待这些外部对象加载好,才可以加载和反序列化自己的这些对象,继续执行后续步骤。这部分其实就是等待的过程,不触发IO操作。在EDL加载过程中,开始时有多少个Import就会把计数设为多少,之后会把自己的回调函数挂到其他资源上,其他资源加载好了会回调回来把自己的计数减1,计数为0的时候就完成了整个Import步骤,每个资源红框这部分会有多个Node(具体有多少个Import就有多少个Node)
Export阶段 :资源内除了包以外,其他的对象。在引擎EDL加载的时候,因为这些对象是包含在包内的,所以这一步会触发IO,加载等待过程和Import一样,只是多了异步IO,完成时会到PostLoad
PostLoad阶段 :这一步就是调用UObject上的PostLoad函数,程序可以在这一步做一些操作,比如修复资源问题之类
软引用和硬引用的区别
前面可以看到资源加载有个Import阶段,如果加载一个资源,这个资源依赖了很多资源,那么一定要等待这些资源都加载完成了,才能继续加载,这样对性能是很不友好的。如果很多依赖的资源不通过UE4这个自动依赖关系加载,而是业务逻辑自己去按需加载,就可以显著的提高资源加载速度。因此就有了软引用和硬引用。
硬引用就是自己的对象上,用UProperty标记的那些UObject指针变量,当在反序列化对象时,因为这些变量引用的资源必须跟着一起序列化好才能保证当前的对象是好的,所以这些变量在存储时会反应到Import表里面。自己写的UProperty资源越多,这个资源加载的就越慢
软引用就是FSoftObjectPath或TSoftObjectPtr引用的资源变量,这些不会随着当前对象一起加载,但是需要业务逻辑在需要用的时候手动调用加载代码来加载。
如果一个资源Import量很少或者没有,那么这个资源的加载速度就会很快。
需要注意的是,在C++重构代码将硬引用改为软引用时候,一定要主动刷新并重新保存一遍以这个C++为基类的所有资源,否则资源内部序列化内容还会是硬引用。
资源的卸载
默认情况下,加载中的资源由引擎持有引用,不会被卸载,加载完成后的资源会依赖引擎的gc卸载。如果没有被使用到,会在下次gc的时候释放掉。如果需要立即释放可以手动强制引擎gc。
但是可能有时候由于内存或其他各种原因,只想立即释放掉指定资源的内存,不想调用全局gc导致游戏产生卡顿,这时候要怎么办呢?我查了引擎官方文档以及网上各种文章,基本没有明确给出答案,下面我会根据以往我的经验介绍一些做法(野路子),不代表正确,但实测确实能立即释放掉内存,只是要自己额外花一些功夫保证安全。
- 大部分UObject,可以手动调用ConditionalBeginDestory,这里会主动先把对象的资源清理掉,留下一个空壳UObject。可以看引擎源码,这个函数相当于主动调用BeginDestroy,在对象gc时候也会调用,但是因为是Conditional的,如果之前已经调用过,在gc的时候就不会再次调用BeginDestroy从而避免了逻辑错误。通过这种方式,业务需要自行保证对象不再被使用,否则会出BUG。
- 如果是贴图,材质或Mesh等资源,可以直接调用ReleaseResource,这里内部会先把贴图或网格内存先释放,执行结束之后留下一个空壳UObject,对于空壳UObject其实就不怎么占用内存了,等待gc的时候销毁即可。另外因为有的ReleaseResource会Flush渲染命令队列可能产生卡顿,所以可以尽量把ReleaseResource这个函数提交到渲染命令队列,虽然内存释放不那么及时,但也比gc快很多,大部分情况一帧内就会释放掉。通过这种方式,业务也需要自行保证对象不再被使用,否则会出BUG。
- 如果是RenderTarget,引擎有提供专门的池,可以回收复用。
- 如果是动态材质,也有专门的函数可以删除。
- 如果是Actor或ActorComponent等,有专门的删除函数,可以调用DestroyActor或DestroyComponent,基本上能释放掉大部分资源。对于SceneComponent可以主动调用DestroyPhysicsState和DestroyRenderState释放掉物理或场景中的对象,也能立即释放一些内存。
整体流程上面其实都说完了,下面具体的流程很长很枯燥,如果想了解源码内部实现可以参考
EventQueue
这是EDL的队列,是个优先级队列,会在Tick中逐个Pop并执行,直到清空队列。Tick开始时先处理请求,之后对队列逐个PopExecute执行
EventLoadGraph
这是EDL中所有请求节点的有向图,每个要加载的资源上都存了一个数组,分两部分PackageNodes和Array
每个资源的每个节点,其实对应的就是下面的每一步
所以一个资源一共会有3+2*Import+3*Export这么多个节点要执行,执行结束后这个资源的Load流程就完成了,下面是Index转换过程
这里有个细节,包在存储Node时候,如果是Import资源,index用负数表示的,如果是Export就用正数表示,这样可以用同一个结构存储导入导出表,取索引的时候要把负数转回来,可以看下图:
这里是每个节点内的具体信息
EventLoadGraph本身是全局的,所以可以做到跨资源的事件依赖,这样A加载依赖B资源完成这样的复杂流程都可以通过EventLoadGraph表示出来。
这里还有个Arc,描述两个Node之间的关系,但是为了省内存,可以不开,只要保证数量正确,流程就不会出问题。如果魔改引擎加载代码出现BUG时,可以打开这个Arc来调试
从LoadPackageAsync开始
内部会调用到AsyncLoadingThread类的LoadPackage函数,看下面代码
这里加到准备队列中,并调用Trigger,加载如果是单独的线程,就会通过这个信号量唤醒阻塞中的加载线程。
到这里,就认为加载真正的启动了
下面简单介绍CreateLinker->FinishLinker的流程
前面有说EventQueue执行流程,下面可以看到pop出来执行CreateLinker操作
前面也能看到,在Tick的前面有一步调用了ProcessIncoming,这个函数就是在检查有没有IO线程过来的完成回调,如果有的话就继续,可以看到下面在Summary完成时,会让FinishLinker进队列
等再PopExecute到FinishLinker时候,内部会根据Linker上的ImportMap和ExportMap初始化节点并放到EventLoadGraph上执行
这里先放上自己前面两个固定的加载节点,然后循环加Import和Export节点
Fire的时候会检查这个节点的依赖数是不是为0,如果为0了就Fire
Fire的时候就可以进入下一步了。
加载流程中的关键部分已经都贴在了上面,后面的流程和上面也相似,就不再说明了,每一步的细节都比较多,可以自行看源码。