了解Unity的序列化语言:YAML

知道吗?你不必在Unity编辑器中使用XML或JSON等序列化语言就可以编辑任何种类的资产。尽管这种方法在大多数时候都能用,但有时你必须直接修改文件。比如为了避免合并冲突或损坏文件。
而在这篇博文中,我们将进一步解读Unity的序列化系统,并介绍几种直接修改资产文件的用例。
为防止损失数据,请备份好你的文件,最好是使用版本控制。手动修改资产文件是一个危险的操作,且Unity不支持直接修改资产。资产文件从设计上并不支持手动修改,如若文件出错也不会输出解释性的错误信息,导致文件错误很难被修复。如果你能更好地了解Unity的工作方式、面对合并冲突更有准备,就可以弥补Asset Database(资产数据库) API的不足。
YAML,也被称为“YAML Ain't Markup Language”,是类似XML和JSON等语言的可读数据序列化语言。由于它具有轻量的特点,相较与其他语言来说更简单,所以也更容易阅读。
Unity所使用的高性能序列化库形成了YAML规范的一个子语言集。两者间的不同在于,Unity文件不支持YAML的空行、注释和其他一些语法。在某些极端情况下,Unity的版本还会偏离于YAML规范。
我们来通过Cube Prefab文件上的YAML代码段来了解下详情。我们首先在Unity中新建一个默认的立方体,将其保存为预制件,然后用任意文本编辑器打开预制件。正如图1中所示,前两行写的是文件的标题(仅出现一次)。第一行写明了文件正使用的YAML版本,第二行则为URI前缀“tag:unity3d.com,2011:”创建了一个名为“!u!”的宏(详细将在下方讨论)。

在标题以下,你会看到一系列的对象定义,比如是否为预制件或场景中的GameObject、每个GameObject的组件构成,有时还会有场景Lightmap(光照贴图)设置等其他对象。

每条对象定义都以两段式标题起头,比如图2的“--- !u!1 &7618609094792682308”,它遵循“--- !u!{CLASS ID} &{FILE ID}”的格式,有着两部分含义:
- !u!{CLASS ID}:这告诉 Unity 该对象属于哪个类。“!u!”部分将被替换成前边定义的宏,即“tag:unity3d.com,2011:1”——这里的数字1是GameObject的ID。每个类 ID 都在 Unity 的源代码中定义,但可以 在此处找到完整列表。
- &{FILE ID}:此部分定义对象本身的ID,用于对象之间相互引用。它代表了对象在特定文件中的ID,也被称为File ID。关于跨文件引用的详情请在后文作进一步了解。
第二个对象标题行是对象的类型名称(这里是GameObject),用于在文件中的读取和识别。

在标题行往下,你可以找到所有序列化的属性。在我们上面的 GameObject 示例中,图 2 提供了其名称 (m_Name:立方体)和层(m_Layer:0).MonoBehaviour的序列化里,你会看到公共(public)字段和私有(private)字段都带有SerializeField特性。这种格式也同样用在ScriptableObject(可编程对象)、Animation(动画)、Material(材质)等文件中。注意,ScriptableObject的对象类型会被设为MonoBehaviour,本身并不算单独的类型。因为它们也由内部的MonoBehaviour类处理。
讲到这里,你就可以开始通过修改YAML来重构动画轨道或实现其他目的。
Unity的动画文件是通过描述一组动画轨或Animation Curve(动画曲线)来工作的;每个有动画的属性都有一条描述。如图4所示,一条Animation Curve会用路径属性来找到需要动画化的对象,路径中会包含对象本身及其子对象的名称。在这个例子中,我们想给一个叫做“JumpingCharacter”的GameObject做动画,它是“Shoulder”对象的一个子对象,子对象本身带有Animator组件用于播放动画。为了将同样的动画应用于不同的对象,动画系统会使用字符串径而非GameObject的ID来识别对象。

在层次结构中重命名动画对象可能会导致一个非常常见的问题:曲线可能会失去对它的追踪。当然,你可以在Animation窗口中重命名每条动画轨道来解决这个问题,但有时一个对象身上会带有数个包含多条动画曲线的动画,这会让解决过程变得冗长且易出错。而直接编辑YAML能够一次性修改多条曲线的路径,整个过程仅要求你用文本编辑器的“搜索和替换”功能修改动画文件。

