9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一人ゲーム開発TipsAdvent Calendar 2024

Day 10

【Unity】マルチシーンエディティングを知って、考えるシーン構造

Last updated at Posted at 2024-12-09

最初に

本記事では、Unityでのマルチシーンエディティング(Multi Scene Editing)機能につい、開発で役立つ知識や実装方法を紹介します。

マルチシーンエディティングについて知ることで、プロジェクトの画面構成の幅が広がり、開発効率を向上させるきっかけになるでしょう。この記事が、プロジェクト開発の土台作りの一助になれば幸いです。

この記事の対象者は次の通りです。

  • プロジェクトの初期段階で、どのような画面構築システムを採用すべきか悩んでいる方
  • すでにプロジェクトでマルチシーンエディティングを導入しているが、まだ使い方をよく知らない人
  • Unityのマルチシーンエディティングについておさらいしたい人

マルチシーンエディティングとは?

マルチシーンエディティング機能とは、複数のシーンを同時に編集・操作できる機能です。

通常、Unityでは1つのシーンを開いて開発を進めますが、マルチシーンエディティングを利用すると、異なるシーンを同時に開き、同時に作業を進めることができます。

また、実行時でも追加のシーンをロード・アンロードでき、その状態を維持することも可能です。

メリット

モジュール化されたシーン管理

シーンを論理的な単位に分けて開発することで、管理が簡単になります。

例えば、以下のようにシーンを分割し、必要に応じて組み合わせて使用できます。

  • UIシーン:ボタン、ゲージ、アイコンなどのUI要素を含むシーン
  • Gameシーン:キャラクター、ステージ、エフェクトなどのUI以外のゲームオブジェクトを含むシーン
  • Logicシーン:サーバーとの通信やマスターデータの読み取り、ゲーム状態の管理などを担当するシーン

効率的なチーム作業

複数のシーンで作業ができるため、チームメンバーが異なるシーンを平行して作業しやすくなり、プロジェクト全体の管理がスムーズになります。Prefabを細かく管理する必要性も減り、シーン操作によるコンフリクトも減少します。Unityの操作に不慣れなアーティストチームと一緒に作業する場合でも、エンジニアチームが円滑に作業を進めやすくなります。

強制疎結合化

役割ごとにシーンを分けることで、強制的に疎結合な構造を実現できます。ただし、staticを使用して直接アクセスできる状態にしてしまうと、疎結合な構造にした意味が失われるため、DI(Dependency Injection)などのテクニックを併用すると良いでしょう。

image.png


気を付ける点

シーンの依存関係

複数のシーンを同時に読み込んで使用する場合、シーン間でオブジェクトやスクリプトが依存しているケースを考慮して読み込む順番に気を付ける必要があります。

例えば、UIシーンがGameシーンのオブジェクト(キャラクターなど)に依存している場合は、Gameシーンを先に読み込む必要があります。依存関係を整理したうえで、シーンの読み込み順序を適切に設定しましょう。

シーンの重複オブジェクト

マルチシーンエディティングを使用すると、複数シーンに同じオブジェクトが存在してしまう場合があります。

もし、カメラやライトなどが各シーンに重複して配置した場合、不要なパフォーマンス負荷や、意図していない表示バグが発生する可能性があります。

ライトやカメラはGameシーンへ、Event SystemはUIシーンへ追加する、といったようにシーンの役割に応じて必要なオブジェクトだけを配置するようにしてみましょう。

複数シーンを開いている際のセーブ

マルチシーンエディティングで複数のシーンを開いている場合、編集後の保存に注意が必要です。複数のシーンに変更を加えた状態でCtrl + Sで保存すると、現在ロードされているすべてのシーンの変更が保存されます。

特定のシーンの変更のみを保存したい場合は、Hierarchy内でそのシーンを右クリックしてコンテキストメニューを開き、「Save Scene」を選択してください。

image.png

リアルタイム性の減少

シーンに多くのデータが含まれていると、シーンの切り替えやロード、アンロードに時間がかかるようになります。特に、データ量が多いシーンを複数同時に管理しようとすると、この問題が顕著に表れます。

