Engine & platform

Unity のシリアライズ言語 YAML を理解する

NICOLAS ALEJANDRO BORROMEO Nicolas Alejandro Borromeo
Jul 28, 2022|13 Min
Hero image

Unity エディターでは、XML や JSON などのシリアライズ言語の扱いに煩わされることなく、あらゆる種類のアセットを編集できることをご存知でしょうか。そして、ほとんどの場合はこれで何の問題も起こりません。しかし、中にはファイルを直接修正しなければならないケースもあります。例えば、マージコンフリクトやファイルの破損といったケースです。

そこで、今回のブログでは、Unity のシリアライズシステムをさらに詳しく解説し、アセットファイルを直接修正することで実行できるユースケースを紹介します。

この記事の内容をお試しになる場合は、いつものようにファイルのバックアップをとるようにしてください。バージョン管理でデータの損失が起きないようにできれば理想的です。アセットファイルを手動で変更することは危険な作業であり、Unity ではサポートされていません。アセットファイルは手動で修正することを想定していないため、エラーが発生しても何が起こったのかを説明する有用なエラーメッセージが出力されず、バグ修正が困難です。Unity の仕組みをよく理解し、マージコンフリクトを解決する準備をすることで、Asset Database API だけでは不十分な状況を補うことができます。

YAML の構造

YAML は、「YAML Ain't Markup Language」としても知られ、XML や JSON のような人間が読めるデータシリアライズ言語のファミリーの 1 つです。他の一般的な言語と比較しても軽量で比較的わかりやすいため、より読みやすいとされています。

Unity は、YAML 仕様のサブセットを実装した高性能なシリアライズ化ライブラリを使用しています。例えば、空行、コメント、その他 YAML でサポートされているいくつかの構文は、Unity のファイルではサポートされていません。特定のエッジケースでは、Unity のフォーマットは YAML 仕様とは乖離しています。

Cube プレハブの YAML コードのスニペットを見ながら、これを探ってみましょう。まず、Unity でデフォルトのキューブを作成し、それをプレハブに変換し、プレハブのファイルを任意のテキストエディターで開きます。図 1 でわかるように、最初の 2 行は後で繰り返されないヘッダーです。最初の行はどの YAML バージョンを使っているかを定義し、一方 2 行目は URI 接頭辞「tag:unity3d.com,2011:」に対して「!u!」というマクロを作成します(後述)。

Code of header lines in YAML format

ヘッダーに続いて、プレハブやシーン内の GameObject、各 GameObject のコンポーネント、そして場合によってはシーンのライトマップ設定などのオブジェクト定義が表示されます。

YAML for a GameObject called Cube

各オブジェクトの定義は、図 2 の例のように 2 行のヘッダーで始まります。「--- !u!1 &7618609094792682308」は「--- !u!{CLASS ID} 」というフォーマットに従っており、2 つのパートに分けて分析されます。

  • !u!{CLASS ID}:ここでオブジェクトがどのクラスに属しているかを Unity に伝えます。「!u!」の部分は、先に定義したマクロに置き換えられ、「tag:unity3d.com,2011:1」(この場合、数字の 1 は GameObject の ID を意味します)となります。各クラス ID は Unity のソースコードで定義されています。リスト全体はこちらでご覧になれます。
  • :この部分はオブジェクト自体の ID を定義するもので、オブジェクト同士の参照に使用されます。特定のファイルに含まれるオブジェクトの ID を表すので、File ID と呼ばれます。ファイル同士の参照については、この記事の後半で詳しく説明します。

オブジェクトのヘッダーの 2 行目は、オブジェクトの種類を表す名前(ここでは GameObject)で、人間がファイルを読むことで識別できるようになっています。

Header format

オブジェクトヘッダーの後に、すべてのシリアライズされたプロパティを見つけることができます。上記の GameObject の例では、図 2 に名前(m_Name: Cube)やレイヤー(m_Layer: 0)などの詳細が示されています。MonoBehaviour のシリアライズの場合、SerializeField 属性で public フィールドと private フィールドの区別が付けられます。このフォーマットは、ScriptableObject、Animation、Material などにも同様に使用されます。ScriptableObject は自身の型を定義するのではなく、MonoBehaviour をオブジェクト型として使用することに注意してください。それは、同じ内部の MonoBehaviour クラスが、それらもホストしているからです。

YAML を使った素早いリファクタリング

これまでに説明した内容を踏まえて、アニメーショントラックのリファクタリングなどの目的から、YAML の修正で出せるパワーを活用することを始めてみましょう。

