【Unity】ScriptableObjectを使用した、コンポーネント・シーン間でのデータ共有について
今回はScriptableObjectを使用したComponentやScene間でのデータ共有についてです。なお、この記事は30回近く書き直した*1という曰く付きの記事で、場合によっては消えるかもしれません。
- ScriptableObject
- 二つのオブジェクトでデータを共有する
- エディターでアセットのインスタンスに書き込むと、ファイルも更新される問題
- ScriptableObjectの保持範囲は、アセットが開放されるまで
- 追記:ScriptableObjectをCreateInstanceすべきか、クラスを使うべきか
- 感想
- 関連
ScriptableObject
ScriptableObjectはScene・Prefab以外のUnityエディターで値を調整する事が可能なオブジェクトです。Unityエディター上で値を調整する場合、ScriptableObjectやScene・Prefabを使用すると割と簡単に調整、値の利用が可能です。
ソレ以外の場合は、自前のシリアライザを用意する必要があります。
ScriptableObjectはアセットでありインスタンスである
ScriptableObjectのアセットは「アセット」ですが同時に「インスタンス」でもあります。この特徴は少し面白く、例えば複数のPrefabやSceneから参照された場合でも、勝手に複製されず単一のインスタンスとして保持されます。
例えば、Scene AとScene Bという二つのシーンがあり、それぞれOBJ 1とOBJ 2というオブジェクトを持っているとします。
ここでOBJ 1とOBJ2が同じScriptableObjectを参照していた場合、OBJ1が参照しているScriptableObjectとOBJ2が参照しているScriptableObjectは同一のものです。
この仕組みを利用して別SceneのAudio SourceとButtonのEventTriggerを接続したのが、以前紹介した「ボタンを押して音を鳴らす方法」でした。
このアプローチでは、FindやStatic等は一切使用しておらず、動的にコンポーネントとコンポーネントを紐付けています。
二つのオブジェクトでデータを共有する
実際にデータを共有してみます。
まずは赤と青の二つのText、そして白のButtonを用意しました。動作としては
- 赤と青のTextはScriptableObject内のカウントを監視し、テキストに表示
- 「ADD」Buttonを押すと、ScriptableObjectのカウントが増える
という単純な物です。うまくいくと、ADDボタンを押すたびに赤と青の両方のカウントが1ずつ増えます。
なお今回紹介する内容は、その気になればstaticやsingletonで代用出来ると思います。強いて違いを上げるとするならば
- Findやstaticを使用せずオブジェクトを参照出来る
- エディターで値が調整出来る
- インスタンスの破棄が出来る
- UnityEventベースで色々出来る
辺りでしょうか。
実際にScriptableObjectにデータを格納して共有する
まずはScriptableObjectと、ScriptableObjectを監視してTextを更新するコンポーネントを作ります。
作成したコードから、ScriptableObjectのアセットを作成しておきます。
後はClickCountUpdaterのコードに作成したClickCountを設定、設定した物を赤と青のTextオブジェクトに貼り付けます。
先にコードに登録しておくと、コンポーネントを登録するときに自動的にScriptableObjectの参照が完了するので、少し楽です。
後はClickCountのAdd()メソッドをButtonのEvent等から呼び出してやれば、Buttonから赤と青のTextの数字を更新出来ます。
このアプローチでは、オブジェクトは生成した瞬間にScriptableObjectを通して参照を構築します。なので、別々にインスタンス化しても問題なく動作します。
下の図では、青いTextとButton、赤いTextの順番に追加しています。特に参照を行うコードは無いですが、問題なく動作しています。
ResourcesからLoadしたインスタンスは一つ、ただAssetBundleは注意
今回のサンプルでは依存関係をエディターで設定しましたが、Resourcesから取り出した場合も同様に単一のインスタンスとして扱われます。
例えば下の青と赤のテキストは個別にResources.LoadでScriptableObjectを取得しますが、値が同じです。Resourcesから取り出したインスタンスは、破棄されない限り同一だと分かります。
AssetBundleの場合も基本的には同じですが、暗黙的参照でインスタンスを参照していた場合、異なるアセットとして扱われるかもしれません。複数のAssetBundleをまたぎそうな場合は、別AssetBundleに明示的に格納しておくことが良さそうです。
Find Reference In SceneでScriptableObjectを参照するオブジェクトを見つけ出す
シーンでScriptableObjectへの参照を持つアセットを探したい場合、Find Reference In Sceneで見つけられます。
ただPrefab化したオブジェクトが参照していた場合、Prefabに含まれる全てのオブジェクトがヒットしてしまうので使い所が難しいです。
エディターでアセットのインスタンスに書き込むと、ファイルも更新される問題
さて、アセットのインスタンスを用いてデータを共有しましたが、実はこの方法は一つ問題があります。アセットの値を更新するという事は、ファイルも更新されるという点です。言い換えれば、Sceneの再生を終了しても数値は元に戻りません。
下の例では、シーンを一度停止しているにも関わらず、再度再生すると途中から数値の加算が行われています。プレイヤーでは問題はないのですが、エディターだと問題になります。
案1:Inspectorで使用する初期値と実際の値を分ける
この問題を回避する方法で一番まともなのは、ScriptableObjectにInspector表示用の値と実際にゲームで使用する値の二種類を用意し、OnEnableのタイミングで値を流し込む事です。
またOnValidateを定義しておくことで、再生中にもInspectorから値を書き換えられるようになっています。
案2:再生停止時にScriptableObjectをUnloadする
案2はScriptableObjectがアセットでありインスタンスである事を利用します。ゲームの再生停止時にScriptableObjectをUnloadしてしまえば、再生中に変更した値は無かったことになり、ScriptableObjectの値がファイルに書き込まれる事は無いという事です。
まずはResettableScriptableObjectをプロジェクトに配置します。これはシーンの再生停止時に自身のインスタンスをアンロードする…という挙動を持ったScriptableObjectの派生クラスです。
これを継承する事で、シーン再生時にScriptableObject値が最後にProject Saveした際の値まで巻き戻ります。
要するに、もし値を確定させたい場合は、Project Saveを実行しないと、巻戻ります。これはResettableScriptableObjectのコメントアウトしてる部分を戻せば回避出来るのですが、どうもUnityエディターを巻き込んでクラッシュする事がある(条件は不明)ので、Project Save推奨です。
ScriptableObjectの保持範囲は、アセットが開放されるまで
少し気になるのが、ScriptableObjectを保持する範囲です。
これは単純に「ScriptableObjectのアセットが開放されるまで」となります。
例えば、シーンをロードした際に、次のシーンでも同一インスタンスのScriptableObjectを参照していた場合、ScriptableObjectの値は保持されます。
またUnloadUnusedAssetsを呼ばれた際に、どこからかScriptableObjectへの参照がある場合、同様にScriptableObjectの値は保持されます。
なお、エディターだとシーンを跨いだ場合でもScriptableObjectが開放されない事があります。
アセットを勝手に開放させない方法
シーンを跨いだり、どこからも参照されない状態でUnloadUnusedAssetsを呼ばれるとアセットが開放される事があります。
アセットを開放させない方法は、思いつく限り3つあります。
- 何処からか常に参照する。例えばstaticにでも登録してやれば開放されない。
- PreLoadAssetsに登録する。挙動的にどうも開放されないらしい
- HideFlags.DontUnloadUnusedAssetを設定す
積極的なアセットの開放は、Resources.Unload。しかしC#のインスタンスが開放される訳ではない
もし値を積極的にリセットしたい場合、シーンをロードする直前でResources.Unloadでインスタンスを開放してやると、次のシーンでマッサラな(アセットからデシリアライズしたての)ScriptableObjectを受け取れます。
例えば下の図では、二つのシーンを行き交っています。この際、連続してScriptableObjectを参照しているため、二つのシーン間で値を保持していますが、Unloadを押すとScriptableObjectが開放され、次のシーンでは値は0に巻き戻っています。
この時、C#のScriptableObjectインスタンスが開放される訳では無い点には注意が必要です。ScriptableObjectは一旦破棄されますが、ScriptableObjectがデシリアライズして生成されたC#のインスタンスはそのまま保持されます。
つまり、EventやC#的に直接参照している物は、Unload後もアクセス出来る事を意味します。
下の図では、左がButtonからイベント呼出、右はスクリプト(C#)がScriptableObjectへの参照をキャッシュして呼び出しています。左はアンロード時に押せなくなりますが、右はアンロード後も押せます。
追記:ScriptableObjectをCreateInstanceすべきか、クラスを使うべきか
今回ScriptableObjectについて書きましたが、こうなると動的なクラス生成の運用でも、単純なクラスの使用は止めてScriptableObjectを使用すべきか考えるかもしれません。
個人的な考えでは、
- エディターでパラメータを修正するようなケース
- ホットリロードを行うようなケース
以外では、動的なインスタンスは通常のクラスを使用する方が良いのではないかなと思います。
逆に、エディターで既にインスタンスを作成しており、複数シーンやオブジェクトで共有するようなケースの場合、ScriptableObjectはかなり良いです。
感想
ScriptableObjectを用いたデータの共有でした。
アンロード(アセットの開放)周りが少しややこしくなるので、面倒なら避けるのは正しい選択かもしれません。
強いていうならば、ボタンを押したら音が出る…を、出来る限りコードを書かずに実現する で紹介したような、オブジェクト間の接続では割と有益かもしれません。このアプローチでは「送信先・送信元」のどちらも存在する場合に使われるので、
- 意図的にUnloadしない限りScriptableObjectはアンロードされない
- どちらも無い場合はアンロードされる
- 再ロード時も参照関係しか保持しないのでパラメータが失われる等の問題は無い
と、悪くないです。
もしくはデータはシーン内のGameObjectが持ち、メッセージのやり取りのみを行わせる等でしょうか。
関連
今回の話を考えたきっかけ
*1:(同タイトルの下書き記事も4つある)