图形引擎实战:AssetBundle构建,更新与动态加载实现

在 Unity 引擎中,AssetBundle 系统允许我们在游戏发布后随时更新资源,还支持在游戏运行时根据实际需要动态加载资源。但是,由于 AssetBundle 系统的特定设计和固有属性,实际项目中使用它来进行资源管理可能会变得复杂,带来不少挑战。本文旨在从开发者的视角出发,探讨 AssetBundle 的构建、下载更新,以及加载与卸载的一些实践方法。

0. Basics

0.1 Assets

在 Unity 引擎中,asset 是构成游戏或 App 的基本元素,并以单个文件的形式存在于 Assets 目录中。它可以是 3D 模型,纹理贴图,材质,音频剪辑,动画,场景,C# 脚本或其它类型的文件。

在制作和生产的过程中,这些 asset 之间会自然地形成依赖(或引用)关系。例如,一个 prefab 可能依赖某些 fbx 模型,而这些 fbx 模型又依赖一些材质,而材质又依赖一些贴图和 Shader。

当 asset 首次被导入时,Unity 会在相同的目录中为其创建一个同名但扩展名为 .meta 的文件,这个 meta 文件中记录了为对应 asset 生成的 GUID。Asset GUID 提供了一种稳定的标识符,确保即使 asset 被修改、重命名或移动到另一个目录,它们之间的依赖关系也不会被破坏。

此外,在导入的时候,Unity 会对 asset 进行处理,把它转成游戏运行时可读取的内部数据格式(例如贴图的压缩等),并缓存在 Library 目录中。而原始的 asset 文件则会按原样保留,不会被修改。

0.2 AssetBundles

AssetBundle 是一个归档文件,类似于 zip 或 rar 压缩包。它包含了一组面向特定平台的(platform-specific)、非代码的 asset。在游戏运行时,Unity 允许我们动态地加载或者卸载 AssetBundle。

就像前面提到的,asset 之间自然存在着依赖关系。在构建 AssetBundle(AB)之后,asset 之间的依赖关系会转变为 AB 之间的依赖关系。

如上图所示,Material 1 依赖 Texture A,Material 2 依赖 Texture X。如此构建 AB 之后,将转变成 AB 1 依赖 AB 2 和 AB 3。在游戏运行时,如果要加载 Prefab A,则先要通过 AssetBundle.LoadFromFile 将三个 AB 都加载到内存中(AB 的加载顺序并不重要;实践中 AB 之间出现循环依赖也是常见的),之后通过在 AB 1 上调用 AssetBundle.LoadAsset 来加载 Prefab A。值得注意的是,Unity 会自动加载 Prefab A 依赖的 Texture A 和 Texture X,并不需要我们手动加载它们。

Unity 为我们提供了从 AB 中加载单个 asset 的能力,而不必一次全部加载。但不幸的是,就目前来说,Unity 没有为我们提供卸载单个 asset 的功能。

这些从 AB 获取的 asset 与它的 AB 之间存在着链接,要卸载这些 asset 则必须首先卸载它的 AB。如果我们使用 AssetBundle.Unload(false) 来卸载 AB,将导致这个 AB 与它的 asset 之间的链接断开:

如图所示,在使用 AssetBundle.Unload(false) 卸载 AB 后,AB 与 M 之间的链接断开了。

之后如果再次加载这个 AB,它并不会与 M 重新建立链接。可能令人感到惊讶的是,如果再次从这个 AB 加载 M,Unity 会重新创建一个新的 M 实例:

类似 M 这种与 AB 断开链接的 asset 后续只能通过调用 Resources.UnloadUnusedAssets 来卸载。

由于 AssetBundle.Unload(false) 并不理想,所以实际应用并不多见。大部分时候,我们会选择使用 AssetBundle.Unload(true) 来卸载 AB,这样在卸载 AB 的同时也会卸载所有从它获取的 asset。通常这意味着我们需要为每个 AB 维护一个引用计数,只有当一个 AB 里的所有 asset 都不再使用时,我们才会卸载这个 AB。

现在,情况变得稍微有些复杂:asset 能够单独加载,但必须(与其 AB 中的其它 asset 一起)批量卸载。