如上文所述,YAML文件中的每个对象都有一个“File ID”。每个对象在同一份文件中都有着独特的ID,以用于对象间的引用。它们类似于GameObject及其组件,或组件之于GameObject,或类似脚本间的引用,比如将“Weapon”组件引用到同一预制件的“SpawnPoint”对象上。
其 YAML 格式为“{fileID:FILE ID}”作为属性的值。在图6中,你可以看到这个Transform属于一个ID为4112328598445621100的GameObject,因为它的 “m_GameObject”属性引用了对象的File ID。你也可以观察到空的“m_PrefabInstance”引用(其File ID为0)。你可以在下文继续了解什么是Prefab Instance(预制件实例)。

我们设想一下重新排布预制件对象父子关系的情形。你可以将Tranform(变换)的“m_Father”属性File ID换成一个新Transform的ID,甚至可以修改父对象Transform的YAML,从“m_Children”组删去该对象,并将其添加到另一个新对象的“m_Children”属性中。

要通过名字查找某个Transform,你必须先在GameObject文件中找到m_Name来确定File ID。然后你才能找到在m_GameObject属性引用了该ID的Transform。
在引用文件外部的对象时,比如在“Weapon”脚本里引用“Bullet”预制件,事情就会变得稍微有点复杂。还记得我们说过File ID是文件的本地变量,意味着同一个ID可以在不同的文件中重复。为了能在另一个文件中识别出对象,我们需要用到一个额外的ID或“GUID”来识别整个文件,而非文件内的单个对象。每个资产的GUID都会在对应的元文件中写出,元文件一般存在于资产文件的同一路径下,文件名也与资产文件完全相同,后缀名则为“.meta”。

对于非Unity原生的文件格式,如PNG图像或FBX文件,Unity在为其创建元文件时会序列化额外的导入设置,如纹理的最大分辨率和压缩格式、3D模型的缩放比例。这样做是为了单独保存扩展的文件属性,并方便在各个版本控制软件中控制其版本。除了这些设置,Unity也会在元文件中保存一般的资产设置,比如GUID(“GUID”属性)或Asset Bundle(“assetBundleName”属性),引擎还会为文件夹或Unity的Materials等原生文件格式保存设置。

考虑到这一点,你可以通过结合元文件中的GUID和对象在YAML里的File ID来产生对象唯一的ID,如图10所示。更具体地说,你可以看到YAML生成了一个Weapon脚本的“bulletPrefab”变量,它引用了预制件的File ID 4551470971191240028和根Game Object文件的GUID afa5a3def08334b95acd2d70ee44a7c2。

你还可以看到第三个称为“Type”的特性。Type用于确定文件是从Assets文件夹还是从Liberary文件夹加载。注意,它只支持以下从2开始的值(0和1已被废弃):
- 类型 2:编辑器可以直接从 Assets 文件夹加载的资源,例如材质和 .asset 文件
- 第 3 类:已处理并写入 Library 文件夹中,并由编辑器从该文件夹中加载的资源,例如预制件、纹理和 3D 模型
在脚本序列化上还有另另一个需要强调的点,即所有脚本的YAML Type都是一样的,皆归为MonoBehaviour。真正的脚本会在“m_Script”属性中引用,引用时使用的是脚本元文件的GUID。有了这个,你就可以和资产一样查看每个脚本的处理方式。

此功能的用法包括但不限于:
- 在所有其他资产中搜索该文件的GUID,找到该资产的每一处引用
- 用整个项目中的另一个资产 GUID 替换该资产的所有用途
- 将一个资产替换为另一个扩展名不同的资产(如用WAV文件替换MP3文件),先删去原资产,再用完全相同的名称命名新资产,加上新后缀后将新扩展改写到原本的元文件中。
- 在删除、重新添加资产后,我们可以用新的GUID替换掉旧的GUID,解决引用缺失的问题。
在场景中使用预制件实例、或使用另一个预制件内的嵌套预制件时,预制件的GameObject和组件不会在预制件上序列化,而会被添加到一个PrefabInstance对象上。正如图12所示,PrefabInstance有两个关键的属性:“m_SourcePrefab”和“m_Modifications”。