Unity のアニメーションファイルは、アニメーションさせたいプロパティごとにトラックまたはアニメーションカーブを記述することで動作します。図 4 に示すように、アニメーションカーブはパスのプロパティを通じて、アニメーションさせるべきオブジェクトを特定します。パスのプロパティには、特定のオブジェクトまでの子ゲームオブジェクトの名前が格納されています。この例では、「JumpingCharacter」という GameObject にアニメーションをつけています。「Shoulder」GameObject の子で、このアニメーションを再生する Animator コンポーネントを持つ GameObject の子でもあります。異なるオブジェクトに同じアニメーションを適用するために、アニメーションシステムは GameObject ID の代わりに文字列ベースのパスを使用します。

Code of path property of an Animation Curve

ヒエラルキー内のアニメーションするオブジェクトの名前を変更すると、非常によく発生する問題があります。カーブがそのオブジェクトを追跡できなくなることがあるのです。通常は、Animation ウィンドウで各アニメーショントラックの名前を変更することで解決しますが、複数のカーブを持つアニメーションを同一オブジェクトに適用する場合もあり、時間がかかりエラーが起きてしまいがちです。YAML を編集する方法をとると、使い慣れたテキストエディターで古典的な「検索と置換」操作を使いアニメーションカーブを編集することで、一度に複数のアニメーションカーブパスを修正することができます。

Original YAML and hierarchy on left, renamed GameObject version on right
ローカル参照

前述のように、YAML ファイルの各オブジェクトは「File ID」と呼ばれる ID を持ちます。この ID は、ファイル内の各オブジェクトに一意であり、オブジェクト間の参照を解決する役割を果たします。GameObject とそのコンポーネント、コンポーネントとその GameObject、あるいは同じプレハブ内の「SpawnPoint」GameObject に対する「Weapon」コンポーネントの参照のようなスクリプト参照を想定します。

YAML フォーマットでは、プロパティの値として「{fileID: FILE ID}」を指定します。図 6 では、この Transform が ID 4112328598445621100 の GameObject に属していることが、「m_GameObject」プロパティで File ID を通して参照されていることから分かります。また、「m_PrefabInstance」のように Null 参照(File ID が 0 になっている)の例も観察できます。記事の残りの部分もお読みいただき、プレハブインスタンスの詳細に触れてください。

Code of Transform associated with a specific GameObject

プレハブ内のオブジェクトを再度ペアリングする場合を考えてみましょう。Transform の「m_Father」プロパティの File ID を新しいターゲット Transform の File ID に変更し、さらに古い親の Transform の YAML を修正してその「m_Children」配列からこのオブジェクトを削除し、新しい親の「m_Children」プロパティにそれを追加することが可能です。

Transform with a parent and a single child

特定の Transform を名前で探すには、まずその GameObject の File ID を、探している m_Name を持つものを検索して特定する必要があります。m_GameObject プロパティがその File ID を参照している Transform を見つけることができるのはこのときだけです。

meta ファイルとファイル間のリファレンス

このファイル外のオブジェクトを参照する場合、例えば「Weapon」スクリプトが「Bullet」プレハブを参照する場合、少し複雑になってきます。File ID はファイルのローカルな値であり、異なるファイルでも同じものが使われることがあることに注意してください。他のファイル内のオブジェクトを一意に識別するためには、そのファイル内の個々のオブジェクトではなく、ファイル全体を識別する追加の ID、「GUID」が必要です。各アセットは、この GUID プロパティを meta ファイルに定義します。meta ファイルは、元になるファイルと同じフォルダーにあり、元のファイルとまったく同じ名前に「.meta」の拡張子が付加されています。

Image of a list of Unity Assets and their meta files

PNG 画像や FBX ファイルなど、Unity のネイティブファイル以外のフォーマットについては、テクスチャの最大解像度や圧縮形式、3D モデルのスケールファクターなど、Unity が meta ファイルに追加したインポート設定をシリアライズしています。これは、拡張されたファイルプロパティを個別に保存し、あらゆるバージョン管理ソフトウェアで便利にバージョン管理できるようにするために行われます。しかし、これらの設定とは別に、Unity は GUID(「GUID」プロパティ)やアセットバンドル(「assetBundleName」プロパティ)など、フォルダーや、マテリアルなどの Unity のネイティブフォーマットのファイルであっても、meta ファイルには一般的なアセット設定が保存されるようになっているのです。

Code for Meta file for a texture