这意味着,在游戏运行的某个时刻,即使某些 asset 当前并不使用,我们可能也无法立即卸载它们。这是因为其它一些正在使用的 asset 直接或间接地引用了这些当前不使用的 asset 的 AB。无法及时卸载的 asset 会持续占用内存,增加内存占用的峰值。在最坏的情况下,内存占用到达系统上限,程序崩溃退出。

由于 asset 的生命周期强行与 AB 捆绑在了一起,致使我们必须将性能优化的主战场从游戏运行时转移到游戏编译时——我们必须巧妙地构建 AB,使每个 AB 所包含的 asset 子集表现出高聚合性(或称局部性),也即,使这个子集中的 asset 几乎总是同时被使用(和不被使用),从而提高内存占用的有效载荷比。这将是我们面临的第一个挑战。

0.3 Asset Redundancy

自 Unity 5 起,AB 构建系统会自动检索 asset 的所有依赖,并将它们统一打包进 AB 中。这个设计的确彻底避免了构建不完全 AB 的可能性(缺少被依赖的 asset),但同时也带来了一个新的问题:asset 的冗余。

请考虑一种常见的情形:Prefab Wall01、Prefab Wall02 和 Prefab Wall03 都依赖于同一个材质,Material Wall。而 Material Wall 则依赖于两张纹理贴图,Albedo.png 和 Metallic.png。为了确保这三个 Prefab 能够彼此独立地加载、卸载,我们在构建 AB 时将它们分配到了三个不同的 AB 中:AB Wall01、AB Wall02 和 AB Wall03。

如上图所示,由于我们没有为 Material Wall 分配 AB,AB 构建系统便自动在每个需要它的 AB 中复制了一份。这种隐式的 asset 冗余将造成游戏文件容量的增加(增幅可能是 30%,也可能是 50%,这取决于 asset 的容量和冗余程度)。更为严重的是,因为 Material Wall 在三个 AB 中的 ID 不同,Unity 会将其视为三个不同的对象,意味着在运行时 Material Wall 可能在内存中存在多个实例,从而引起内存占用的增加及运行性能的降低。

为了解决这个问题,我们需要显式地为 Material Wall 分配 AB,比如可以将它放在一个独立的 AB 中。正如之前提到的那样,asset 之间的依赖关系会转变为 AB 之间的依赖关系。如上图所示,现在 AB Wall01、AB Wall02 和 AB Wall03 现在都依赖于新的 AB:AB Wall Material。这意味着 Material Wall 以及它依赖的 Albedo.png 和 Metallic.png 现在只有唯一的实例。

这个例子简明地展示了什么是 asset 冗余,以及如何避免这种冗余。然而,在现实世界中,我们面对的情况往往比这要复杂得多。在一个真实的游戏项目里,asset 之间可能会有非常复杂的依赖关系链。这意味着我们在构建 AB 时,需要仔细遍历 asset 之间的每一个依赖关系,并为每个 asset 指定合适的 AB,这样才能确保彻底消除 asset 的冗余。

1. The Building Map

1.1 Collecting

Unity 引擎提供了一个内置的 API,允许我们程序化地构建 AB:

public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

这个 API 要求我们通过 builds 参数,提供一个 “AssetBundle building map”,来告诉 Unity 这次构建都有哪些 asset 参与、每个 asset 指派给哪个 AB。这引出了两个问题:一是如何收集 asset,二是如何为收集到的这些 asset 分配 AB。

通常情况下,对于一个真实的游戏项目来说,Assets 目录中的 asset 并不总是必要或有用的。因此,简单地扫描 Assets 目录并不是一个很好的主意。即使有办法避开那些不使用的 asset,也无法确定 asset 的主次关系,而这对于 asset 的合理划分将是重要的依据。

我们可以从游戏逻辑的加载需求出发,在程序员的视角下,将所有 asset 分为两部分看待:Source Asset(源资源)和 Derived Asset(衍生资源)。Source Asset 是游戏逻辑直接、主动加载的 asset,而 Derived Asset 则是随着前者被动加载的 asset。

例如,当游戏逻辑需要加载 Scene A01 时,只需在相应的 AB 加载后调用:

SceneManager.LoadScene("Scene A01");

