Unity Resources 和 AssetBundles 系列文章(一):资产,对象和序列化 (Assets, Objects and serialization)

assets-objects-and-serialization

这是一系列文章,深入讨论了Unity引擎中的资产(Asset)和资源管理(Resource Management)。旨在为高级开发人员提供Unity资产和序列化系统的深入原理的知识。研究了Unity的AssetBundle系统的技术基础和当前使用它们的最佳使用方法。

该指南分为四章:

  1. 资产,对象和序列化(Assets,Objects and serialization)讨论了Unity如何序列化资产和处理资产之间引用的底层细节。强烈建议读者从本章开始,因为它介绍了整个指南中使用的术语。
  2. Resources文件夹 讨论了内置的Resources API。
  3. AssetBundle基础知识 是建立在第1章的知识基础上,用于描述 AssetBundles 的运作方式,并讨论 AssetBundles 的加载和 AssetBundles 的资产加载。
  4. AssetBundle使用指南 是一篇长篇文章,讨论 AssetBundles 实际使用的许多方式。包括将有关将资产分配给 AssetBundles 和管理已加载资产的部分,并描述了使用 AssetBundles 的开发人员遇到的许多常见问题。

注意:本指南的 ObjectsAssets 术语与Unity的API中命名有所不同。

本指南调用 Objects 的数据在许多Unity API 中称为Assets,例如AssetBundle.LoadAssetResources.UnloadUnusedAssets。本指南称之为 Assets 的文件很少暴露给任何公共API。当它们被公开时,通常只在与构建相关的代码中,例如AssetDatabaseBuildPipeline。在这些情况下,它们在公共API 中称为文件(files)

0×00 前言

这是关于Unity 5中资产,资源和资源管理的系列文章的第2章。

本章介绍Unity的序列化系统的内部原理,以及Unity如何在Unity Editor和运行时维护不同对象之间的引用关系。它还讨论了对象和资产之间的技术区别。这里涉及的主题是理解如何在Unity中有效加载和卸载资产的基础。正确的资产管理对于缩短加载时间和降低内存使用率至关重要。

0×01 内部资产和对象

要了解如何在Unity中正确管理数据,了解Unity如何识别和序列化数据非常重要。第一个关键点是AssetsUnityEngine.Objects之间的区别。

一个资产是磁盘上的文件,存储在资产为Unity项目的文件夹中。纹理,3D模型或音频剪辑是常见的资产类型。有些资产以Unity原生格式包含数据,例如材质。其他资产需要处理为本机格式,例如FBX文件。

UnityEngine.Object,或以大写“O”为开头的对象 (Object),是一组序列化的数据统称为描述资源的特定实例。这可以是Unity Engine使用的任何类型的资源,例如网格 (mesh),精灵 (sprite),音频剪辑 (AudioClip) 或动画文件 (AnimationClip)。所有对象都是UnityEngine.Object基类的子类。

虽然大多数对象类型都是内置的,但有两种特殊类型。

  1. 一种 ScriptableObject 提供了一个方便的系统,开发人员可以定义自己的数据类型。Unity可以对这些类型进行本机序列化和反序列化,并在Unity Editor的Inspector窗口中进行操作。
  2. 一种 MonoBehaviour 提供了一个链接到 MonoScript 的包。MonoScript 是一种内部数据类型,Unity用于保存对特定程序集和命名空间内特定脚本类的引用。该 MonoScript 并没有包含任何实际的可执行代码。

资产和对象之间存在一对多的关系; 也就是说,任何给定的Asset文件都包含一个或多个Objects。

0×02 对象间引用

所有 UnityEngine.Objects 都可以引用其他 UnityEngine.Objects。这些其他对象可以保存在同一资产文件中,也可以从其他资产文件导入。例如,材质Object通常具有一个或多个对纹理对象的引用。这些纹理对象通常从一个或多个纹理资源文件(例如PNG或JPG)导入。

序列化时,这些引用由两个单独的数据组成:文件GUID本地ID。文件GUID标识存储目标资源的资产文件。本地惟一的(引用)ID标识资产文件中的每个对象,因为一个资产文件可能包含多个对象。

