[unity2019.LTS]お手軽シーン切り替え

このテスト環境は以下のフリーアセットを使用しています。問題があれば対処しますので、ご連絡ください。
Glowy Space - 2D Toon Parallax、Free Asset - 2D Handcrafted Art
© Unity Technologies Japan/UCL

サンプル環境

  1. 新規 unity プロジェクトを作成します(環境は 2019.4.6f1、テンプレート 3D)
  2. ダウンロードした zip を解凍し、unitypackage を新規プロジェクトにインポートします(Project 右クリック - Import Package - Custom Package...)
  3. Scenes/main シーンを開きます
  4. File - Build Settings の Scenes In Build に main を追加します

画面サイズ可変対応はしていないので、描画域が 960x540 以外だと、動画とは感じが変わるかもしれません
記事にある大きなファイルは、テスト環境では減らしてあります。

解説

今回のサンプルでは以下のコードを使っています。

まずメインシーンが起動します。main には 左上に3 つのボタンがあり、これはアプリが終了するまで常に存在します。

ボタンを押したシーンは main の下に Additive モードで追加されます(サブシーン)。既に他のサブシーンがある時は、そのサブシーンを消してから新しいシーンを追加します。
2 つのシーンは必ず同時に存在しないので、一瞬でもメモリを圧迫することはありません。

2 つのシーンがオーバーラップするような演出は出来ませんが、こういった演出はシーンでやるより、prefab で表現した方が楽でしょう。
シーンを切り替える時は、あくまで「メモリのゴミを綺麗にする」を第一にしておくと、ややこしい事を考えずに済むんじゃないかと思います。

シーン切り替えは SubScene.csBundleScenes.cs が担当します。
ボタンを押した時に呼び出されるコードはこんな感じです。

public class SceneChangeButton : MonoBehaviour
{
    [SerializeField]
    SubScene scene = null;

    public void ToScene1()
    {
        scene?.ChangeScene(BundlePath.BUNDLE_SCENE1);
    }

    public void ToScene2()
    {
        scene?.ChangeScene(BundlePath.BUNDLE_SCENE2);
    }

    public void ToScene3()
    {
        scene?.ChangeScene(BundlePath.BUNDLE_SCENE3);
    }
}

既に表示されているサブシーンのアンロードは、自動的に行うので意識する必要はありません。
また、例えば scene1 で読み込む必要のある unitychan、images、fonts/font のアセットバンドルもシーン読み込み前に、自動的に読み込みます。
(この辺は CreateAssetBundle が頑張っている結果です)

メモリ管理の改善策

さて、シーンのアセットバンドルを作るのはとても簡単ですが、シーンだけをアセットバンドルにした場合、全ての使用アセットを含めた巨大アセットバンドルになります。

fonts/font はテスト用に 8096 x 8096 のフォント(普通作らない巨大サイズ…)を 6 つも置いたため、800 MB 近くもありました(アップしてある環境は小さくしています)

大抵フォントはどのサブシーンにも使われるはずです。このフォントテクスチャを現在はサブシーンが切り替わる度にアンロード→ロードとしてしまうため、大変効率が悪いです。
フォントだけ Main 起動時に読み込み、後はアプリが終わるまでずっとフォントテクスチャを持っていればいいんじゃない? どうせ使うし。

その方法を考えるまでもなく、BundleAssets ではサブシーン起動前に以下のコードでフォントテクスチャを読み込んでおけば、うまいことやってくれます。詳しい説明は BundleAssets の参照カウンタあたりをご覧ください。

// Main.cs の initialize() で、アプリ終了まで必要なアセットは先読みしておく        
BundleAssets.LoadAsync(BundlePath.BUNDLE_FONTS_FONT);
yield return BundleAssets.WaitLoad();

Unity editor でサブシーンから再生したい

unity editor で製作中のサブシーンから起動してほしい事はよくあります。
しかし、main がないと、サブシーンは存在できない構造になっているのが問題です。

この構造を起動直後に作り出すために、以下の方法を考えてみました。

  • 一旦 main のシーンを呼び出して、サブシーンを消す
  • 消したサブシーンを Additive で再び呼び出す
public class Main : MonoBehaviour
{
    const string  sceneMain = "main";
    static string sceneName;

