Unityでの複数シーンを使ったゲームの実装方法とメモリリークについて


複数シーン実装についてまとめた背景

公式ドキュメントがない

Unityでは、シーンを編集してゲームを作っていくのが基本です。
シーンは一つではなく複数使ってゲームを作ることができるのですが、詳しいやり方に関してはUnityの公式ドキュメントにはどこにも載っていません。
Unity公式ドキュメント「シーン」には、単一シーンの簡単な解説しかありません。
複数シーンの編集というページはあるのですが、基本的にはUnityエディタ上で複数のシーンを同時に編集する際のやり方であって、肝心の「実装方法」に関してはTIPS程度しかないようです。
ドキュメントではなくスクリプトリファレンスのほうには個別の機能の説明があるのですが、日本語訳がほぼされていません。

人によってやり方が違う

Unityの複数シーンを使った実装方法自体は古くから議論されていています。
インターネット上にもいくつか情報がありますが、書籍などのまとまった情報は非常に少ないようです。
「これが正解」というものなく、プロジェクトの性質や実装者が何を重視するかでやり方も違う、いうなれば「Unityプロジェクトの設計」そのものでもあるため、なかなかに扱うのが難しいのだと思います。
かくいう私も、ずいぶん昔に複数シーンの実装に関してまとめたことがあるのですが、だいぶ偏ったやり方だったため「参考にしたもののバグの温床になった」「バッドノウハウ」というフィードバックを受けていたりします。
実際、個人個人やり方が違って当然なので、それぞれに最適なやり方を見つけていけばよいと思います。
ただ、だんだん情報が断絶してブラックボックスになってしまっているため、初心者の人にとってはどんどん学習ハードルが高くなっているように感じています。
実際、ノベルゲームツール「宴」のサポートをしていると、この点で躓く人が多くいました。
なので、この機会にもう一度まとめてみようかと思います。
初心者の人にもわかりやすくはしたいのですが、あまり省略するとまたバッドノウハウになってしまうので専門的な話も混ざってしまっています。
わからないところは読み飛ばして躓いたら読み直す程度で全体像をつかんでいただければと。

複数シーン実装のメリットとデメリット

複数シーンで実装するメリットの一番は「編集シーンを複数に分けられるので、分担作業をしやすい」という点です。
チーム開発では、シーンを同時に編集してしまうとファイルの変更が衝突してしまうため、これを避けるという目的が多いと思います。
また、慣れてくると「UIシーン」「マネージャーシーン」「バトルのシーン」などのように機能別に分けられるようになって、開発がスムーズになるというメリットもあります。
逆にデメリットとしては、実装のハードルです。
基本的には自力でプログラムをするしかないです。
シーンをロードするという基本的な部分もそうですし、Unityは編集中はほかのシーンへの参照をもてないため、実行中に別のシーン同士で情報をやりとりできるよう実装する必要があります。
そしてそれによってシーン外に参照関係が増えてしまうため、メモリリークという厄介な問題を引き起こしやすいという難点が出てきてしまいます。
メモリリークに関しては問題にならないときは何もしなくても良いのですが、いざ問題がおきると専業のプログラマーでも頭を悩ませる技術的に難しい問題になってしまいます。

そもそも単一シーンで実装すればいいでのは?

正直なところ、単一のシーンでプロジェクトを構成できるならそれが一番楽なので、それで良いと思います。
実際、単一シーンで実装しているプロジェクトも多いとかと思います。
各UI画面などのプレハブを別々のシーンで作成し、実際のゲームではそのプレハブを組み合わせて実装するというやり方をすれば、編集シーンの分担作業も可能です。
昔はコツがいるようでしたが、Unityのプレハブは入れ子ができるようになったためかなり実装ハードルは下がっていると思われます。

複数シーンの実装の基本

Unityで複数シーンを使うにはまず、BuildSettingsに使用するシーンをすべて設定する必要があります。
これをしておかないと、シーンのロードができません。

ゲームの実行中にUnityで別のシーンをロードするには、プログラムで呼び出すしかなく、SceneManager.LoadSceneを使います。
非同期版SceneManager.LoadSceneAsyncもあり、実際にはこちらを使うケースのほうが多いかと思います。
LoadSceneもLoadSceneAsyncの違いは、同期処理(重いシーンの場合はロードが終わるまでゲームが固まる)か、非同期(ロードしている最中もゲームを止めないですむ)という違いです。
第二引数には「LoadSceneMode.Single」と「LoadSceneMode.Addtive」のいずれかが指定でき、指定がなかった場合のデフォルトはLoadSceneMode.Singleです。
この二つは大きく目的も使い方も異なります。

ロードするシーンは一度に一つだけ LoadSceneMode.Single

概要

LoadScene(またはLoadSceneAsync)で第二引数を指定しないか、「LoadSceneMode.Single」とすると、前のシーンをすべて削除して次のシーンをロードします。
「Single」の名の通り、一度にロードしておけるシーンは一つだけという使い方になります。