文件GUID存储在.meta文件中。这些.meta文件是在Unity首次导入资产时生成的,并存储在与资产相同的目录中。

可以在文本编辑器中查看上述标识和引用系统:创建一个新的Unity项目并更改其编辑器设置以公开可见元文件并将资产序列化为文本。创建材质并将纹理导入到项目中。将材质指定给场景中的立方体并保存场景。

使用文本编辑器打开与材料关联的.meta文件。标有“guid”的行将出现在文件顶部附近。此行定义材料Asset的文件GUID。要查找本地ID,请在文本编辑器中打开材料文件。材质Object的定义如下所示:

--- !u!21 &2100000
Material:
 serializedVersion: 3
 ... more data …

在上面的示例中,以&符号开头的数字是素材的本地ID。如果此材料Object位于由文件GUID“abcdefg”标识的资产内,则可以将材料Object唯一标识为文件GUID“abcdefg”和本地ID“2100000”的组合。

0×03 为什么必需文件GUID和本地ID?

为什么Unity的文件GUID和本地ID系统是必需的?答案是稳健性,并提供灵活的,独立于平台的工作流程。

文件GUID提供文件特定位置的抽象。只要特定的文件GUID可以与特定文件相关联,该文件在磁盘上的位置就变得无关紧要了。该文件可以自由移动,而无需更新引用该文件的所有对象。

由于任何给定的资产文件可能包含(或通过导入生成)多个UnityEngine.Object资源,因此需要使用本地ID来明确区分每个不同的对象。

如果与资产文件关联的文件GUID丢失,则对该资产文件中所有对象的引用也将丢失。这就是为什么重要的是.meta文件必须保持与相关文件名相同的文件名字,并与相关的资产文件保存在同一文件夹中。请注意,Unity将重新生成已删除或放错位置的.meta文件。

Unity Editor具有指向已知文件GUID的特定文件路径的映射。只要加载或导入资产,就会记录映射条目。映射条目将Asset的特定路径链接到Asset的文件GUID。如果在.meta文件丢失且资产路径未更改时Unity编辑器处于打开状态,则编辑器可以确保资产保留相同的文件GUID。

如果在关闭Unity编辑器时丢失.meta文件,或者资产的路径发生更改而.meta文件并没有随资产一起移动时,那么对该资产中对象的所有引用都将被破坏。

0×04 综合资产和进口商

如 “内部资产和对象” 部分所述,必须将非本机资产类型导入Unity。这是通过资产导入者操作完成的。虽然这些导入器通常是自动调用的,但它们也通过AssetImporter API 暴露给脚本。例如,TextureImporter API提供对导入单个纹理资源(如PNG文件)时使用的设置的访问。

导入过程的结果是一个或多个UnityEngine.Objects。这些在Unity编辑器中可见为父资产中的多个子资产,例如嵌套在纹理资产下的多个精灵已导入为精灵图集。这些对象中的每一个都将共享文件GUID,因为它们的源数据存储在同一资产文件中。它们将通过本地ID在导入的纹理Asset中区分。

导入过程将源资源转换为适合Unity Editor中选择的目标平台(如 windows)的格式。导入过程可以包括许多其他特殊操作,例如纹理压缩。由于这通常是一个耗时的过程,因此导入的资产会缓存在Library文件夹中,从而无需在下次编辑器启动时再次重新导入Assets。

具体来说,导入过程的结果存储在以Asset的文件GUID的前两位数命名的文件夹中。该文件夹存储在 Library / metadata / 文件夹中。资产中的各个对象被序列化为单个二进制文件,其名称与Asset的文件GUID相同。

此流程适用于所有资产,而不仅仅适用于非本地资产。本机资产不需要冗长的转换过程或重新序列化。

0×05 序列化和实例

虽然文件GUID和本地ID的引用记录是健壮的,但GUID比较慢,并且在运行时需要更高性能的系统。 Unity内部维护着一个缓存,它将文件GUID和本地ID转换为simple。这些被称为实例ID,并且在向缓存注册新对象时以简单,单调递增的顺序分配实例ID。