シーン間のコミュニケーション

先ほど疎結合な構造について触れましたが、マルチシーンエディティングを活用する場合、シーン間でオブジェクトやデータをやり取りする方法を検討する必要があります。

具体的な方法として、例えば以下のようなアプローチが考えられます。

シングルトンパターン

シングルトンパターンを使用すると、シーン間で共有するデータやオブジェクトを一元管理できます。特に、ゲーム全体で共通して使用するマネージャークラスや設定情報、プレイヤーデータなどはシングルトンとして管理されることが多いです。

例えば以下のように、SingletonMonoBehaviourクラスを用意してみます。

public class SingletonMonoBehaviour<T> : MonoBehaviour where T : SingletonMonoBehaviour<T>
{
    public static T Instance { get; private set; }

    protected virtual void Awake()
    {
        if (Instance != null)
        {
            Debug.LogWarning($"You are about to create more than one instance of {typeof(T)}!" +
                             $" It will be removed from {gameObject.name}.");
            Destroy(this);
        }
        else
        {
            Instance = this as T;
        }
    }

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

SingletonMonoBehaviourクラスを継承して、GameManagerクラスを用意することで、各クラスからGameManagerの公開プロパティにアクセス可能となります。

public class GameManager : SingletonMonoBehaviour<GameManager>
{
    public int PlayerScore { get; set; }
    
    // 他のシーンでも GameManager.Instance.PlayerScore にアクセス可能
}

シングルトンパターンは実装や使用が簡単ですが、多用すると各クラス同士の依存が強くなり、疎結合な構造が失われてしまいます。使用する場合は、特定の要件を満たすクラスにのみ適用することをおすすめします。

例えば、Loggerクラスのように、「他のクラスに極力依存しない」且つ「ゲーム全体のどこからでも簡単にアクセス可能にする必要がある」 クラスに対してシングルトンパターンを使用するのが適していると思います。

ただ、DIライブラリを上手く活用することができれば、シングルトンパターンも使用する箇所を大幅に減らすことができるので、この後紹介するDIライブラリの導入も検討してみてください。

DIライブラリの活用

ZenjectVContainer などのDI(依存性注入)ライブラリを導入することで、オブジェクト依存関係を効率的に管理できます。

これにより、疎結合な設計を維持しつつ、シーン間でオブジェクトやサービスクラスを柔軟に注入できます。特定のシーンやクラスに依存しない状態を構築できるため、テストが容易になり、コードの再利用性も向上します。実際に、3Dシーンを含むような複雑なプロジェクトだと、DIを用いて開発を進めることがよくあります。

public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        // 必要なサービスをシーン間で共有
        Container.Bind<IPlayerService>().To<PlayerService>().AsSingle();
    }
}

メッセージングシステム

DIと組み合わせてよく使用されるライブラリに、UniRx(最新バージョンはR3)があります。

このライブラリを利用すると、Subjectを活用したイベント駆動型のメッセージングが可能になります。シーン間でイベントを発行し、他のシーンやコンポーネントがそのイベントを購読して応答できるため、シーン間でのモジュール間の結合度を低く保つことができます。

以下はVContainerとR3を使用した場合の一例です。

public class BattleMenuView : MonoBehaviour, IMenuView
{
    [SerializeField]
    private Button openMenuButton;
    
    public Observable<Unit> OnOpenMenuButtonClick => openMenuButton.OnClickAsObservable();
    
    ...
}
public class LogicSceneManager : MonoBehaviour
{
    private IMenuView _battleMenuView;

    [Inject]
    public void Construct(IMenuView battleMenuView)
    {
        _battleMenuView = battleMenuView;
    }

    private void Start()
    {
        // ボタンクリックイベントを購読
        _battleMenuView.OnOpenMenuButtonClick
            .Subscribe(_ =>
            {
                // 何かしらの処理
            })
            .AddTo(this); // メモリリークを防ぐためにAddToを使う
    }
}

Prefabを用いて管理する方法との違い

よく使われる手法として、Prefabを作成し、そのPrefab内でUIや処理を管理する方法があります。それでは、シーン分割による管理とPrefabの管理方法の違いとは何でしょうか?また、どちらの方法を選ぶべきなのでしょうか?