これを踏まえて、図 10 に示すように、meta ファイルの GUID と YAML 内のオブジェクトの File ID を組み合わせることで、オブジェクトを一意に識別することができます。具体的には、YAML が Weapon スクリプトの「bulletPrefab」変数を生成し、GUID afa5a3def08334b95acd2d70ee44a7c2 のプレハブの、File ID 4551470971191240028 のルート GameObject を参照することがわかります。

Code of Reference to another file object

また、「Type」という 3 つ目の属性が表示されているのがわかります。Type は、ファイルを Assets フォルダと Library フォルダのどちらから読み込むかを決定するために使用されます。2 から始まる以下の値しかサポートしないことに注意してください(0 と 1 は非推奨であることから)。

  • Type 2:Assets フォルダーからエディターで直接読み込むことができるアセット(マテリアルや .asset ファイルなど)。
  • Type 3:処理され Library フォルダーに書き込まれた、プレハブ、テクスチャ、3D モデルなどのアセットがエディターから読み込まれます。

スクリプトのシリアライズに関して強調すべきもう 1 つの要素は、すべてのスクリプトで YAML での型は同じであるということです。MonoBehaviour しかありません。実際のスクリプトは、「m_Script」プロパティで、スクリプトのメタファイルの GUID を使用して参照されます。これによって、それぞれのスクリプトがどのように扱われるのか、まさにアセットとして観察することができます。

MonoBehaviour YAML referencing a Script asset

このシナリオのユースケースは、以下のようなものがありますが、これに限定されるものではありません。

  • あるアセットの GUID を他のすべてのアセットで検索することで、そのアセットが使われている場所をすべて見つける
  • プロジェクト全体で、そのアセットが使われている場所を別のアセット GUID に置き換える
  • あるアセットを拡張子の異なる別のアセットに置き換える(例:MP3 ファイルを WAV ファイルに置き換える)。この操作は、元のアセットを削除し、新しいアセットに元のアセットの名前の拡張子を新しいもので置き換えた名前をつけ、元のアセットの meta ファイルの名前の拡張子の部分を新しい拡張子に変更することで実行できる。
  • 同じアセットを削除して再追加する際に、新バージョンの GUID を旧バージョンの GUID に変更することで、失われた参照を修復する
プレハブインスタンス、ネスト状のプレハブ、バリアント

シーン内でプレハブインスタンスを使用する場合、または別のプレハブの中にネスト状のプレハブを使用する場合、プレハブの GameObject とコンポーネントは、それらを使用するプレハブ内でシリアライズされず、PrefabInstance オブジェクトが追加されることになります。図 12 で分かるように、PrefabInstance には 2 つの重要なプロパティがあります。「m_SourcePrefab」と「m_Modifications」です。

YAML for a Nested Prefab

お気づきのように、「m_SourcePrefab」はネスト状のプレハブアセットへの参照になっています。さて、その File ID をネスト状のプレハブアセットで検索しても、見つかりません。この場合、「100100000」は Prefab Asset Handle というプレハブのインポート時に作成されるオブジェクトの File ID で、YAML には存在しません。

さらに、「m_Modifications」は、オリジナルのプレハブに対して行われた修正または「オーバーライド」のセットから構成されます。図 12 では、ネスト状のプレハブ内の Transform の元のローカル位置の X、Y、Z 軸の値を上書きしています(Target プロパティの File ID で識別可能)。なお、上記の図 12 は読みやすくするために短縮しています。実際の PrefabInstance では、m_Modifications セクションにもっと多くの項目があるのが普通です。

さて、外側のプレハブにネスト状のプレハブのオブジェクトがない場合、ネスト状のプレハブの中にあるオブジェクトをどのように参照するのか、疑問に思われるかもしれません。このようなシナリオのために、Unity はプレハブ内に「プレースホルダー」オブジェクトを作成し、ネスト状のプレハブの中にある適切なオブジェクトを参照するようにします。これらのプレースホルダーオブジェクトには「stripped」タグが付けられ、プレースホルダーオブジェクトとして動作するために必要なプロパティのみに単純化された形で格納されています。

Placeholder Nested Prefab Transform to be referenced by its children

図 13 は、同様に「stripped」タグでマークされた Transform が、通常の Transform のプロパティ(「m_LocalPosition」のようなもの)を持っていない様子を示しています。その代わりに、「m_CorrespondingSourcePrefab」と「m_PrefabInstance」プロパティが、所属するファイル内のネスト状のプレハブのアセットと PrefabInstance オブジェクトを参照するように記入されています。その上には、「m_Father」がこのプレースホルダーの Transform を参照している、別の Transform の一部が見えます。これにより、その GameObject がネスト状のプレハブオブジェクトの子になります。ネスト状のプレハブで参照するオブジェクトが多くなってくると、これらのプレースホルダーオブジェクトが YAML に追加されます。