サンプルコード

using UnityEngine;
using UnityEngine.SceneManagement;

public class Sample: MonoBehaviour
{
    public void OnLoadSceneSingle()
    {
        //SceneBをロード。現在のシーンは自動的に削除されて、シーンBだけになる
        SceneManager.LoadScene("SceneB");
    }
    
}

初心者の人が躓きがちな点ですが、一度削除されたシーンを二回目以降ロードした場合、前回の状態は復帰されずに最初の状態からロードされます。
ゲームのUI画面を切り替えるように「さっきの状態に戻る」というつもりでシーンを切り替えることはできません。

シーンをまたいで消さないオブジェクトを作るにはDontDestroyOnLoadを使う

LoadSceneMode.Singleでシーンをロードすると、基本的にはそれまでのシーン内のオブジェクトはすべて破棄されて消えてしまいます。
ただ、それだとゲームのスコアなどといった「前のシーンの情報を残しておく」ことやサウンドマネージャーのような「一度ゲーム起動したら削除したくないオブジェクト」まで消えてしまいます。
それを避けるためには、消したくないオブジェクトにDontDestroyOnLoadを使います。

サンプルコード

using UnityEngine;

public class Sample: MonoBehaviour
{
    void Awake()
    {
        DontDestroyOnLoad(this.gameObject);
    }
}

DontDestroyOnLoadとしたGameObjectは、DontDestroyシーンに移動します。
DontDestroyシーンに移ったGameObjectは、シーンのシングルロード時にも削除されず自分で明示的に消さない限りはゲーム中ずっと存在し続けるようになります。

マネージャーシーン内のオブジェクトをDontDestroyOnLoadにする

簡単な実装方法としては、常にゲーム全般を管理する「GameManager」のようなものをもった「マネージャーシーン」を作り、GameManagerにDontDestroyOnLoadを設定することです。
マネージャーシーンから起動するようにして、あとはゲームのUI遷移など応じて順番にシーンを切り替えていけばよくなります。
DontDestroyするのはGameManager一つとは限らずに、ほかにサウンドマネージャーなど複数のオブジェクトを同じように扱ってもよいですし、GameManagerの子オブジェクトしてサウンドマネージャーなどを配置しても良いです。

プレハブ化したマネージャーオブジェクトをDontDestroyOnLoadにする

「マネージャーシーンから起動するようにする」というのは、編集中のシーンをいったん閉じてからマネージャーシーンをいちいち開かないといけないので、開発の障害になることもあります。
そこで、GameManagerをプレハブ化し
各シーンに下記のような「プレハブからGameManagerを1度だけ自動的に作成しDontDestroyOnLoadする」コンポーネントを作成し、
そのコンポーネントを各シーンに設定します。

サンプルコード

using UnityEngine;

public class GameManagerCreator : MonoBehaviour
{
    private static bool Loaded { get; set; }

    [SerializeField] 
    GameObject[] gameManagerPrefabs = null;
    
    void Awake()
    {
        //すでにロード済みなら、二重に作成しない
        if(Loaded) return;

        Loaded = true;

        //プレハブをインスタンス化して、DontDestroyOnLoad
        foreach (var prefab in gameManagerPrefabs)
        {
            GameObject go = Instantiate(prefab);
            DontDestroyOnLoad(go);
        }
    }
}


実際のところは、シーン構成しだいではそうそう単純にはいかないのですが、考え方の方向性として「各シーンを開いたときに必要な初期化を行うようにする」ということを抑えてプロジェクトごとに応用しておけばよいかと思います。

DontDestroyOnLoadは非推奨?

さて、DontDestroyOnLoadを使ったアプローチは、長い間(おそらく今でも)定番の一つだったのですが、実はUnityのドキュメントには非推奨という旨の記述があります。
日本語ドキュメントはちょっと訳が荒いのですが、原文は以下の通りです

It is recommended to avoid using DontDestroyOnLoad to persist manager GameObjects that you want to survive across scene loads. Instead, create a manager scene that has all your managers and use SceneManager.LoadScene("path", LoadSceneMode.Additive) and SceneManager.UnloadScene to manage your game progress.

なので、訳としてはこんな感じになると思います。
シーンロードをまたいで生き残らせたいマネージャーGameObjectsに、 DontDestroyOnLoad を使用するのは避けることを推奨します。代わりに、すべてのマネージャーを持ったマネージャーシーンを作成し、SceneManager.LoadScene("path",LoadSceneMode.Additive) と SceneManager.UnloadScene を使って、ゲームの進行を管理してください。