メリット

再利用性が高い

Prefabは一度作成すると、異なるシーンでも容易に再利用できます。

Prefab内の依存関係が整理されていれば、Prefabを別のプロジェクトに流用することも可能です。この場合、UIや3Dモデル、テクスチャなど、そのPrefabに関連するアセットも一緒に移行する必要があります。

動的生成・削除が簡単

Prefabを使用すると、アプリケーションの実行中にインスタンス化(生成)や削除が容易に行えます。たとえば、キャラクターやアイテム、エフェクト、リストのスロットなどを動的に生成する場面で非常に便利です。

シーンが軽くなる

シーン内にオブジェクトを直接配置せず、必要なオブジェクトをあらかじめPrefabにまとめておき、必要なタイミングでPrefabを動的にロードや生成することで、シーンの初期状態を軽く保つことができます。


デメリット

依存関係が複雑化しやすい

Prefab内にさらにPrefabを追加したり、Prefab Variantを作成していくと、依存関係が複雑化し、管理が難しくなります。Prefabの運用にはある程度のルール設定が必要で、適切なルールがないとバグ発生時の原因特定が困難になり、拡張性も失われる可能性もあります。

メモリ使用量が増加しやすくなる

動的生成が簡単に行えるため、適切にPrefab管理を行わないとメモリ使用量が無駄に増加し、パフォーマンスの低下につながります。

バージョン管理の難しさ

これはPrefabだけでなくシーン変更全般に言えることですが、バージョン管理がやや難しくなります。コンフリクトが発生した場合、解決にはある程度経験のあるエンジニアが必要で、巻き戻しが発生する可能性もあります。このデメリットはプロジェクトの規模が大きくなるほど顕著です。

Prefab管理とシーン分割管理の比較

項目 Prefab管理 シーン分割管理
再利用性 高い。Prefabは簡単に再利用可能。 シーンごとに異なる場合、再利用性は低い。
動的生成 簡単に動的生成・削除が可能。 シーン自体のロード・アンロードが必要。
パフォーマンス インスタンスの数が多いとメモリ負荷が増加。 必要なシーンだけロードすることで最適化可能。
依存関係の管理 複雑になりがち。Prefab間の依存が増える。 依存関係を分けやすいが、シーン間の通信が必要。
管理のしやすさ 小規模〜中規模のプロジェクト向き。 大規模プロジェクトやチームでの分業向き。
データ共有 Prefab間のデータ共有がやや煩雑。 シーン間のデータ共有がやや複雑。
デバッグ・テスト Prefabの個別テストがしやすい。 複数シーンの依存関係によりテストが複雑化。
ロード・アンロード Prefabごとの動的ロードが可能。 シーン単位でのロード・アンロードが可能。

どちらを選ぶべきか?

私の意見で言えば、シーン管理とPrefabの両方を使い分けることをおすすめします。

基本的なGameObjectはシーンに配置し、それ以外のランタイム中に変化・生成が必要な要素はPrefabで管理・生成すると良いでしょう。こうすることで、両方の利点を最大限に活かせます。

例えば、UIシーンの場合、基本的なUIレイアウトはあらかじめシーン内に配置し、リストなど動的生成が必要なUI要素をPrefabで管理し、シーン側のスクリプトで制御するのが良いでしょう。

シーンを作成するにあたって

マルチシーンエディティングを前提にシーンを構築していく場合は、いくつか気にするべきポイントがあります。

MainCameraの配置について

MainCamera は通常、ゲームの3Dシーンを描画するために使用されます。

UIシーンと3Dシーンを分ける場合、3Dシーンにはプレイヤーの視点やゲーム世界を表示するためのカメラが必要です。一方で、UIシーンは2D Canvas上で描画されることが多いため、3Dシーンにのみカメラを配置するのが一般的です。

Event Triggerの配置について

Event Triggerは、UI要素に対してクリックやホバーなどのイベントをトリガーさせるためのコンポーネントです。UIに関するイベント処理を行うためのものなので、UIシーンにのみ配置するのが適切です。

