imog

主にUnityとかの事を書いています

Unityでシーンをキャッシュする仕組み

2時間遅れの公開になります。遅れてしまった・・。

こちらはUnityアドベントカレンダー、12/5の記事です。

qiita.com

前回は凹さんの「Unity で Windows のデスクトップ画面をテクスチャとして表示するプラグインを作ってみた」でした。

tips.hecomi.com

シーンを保持したい問題

Unityでよく悩む問題として、シーン遷移時に前の状態に戻したいということがたびたびあります。

  • シーンAからシーンBに遷移
  • Bからキャンセル等の動作でAに戻る
  • Aのカーソル位置や状態が初期状態に戻る

RPGのフィールド画面 -> 戦闘画面 -> フィールド画面とか パーティゲームのキャラ選択 -> ステージ選択キャンセル -> キャラ選択に戻る

とか。 つまり、前の状態を保持したままシーン遷移を行いたくなることが多いです。 やり方としては、パッと2パターンくらい浮かびました。

  • シーン遷移時に必要な情報をシリアライズしてシーンを破棄、再読み込み時に流し込む
  • シーンを破棄せずまるごと非アクティブ化。再読み込み時にアクティブにする

前者の方がメモリを食わずに済むので便利そうですが、何をシリアライズするか具体的に決まっていないと作れなさそうな気がしました。 後者は汎用的に使える仕組みではありますが、シーンが残ったままなのでメモリが心配です。特にモバイルだと処理落ちしないかちょっと心配。

今回はこんなやり方どうよというところを紹介したいので、後者を実装します。

シーンのキャッシュ

結論から言うとこんな感じにできました。

以下のことができます。

  • 任意のシーンにロードして以前のシーンを非アクティブ化。キャッシュがある場合はシーンをアクティブ化。
  • 任意の複数のシーンをロードして合成、一つのシーンとしてキャッシュする

追加したクラスは2つで、シーンのロードを担当する SceneManagerWithCache と、シーンの状態を管理する SceneProperty です。

シーンの状態を持つSceneProperty

using UnityEngine;
using UniRx;

public class SceneProperty : MonoBehaviour
{
    public bool cacheable;
    public string sceneName { get { return gameObject.scene.name; } }

    private Subject<Unit> sceneEnabledStream = new Subject<Unit>();
    public IObservable<Unit> sceneEnabledAsObservable
    {
        get { return sceneEnabledStream.AsObservable(); }
    }

    private Subject<Unit> sceneDisabledStream = new Subject<Unit>();
    public IObservable<Unit> sceneDisabledAsObservable
    {
        get { return sceneDisabledStream.AsObservable(); }
    }

    public void Disable()
    {
        sceneDisabledStream.OnNext(Unit.Default);
        foreach (var root in gameObject.scene.GetRootGameObjects())
        {
            root.SetActive(false);
        }
    }

    public void Enable()
    {
        sceneEnabledStream.OnNext(Unit.Default);
        foreach (var root in gameObject.scene.GetRootGameObjects())
        {
            root.SetActive(true);
        }
    }
}

ScenePropertyはシーンのRootのオブジェクトに貼ります。 役割は以下です。

  • シーンがキャッシュできるかどうかの設定を持つ
  • シーンの有効化、無効化処理のインタフェース

例えば絶対に使いまわししないであろうシーン(スコアをサーバに送信したりとか)の場合はcacheableのチェックを外しておき、シーンが使われなくなった時点で削除するようにします。 Enable,Disableメソッドを外部から叩いて、シーン全体のアクティブ、非アクティブ化を行います。 Rootオブジェクトに貼り付けてるのに GetRootGameObjects でルートを取得するのは、後述の複数シーン合成に対応するためです。 ちなみに、アクティブ、非アクティブのタイミングでストリームを発行するので各種シーン側でスクリプトを噛ませてごにょごにょできます。

シーンのロードを担当する SceneManagerWithCache

using System.Collections;
using System.Linq;
using UnityEngine;
using UniRx;
using UnityEngine.SceneManagement;

public class SceneManagerWithCache : MonoBehaviour
{
    [SerializeField]
    private int sceneCacheCapacity = 3;

    public ReactiveCollection<SceneProperty> scenes = new ReactiveCollection<SceneProperty>();

    private void Awake()
    {
        var sceneAddAsObservable = scenes.ObserveAdd()
                                         .Publish()
                                         .RefCount();
        sceneAddAsObservable
            .Where(s => s.Index >= sceneCacheCapacity)
            .Subscribe(s =>
            {
                var unloadScene = scenes.First();
                scenes.Remove(unloadScene);
                SceneManager.UnloadSceneAsync(unloadScene.gameObject.scene);
            });

        sceneAddAsObservable
            .Select(currentScene => scenes.Where(s => !(s.cacheable | s.Equals(currentScene.Value))))
            .Where(unCachedScens => unCachedScens.Count() > 0)
            .Subscribe(unCachedScens =>
            {
                for(int i = 0; i < unCachedScens.Count(); i++)
                {
                    var s = unCachedScens.ToList()[i];
                    scenes.Remove(s);
                    SceneManager.UnloadSceneAsync(s.sceneName);
                }
            });
    }