便利なことに、これがプレハブのバリアントになっても違いはありません。バリアントのベースになるプレハブは、単に親を持たない Transform を持つ PrefabInstance であり、それはバリアントのルートオブジェクトであることを意味します。図 14 では、PrefabInstance の「m_TransformParent」プロパティが「fileID: 0」を参照していることが分かります。これは親がいないことを意味し、このオブジェクトをルートオブジェクトにします。

Code of Prefab instance with no parent, making it the base Prefab for the file

この知識を使えば、ネスト状のプレハブやバリアントベースのプレハブを別のものに置き換えることができますが、このような改造は危険な場合があります。万が一に備え、バックアップをとっておくなど、慎重に進めてください。

まず、PrefabInstance オブジェクトとプレースホルダーオブジェクトの両方で、現在のベースのプレハブの GUID への参照をすべて新しいプレハブの GUID に置き換えることから始めます。プレースホルダーオブジェクトの File ID を必ず控えておいてください。「m_CorrespondingSourceObject」プロパティは、アセットだけでなく、その中のオブジェクトを File ID で参照します。現在のプレハブオブジェクトの File ID は新しいプレハブのそれと異なる可能性が非常に高く、それを修正しなければ、オーバーライド、参照、オブジェクトなどのデータを失うことになります。

このように、ベースのプレハブやネスト状のプレハブを変更するのは、思ったほど簡単なことではありません。これが、プレハブの YAML を直接変更する操作がエディター内でネイティブにサポートされていない主な理由の 1 つです。

古くなった参照

古くなったオブジェクトや参照を YAML に残るシナリオはいくつかあります。典型的なケースはスクリプトの変数が削除されたときです。Player プレハブに Weapon スクリプトを追加する場合、Bullet プレハブの参照を既存のプレハブに設定し、Weapon スクリプトから Bullet Prefab 変数を削除する必要があります。Player プレハブを再度変更して保存し、その過程で再シリアライズしない限り、Bullet の参照は YAML に残されます。他の例としては、ネスト状のプレハブのプレースホルダーオブジェクトが、元のプレハブからオブジェクトを削除しても削除されないことが挙げられますが、これもプレハブを変更し保存することで修正できます。最後に、AssetDatabase.ForceReserializeAssets API を使用して、スクリプトでアセットを再シリアライズすることができます。

しかし、なぜ Unity は上記のシナリオで古い参照を自動的に削除しないのでしょうか。これは主にパフォーマンスのためで、スクリプト 1 つ、ベースのプレハブ 1 つを変更するたびにすべてのアセットを再シリアライズするのを防ぐためです。もう 1 つの理由は、データの損失を防ぐためです。例えば、スクリプトのプロパティ(Bullet Prefab など)を誤って削除してしまい、それを復旧させたいとします。これを行うには、スクリプト上で変更を元に戻すだけです。削除した変数と同じ名前の変数がある限り、変更内容が失われることはありません。参照されていた Bullet Prefab を削除しても同じことが起こります。meta ファイルを含め、プレハブをそっくりそのまま復元すれば、参照は保存されます。

Unity が Player や Addressables をビルドする際に、これらの古いオブジェクトや参照はクリアされるため、実行時には通常問題にはなりません。しかしそれでも古い参照が問題になる場合があります。それは、純粋なアセットバンドルを使用する場合です。アセットバンドルの依存性の計算を行うときは古い参照が考慮されるため、バンドル間に不要な依存性が生じ、実行時に必要以上の負荷がかかることがあります。これは、アセットバンドルを使用する際に考えるべきことです。不要な参照を削除するためのツールを作成するか、既存のツールを使用します。

結論

YAML を完全に無視してもほとんどの場合において問題にはなりませんが、Unity のシリアライズシステムを理解するうえで、YAML の知識は有用です。大規模なリファクタリングに取り組み、アセット処理ツールで直接 YAML を読んだり修正したりすることは高速で効率的ですが、可能な限り Unity Asset Database API に基づくソリューションを探すことが強く推奨されます。また、バージョン管理におけるマージの問題を解決するのに特に有効です。Smart Merge ツールを使って、コンフリクトを起こしている Prefab を自動的にマージすることをお勧めします。また、YAML についての詳細は、公式ドキュメンテーションを参照してください。