ユニティ・シリアライゼーション

あなたはUnityでとてもクールなエディター・エクステンションを書いていて、とてもうまくいっているようですね。データ構造をすべて整理し、自分の書いたツールがどのように機能するかに本当に満足している。
そしてプレーモードに入り、プレーモードを終了する。
突然、入力したデータがすべて消え、ツールはデフォルトの初期化されたばかりの状態にリセットされる。とても悔しいよ!「なぜこんなことが起こるのか?その理由は、Unityのマネージド(モノ)レイヤーの仕組みに関係している。それを理解すれば、物事はずっと簡単になる)
アセンブリがリロードされるとどうなりますか?
プレイモードを開始/終了したり、スクリプトを変更したりすると、Unityはモノアセンブリ、つまりUnityに関連付けられているDLLをリロードする必要があります。
ユーザー側では、これは3ステップのプロセスである:
- すべてのシリアライズ可能なデータをマネージドランドから引き出し、UnityのC++側でデータの内部表現を作成する。
- Unityのマネージドサイドに関連するすべてのメモリ/情報を破棄し、アセンブリをリロードする。
- C++で保存されたデータを再シリアライズして、管理された土地に戻す。
これが意味するのは、データ構造/情報をアセンブリのリロードに耐えられるようにするには、c++のメモリに正しくシリアライズして、メモリから取り出せるようにする必要があるということだ。こうすることで、(若干の修正を加えれば)このデータ構造をアセットファイルに保存し、後日再読み込みすることもできる。
Unityのシリアライゼーションと連携するには?
Unityのシリアライゼーションについて学ぶ最も簡単な方法は、例題を通して作業することです。簡単なエディター・ウィンドウから始めましょう。このウィンドウには、アセンブリのリロードを生き延びさせたいクラスへの参照が含まれています。
using UnityEngine;
using UnityEditor;
public class MyWindow : EditorWindow
{
private SerializeMe m_SerialziedThing;
[MenuItem ("Window/Serialization")]
static void Init () {
GetWindow ();
}
void OnEnable ()
{
hideFlags = HideFlags.HideAndDontSave;
if (m_SerialziedThing == null)
m_SerialziedThing = new SerializeMe ();
}
void OnGUI () {
GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
m_SerialziedThing.OnGUI ();
}
}
using UnityEditor;
public struct NestedStruct
{
private float m_StructFloat;
public void OnGUI ()
{
m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
}
}
public class SerializeMe
{
private string m_Name;
private int m_Value;
private NestedStruct m_Struct;
public SerializeMe ()
{
m_Struct = new NestedStruct();
m_Name = "";
}
public void OnGUI ()
{
m_Name = EditorGUILayout.TextField( "Name", m_Name);
m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);
m_Struct.OnGUI ();
}
}これを実行し、アセンブリを強制的にリロードすると、変更したウィンドウの値が残っていないことに気づくだろう。これは、アセンブリがリロードされると、'm_SerialziedThing'への参照が消えてしまうからである。シリアライズされるようにマークアップされていない。
このシリアライゼーションを正しく機能させるためには、いくつかやらなければならないことがある:
In MyWindow.cs:
- フィールド'm_SerializedThing'には属性[SerializeField]を追加する必要があります。これはUnityに、アセンブリのリロードや同様のイベントでこのフィールドのシリアライズを試みるように指示する。
In SerializeMe.cs:
- SerializeMe'クラスに[Serializable]属性を追加する必要があります。これはUnityにクラスがシリアライズ可能であることを伝える。
- 構造体 'NestedStruct' に [Serializable] 属性を追加する必要があります。
- シリアライズしたい(パブリックでない)各フィールドには、[SerializeField]属性を追加する必要があります。
これらのフラグを追加した後、ウィンドウを開き、フィールドを修正する。アセンブリをリロードしても、フィールドはその値を保持することに気づくだろう。構造体は直列化にはあまり適していないのだ。NestedStruct」を構造体からクラスに変更すると、この問題は解決する。
コードは次のようになる:
using UnityEngine;
using UnityEditor;
public class MyWindow : EditorWindow
{
private SerializeMe m_SerialziedThing;
[MenuItem ("Window/Serialization")]
static void Init () {
GetWindow ();
}
void OnEnable ()
{
hideFlags = HideFlags.HideAndDontSave;
if (m_SerialziedThing == null)
m_SerialziedThing = new SerializeMe ();
}
void OnGUI () {
GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
m_SerialziedThing.OnGUI ();
}
}
using System;
using UnityEditor;
using UnityEngine;
[Serializable]
public class NestedStruct
{
[SerializeField]
private float m_StructFloat;
public void OnGUI ()
{
m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
}
}
[Serializable]
public class SerializeMe
{
[SerializeField]
private string m_Name;
[SerializeField]
private int m_Value;
[SerializeField]
private NestedStruct m_Struct;
public SerializeMe ()
{
m_Struct = new NestedStruct();
m_Name = "";
}
public void OnGUI ()
{
m_Name = EditorGUILayout.TextField( "Name", m_Name);
m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);
m_Struct.OnGUI ();
}
}いくつかのシリアライズ規則
- 構造体を避ける
- シリアライズ可能にしたいクラスには、[Serializable]を付ける必要がある。
- パブリック・フィールドは([Serializable]クラスを参照する限り)シリアライズされる。
- プライベート・フィールドは状況によってはシリアライズされます(エディター)。
- プライベートフィールドをシリアライズしたい場合は、[SerializeField]としてマークします。
- [NonSerialized]は、シリアライズしたくないフィールドのために存在します。
ScriptableObject
ここまでは、シリアライズに関して通常のクラスを使うことについて見てきた。残念ながら、プレーンなクラスを使用すると、Unityでのシリアライズに問題があります。例を見てみよう。
using System;
using UnityEditor;
using UnityEngine;
[Serializable]
public class NestedClass
{
[SerializeField]
private float m_StructFloat;
public void OnGUI()
{
m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
}
}
[Serializable]
public class SerializeMe
{
[SerializeField]
private NestedClass m_Class1;
[SerializeField]
private NestedClass m_Class2;
public void OnGUI ()
{
if (m_Class1 == null)
m_Class1 = new NestedClass ();
if (m_Class2 == null)
m_Class2 = m_Class1;
m_Class1.OnGUI();
m_Class2.OnGUI();
}
}
これは、Unityのシリアル化システムの非常に特殊なコーナーケースを示すための作為的な例です。NestedClass型のフィールドが2つあることにお気づきでしょう。m_Class1とm_Class2は同じリファレンスを指しているので、片方を変更するともう片方も変更される。
プレイモードに入ったり出たりして、アセンブリをリロードしてみてください。リファレンスは切り離された。これは、クラスを単に[Serializable]とマークした場合に、シリアライズがどのように機能するかによるものである。
標準的なクラスをシリアライズする場合、Unityはクラスのフィールドを走査し、参照が複数のフィールド間で共有されている場合でも、各フィールドを個別にシリアライズします。つまり、同じオブジェクトを複数回シリアライズしても、デシリアライズ時にシステムはそれらが本当に同じオブジェクトであることを認識できないということだ。複雑なシステムを設計している場合、これはクラス間の複雑な相互作用が正しくキャプチャできないことを意味するので、フラストレーションのたまる制限だ。
ScriptableObjectsに入る!ScriptableObjectは、参照として正しくシリアライズされるクラスの一種である。これにより、複雑なクラスの相互作用が、期待通りの形で保存されるようになる。ユーザーランドのコードでは、GameObjectにアタッチされていないScriptableObjectを持つことができます。一般的なデータ構造のシリアライズには最適だ。
シリアライズを適切に処理できるように、サンプルを修正してみよう:
using System;
using UnityEditor;
using UnityEngine;
[Serializable]
public class NestedClass : ScriptableObject
{
[SerializeField]
private float m_StructFloat;
public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }
public void OnGUI()
{
m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
}
}
[Serializable]
public class SerializeMe
{
[SerializeField]
private NestedClass m_Class1;
[SerializeField]
private NestedClass m_Class2;
public SerializeMe ()
{
m_Class1 = ScriptableObject.CreateInstance ();
m_Class2 = m_Class1;
}
public void OnGUI ()
{
m_Class1.OnGUI();
m_Class2.OnGUI();
}
}ここで注目すべきは、3つの変更点である:
- NestedClassがScriptableObjectになりました。
- コンストラクターを呼び出す代わりに、CreateInstance<>関数を使用してインスタンスを作成する。
- 非表示フラグも設定する...これについては後で説明する
これらの単純な変更により、NestedClassのインスタンスは一度だけシリアライズされ、クラスへの各参照は同じものを指すようになります。
スクリプト可能オブジェクトの初期化
これで、外部参照が必要な複雑なデータ構造には、ScriptableObjectを使うのが良いということがわかった。しかし、ユーザーコードからScriptableObjectを扱う正しい方法は何だろうか?特にUnityのシリアライゼーション・システムから。
コンストラクタはScriptableObject上で呼び出される。
データは、unityのc++側からオブジェクトにシリアライズされる(そのようなデータが存在する場合)。
OnEnable() が ScriptableObject で呼び出される。
この知識を使って、いくつか言えることがある:
- コンストラクタで初期化を行うのはあまり良いアイデアではない。データがシリアライズシステムによって上書きされる可能性があるからだ。
- シリアライズはコンストラクションの後に行われるので、コンフィギュレーションはシリアライズの後に行うべきだ。
- OnEnable()は、初期化の最有力候補のようだ。
SerializeMe'クラスを変更して、ScriptableObjectにしよう。これで、ScriptableObjectの正しい初期化パターンを見ることができる。
// also updated the Window to call CreateInstance instead of the constructor
using System;
using UnityEngine;
[Serializable]
public class SerializeMe : ScriptableObject
{
[SerializeField]
private NestedClass m_Class1;
[SerializeField]
private NestedClass m_Class2;
public void OnEnable ()
{
hideFlags = HideFlags.HideAndDontSave;
if (m_Class1 == null)
{
m_Class1 = CreateInstance ();
m_Class2 = m_Class1;
}
}
public void OnGUI ()
{
m_Class1.OnGUI();
m_Class2.OnGUI();
}
}
このクラスはScriptableObjectを継承し、コンストラクタの代わりにOnEnable()を持っている。注目すべき重要な部分は、もう少し微妙なところだ...。OnEnable()はシリアライズの後で呼び出されます。このため、[SerializedFields]がNULLかどうかを確認することができます。NULLの場合は、これが最初の初期化であり、インスタンスを構築する必要があることを示している。もしNULLでなければ、それらはメモリにロードされており、構築する必要はない。OnEnable()では、コンストラクタで行うのと同じように、オブジェクトのプライベート・フィールドや非シリアライズ・フィールドを設定するために、カスタムの初期化関数を呼び出すのが一般的です。
HideFlags
ScriptableObjectsを使用した例では、オブジェクトの「hideFlags」をHideFlags.HideAndDontSaveに設定していることにお気づきでしょう。これは、シーンにルートを持たないカスタム・データ構造を書くときに必要となる特別な設定である。これは、Unityのシーンローディングの仕組みを回避するためです。
シーンが内部的にロードされると、unityはResources.UnloadUnusedAssetsを呼び出します。もし何もアセットを参照していなければ、ガベージコレクタがそれを見つけるでしょう。GCはシーンを "ルート "として使い、何がGCされるかを確認するために階層をトラバースする。ScriptableObjectにHideAndDontSaveフラグを設定すると、Unityはそのオブジェクトをルートオブジェクトとみなします。このため、アセンブリーのリロードによって消滅することはない。Destroy()を呼び出せば、オブジェクトはまだ破壊できる。
ScriptableObjectのルール
- ScriptableObjectは一度しかシリアライズされないので、参照を適切に使うことができる。
- OnEnable を使用して ScriptableObject を初期化します。
- ScriptableObjectのコンストラクタは決して呼び出さず、代わりにCreatInstanceを使う。
- 一度しか参照されないネストされたデータ構造では、オーバーヘッドが大きくなるのでScriptableObjectは使わないこと。
- スクリプト可能なオブジェクトがシーンにルートされていない場合は、hideFlagsをHideAndDontSaveに設定します。
具体的な配列のシリアライズ
具象クラスをシリアライズする簡単な例を見てみよう。
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
[Serializable]
public class BaseClass
{
[SerializeField]
private int m_IntField;
public void OnGUI() {m_IntField = EditorGUILayout.IntSlider ("IntField", m_IntField, 0, 10);}
}
[Serializable]
public class SerializeMe : ScriptableObject
{
[SerializeField]
private List m_Instances;
public void OnEnable ()
{
hideFlags = HideFlags.HideAndDontSave;
if (m_Instances == null)
m_Instances = new List ();
}
public void OnGUI ()
{
foreach (var instance in m_Instances)
instance.OnGUI ();
if (GUILayout.Button ("Add Simple"))
m_Instances.Add (new BaseClass ());
}
}この基本的な例にはBaseClassesのリストがあり、'Add Simple'ボタンをクリックするとインスタンスが作成され、リストに追加されます。SerializeMeクラスがシリアライゼーションのために適切に設定されているため(前述したとおり)、「ただ動く」のだ。Unityは、リストがシリアライズ用にマークされていることを確認し、リストの各要素をシリアライズします。
一般的な配列のシリアライズ
基本クラスと子クラスのメンバーを含むリストをシリアライズするように、この例を修正してみましょう:
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
[Serializable]
public class BaseClass
{
[SerializeField]
private int m_IntField;
public virtual void OnGUI() { m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10); }
}
[Serializable]
public class ChildClass : BaseClass
{
[SerializeField]
private float m_FloatField;
public override void OnGUI()
{
base.OnGUI ();
m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
}
}
[Serializable]
public class SerializeMe : ScriptableObject
{
[SerializeField]
private List m_Instances;
public void OnEnable ()
{
if (m_Instances == null)
m_Instances = new List ();
hideFlags = HideFlags.HideAndDontSave;
}
public void OnGUI ()
{
foreach (var instance in m_Instances)
instance.OnGUI ();
if (GUILayout.Button ("Add Base"))
m_Instances.Add (new BaseClass ());
if (GUILayout.Button ("Add Child"))
m_Instances.Add (new ChildClass ());
}
}
この例ではChildClassを拡張していますが、BaseClassを使ってシリアライズしています。ChildClassとBaseClassのインスタンスをいくつか作成すれば、正しくレンダリングされます。問題が発生するのは、アセンブリーのリロードを経た場合だ。リロードが完了すると、すべてのインスタンスは ChildClass の情報がすべて取り除かれた BaseClass になります。インスタンスは直列化システムによって切り離される。
このシリアライゼーション・システムの制限を回避する方法は、再びScriptableObjectsを使うことである:
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
[Serializable]
public class MyBaseClass : ScriptableObject
{
[SerializeField]
protected int m_IntField;
public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }
public virtual void OnGUI ()
{
m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
}
}
[Serializable]
public class ChildClass : MyBaseClass
{
[SerializeField]
private float m_FloatField;
public override void OnGUI()
{
base.OnGUI ();
m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
}
}
[Serializable]
public class SerializeMe : ScriptableObject
{
[SerializeField]
private List m_Instances;
public void OnEnable ()
{
if (m_Instances == null)
m_Instances = new List();
hideFlags = HideFlags.HideAndDontSave;
}
public void OnGUI ()
{
foreach (var instance in m_Instances)
instance.OnGUI ();
if (GUILayout.Button ("Add Base"))
m_Instances.Add(CreateInstance());
if (GUILayout.Button ("Add Child"))
m_Instances.Add(CreateInstance());
}
}
これを実行し、いくつかの値を変更し、アセンブリをリロードすると、派生型をシリアライズしている場合でも、ScriptableObjectを配列で安全に使用できることに気づくだろう。というのも、標準的な[Serializable]クラスをシリアライズするときは「その場で」シリアライズされるが、ScriptableObjectは外部でシリアライズされ、その参照がコレクションに挿入されるからだ。シアリングが発生するのは、シリアライゼーション・システムがその型を基本型とみなしているため、正しくシリアライズできないからである。
抽象クラスのシリアライズ
これで、(メンバがScriptableObject型である限り)一般的なリストをシリアライズできることがわかった。抽象クラスの振る舞いを見てみよう:
using System;
using UnityEditor;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public abstract class MyBaseClass : ScriptableObject
{
[SerializeField]
protected int m_IntField;
public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }
public abstract void OnGUI ();
}
[Serializable]
public class ChildClass : MyBaseClass
{
[SerializeField]
private float m_FloatField;
public override void OnGUI()
{
m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
}
}
[Serializable]
public class SerializeMe : ScriptableObject
{
[SerializeField]
private List m_Instances;
public void OnEnable ()
{
if (m_Instances == null)
m_Instances = new List();
hideFlags = HideFlags.HideAndDontSave;
}
public void OnGUI ()
{
foreach (var instance in m_Instances)
instance.OnGUI ();
if (GUILayout.Button ("Add Child"))
m_Instances.Add(CreateInstance());
}
}
このコードは前の例とよく似ている。しかし、それは危険だ。その理由を見てみよう。
関数 CreateInstance<>() は ScriptableObject を継承する型を想定していますが、クラス 'MyBaseClass' は実際に ScriptableObject を継承しています。これは、抽象クラスMyBaseClassのインスタンスをm_Instances配列に追加できることを意味する。そうして抽象メソッドにアクセスしようとすると、その関数の実装がないために悪いことが起こる。この具体的なケースでは、OnGUIメソッドとなる。
リストやフィールドのシリアライズ型として抽象クラスを使うことは、それらがScriptableObjectを継承している限りは有効ですが、推奨されるやり方ではありません。個人的には、空の仮想メソッドを持つ具象クラスを使う方がいいと思う。これによって、物事が悪くなることはない。
ScriptableObjectはいつシーン/プレハブファイルに永続化されますか?
GameObjectとそのコンポーネントは、デフォルトでシーンに保存されます。コードから作成されたアセットタイプ(Materials / Meshes / AnimationClip / SerializedObject's)は、シーン内のゲームオブジェクトまたはそのコンポーネントがそれを参照している限り、シーンに保存されます。
アセットタイプは、AssetDatabase.CreateAssetを使用してアセットとして明示的にマークすることもできます。その場合、それらはシーンに保存されるのではなく、単に参照されるだけである。アセットタイプやゲームオブジェクトタイプがHideAndDontSaveとマークされている場合、シーンにも保存されません。
お問い合わせ先