我们将 Scene A01 的 asset,Scene_A01.unity,看作是 Source Asset;而将 Scene_A01.unity 依赖的 asset(例如 3D 模型、材质、纹理贴图等)看作是 Derived Asset。同理,当游戏逻辑加载 Prefab Role 01 时,只需调用:

AssetBundle.LoadAsset("Role_01.prefab");

我们将 Role_01.prefab 视作 Source Asset,而将它依赖的那些 asset 视作 Derived Asset。

这种方法将简化 asset 的收集过程:一般来说,Source Asset 是所有 asset 的一个很小的子集,并且这个子集是立等可取的——实际的游戏项目通常是数据驱动的(Data-driven),会将运行时需要加载的场景、prefab 等 asset 的名字以某种形式组织在一起(例如 CSV 列表或者 JSON 数组),所以我们只要把这个 asset 的名字列表读出来就可以了。一旦确定了 Source Asset,我们就可以利用 Unity 提供的 API,AssetDatabase.GetDependencies,获取其依赖的 Derived Asset。这样我们就能得到一个参与构建 AB 的完整的 asset 集合,同时,排除了所有不需要的 asset。

1.2 Assigning

一个 Source Asset 及其有关的 Derived Asset 总是被同时使用(或同时不使用),而不同的 Source Asset 之间则是无关的。基于这样的事实,我们应该尽量将一个 Source Asset 及其相关的 Derived Asset 放在同一个 AB 中,而将不同的 Source Asset(及其 Derived Asset)分配到不同的 AB 中。也就是说,我们应该提高每个 AB 的聚合度,减少不同 AB 之间的耦合度。这样除了有利于 AB 的即时卸载之外,理论上还能利用局部性提高读取性能。

为了实现上述的目标,我们需要设计一些方法,实现程序化的 asset 分配,毕竟手动为成千上万个 asset 指定 AB 并不现实。下面我们将介绍两种在实践中行之有效的方法,一种是基于集合(Sets)的方法,另一种是基于有向图(Directed Graph)的方法。

1.2.1 Sets Based Method

请回忆前文 0.3 Asset Redundancy 中给出的例子:一开始我们将 Prefab Wall01、Prefab Wall02 和 Prefab Wall03 分别指定到了三个不同的 AB 中,但这样做会导致它们共同依赖的材质,Material Wall,及其依赖的 Albedo.png 和 Metallic.png,在每个 AB 中都存有一份副本,造成了冗余;于是我们将三个 Prefab 共享的部分,Material Wall 及其依赖,单独提了出来,并指定到了一个新的 AB 中,从而消除了冗余。这样的思路与集合方法是一致的。

如图所示,我们将 Prefab Wall01、Prefab Wall02 和 Prefab Wall03,三个 Source Asset,连同它们各自关联的所有 Derived Asset,组织成三个 asset 集合。我们知道,它们之间存在着冗余,所以我们不能停在这里。

为了消除冗余,接下来我们求出这些 asset 集合之间的交集,也即找出它们之间相同的部分。

然后我们将它们之间的交集组织成一个新集合,并分别计算它们与新集合的差集,以便从它们中减去新集合。于是我们得到了四个完全独立的集合。

我们可以为每个 asset 集合分配一个独立的 AB,例如 AB Wall01,AB Wall02,AB Wall03 和 AB Wall Material。AB Wall01,AB Wall02 和 AB Wall03 彼此无关,但它们都依赖 AB Wall Material。如此一来,三个 Prefab 可以独立加载,当不再使用时也能及时地卸载——这正是我们想要达到的目标。

集合方法可以描述为:

  1. 获取 Source Asset 并组织成列表: A= {a1 ,a2 ,a3 ,a4 ..., an}
  2. 初始化集合列表 L= {S1 ,S2 ,S3 , ..., Sn},使其中每个集合Si包含对应的 Source Asset ai及其关联的 Derived Asset;即 Si = { ai } ∪Derived  ( ai )
  3. 检查集合列表中的任意两个集合Si和Sj,如果  Si ∩ Sj ≠ ∅,则执行以下步骤:

①定义新的集合Snew=Si ∩ Sj 

②更新原有的集合为它们与Snew的差集,即Si=Si−Snew 和Sj=Sj−Snew

③将Snew加入到集合列表L中

