【Unity】シーンの重複読み込みをLINQで防ぐ


これは、 C# その2 Advent Calendar 2019 の5日目の記事です。
昨日は @advanceboy さんによる C# の REPL, スクリプティング環境の比較 でした。

Unityのシーンの重複読み込み対応をLINQでやる

Unityで、2つのシーンをまたいで共通する処理がある場合、追加読み込み専用の共通シーンを作ったりすることがあります。

ただし、追加読み込みは気を付けないと、重複して読み込んだりして、バグのもとになります。

今回は、LINQを使って、そのあたりを効率よくコーディングする手法を紹介します。

結論だけ知りたい方へ

以下のような静的クラスを作っておいて、シーン読み込みする際に、すでに読み込まれているかどうかを検査すれば、重複読み込みを避けることができます!

SceneController.cs
using UnityEngine.SceneManagement;
using System.Linq;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        return SceneManager.GetAllScenes()
            .Any(scene => scene.name == name);
    }
}

この結論だけ読んでもピンと来ない方もいらっしゃると思いますので、順番に説明しますね!

サンプルプロジェクト

サンプルプロジェクトを以下に置きましたので、必要に応じてご確認ください。

GitHub / segurvita / UnityScenePractice

TitleシーンとMainシーンを行き来したい

ゲームを作る際に、タイトル画面からメイン画面に遷移するといった機能を実装することがよくあります。

たとえば、

  • タイトル画面は Title.unity
  • メイン画面は Main.unity

のように、別々のシーンファイルにするのが一般的です。

上記の図のように Mキーを押したらMainシーンに遷移 したい場合は、以下のようなスクリプトを書くかと思います。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main");
        }
    }
}

Mainシーンの方にも同じようなスクリプトを設置すると思います。

これで、とりあえずシーン遷移をすることはできるようになりました!

Commonシーンを共通で読み込むことになった

以下の図のように Common シーンを読み込むことになったとします。

Titleシーンが起動した際に、Commonシーンを読み込むようにすれば、できそうです。

以下のように Awake() を追加してみました。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        SceneManager.LoadScene("Common", LoadSceneMode.Additive);
    }

    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main");
        }
    }
}

LoadSceneMode.Additive を指定することで、シーン遷移をせずにCommonシーンを追加で読み込むことができます。

これと同じような変更をMainシーン側にもしてみます。

これによって、Titleシーン、Mainシーン、どちらに遷移してもCommonシーンを読み込むことができるようになりました!

Commonシーンを破棄しちゃダメ!って言われた場合

ここまでのコードだと、シーン遷移する度に、Commonシーンも破棄されています。

もし、Commonシーンで音楽の再生等をしていた場合は、シーン遷移するときに音楽も止まってしまいますね!

これだと困るので、以下のように、シーン遷移も LoadSceneMode.Additive でやってしまいましょう!

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        SceneManager.LoadScene("Common", LoadSceneMode.Additive);
    }

    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            SceneManager.LoadScene("Main", LoadSceneMode.Additive);
        }
    }
}

これと同じような変更をMainシーン側にもしてみます。

するとどうなるでしょうか?

実はこれ、非常に危険なコードです!

このままだと、シーン遷移する度に、Commonシーン・Mainシーン・Titleシーンの3つが重複して読み込まれてしまうんです!

しかも、性質の悪いことに指数関数的に累積していくので、あっという間にメモリがあふれます。

シーンがすでに読み込まれているか確認する

重複してシーンが読み込まれるのを防ぐためには、同じ名前のシーンがすでに読み込まれているか確認する必要があります。

以下のような静的クラスを作りましょう。

SceneController.cs
using UnityEngine.SceneManagement;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        for (int i = 0; i < SceneManager.sceneCount; i++)
        {
            if (SceneManager.GetSceneAt(i).name == name)
            {
                return true;
            }
        }
        return false;
    }
}

SceneManager.sceneCount で、現在アクティブなシーンの数を数え、 SceneManager.GetSceneAt(i) でそのシーンのデータを取得しています。

すべてのシーンの name を確認していき、一致したものがあれば、そのシーンはすでに読み込まれているということになります。

この静的クラスを適当なフォルダーに設置した上で、さきほどの TitleScene.cs を以下のように改修しましょう。

TitleScene.cs
using UnityEngine;
using UnityEngine.SceneManagement;

public class TitleScene : MonoBehaviour
{
    void Awake()
    {
        if (!SceneController.AlreadyLoadScene("Common"))
        {
            SceneManager.LoadScene("Common", LoadSceneMode.Additive);
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKey(KeyCode.M))
        {
            if (SceneController.AlreadyLoadScene("Title"))
            {
                SceneManager.UnloadSceneAsync("Title");
            }
            SceneManager.LoadScene("Main", LoadSceneMode.Additive);
        }
    }
}

さきほど作った SceneController.AlreadyLoadScene() で、シーンがすでに読み込まれているかを確認して、それによって、 LoadSceneUnloadSceneAsync を呼ぶようにしました。

これで、メモリがあふれる心配はなくなりました!

LINQで改良してみる

せっかく作った SceneController.AlreadyLoadScene() ですが、 for 文を使ってる部分が、少し冗長な気がします。

LINQで改良したものがこちらです。

SceneController.cs
using UnityEngine.SceneManagement;
using System.Linq;

public static class SceneController
{
    /** 既にシーンが読み込まれているかどうか */
    public static bool AlreadyLoadScene(string name)
    {
        return SceneManager.GetAllScenes()
            .Any(scene => scene.name == name);
    }
}

解説をすると、まず、 SceneManager.GetAllScenes() で、アクティブなシーンの一覧を取得しています。

その次に、 .Any(scene => scene.name == name) で名前の一致するシーンが存在するかどうか確認しています。

コード量が減ってかなりスッキリしましたね!

さいごに

今回ご紹介した方法のほかにも、 DontDestroyOnLoad を使った方法等があると思います。

そのあたりは、ご自身の趣味趣向に合わせて、使いたいものを使うのがよいかと思います。

本記事作成にあたり、以下のページを参考にさせていただきました。ありがとうございました。

これは、 C# その2 Advent Calendar 2019 の5日目の記事でした。
明日は @s51517765 さんによる C#で複数のデータをreturnする です。