    public IObservable<SceneProperty> LoadMergedScene(string baseSceneName, bool useCache, params string[] subSceneNames)
    {
        if (useCache)
        {
            IObservable<SceneProperty> propertyObservable = GetCacheScene(baseSceneName);
            if (propertyObservable != null)
            {
                return propertyObservable;
            }
        }

        return LoadScene(baseSceneName, useCache)
            .Take(1)
            .Select(p => p.gameObject.scene)
            .Concat(Observable.FromCoroutine<Scene>(observer => LoadSubSceneCoroutine(observer, subSceneNames)))
            .Do(loadedScene =>
            {
                var baseScene = SceneManager.GetSceneByName(baseSceneName);
                SceneManager.MergeScenes(loadedScene, baseScene);
            })
            .Select(_ => scenes.Last(s => s.sceneName == baseSceneName));
    }

    private IEnumerator LoadSubSceneCoroutine(IObserver<Scene> observer, params string[] subScenes)
    {
        foreach (var s in subScenes)
        {
            yield return SceneManager.LoadSceneAsync(s, LoadSceneMode.Additive)
                .AsObservable()
                .ToYieldInstruction();
            observer.OnNext(SceneManager.GetSceneByName(s));
        }
        observer.OnCompleted();
    }

    public IObservable<SceneProperty> LoadScene(string name, bool useCache = true)
    {
        if (useCache)
        {
            IObservable<SceneProperty> propertyObservable = GetCacheScene(name);
            if (propertyObservable != null)
            {
                return propertyObservable;
            }
        }

        return SceneLoadedAsObservable(name);
    }

    private IObservable<SceneProperty> GetCacheScene(string sceneName)
    {
        var scene = scenes.LastOrDefault(s => s.sceneName == sceneName && s.cacheable);
        if (scene == null)
        {
            return null;
        }
        scene.Enable();
        var beforeScene = scenes.Last();
        if (beforeScene != null)
        {
            beforeScene.Disable();
        }
        scenes.Remove(scene);
        scenes.Add(scene);
        return Observable.Return<SceneProperty>(scene);
    }

    private IObservable<SceneProperty> SceneLoadedAsObservable(string name)
    {
        return SceneManager.LoadSceneAsync(name, LoadSceneMode.Additive)
                  .AsObservable()
                  .Select(_ => SceneManager.GetSceneByName(name))
                  .Select(scene => scene.GetRootGameObjects().First().GetComponent<SceneProperty>())
                  .Do(property =>
                  {
                      var beforeScene = scenes.LastOrDefault();
                      if (beforeScene != null)
                      {
                          beforeScene.Disable();
                      }
                      scenes.Add(property);
                  });
    }
}

100行超えてつらい。

外部から呼べるメソッドは二つです。

  • LoadScene
  • LoadMergedScene

LoadSceneSceneManager.LoadScene と同じ感覚で、シーン名を指定してロードします。 通常時と違うのは、キャッシュを使うか否かbooleanで渡せるところです。 useCache = trueで渡すと、すでにシーンが非アクティブで存在する場合そちらを有効化します。 見つからなかった場合は通常のシーン読み込みが行われます。

内部的にはロード時は以下のフローです

  • キャッシュが無い場合SceneManager.LoadSceneAsync でシーンロード
  • キャッシュがある場合、該当シーンから ScenePropertyを取得して有効化
  • 直前のシーンを無効化
  • シーン管理リストに追加
  • キャッシュ数上限を超える or キャッシュしないシーンをリストから削除、 SceneManager.UnLoadAsyncヒエラルキーから削除

呼び出し元には IObservable<SceneProperty> を返して、ロード完了後にイベントを発行します。 完了後のなにかしらはシーン側にお任せします。

キャッシュ数上限は sceneCacheCapacity が保持しており、インスペクタ上から設定できます。

LoadMergedScene は、シーン名を指定してロードするのは同じですが、こちらは複数ロードすることができます。 ベースとなるシーンAと、可変長引数のシーン名配列を渡すことで、該当のシーンをすべてロードした上でシーンAに統合してくれます。

これは、1つのシーンを機能ごとに分割して管理をしている場合に有効です。 例えばデザイナと分担するためにシステムとUIをシーン分けて作業しているけど、実際に使うときは完全に統合したいんだよみたいなときとか。 どういうシチュエーションだろうか、というところは前回のエントリなどを見ていただけるとイメージがつかめるかもしれません。

adarapata.hatenablog.com

こちらも最終的な返り値は IObservable<SceneProperty> なので通常のシーン読み込みと同じように扱えます。

ちなみに呼び出し側はこんな感じ。

GetComponent<ObservableEventTrigger>()
    .OnPointerClickAsObservable()
    .Subscribe(_ => {
        var manager = FindObjectOfType<SceneManagerWithCache>();
        manager.LoadScene(sceneName).Subscribe(s => print("loaded: " + s.sceneName));
    }

とりあえず、この2つのスクリプトでキャッシュ機構は実現できそうです。

作ってみて

どのプロジェクトにも持っていけるので結構便利。 重さに関しては、少なくともPCで動かして処理落ちなどはないです。 ただモバイルは未検証。正直きついのでは・・と思う。

処理がかなりUniRxゴリゴリ書いているんだけど、Do多用して副作用多かったりだいぶ辛い感じなのでアドバイスいただきたいところ・・。

次は utibenkeiさんです。 qiita.com