どのシーンをアクティブにするべきか

ロジックシーンをアクティブシーンにするケース

ロジックシーンはゲーム全体の状態を管理し、フィールドシーンやUIシーンに指示を送る重要な役割を担います。このシーンをアクティブにすることで、ロジックシーンから各シーンへの操作やデータのやり取りを効率よく行えます。インゲームで活用するのに適しているでしょう。

  • 利点
    ゲーム全体のコントロールをロジックシーンに集中でき、フィールドシーンやUIシーンに命令をスムーズに送信できます。
  • 考慮点
    フィールドシーンやUIシーンで頻繁にGameObjectの生成や更新が発生する場合、その都度MoveGameObjectToScene()Transform.SetParent()を使用して各シーンにオブジェクトを渡す必要があります。この構成だと、ロジックシーン、UIシーン、ゲームシーンの3つが必要になります。こも踏まえて設計をよく考える必要があります。

3Dシーンをアクティブシーンにするケース

3Dシーンでは、キャラクターの動きやバトルエフェクトなど、多くのリアルタイムの更新や生成が行われます。このシーンをアクティブにすることで、3Dシーンのパフォーマンスや管理のしやすさが向上するため、こちらもインゲームでの使用に適しています。

  • 利点
    3Dシーンでの動的な操作やエフェクトの更新を優先できるため、アクションゲーム等のリアルタイム性が求められるジャンルにおすすめです。
  • 考慮点
    UIの更新が多い場合は、UIシーンに都度命令を送る必要があります。また、この構成では3Dシーンがロジックシーンの役割を兼ねるため、シーンは合計で2つとなることが多いです。

UIシーンをアクティブシーンにするケース

ユーザーの入力によって変動するUI(スキル発動やカード更新など)やHPバー、ダメージ表示など、UIの更新が頻繁に行われる場合、UIシーンをアクティブにすることで、UI周りの管理を簡単に行えます。ただし、UI以外のオブジェクト管理もUIシーンで行う必要性が出るため、インゲームよりアウトゲームで活用することをおすすめします。

  • 利点
    UI処理が多い場合、UIの生成や更新が効率よく行えます。リアルタイムでHPやスコアを更新する必要があるゲームにも便利です。また、アウトゲーム機能にも向いています。
  • 考慮点
    3Dのバトルやフィールドでのオブジェクト操作が頻繁に行われる場合、それをロジックシーンやゲームシーンで処理する必要があります。

マルチシーンのロード・アンロード

マルチシーンにしていくにあたり、実際にどのようにしてロード・アンロードをしていけば良いのでしょうか?

静的なシーン追加

各シーンを組み合わせた時の見え方を調整したい場合、Editor上でシーンをHierarchyにドラッグ&ドロップしてロードする方法が手軽で便利です。

UIシーンと3Dのゲームシーンを用意し、組み合わせてみましょう。

image.png

image.png

3Dゲームシーンを開いている状態でUIシーンをD&Dしてください。

image.png

そうすると画面の見え方は以下のようになります。

image.png

別のシーンをアクティブシーンに切り替えたいときは、該当のシーン上でコンテキストメニューを出し、「Set Active Scene」を押下してください。

image.png

AddressablesのアセットはEditorモード中にアドレスから直接読み込むことができません。そのため、エディターモードでシーンをAdditiveで読み込みたい場合は、EditorSceneManager.OpenSceneを使用してください。

    // ロードするシーンのパス
    private const string InGameScenePath = "Assets/GameScene.unity";
    private const string LogicScenePath = "Assets/LogicScene.unity";
    
    // シーンをエディターモードで追加ロードするメソッド
    public void LoadScenesInEditor()
    {
        #if UNITY_EDITOR
        // InGame シーンを追加ロード(Additive)
        if (!EditorSceneManager.GetSceneByPath(InGameScenePath).isLoaded)
        {
            EditorSceneManager.OpenScene(InGameScenePath, OpenSceneMode.Additive);
            Debug.Log("InGame シーンが追加ロードされました");
        }

        // Logic シーンを追加ロード(Additive)し、アクティブシーンに設定
        if (!EditorSceneManager.GetSceneByPath(LogicScenePath).isLoaded)
        {
            var logicScene = EditorSceneManager.OpenScene(LogicScenePath, OpenSceneMode.Additive);
            EditorSceneManager.SetActiveScene(logicScene);
            Debug.Log("Logic シーンが追加ロードされ、アクティブシーンに設定されました");
        }
        #endif
    }