你可能已经发现,“m_SourcePrefab”是对嵌套预制件资产的引用。这时,如果你将无法直接用File ID在嵌套预制件中搜索到资产。此时,“100100000”是预制件导入时创建的File ID,称作Prefab Asset Handle(预制件资产把手),它并不包括在YAML中。
另外,“m_Modifications”还会对原预制件进行一系列的修改或“覆盖”。在图12中,我们覆盖了嵌套预制件内某个Transform的本地原始X、Y、Z位置,这个修改可通过识别目标属性中的File ID来应用。为了方便阅读,上面的图12其实已经经过简化。真正PrefabInstance的m_Modifications部分通常会有更多的条目。
到这里,你可能会想,如果外层预制件里没有嵌套预制件的对象,那要如何引用这些对象呢?对于这种情况,Unity会在预制件上创建一个“占位”对象,用于引用嵌套预制件内的对象。这些占位对象会被加上“stripped”标签,表示其是一个简化后的对象,只有占位所需的基本属性。

图13同样展示了一个带有“stripped”标签的Transform,它不带有通常的Transform属性(比如“m_LocalPosition”)。相反,它的“m_CorrespondingSourcePrefab”和“m_PrefabInstance”属性套用了对其所属文件的嵌套预制件和PrefabInstance对象。在上边,你可以部分看到另一个变换,它的“m_Father”引用了该占位变换,使其成为了嵌套预制件的一个子对象。若你要在嵌套预制件里引用更多的对象,则YAML便会产生更多的占位对象。
方便的是,这些引用不会改变预制件变体的使用。变体的基础预制件只是一个带有Transform的PrefabInstance,这个没有父对象的实例便是变体的根对象。在图 14 中,您可以看到 PrefabInstance 的“m_TransformParent”属性引用了“fileID:0.”这意味着它没有父对象,本身即是根对象。

尽管你可以将嵌套预制件或基础预制件替换成另一个预制件,但这种修改是有风险的。请谨慎替换并做好备份。
首先,在PrefabInstance对象和占位对象中,用新的GUID替换基础预制件的所有引用。记下占位对象的File ID。对象的“m_CorrespondingSourceObject”属性不仅会引用资产,还会通过File ID引用其中的对象。而当前预制件的对象很可能与新预制件有不一样的File ID——如不加以修复,预制件将失去所有的覆盖、引用、对象和其他数据。
可以看到,修改基础或嵌套预制件并非想象中的那样简单。这也是编辑器不支持修改预制件的主要原因之一。
在特定几种情况下,你可以留下YAML的旧对象和引用;一个典型情况就是删除脚本的变量。如果玩家预制件在添加Weapon脚本时必须引用Bullet预制件,而你又想从Weapon脚本中删去Bullet Prefab变量。除非你再次修改、保存并重新序列化预制件,否则Bullet的引用将一直留在YAML中。另一个例子,如果嵌套预制件的对象被删除,其占位对象仍会被保留在YAML中。这个问题同样可以通过修改并保存预制件解决。最后,可以通过使用 AssetDatabase.ForceReserializeAssetsAPI 编写脚本强制重新序列化资产。
那为什么Unity不会自动清除这些旧引用呢?这主要是出于性能考虑;如果脚本或基础预制件的每次改动都要重新序列化所有资产,便会产生不小的性能负担。另一个原因是防止数据丢失。假设你误删了某个脚本属性(比如Bullet Prefab),想要进行恢复。你只需要撤销脚本的修改即可。只要你能保留同名变量,改动就不会丢失。即使你删掉Bullet Prefab的引用也能进行恢复。预制件及其元文件在被完全恢复后会保留原先的引用。
旧对象和引用在运行时并不会构成问题,因为Unity在构建运行版或Addressable资产时,这些旧对象或引用会被自动清除。但旧引用偶尔也会造成问题,比如在使用纯Asset Bundle时。Asset Bundle的依赖项解析会算进旧引用,导致多个资源集之间产生不必要的依赖,在运行时加载过多的内容。这个问题在使用Asset Bundle时值得我们考虑。推荐创造或使用现有工具来清理不必要的引用。