4.重复步骤 3,直到任意两个集合之间不存在交集;即对于所有的 i 和 j(其中 i ≠ j )有 Si ∩ Sj  = ∅

1.2.2 Directed Graph Based Method

有向图方法的思路与集合方法刚好相反。虽然它的代码实现要比集合方法复杂,但它具有更好的性能,并且具备更强的可操作性。

请考虑这样一个有向图:它的每个 Node 包含一个 asset 集合,Node 间的 Edge 表示它们之间的依赖关系。Edge 可以分为 OutEdge 和 InEdge;OutEdge 连接到当前 Node 所依赖的 Node,而 InEdge 连接到依赖当前 Node 的 Node。

在初始化有向图的过程中,我们为每一个 Source Asset 创建对应的 Node,并为它关联的 Derived Asset 递归地创建 Node。同时,依照 asset 之间的依赖关系,在这些 Node 之间建立相应的 Edge。

如图所示,我们向有向图中添加 Prefab Wall01 之后,图中共有 5 个 Node,它们彼此之间建立了 4 个 OutEdge(此处忽略 InEdge)。

继续向图中添加 Prefab Wall02 和 Prefab Wall03。请注意 Material Wall 及其依赖的 Albedo.png 和 Metallic.png 并没有重复创建新的 Node。

将所有 Source Asset 都添加到有向图之后,我们得到了参与构建 AB 的全部 asset。每个 asset 在图中都有唯一对应的 Node(也就是每个 Node 的 asset 集合中有唯一的 asset),Node 之间的 Edge 为 asset 之间的依赖关系。

接下来,我们考虑将图中的 Node 按照某种规则进行合并——毕竟我们不能为每个 asset 都指定一个 AB,那样将产生过多的 AB。过多的 AB 会导致很多问题,例如增加内存占用、降低 IO 效率等。

请回想我们的目标:尽量将一个 Source Asset 及其相关的 Derived Asset 放在同一个 AB 中。也就是说,我们需要设法将 Source Asset Node 及其相关的 Derived Asset Node 合并到一起。考虑到多个不同的 Source Asset 所关联的 Derived Asset 存在交叠的部分,我们必须保证合并操作的公平性,不能偏向于某个 Source Asset,否则将导致 AB 的有效载荷比的降低。

为了实现这样的目标,我们为每个 Source Asset 分配一个唯一的 Source Index,也即一个大于等于 0 的整数。然后,我们为有向图中的每个 Node 附加一个新的集合,Source Indices——正如它的名字说的,这个集合保存一组 Source Index。然后,我们向每个 Source Asset Node 添加相应的 Source Index,并将这个 Source Index 沿着 OutEdge 传染给关联的 Derived Asset Node。

如图所示,我们为 Prefab Wall01、Prefab Wall02 和 Prefab Wall03 指定了 Source Index,并将它们的 Source Index 传染给了 Material Wall 及其依赖的 Albedo.png 和 Metallic.png。

接下来,我们找出 Source Indices 完全相同的 Node,并依此为 Node 进行分组。

如图所示,所有 Node 被分成了 4 个组(被虚线包围的 Node 在一个组中)。

最后,我们根据分组信息,将同一个组内的 Node 进行合并(合并 asset 集合以及 Source Indices,并维护 Edge 的正确连接)。

如图所示,合并之后,我们得到了 4 个 Node(从而 Edge 也减少了)。

在完成合并操作之后,我们可以为有向图中的每个 Node 指定一个 AB。如我们所见,有向图方法能够得到与集合方法完全一致的结果,从而实现了我们的目标。

1.3 Version Approaches

前文我们已经介绍了两种分配 asset 的方法,尽管它们的策略和途径不同,但本质上都是将 asset 集合适当地划分为多个互相独立的子集。在确定这些子集之后,我们的确可以为每个子集指定一个 AB,并开始构建这些 AB。然而,对于一个允许在发布后更新内容的游戏或 App 来说,有一个至关重要的问题我们需要提前考虑,那就是版本管理。

现如今 3D 游戏的容量已经越来越大,几个甚至几十个 GB 已经变得司空见惯。如果每次游戏内容的更新都要求玩家重新下载所有内容,这无疑将对玩家造成极大的不便,可能导致玩家流失,从而影响产品的成功。因此,实现有效的版本管理变得至关重要。这样的系统应该能够让玩家只需下载新增的或已修改的部分,而非全部内容,从而节省玩家的时间和网络带宽。我们将这种技术称之为增量更新。