動的なシーン追加

ランタイム中に複数シーンをロードして組み合わせる場合は、プロジェクトで使用しているシステムに合わせて適切なAPIを選択する必要があります。

Addressable 経由

プロジェクトでAddressable Systemを採用し、Sceneアセットもアドレスで管理している場合は、AddressablesのAPIを使用して複数シーンをロードしましょう。

例えば以下のような処理でロードできます。

 private const string LogicSceneAddress = "LogicScene";
 private const string InGameSceneAddress = "3DModelScene";
 
private async UniTask LoadScenesAsync()
{
    // 3D シーンをロード (Additive) - 既存シーンに追加ロード
    var inGameSceneHandle =
        Addressables.LoadSceneAsync(InGameSceneAddress, LoadSceneMode.Additive);

    await inGameSceneHandle;

    // Logic シーンをロードし、アクティブシーンに設定
    var logicSceneHandle =
        Addressables.LoadSceneAsync(LogicSceneAddress, LoadSceneMode.Additive);
        
    await logicSceneHandle;

    if (logicSceneHandle.Status == AsyncOperationStatus.Succeeded)
    {
        // ロードした Logic シーンをアクティブに設定
        SceneManager.SetActiveScene(logicSceneHandle.Result.Scene);
    }
}

そのほか、ロード完了時に発火されるCompleted コールバックが使用可能です。

inGameSceneHandle.Completed += handle =>
{
    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        Debug.Log($"{InGameSceneAddress} loaded successfully (Completed).");
    }
    else
    {
        Debug.LogError($"Failed to load {InGameSceneAddress}.");
    }
};

アンロード処理は、UnloadSceneAsyncを使用して実装できます。

var unloadLogicSceneHandle = Addressables.UnloadSceneAsync(logicScene);
unloadLogicSceneHandle.Completed += handle =>
{
    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        Debug.Log($"{LogicSceneAddress} unloaded successfully.");
    }
    else
    {
        Debug.LogError($"Failed to unload {LogicSceneAddress}.");
    }
};
await unloadLogicSceneHandle;

ロードの進捗率はPercentCompleteから確認できます。

while (!inGameSceneHandle.IsDone)
{
    Debug.Log($"Loading {InGameSceneAddress}: {inGameSceneHandle.PercentComplete * 100}%");
    await UniTask.Yield();
}

SceneManager経由

Addressablesを使用せず、シンプルにロード・アンロードする場合は、SceneManagerのAPIを使用します。

private const string LogicSceneAddress = "LogicScene";
private const string InGameSceneAddress = "3DModelScene";

// 同期でシーンを追加ロード
public async UniTask LoadSceneSynchronously()
{
    // LoadSceneを使ってシーンを同期で追加ロード
    SceneManager.LoadScene(InGameSceneAddress, LoadSceneMode.Additive);
    Debug.Log($"{InGameSceneAddress} loaded synchronously.");
}

// 非同期でシーンを追加ロード
public async UniTask LoadSceneAsynchronously()
{
    // 非同期でシーンをロードし、進捗率をログに表示
    var asyncOperation = SceneManager.LoadSceneAsync(LogicSceneAddress, LoadSceneMode.Additive);
    
    // UniTaskを使って進行状況を監視
    while (!asyncOperation.isDone)
    {
        Debug.Log($"Loading {LogicSceneAddress}: {asyncOperation.progress * 100}%");
        await UniTask.Yield();
    }

    Debug.Log($"{LogicSceneAddress} loaded asynchronously.");
}