    /// <summary>
    /// Program entry point
    /// </summary>
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void main()
    {
#if UNITY_EDITOR
        sceneName = SceneManager.GetActiveScene().name;

        // main 以外で起動した場合、一旦 main にしてから、それ以外のシーンを呼び出す
        if (sceneName != sceneMain)
        {
            // load 'Main' scene
            SceneManager.LoadSceneAsync(sceneMain, LoadSceneMode.Single);
        }
#endif
    }

    /// <summary>
    /// awake
    /// </summary>
    void Awake()
    {
#if UNITY_EDITOR
        if (sceneName != sceneMain)
        {
            // 元のシーンを追加読み込み
            BundleScenes.ChangeSceneAsync(sceneName);
        }
#endif
    }
}

RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad) main() は起動直後、シーンが開かれる前に呼ばれるメソッドになります。

この時 main 以外のサブシーンが起動していたら、Single モードで main シーンを呼び出します。
(Single モードで呼び出すと、現在のサブシーンは一旦開放されます)

その後、main シーンの Awake() がコールされるので、ここで一旦開放されたサブシーンを再びコールします。Additive モードでコール、main が残ったまま呼び出され、main の下にサブシーンがある、という構造に置き換わりました。

なお、これが可能なのは Unity Editor のみです。
ビルドでは Build Settings に一つだけ登録された main から必ず起動するので、似たようなことをしたいのであれば、Awake() にその直後起動して欲しいサブシーンを書いておけばいいと思います。

RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad) main() は必ずシーン開始前に実行されるので、共有データの初期化なんかも行っておくと便利かもしれません。

サンプル環境では Main.cs として入っていますので、参考にしてください。
(アセットバンドルの先読みロードも入っているので、若干上記コードに追加されています)

シミュレーションモードの闇

アセットバンドルを作成しなくても、それっぽくプログラムを実行できるシミュレーターですが、あくまで「それっぽく」であり、アセットバンドルの恩恵は「ほぼ」ありません。

以下のような問題点があることを理解した上でお使いください。
とはいえ、そもそも Unity Editor のみなので、Unity Editor で動くならメモリはどうなっててもいい、そう思えるなら使う価値ありです。

  • アセットバンドルを作成したわけではないので、依存関係は全くない
  • そのため、同じファイルを複数読むなどの弊害が(内部では)起こっている
  • メモリ改善策などもシミュレーターでは機能しない

前説の懸念は払しょくできたのか?

完璧とは行きませんでしたが、まあまあ払しょくできたかと思います。

キャッシュ機能

BundleAssets にキャッシュ機能が実装されています。参照カウンタによる安全なアンロードも可能なので、予期せぬ問題は起こり辛い(筈)。

ヒエラルキーに画像やモデルを置いても、シーン毎に別々にメモリを確保しない

「メモリ管理の改善策」でも示したように、どのシーンでも使うデータを、

  • 共通アセットバンドルにする
  • Main.cs などで先読みして、アプリ終了まで取っとく

とすれば、アセットを使いまわしてくれます。
なお、Sprite Atlas は同じアセットバンドルファイルに Sprite も含めておくことをお勧めします。
細かい仕組みは省きますが、そうしないと別々にメモリを確保してしまいます…。

シーン読み込みのカク付き制御

これは正直、(完璧を目指すなら)Resources.Load() の方が制御しやすそうです…。
ただし、「メモリ管理の改善策」で示したような対策を施すことで、気にならないレベルにすることは出来ます。
今回のサンプルではその対策のお陰で、800MB のフォントがあってなお、ほぼシーン移動にもたつきは感じませんでした。
一応進捗ゲージも表示してみましたが、表示しなくてもいいレベルです。

開発ガイドライン云々

アセットバンドルを使っているので、ひっかかることはないでしょう!

感想

思ったより全然面倒でした!!!😢

とはいえほとんどは暗号化とか、バンドルの依存関係を自動検出、勝手に読み込んでくれる機能など、前説になかった機能を実装したせいです。欲が出てしまいました。

そのおかげで、使い勝手は Resources.Load() と変わらないか、若干よくなったくらいではないかと思いますので、AssetBundle や Addressable から及び腰の方々のお役に立てれば幸いです。