为了实现增量更新,我们首先要确保,在多个不同的版本间,绝大部分的 AB 文件保持稳定。其次,我们需要有一种可靠的机制,能够检测出哪些 AB 文件发生了修改。

要实现 AB 文件保持稳定的目标,最关键的一点是,每次调用 BuildAssetBundles 来构建 AB 时,我们应当尽可能保证“AssetBundle building map”的稳定——换句话说,我们应该总是将同一组 asset 分配给同一个 AB。

幸好,不论是通过集合方法还是有向图方法确定 asset 子集,这些方法都是相对稳定的。尽管在制作和生产的过程中,引入新的 asset 或者修改现有的 asset 会或多或少地影响它们的结果,但这些影响通常都是有局限性的。当前结果中的大多数子集都将能够在之前的结果中找到。

于是,我们要做的就是在这些子集与 AB 之间建立一层映射关系,并将映射关系表记录下来。每次构建 AB 的时候,我们读取前一个版本的映射关系表,并依照该表将同一组 asset 分配给同一个 AB。因此,我们需要一种可以稳定地识别同一组 asset 的方法。在实践中,我们可以基于 Asset GUID 为一组 asset 做签名,例如:

static string ComputeAssetSetSignature(IEnumerable<string> assetNames)
{
    var assetGuids = assetNames.Select(AssetDatabase.AssetPathToGUID);

    MD5 md5 = MD5.Create();

    foreach (string assetGuid in assetGuids.OrderBy(x => x))
    {
        byte[] buffer = Encoding.ASCII.GetBytes(assetGuid);

        md5.TransformBlock(buffer, 0, buffer.Length, null, 0);
    }

    md5.TransformFinalBlock(new byte[0], 0, 0);

    return BytesToHexString(md5.Hash);
}

在上述代码中,ComputeAssetSetSignature 函数接收一组 asset 的名字,并通过 AssetDatabase.AssetPathToGUID 获取这些 asset 的 GUID。该函数会有序地遍历这些 GUID,并使用 MD5 算法计算这些 GUID 的 Hash 值。最终,它将 MD5 Hash 值转换为十六进制字符串并返回。

除了映射关系之外,我们可能还需要维护一个 AB 名称表。这是为了当出现新的子集时,能为其分配一个新的、不与现有 AB 冲突的文件名。AB 的文件名可以简单地递增以保证其唯一性,例如 bundle_1bundle_2bundle_3 等等。

在 AB 稳定性问题解决之后,我们面对的下一个问题是如何检测 AB 的修改。我们需要一种可靠的机制,能够在两个版本之间找出变化的 AB 文件,这样才能建立一个下载文件列表,实现增量更新。

我们在这里不得不提及 Unity 提供的一个机制:在构建 AB 时使用 BuildAssetBundleOptions.AppendHashToAssetBundleName 标识,能够在 AB 文件名后面自动追加 Hash 后缀,产生类似

bundle_6824f8ed4877d08383dcf3e63cc4ffa0

这样的文件名。这个机制似乎很诱人——我们只需要对比两个版本的文件列表就可以找出哪些 AB 发生了变化。但不幸的是,由于 Asset Hash 并不可靠,使用这样的机制会带来内容丢失或游戏崩溃的风险,所以官方不推荐使用,具体请看这里。另外,基于同样的原因,官方也不推荐在发布内容时使用增量构建(Incremntal build)。即便 Asset Hash 将来变得可靠,为了区别版本而修改文件名也不是一个良好的设计,所以我们不打算采用这样的机制。

要解决这个问题其实并不复杂:我们只需在 AB 构建成功后,使用 MD5 或其他更可靠的 Hash 算法为 AB 文件本身生成签名即可。我们可以将这些 AB 文件的签名连同文件的尺寸保存在一个数据表中,例如:

...
bundle_5d/8296584/aeb20c5467161e2f823b19ad8f613541
bundle_7a/8299448/e25774eee5fcc81fe23790e1bf07d98f
bundle_7c/7634143/96c1dcaba7ae6b57e2e5c137b2de1cb2
bundle_8c/5142199/8de8888cc68bbaba2e1541cb14a29b5a
...