// シーンをアンロード
public async UniTask UnloadSceneAsync()
{
    var scene = SceneManager.GetSceneByName(InGameSceneAddress);
    
    if (scene.isLoaded)
    {
        var asyncUnload = SceneManager.UnloadSceneAsync(scene);
        
        // UniTaskでアンロードの進行状況を監視
        while (!asyncUnload.isDone)
        {
            Debug.Log($"Unloading {InGameSceneAddress}: {asyncUnload.progress * 100}%");
            await UniTask.Yield();
        }

        Debug.Log($"{InGameSceneAddress} unloaded successfully.");
    }
    else
    {
        Debug.LogWarning($"{InGameSceneAddress} is not loaded.");
    }
}

マルチシーン上での動的なGameObject生成

複数シーンをロードしている状態で、動的にGameObjectを生成し、それぞれのシーンに割り当てる方法について見ていきましょう。

遷移先のシーンにあるGameObjectをParentとして設定する

Transform.SetParent()を使うと、移動先のシーンに存在するGameObjectを親オブジェクトとして設定し、そのシーンに関連付けることができます。

単純にシーンの境界を越えて親子関係を設定するだけとなりますので、リアルタイムやエディタモードのどちらでも使えるため、非常に扱いやすい方法です。

ただし、この方法はあらかじめ対象シーンにparentオブジェクトが存在していることが前提です。もし、parentオブジェクトも動的に用意したい場合は、次に紹介する方法を利用すると良いでしょう。

MoveGameObjectToSceneを使用する

例えば、以下のコードのように、MoveGameObjcetToSceneを使用して、新規作成したGameObjectを指定した別のシーンのObjectとして移動させることが可能です。

private const string InGameSceneAddress = "3DModelScene";

private void Start()
{
 var testGameObject = new GameObject();
 var scene = SceneManager.GetSceneByName(InGameSceneAddress);
 SceneManager.MoveGameObjectToScene(testGameObject, scene);
}

MoveGameObjectToSceneはシーン内のルートにあるGameObjectに対してのみ使用可能です。そのため、次のコード例のように対象GameObjectが親オブジェクトを持ち階層構造ができている場合、エラーが発生します。

private void Start()
{
  // parent の生成
  var parent = new GameObject("Parent").transform;

  var gameObject = new GameObject
  {
      transform =
      {
          parent = parent,
      },
  };

  var scene = SceneManager.GetSceneByName("シーンの名前");
  
  // ArgumentException: Gameobject is not a root in a scene が発生する
  SceneManager.MoveGameObjectToScene(gameObject, scene);
}

前述のTransform.SetParent()を使った方法と組み合わせることで、GameObjectのセットを簡単に受け渡しすることもできます。

var gameObject = new GameObject("Parent");
var scene = SceneManager.GetSceneByName("シーンの名前");
SceneManager.MoveGameObjectToScene(gameObject, scene);

var test = new GameObject("child")
{
    transform =
    {
        parent = gameObject.transform,
    },
};

まとめ

本記事では、Unityのマルチシーンエディティング機能を活用したプロジェクト構築について、基本的な概念から具体的な実装手法まで紹介しました。マルチシーンエディティングを使うことで、シーンごとにUI、ロジック、3Dモデルなどの役割を分離し、開発効率とメンテナンス性を向上させることが可能です。また、依存関係の管理、チーム開発でのコンフリクト軽減、シーンの役割に応じたモジュール化など、さまざまなメリットがあります。

しかし、シーン間のデータやオブジェクトのやり取りを効率的に行うための工夫も必要です。シングルトンパターンやDI(依存性注入)ライブラリの導入を行うことで、シーン間の疎結合を保ちながら、柔軟な構造を構築することができます。

Prefabとシーン分割のどちらを使うかの選択も、プロジェクトの要件や規模に応じて適切に判断することが重要です。どちらも特性が異なるため、各手法のメリット・デメリットを理解し、必要に応じて併用することで、パフォーマンスやメモリ効率を最適化しつつ、管理のしやすいプロジェクトを実現できるでしょう。

マルチシーンエディティングの活用は、特に中~大規模なプロジェクトでの効率化に寄与するものですが、小規模なプロジェクトでもデータのモジュール化と再利用性の向上に役立つため、ぜひ試してみてください。この記事が皆さんのプロジェクトにおけるシーン管理の改善や効率化の参考になれば幸いです。

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?