缓存维护给定实例ID、文件GUID和定义对象源数据位置的本地ID之间的映射,以及对象在内存中的实例(如果有的话)。这允许UnityEngine.Objects可以稳健地维护对彼此的引用。解析实例ID引用可以快速返回由实例ID表示的已加载对象。如果尚未加载目标对象,则可以将文件GUID和本地ID解析为对象的源数据,从而允许Unity即时加载对象。

在启动时,实例ID缓存被初始化为项目立即需要的所有对象的数据(即,在构建场景中引用),以及Resources文件夹中包含的所有对象。 当运行时 (3) 导入新资产时,以及从 AssetBundle 加载对象时,额外的条目被添加到缓存中。仅当卸载提供对特定文件GUID和本地ID的访问权限的AssetBundle时,才会从缓存中删除实例ID条目。发生这种情况时,将删除实例ID,其文件GUID和本地ID之间的映射以节省内存。如果重新加载AssetBundle,将为从重新加载的AssetBundle加载的每个Object创建一个新的实例ID。

有关卸载AssetBundle的含义的更深入讨论,请参阅AssetBundle Usage Patterns一文中的Managing Loaded Assets部分。

在特定平台上,某些事件可能会强制对象内存不足。例如,当应用程序暂停时,可以从iOS上的图形内存卸载图形资产。如果这些对象源自已卸载的AssetBundle,则Unity将无法重新加载对象的源数据。对这些对象的任何现有引用也将无效。在前面的示例中,场景可能看起来具有不可见的网格或洋红色纹理。

实现注意事项:在运行时,上述控制流程并不准确。在运行时比较文件guid和本地id,以及在负载操作期间不会有足够的性能。构建Unity项目时,文件GUID和本地ID被确定地映射成更简单的格式。但是,这个概念仍然是相同的,并且在运行时期间考虑文件GUID和本地ID仍然是一个有用的类比。这也是为什么资产文件guid不能在运行时查询的原因。

0×06 MonoScripts

重要的是要理解MonoBehaviour有对MonoScript的引用,而MonoScripts仅仅包含定位特定脚本类所需的信息。 这两种类型的对象都不包含脚本类的可执行代码。

MonoScript包含三个字符串:程序集名称,类名称和命名空间。

构建项目时,Unity会将Assets文件夹中的所有松散脚本文件编译为Mono程序集。插件子文件夹之外的C#脚本放在Assembly-CSharp.dll中Plugins子文件夹中的脚本放在Assembly-CSharp-firstpass.dll中,依此类推。此外,Unity 2017.3还引入了定义自定义托管程序集的功能

这些程序集以及预构建的程序集DLL文件都包含在Unity应用程序的最终版本中。它们也是MonoScript引用的程序集。与其他资源不同,Unity应用程序中包含的所有程序集都在应用程序启动时加载。

这个MonoScript对象是AssetBundle(场景或预制体) 中的任何一个MonoBehaviour组件中实际上不包含可执行代码的原因。这允许不同的MonoBehaviours引用特定的共享类,即使MonoBehaviours位于不同的AssetBundle中。

0×07 资源生命周期

要减少加载时间并管理应用程序的内存占用,理解UnityEngine.Objects的资源生命周期非常重要。在特定和定义的时间将对象加载到内存中或从内存中卸载。

在以下情况下自动加载对象:

  1. 映射到该Object的实例ID将被取消引用
  2. Object当前未加载到内存中
  3. 可以定位Object的源数据。

也可以通过创建对象或通过调用资源加载API(例如:AssetBundle.LoadAsset)在脚本中显式加载对象。加载对象时,Unity会尝试通过将每个引用的文件GUID和本地ID转换为实例ID来解析任何引用。如果两个条件为真,则在第一次取消引用其实例ID时,将按需加载对象:

  1. 实例ID引用当前未加载的Object
  2. 实例ID具有在缓存中注册的有效文件GUID和本地ID

这通常在加载和解析引用本身后不久发生。