よほど特殊なケースでもない限り、DontDestroyOnLoadを使わずにLoadSceneMode.Singleでゲームアプリを設計するのは無理だと思いますので、
実質はLoadSceneMode.Singleを使わずに、LoadSceneMode.Additiveを使うのが推奨されているようです。
なぜなのかの理由が書いていないので何とも言えないのですが、おそらくはDontDestroyにしたGameObjectはSceneManager(後述)経由でアクセスできなくなってしまうので、それを避けるためだと思います。
ただ、個人的にはDontDestroyOnLoadを使ったアプローチは簡便さという点でも十分有用だと思います。

複数のシーンを同時にロードしておける LoadSceneMode.Additive

概要

LoadScene(またはLoadSceneAsync)で第二引数を「LoadSceneMode.Additive」とすると、前のシーンは残ったままでそれに追加して新たなシーンをロードします。
つまり、同時に複数のシーンを使用するという使い方です。
このやり方は加算シーンなどと呼ばれることもあります。

サンプルコード

using UnityEngine;
using UnityEngine.SceneManagement;

public class Sample : MonoBehaviour
{
   
    public void OnLoadSceneAdditive()
    {
        //SceneBを加算ロード。現在のシーンは残ったままで、シーンBが追加される
        SceneManager.LoadScene("SceneB",LoadSceneMode.Additive);
    }
}


Unityのヒエラルキーウィンドウでも、このように二つのシーンが表示されます。

前のシーンは残ったまま動いていますし両方とも表示されるので、状況に応じてアンロードしたり、非アクティブにしたり非表示にする必要があります。
実際はシーンBをロードする前の段階でフェードアウトなどしてで一部の処理を止めたり非表示にしてからロードするほうが多いかもしれません。
前のシーンが残ったままなので、それなりに手間をかける必要はありますが、LoadSceneMode.Singleと違って「さっきの状態に復帰する」というのは比較的やりやすいです。

非アクティブや非表示ではなく、シーンをアンロード(消してしまう)には、SceneManager.UnloadSceneAsyncを使います。
アンロードしただけだと、そのシーンだけで使っていたリソース(アセット)はアンロードされないので、必要に応じてResources.UnloadUnusedAssetsを使います。
Resources.UnloadUnusedAssetsについては後述します。

サンプルコード

using UnityEngine;
using UnityEngine.SceneManagement;
public class Sample : MonoBehaviour
{
    public void OnUnloadScene()
    {
        StartCoroutine(CoUnload());
    }

    IEnumerator CoUnload()
    {
        //SceneAをアンロード
        var op = SceneManager.UnloadSceneAsync("SceneA");
        yield return op;

       //アンロード後の処理を書く

        //必要に応じて不使用アセットをアンロードしてメモリを解放する
        //けっこう重い処理なので、別に管理するのも手
        yield return Resources.UnloadUnusedAssets();
     }
}

実装パターン

マネージャーシーンを常駐させ、必要に応じて追加のシーンをロード・アンロードする 

簡単な実装方法としては、マネージャーシーンから起動するようにして、あとはゲームのUI遷移など応じて順番にシーンを加算でロードとアンロードをしていくことです。
マネージャーシーンはアンロードしないようにするだけで、常にマネージャーシーンのオンブジェクトはゲーム中に存在するようになります。

マネージャーシーンを起動シーンに固定しない 

DontDestroyOnLoadの時と同じ話ですが、マネージャーシーン以外のシーンからも起動できるようにするとテストプレイが楽になります。
各シーンに下記のような「マネージャーシーンを1度だけ自動的にロードする」コンポーネントを作成し、
そのコンポーネントを各シーンに設定すると、どのシーンから開始しても自動的にマネージャーシーンも一緒にロードされるようになります。

サンプルコード

using UnityEngine;
using UnityEngine.SceneManagement;

public class ManagerSceneLoader : MonoBehaviour
{
    private static bool Loaded { get; set; }
    
    void Awake()
    {
        if(Loaded) return;

        Loaded = true;
        SceneManager.LoadScene("ManagerScene", LoadSceneMode.Additive);
    }
}

実際は、プロジェクトやシーンの構成次第で初期化の流れを調整する必要があります。
特に、各シーン内のオブジェクトはAwake時などに、マネージャーシーンがロード済みとは限らないので、その点がネックになるケースがあるかもしれません。

シーンをアンロードせずに非表示にするだけ

慣れてきたら、UIシーンなどは画面ごとにわけて作成し、ゲーム起動時にすべてロードしておいて、必要に応じて表示・非表示を切り替えるという手法をとるといったことも可能です。

ただ、複数シーンに限った話ではないですが、Unityには基本的には特定の機能を中断・再開するような汎用的な仕組みがあるわけではありません。
特に、GameObjectを非アクティブにした場合は、コルーチンが止まってしまい再開できなくなってしまう恐れもありますので、その点は注意しましょう。

異なるシーン間でのデータのやり取り