随后我们只需要对比该表就能找到不同版本之间哪些文件发生了变化。

至于 CDN 缓存陈旧的问题,同样十分简单——我们只需要为每个版本创建不同的 URL 前缀即可(也就是 origin server 上的不同目录),于是不同版本之间,每个同名的 AB 文件都会有唯一的 URL。

1.4 Downloadable Content

支持 DLC(Downloadable Content)的游戏允许玩家单独获取(购买)与主体游戏独立的附加内容,例如新的关卡、角色、道具等。

在 Unity 引擎中,为了消除 AB 中 asset 的冗余,我们通常应该一次性构建所有 AB。为了支持 DLC,我们需要一种技术,能够合理地将所有 AB 划分为不同的部分,例如 MainGame、DLC0、DLC1、DLC2 等等,并允许玩家选择性地下载或移除某些部分。

这类技术也可用于实现流式安装(streaming installation)或递增下载(progressive downloading)。比如,我们可以把前几个关卡和游戏主角划归为 MainGame,将其余的游戏内容划归为 DLC0;MainGame 一旦下载完成,便可允许玩家开始游戏,而 DLC0 则可以在后台继续下载。

前文我们介绍过如何从 Source Asset 出发,收集 asset,并合理地划分 asset 子集,从而得到高有效负荷率的 AB。由于这些 AB 已经基于 Source Asset 的角度进行了划分,因此,从相同的角度来看,将这些 AB 分配到不同的 DLC 中将会变得相对简单。

让我们假设参与构建 AB 的 Source Asset 的全集为:

Acomplete = { MainMenu, Level1, Level2, Level3, Level4, Level5 }

DLC0 包含的 Source Asset 的子集为:

Adlc0 = { Level3, Level4, Level5 }

则 DLC0 的独立 AB 集合将是(其中 Bundle 的输入为一个 asset 集合,输出为在加载该 asset 集合之前需加载的 AB 集合):

Tdlc0 = Bundle ( Acomplete ) - Bundle ( Acomplete - Adlc0 )

这些 AB 文件可以被安全地从初始版本中剔除,并作为单独的部分供玩家下载。

欢迎加入我们!

感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com

#我的成功项目解析##许愿池##春招你拿到offer了吗##搜狐畅游##游戏引擎#
全部评论

相关推荐

头像
04-12 15:37
C++
岗位直接对接HR,&nbsp;人岗点对点匹配Web技术研究员岗位职责:面向公司Web领域,聚焦Web内核和框架技术,拓展生态,持续构建产品竞争力,岗位职责包括但不限于:1、深刻理解Web技术全景与发展趋势(含相关核心算法),例行洞察学术界、产业界、公司产品OS&nbsp;Web相关产业问题、技术诉求,识别高价值场景和差异技术创新突破机会;2、负责Web内核与前端领域(包括:Web渲染、JS引擎、Web图形和媒体、WebAssembly、WebGPU、Web前端框架、web安全等)的关键技术探索、性能优化、新特性开发与创新,构建鸿蒙下一代web内核。3、参与Web技术标准制定、认证和生态拓展,构建业界影响力;参与与学术界、产业界的连接以及技术合作。岗位要求:熟练使用C/C++/Rust/JS等语言,了解Web相关理论,计算机系统架构,计算机图形学。满足以下条件者优先:1.&nbsp;计算机、操作系统、人工智能相关专业,有结合算法进行复杂系统开发的经验者优先;2.&nbsp;熟悉Chromium/WebKit/Servo/Gecko软件架构,在Web渲染、JS引擎以及在GPU渲染、并发架构、UI布局方面有过课题经验或有相关开源项目者优先;3.&nbsp;熟悉React,Vue,Angular,NodeJS等Web前、后端相关技术,有相关经验者优先;熟悉计算机网站安全相关知识优先。岗位投递链接:https://career.huawei.com/reccampportal/portal5/campus-recruitment-detail.html?jobId=4700&amp;dataSource=1&amp;jobType=0==========================================了解详情私我
投递华为等公司10个岗位 华为
点赞 评论 收藏
转发
4 6 评论
分享
牛客网
牛客企业服务