如果文件GUID和本地ID没有实例ID,或者具有卸载的对象的实例ID引用无效的文件GUID和本地ID,则保留引用但不会加载实际的对象。参考在Unity编辑器中显示为“(Missing)”。在正在运行的应用程序中,或在“场景视图(Scene View,)” 中,“(Missing)”对象将以不同的方式显示,具体取决于其类型。例如,网格看起来是不可见的,而纹理可能看起来是洋红色。

在三种特定方案中卸载对象:

  • 对象在发生未使用的资产清理时自动卸载。当场景被破坏性地更改时(即 SceneManager.LoadScene 时),或者当脚本调用Resources.UnloadUnusedAssets API 时,此过程将自动触发。此过程仅卸载未引用的对象; 只有当没有Mono变量持有对对象的引用,并且没有其他活动对象持有对对象的引用时,才会卸载对象。此外,请注意,不会卸载任何标有HideFlags.DontUnloadUnusedAssetHideFlags.HideAndDontSave的内容。
  • 可以通过调用Resources.UnloadAsset API 显式卸载源自Resources文件夹的对象。这些对象的实例ID仍然有效,并且仍将包含有效的文件GUID和本地ID。如果任何Mono变量或其他Object包含对使用Resources.UnloadAsset卸载的Object的引用,则只要取消引用任何实时引用,就会重新加载该Object。
  • 在调用AssetBundle.Unload(true)API 时,会立即自动卸载源自AssetBundles的对象。这将使对象实例ID的文件GUID和本地ID无效,并且对已卸载对象的任何实时引用将变为“(Missing)”引用。从C#脚本尝试访问卸载对象上的方法或属性时将产生NullReferenceException

如果调用AssetBundle.Unload(false),来自卸载的AssetBundle的活动对象将不会被销毁,但Unity将使实例ID的文件GUID和本地ID引用无效。如果稍后从内存中卸载这些对象并且仍然存在对已卸载对象的实时引用,则Unity将无法重新加载这些对象。

0×08 加载大型层次结构

序列化Unity游戏对象的层次结构时,例如在预制体序列化期间,重要的是要记住整个层次结构将完全序列化。也就是说,层次结构中的每个GameObject和Component将在序列化数据中单独表示。这对加载和实例化GameObjects层次结构所需的时间产生了有趣的影响。

在创建任何GameObject层次结构时,CPU时间以几种不同的方式使用:

  • 读取源数据(来自存储,来自AssetBundle,或来自另一个GameObject等)
  • 在新转换之间建立父子关系
  • 实例化新的GameObjects和组件
  • 在主线程上唤醒新的GameObjects和Components

后三种时间成本通常是不变的,无论层次结构是从现有层次结构克隆还是从存储加载。但是,读取源数据的时间随序列化到层次结构中的组件和游戏对象的数量线性增加,并且还要乘以数据源的速度。

在所有当前平台上,从内存中的其他位置读取数据要比从存储设备加载数据快得多。此外,可用存储介质的性能特征在不同平台之间变化很大。因此,当在存储速度较慢的平台上加载预制体时,从存储中读取预制体的序列化数据所花费的时间可能很快超过实例化预制体所花费的时间。也就是说,加载操作的成本与存储I / O时间有关。

如前所述,在序列化单片预制体时,每个GameObject和组件的数据都是单独序列化的,这可能会重复复制数据。例如,具有30个相同元素的UI画布将具有序列化30次的相同元素,从而产生大量二进制数据。在加载时,必须从磁盘读取这30个重复元素的每一个游戏对象(gameobject) 和组件(compontent) 的数据,然后再传输到新实例化的对象。此文件读取时间是实例化大型预制体的总体成本的重要因素。大型层次结构应该在模块化块中实例化,然后在运行时拼接在一起。

Unity 5.4注意: Unity 5.4改变了内存中转换的表示。每个根变换的整个子层次结构都存储在紧凑的,连续的内存区域中。当实例化新的GameObject(游戏对象) 时,请考虑使用新的GameObject.Instantiate接受父参数的重载变量。使用此重载可避免为新GameObject分配根变换层次结构。在测试中,这将实例化操作所需的时间加速约5-10%。

-EOF-

不止于游戏,还有技术!

发表评论

电子邮件地址不会被公开。 必填项已用*标注

Post comment

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据