さて、SingleにしてもAdditiveにしても、複数シーンを使っている場合「他のシーンのオブジェクトを参照する」ということが必須になります。
Unityはシーン編集時も複数シーンをロードして同時に編集可能ですが、インスペクターなどにほかのシーンのオブジェクトを設定することはできません。
正確に言うと、セーブできずに無効であるというエラーが表示され、ゲームプレイ時に利用することはできません。
なので、別の手段が必要となります。代表的な手段を以下にまとめました。

SceneManagerを活用

SceneManagerにはロードされたシーンを取得する機能がありますので、これを使ってほかのシーンのオブジェクトを参照できるようになります。
たとえば、Managerシーンの直下にあるGameManagerコンポーネントを取得したいのであれば、以下のようにすれば可能です。

サンプルコード

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneManagerSample : MonoBehaviour
{
    public void OnAccessGameManager()
    {
        //ロード済みのシーンであれば、名前で別シーンを取得できる
        Scene scene = SceneManager.GetSceneByName("ManagerScene");
        
        //GetRootGameObjectsで、そのシーンのルートGameObjects
        //つまり、ヒエラルキーの最上位のオブジェクトが取得できる
        foreach (var rootGameObject in scene.GetRootGameObjects())
        {
            GameManager gameManager = rootGameObject.GetComponent<GameManager>();
            if (gameManager != null)
            {
                //GameManagerが見つかったので
                //gameManagerのスコアを取得
                Debug.Log( "スコアは" + gameManager.Score + "です");
                break;
            }
        }
    }
}

ルートにないコンポーネントを拾ってきたいときは、GetComponentInChildrenを使うなどして応用してください。
SceneManagerには他にもいくつか機能がありますので、それを使えば「全シーンから、指定のコンポーネントを検索」なんてこともできます。
詳しくは、SceneManagerの公式リファレンスを参考にしてください。
単純にコンポーネントを取得するだけでも、けっこう長いコードを書くことになるので、ある程度自分なりに拡張したユーティリティクラスのようなものを作ってしまったほうがよいでしょう。

SceneManagerはDontDestroyOnLoadシーンには使えない

SceneManagerはDontDestroyOnLoadシーンにはアクセスできません。
UnityがマネージャーオブジェクトをDontDestroyOnLoadにする設計を非推奨にしてるのは、このせいだと思います。
そのため、DontDestroyOnLoadを使ったアプローチをしているものは別の手段が必要になります。

シングルトンパターンを活用

もっと定番なのは、シングルトンパターンを使うことです。
シングルトンパターンというのはプログラム全般で使われる用語です。そちらについての詳細は述べませんので興味がある人は調べてみてください。
ここでいうシングルトンパターンは「Unityのコンポーネント(MonoBehaviour)をシングルトンパターンで実装し、それを使って別シーン同士で参照を可能にする」というものです。
SceneManagerができる以前から、Unityでシーン間のデータ参照にはだいたいこれが使われていました。
シングルトンは、どこからでもアクセスできるメリットがある反面、「ゲーム中に同じコンポーネントのものは1つだけしか作ってはいけない」という制約があります。
そのため、サウンドマネージャやゲームマネージャーのような「ゲーム中で1つしかなくいろいろなところから参照される共通の仕組み」を実装するのに向いています。
作り方はネット上にもたくさんあるのですが、サンプルコードを改めて載せるとこんな感じです。

シングルトンのひな型となる基底クラスのサンプルコード

	//シングルトンなMonoBehaviourの基底クラス
	public class SingletonMonoBehaviour<T> : MonoBehaviour where T : MonoBehaviour
	{
		static T instance;
		public static T Instance
		{
			get
			{
				if (instance != null) return instance;
				instance = (T)FindObjectOfType(typeof(T));

				if (instance == null)
				{
					Debug.LogError(typeof(T) + "is nothing");
				}

				return instance;
			}
		}

		public static T InstanceNullable
		{
			get
			{
				return instance;
			}
		}

		protected virtual void Awake()
		{
			if (instance != null && instance != this)
			{
				Debug.LogError(typeof(T) + " is multiple created", this);
				return;
			}

			instance = this as T;
		}

		protected virtual void OnDestroy()
		{
			if (instance == this)
			{
				instance = null;
			}
		}
	}

上記で作ったクラスを継承(C#プログラムの仕組みです)することで、GameManagerなど自分の作った好きなコンポーネントをシングルトンにすることができます。

シングルトンなGameManagerコンポーネント

//SingletonMonoBehaviourを継承した、シングルトンなGameManagerコンポーネント
public class GameManager : SingletonMonoBehaviour<GameManager>
{
    public int Score { get; set; } 
}

シングルトンにしたコンポーネントは、どのシーンからでもこのようにアクセスできます。


		void OnAccessGameManger()
		{
			//コンポーネント名.Instanceでシングルトンなコンポーネントを取得できる
			GameManager gameManager = GameManager.Instance;
			
			//GameManagerのスコアを表示
			Debug.Log( gameManager.Score);
		}

もとのコンポーネントのあるシーンがまだロードされていない場合は、当然ですがエラーになります。そのため、実際に使うときはロードや初期化の順番に注意してください。
また、メモリリークという問題がつきまといます。これはちょっと根深いのでさらに後で詳しく述べます。

ScriptabelObjectを活用

さて、あまりメジャーではないかもしれませんが、ScriptabelObjectを使うという手法もあります。
ただ、これは場合によってはゲームの設計方針そのものから変える必要があることもあるのであえて使う必要はないかもしれません。

ScriptabelObject自体あまり使ってない人も多いとは思うのですが、これはイメージとしては「データの固まり」のようなもので、
シーン内ではなくテクスチャやサウンドのリソースなどと同じようにProjectWindow内に作成されるものです。
テクスチャが別々のシーンでも同じものを使えるように、ScriptabelObjectも別々のシーンであっても同じものを使えます。
そして、ここがScriptabelObjectの最大の特徴なのですが、データだけではなくロジックを持たせることもできます。
なので、それを利用してシーン間でデータのやり取りをすることができます。

ScriptabelObjectはエディタ拡張やUnity.Objectのシリアライズにも絡んでくるため、必要とされるUnityへの理解度が高いです。
具体的な実装は下記のサイトなどを参考にしてみてください。
初心者向きではないので、下記のリンクを見て「なるほどわかった試してみよう」というくらいの理解度があってはじめて挑戦したしたほうが良いかもしれません。
ScriptableObjectを使用した、コンポーネント・シーン間でのデータ共有について
Unity Pro Tips > オフィシャル記事 > DEVELOPER > ゲーム構築を劇的にスマートにする Scriptable Objectの 3つの活用方法
Unity Pro Tips > オフィシャル記事 > DEVELOPER > プロジェクトが大規模化してもコードを整然とした状態に保つコツ
Unityブログ ScriptableObject を使ってシーンワークフローを改善しよう

GameObject.Findを活用

GameObject.Findを使うことも可能です。
検索対象は全てのシーンなので、対象の名前さえわかっているならこれを使って探すことができます。
ただ、GameObject.Findは推奨されていないようです。
全シーンから検索するので処理が重いですし、名前が変更される可能性や、同名の別のGameObjectを探してきてしまう危険などがあって不具合を生むこともあるからです。
基本的には別の手段を使いましょう。

メモリが足りなくなる問題への対策 

複数シーンを使っていると、メモリで問題になることがあります。

といっても複数シーンに限らずUnityの開発、もっというとゲーム開発においてメモリ管理はとても重要で面倒です。
メモリに関して大雑把に問題になるのは
・メモリ確保を頻繁に行うせいで問題がおきる(処理落ちやGCスパイク、断片化など)
・メモリが足りない(メモリリークなど)
という二点です。複数シーンで特有の問題が起きがちなのは主に後者なのでそれについて詳しく述べます。

スマートフォンなどではメモリが足りなくなったアプリは強制終了が基本です。
低スペック端末だと「カクカクする」という以前の「プレイ中に強制終了」という状態になります。
しかも、さらに問題なことに「メモリが足りない原因」というのを見つけるのはかなり難しいです。
なぜかというと、「メモリを使いすぎている原因」を積み重ねて「これ以上無理!」となったときにアプリが強制終了するからです。
通常のバグのように、影響が即座に現れるわけではないので、「いつどこで起きたか?」というのが追いづらいのです。
プロファイラーなどをうまく使用すれば、何がどれくらいメモリを使っているかを見れるのですが、これ自体が使い方が難しかったりします。
プロファイラーで「どれが原因か」はわかっても、「いつ」が原因かはわかりませんし、「なぜ」起きるか「どうすれば解決するか」はUnityやC#プログラムの仕組みを知る必要があります。
そして苦労して原因と改善策が分かったとしても、後から改善しようとするとプロジェクトの構成を変える必要すらある場合があります。

ただ、ゲームで使えるメモリはハードによって何倍も違いがあったりします。
すんなり動いてる場合は何も問題がないので、「低スペック端末は考慮しない」とか「長時間プレイでたまに落ちるのはしょうがない」という割り切りをするのであれば、あまり気にしなくてよいです。
基本的には安定化の部類なので、対策したところで見た目がよくなるわけでも動作が軽くなるわけでもないからです。
それよりも、別の部分に労力を割くというのは十分ありうる判断かと思います。
そもそもゲームで使えるメモリ容量が不安定な環境では、完璧な対応は事実上不可能だと思っています。程度の問題です。
誰もが知っているようなメジャーなゲームであっても、この問題が頻繁に起こることもあったりしますので、
カジュアルゲームなどでどこまで気にすべきかは判断が必要だと思います。

複数シーンで特に陥りがちな問題に絞ってまとめたくはあるのですが、どうしてもメモリ管理全般の仕組みに言及せざるを得ないので正直難解かもしれません。
本当のところをいうと、この話は複雑なので省こうかとも思いました。
ただ、先述した昔まとめたものが「バッドノウハウというフィードバックを受けた」というときの、問題の原因でもあるので一応詳しく載せておこうと思います。

不使用アセットをアンロードする Resources.UnloadUnusedAssets

今時のゲーム開発では、プログラム自体が使用するメモリサイズ自体ははほとんど問題になりません。
それよりも、(特にUnityでは)テクスチャやサウンドなどリソースのために消費するメモリのほうが桁違いに大きいです。
複数シーンを使った実装は、一度に多くのリソースを同時にロードしている状態になってるため自然とメモリを大きく消費しがちです。
なので、使い終わったリソース(アセット)は適度に解放していく必要があります。

Unityの不使用アセットを手動でアンロードして、使用していたメモリを解放するには、Resources.UnloadUnusedAssetsを使います。

不使用アセットのアンロード LoadSceneMode.Singleの場合は自動

LoadSceneMode.Singleの場合、シーンのロードが終わったときにResources.UnloadUnusedAssetsと同じ処理が自動的に行われますので、あまり気にしなくてよいです。
(ただし、Unityのリファレンスやドキュメントには記述がないので正式な仕様かは不明)
これは昔はかなり重い処理だったので、シーン切りかえ時にゲームが秒単位で止まってしまい「UIを切り替えるたびに画面が固まるので操作性が悪い」なんてことが起きていました。
現在はUnity自体の高速化とハード面での高速化で、今はそこまで重い処理にはなっていないようです。

不使用アセットのアンロード LoadSceneMode.Additiveの場合は手動

LoadSceneMode.Additiveの場合は、SceneManager.UnloadSceneAsyncでシーンをアンロードした場合も、不使用アセットのアンロードは行われません。
Resources.UnloadUnusedAssetsを使って自力でアンロードするようにしましょう。

ただ、頻繁に使うには重い処理だというのもありますので、シーンのアンロード時は使わずに別途管理するほうが良いかもしれません。
メモリに余裕があるなら、リソースはアンロードしないほうが次に使うときに再ロードする必要なくすぐ使えるというメリットもあります。
また、Resources.UnloadUnusedAssetsは複数シーンでのリソース管理に限った機能ではないので、アセットバンドルなどの動的にロードするリソースなどのアンロードなどでも統一的に使われます。
Additiveの場合は、せっかくシーンのアンロードとリソースのアンロードを分けて使えるので、プロジェクトの特性に合わせて最適なやり方を実装しましょう。
どの程度の頻度でどう呼ぶかは判断が難しいのですが、最近のUnityでは、メモリが少なくなった段階で呼ばれるイベントもあるので、最低限これを使うのがよいかと思います。

メモリリークとは

複数シーンを使っている場合は、不使用アセットのアンロードをしたつもりで、完全にアンロードできていないということが起きがちになります。
メモリリークといって「不使用のつもりが不使用になっていないので、いつまでたっても消えない」という状態になってしまっているのが原因です。

メモリリークが起きる原因は参照が残っているから

複数シーンに限った話ではないのですが、一言でいうと「シーン内に残っているものから参照されているものは消えない」というのが基本です。

たとえば、テクスチャを参照するオブジェクトAと、それを参照するオブジェクトBがあった場合


public class SampleA : MonoBehaviour
{
    //インスペクター上でテクスチャを参照している
    public Texture texture;
}

public class SampleB : MonoBehaviour
{
    //インスペクター上でAを参照している
    public SampleA sampleA;
}


Aを削除(Destroy)しても、Bに参照が残っている限りは、Aは消えずテクスチャへの参照も残るため、不使用状態にならないためResources.UnloadUnusedAssetsの対象になりません。

nullだけどnullじゃない問題

UnityではGameObjectやコンポーネントを削除するときはDestroyを使います。
ですが、このDestroyでヒエラルキー上からは消えるのですが、どこかに参照が残っている限りは本当の意味では消えません。
そして、この状態のインスタンスはnullとして判定されます。
これは、UnityのObjectが==演算子をoverrideしているから起きます。
一部では結構有名な話です。

基本的には、Destroyしたものへの参照を持つようにプログラムすることはあまりないですし、
仮にあったとしても、使おうとするとエラーメッセージがでるので大抵は自然と直していると思います。

ただ、nullチェックをしてしまったためにかえって問題に気づきづらくなるという落とし穴はありえます。

public class SampleB : MonoBehaviour
{
    //Aを参照している
    public SampleA sampleA;

    private void Update()
    {
        //Destroyされたオブジェクトにアクセスすると
        //エラーメッセージがでる
        Debug.Log(sampleA.name);

        if (sampleA != null)
        {
            //nullチェックしていると、問題に気づきづらいことはありうる
            Debug.Log(sampleA.name);
        }
    }
}

参照消せば、リークは解消する

リークを解消するには、nullを代入して参照を消すことです。

public class SampleB : MonoBehaviour
{
    //Aを参照している
    public SampleA sampleA;

    public void DestroySampleA()
    {
        Destroy(sampleA.gameObject);
    //nullを代入することで参照は消える
        sampleA = null;
    }
}

参照している場合は死ねる。参照されている場合は死ねない

混乱しがちですが、参照している場合は問題ありません。
「参照されている」場合にリークが起きます。


public class SampleA : MonoBehaviour
{
    //テクスチャを参照している
    public Texture texture;

    //Bを参照している
    public SampleB sampleB;
}

public class SampleB : MonoBehaviour
{
    //Aを参照してはいない
}

消えるか消えないかは、根本から参照が辿れるかどうか

メモリを解放するGC(ガベージコレクション)の仕組みは、「根本から参照をたどれるか?」で判断されます。
この根本というのは(Unity内部なので詳細は不明ですが)、基本的にはシーン上にあるかどうかということです。

相互に参照しているだけのものでれば、参照が残っていても両方ともDestroyすれば消えます。
シーン内から参照を辿れないからです。

いっぽう、シーンに存在しているものから間接的にでも参照を辿れるならまだGCの対象にならず存在し続けます。
「テクスチャを参照するA」←「を参照するB」←「を参照するC」
などがあった場合、Cが生きていて参照が消えない限りは、存在し続けます。
長く続くシーンの中で頻繁にオブジェクトをDestroyしたり生成したりする場合は、Destroyしたものへの参照が残らないように注意しましょう。

複数シーンでメモリリークが起きやすい理由は丸ごと消せないから

どんなに構成が複雑であったとしてもシーン丸ごと消してしまえば全部消えるので、シーンをアンロードする前提の構成であればメモリリークはあまり気しなくても起きづらいです。

ですが、それでもゲームマネージャー的なもののためにSingletonやScriptableObjectのような「消えない」ところに参照を残してしまうことがあるので、これが原因でメモリリークが起きることがあります。
基本的にはそういった部分に参照をもたせる場合、基本に忠実に「常に使い終わったら後片付けをして消しておく」必要があります。

複数シーンで起きがちな問題

以下、複数シーンで起きがちな問題をまとめました。

イベントコールバックには注意

インスタンス自体への参照でなくても、Actionやイベントなどにメソッドやデリゲードを登録している場合、それは元のインスタンスへの参照扱いになるので注意してください。
イベント駆動的な処理をするためにゲームマネージャーなどシーン外のオブジェクトにイベントを登録している場合、
使い終わったら-=などしてちゃんと削除する必要があります。


public class GameManager : SingletonMonoBehaviour<GameManager>
{
  //ゲームマネージャーからイベントを通知する
    public event Action OnNewEvent;
}

public class SampleScene : MonoBehaviour
{
    void OnEnable()
    {
        GameManager.Instance.OnNewEvent += NewEvent;
    }
    
    void OnDisable()
    {
        GameManager.Instance.OnNewEvent -= NewEvent;
    }

    //ゲームマネージャーからイベントが呼ばれた時の処理
    void NewEvent()
    {
    }
}

OnDestroyは一度もアクティブになっていないと呼ばれない

ただし、「使い終わったら」のタイミングをOnDestroyなどでとっている場合、「一度もアクティブになっていない場合OnDestroyは呼ばれない」というUnityの落とし穴もあるので注意が必要です。
UI用のシーンなどで「ロード直後は非表示にするため最初から非アクティブにしておく」「ゲームマネージャーにコールバックを登録しておいて、イベント駆動で表示」、なんて使い方をしていると見事にこのパターンにはまります。

    //外部から任意のタイミグで初期化 
    public void Init()
    {
        GameManager.Instance.OnUiOpen += Open
    }

    //OnDestroyが呼ばれるとは限らない
    void OnDestroy()
    {
        GameManager.Instance.OnUiOpen -= Open
    }

    //ゲームマネージャーからUIを開くイベントを受け取る
    void Open()
    {
        this.gameObject.SetActive(true);    
    }

シングルトンを使うときに気をつけること

シングルトンやstaticの場合は、シーン内から参照が辿れるかに関係なく、staticな部分に参照が残っている限りは消えないので注意が必要です。
たとえば、仮にマネージャーシーンを消したとしても、シングルトンパターンのstaticな部分に参照が残っている場合はそこからの参照は消えません。
基本的には一度作ったら消さない前提の使い方をするとは思うので、あまり問題になることはないかもしれませんが、
仮に消す場合はOnDestroyでインスタンスにnullを設定するなどしてstaticな参照をちゃんと解放する必要があります。

    protected virtual void OnDestroy()
    {
        if (instance == this)
        {
            instance = null;
        }
    }

ScriptableObjectを使うと起きがちなミス

ScriptableObjectにロジックを持たせる場合、基本的にはシングルトンのような使い方とあまり変わらないです。

ただ、ScirptableObject自身にはOnDestroyのようなシーン内から削除されたときに呼ばれる処理はありません。
基本的には各シーン内のコンポーネントのほうが自身が登録した参照の解放をする必要があります。
一応、参照がなくなくなってアンロードされるときにOnDisableが呼ばれるのですが、必要なのはその前段階なので意味がありません。
またエディタ上からも参照されている場合、シーン内から参照がなくなってもアンロードされないという特性もあります。
下手にOnDisableを使うと、エディタ上と実機上で違う動作をしかねなくなります。
この辺のイベント関数をコンポーネントと同じようなつもりで使うことはできないので、その点は注意してください。

基本的なルールを決めるほうが良い

たとえば
・マネージャーシーンはゲーム起動後常に存在させ消さない。
・各シーンでは、マネージャーシーン以外のほかのシーンへの参照は極力残さない。参照を持ったら後始末をしっかりする。
・マネージャーシーンから各シーンへの参照は極力しない。
・マネージャーシーンに各シーンのコールバックを登録した場合、登録した各シーンが各自で後始末をする。
といった感じです。
ただし、参照を残さないことにこだわりすぎると、毎回検索したりGetComponentせざるをえなくなるので、パフォーマンス的にはよくないですので場合によっては必要です。

現実的には、仮にリークしていたとしても、リークしているコンポーネントが直接または間接的にアセットの参照を持っていない限りはアセットがアンロードできるなくなるということはありません。
リソース以外はメモリサイズは限られているので、被害がほとんどないことも多いです。

繰り返しますが、メモリリークへの対処は難しいうえに、割り切ってしまっても問題ないときは問題ないので、本当に労力を割くべきかは各自で判断してください。
ここに書いてあることも、私がそのように解釈しているだけで、本当にそうだという保証はどこにもないので、最終的には自分で検証して試行錯誤してください。

メモリ管理の参考資料

メモリ管理は技術的に難しいので、中堅以上のプログラマーじゃないと把握は難しいかもしれません。
以下、参考資料を列挙しておきます。
Unity公式からの情報
Unity公式ドキュメント ベスト プラクティス ガイド Unity における最適化 メモリ
Unity公式ドキュメント ベスト プラクティス ガイド Unity における最適化 マネージヒープ
【CEDEC2018】一歩先のUnityでのパフォーマンス/メモリ計測、デバッグ術
メモリリークに関してはこちらが詳しいです。
Unityにおけるメモリ管理

さいごに

ここでまとめたものは、どちらかというと概要の説明なので、実際に使うには各プロジェクトの設計に合わせて自力でプログラムをしていく必要があります。
実際には、上記の各機能はどれかを使うか一つ決めるのではなく、場合によっては組み合わせて使うことが多くなると思います。
「基本的にはAdditiveを使い、タイトルなどの起動シーンに戻るときだけSingleロードする」などといった使い方も考えられますし
「DontDestroyOnLoadとシングルトンとScriptableObjectとSceneManger全部使う」ということもあると思います。
また、アセットストアのアセットはDontDestroyOnLoadを使っているものがあります。たとえばDoTweenなどがそうです。
アセット開発者としては、ユーザーが加算シーンを使うかどうかわからないため、そうせざるを得ないのだと思います。
それぞれの特徴を把握して自分なりに最適な使い方をしていただければと思います。

このほかに、UnityにはNaviMeshやライトマップなどシーンに強く依存した機能があります。
その点を踏まえて複数シーンをどう編集しどう実装するかに関しては(やったことがないので)私もわかりません。
Unity公式ドキュメントのマルチシーン編集にはその点の注意点が載っているので、それを参考にしましょう。

今回まとめたことはすべて、あくまで個人的に知る限りをまとめただけですので、間違っているかもしれませんし、バッドノウハウとみなされるものもあるかもしれません。
あたかもUnityの仕様のように書いてしまっている部分もありますが、経験上そうなるから書いてるだけのところも多いです。
本当に仕様なのかはわかりませんし、Unityのバージョンによっては仕様が違うかもしれません。
詳細に関してはいつかUnity公式ドキュメントが整うことを祈ります。

シーン関係の参考

上記で上げたことは、実装方法としては具体性に欠ける面もあります。
Unityのシーン関係の情報はネット上にほかにもたくさんありますので、そちらも参考にしてみてください。
[Unity初心者Tips]画面を切り替える3つの方法、そしてSceneManager.LoadSceneAsyncなど
[Unity] Sceneの役割や使い方をざっくりまとめた
シーン間でスコアを共有 まとめ
全てのシーンに存在し、かつ、一つしか存在してはいけないマネージャー的存在の実装方法