Spartial Partition パターンを用いたレベルデザインエディタの実装【Game Development Pattern With Unity】

こちらの書籍の第13章「Implementing a Level Editor with Spatial Partition」の解説です.

Environment

  • Unity 2021.2.0b6

C# 9 の機能である「Target-typed new expression」を利用しているため,Unity 2021.1 以前ではエラー発生する箇所があります.

Spatial Partition パターン

直訳すると空間分割パターンです.

Spatial Partition パターンの名前は CG 分野で重要な役割を果たし,レイトレーシングレンダリングの実装でよく使用される,
Space Partitioning と呼ばれるプロセスに由来します.

Space Partitioning は 仮想シーン内のオブジェクトを BSP(Binary Space Partitioning)ツリーなどの空間分割データ構造に格納して
整理することで,大規模な 3D オブジェクトのセットに対するジオメトリクエリの実行を高速化しています.

本稿では,Spatial Partition パターンを CG 分野での通常の実装方法ではない利用方法で扱います.

Unity の Stack と ScriptableObject を利用することで簡易に Spatial Partition パターンを実現することが出来ます.

Spatial Partition パターンの利点としては,オブジェクトを位置順に並べたデータ構造に格納することで,
オブジェクトの位置を効率的に把握することが出来るようになります.

シーン内のオブジェクトの空間関係を把握しながら,膨大な数のオブジェクトに素早くクエリを実行する必要がある場合に便利かも.

When to Use

色んな分野にまたがるゲーム開発チームで働く場合,ゲームプログラマーの責務は,
ゲームのメカニズムや機能を実装することだけではありません.
アセットを統合するパイプラインや編集ツールの構築を任されることもあります.

制作サイクルの初期段階で実装する必要がある最も一般的なツールは,
チームのレベルデザイナーのためのカスタムレベルエディタです.
コードを書く前に,これから作るゲームにはコアゲームシステムに組み込まれたランダム性がないことを念頭に置く必要があります.

本稿のサンプルプロジェクトにはバイクゲームを作成しますが,このゲームは各トラックの複雑な構造を記憶し,
できるだけ速くゴールすることで,スコアボードのトップに到達することを第一の目標とするスキルゲームです.

特定のルールや制約に基づいてランダムに障害物を発生させるプロシージャルな生成マップのようなソリューションは,
デザインの柱とすることができません.レーストラックのレイアウトはレベルデザイナーが手作業で作成することになります.
これが最大の課題です.

本作ではバイクが3Dの世界を直線的に高速で走行します.十数秒のレースを実現するためには,
メモリ上に膨大な量のアセットが必要となり,デザイナーはエディタ上で膨大なレベルの編集作業を行わなければなりません.
この方法は実装段階でもライタイムでも効率的ではありません.

そこで,1つのレーストラックを1つの実体として管理するのではなく,
セグメントに分割し,各セグメントを個別に実装し,特定の順序で組み立てて1つのトラックを形成します.

以下の左図から右図へ空間を分割していくイメージです.

https://gameprogrammingpatterns.com/images/spatial-partition-quadtree.png https://gameprogrammingpatterns.com/spatial-partition.html

Design Image

実装イメージです.

f:id:xrdnk:20210905143024j:plain

ここでは Track がレベル全体で,それを分割したものを Segment だと理解してください.

この実装方法は,以下の利点があります.

  • レベルデザイナーは新しいセグメントを作成して,様々なレイアウトで配列することで,新しいトラックを作成可能
  • メモリに Track 全体をロードするのが不要になり,プレイヤーの現在位置に応じて必要なセグメントを適切なタイミングで生成可能

このシステムを導入する際に留意すべきゲームの特徴は,以下の2点です.

① プレイヤーは初期位置から動かないこと.プレイヤーに向かって動くのは,トラックのセグメントです.
そのため,スピード感や動きがシミュレートされ,視覚的な錯覚をもたらします.

② プレイヤーは前方しか見ることができません.バックビューウィンドウやルックバックカメラはありません.
このカメラビューの制限により,トラックセグメントがプレイヤーの視界の後ろに入った直後に,
不要になったトラックセグメントをアンロードすることができます.

Implement

Track (ScriptableObject)

全体のレベルデザインになる Track の ScriptableObject を作ってみます.

using System.Collections.Generic;
using UnityEngine;

namespace Denity.SpatialPartition
{
    [CreateAssetMenu(fileName = "New Track", menuName = "Track")]
    public class Track : ScriptableObject
    {
        [SerializeField, Tooltip("セグメント空間自体の長さ")] 
        float _segmentLength;

        [SerializeField, Tooltip("予想されるローディング順にセグメントを追加")]
        List<GameObject> _segments = new ();

        public float SegmentLength => _segmentLength;
        public List<GameObject> Segments => _segments;
    }
}

次にデザイナーが _segments に順番に並べたセグメントを自動的に生成する TrackController を実装します.

TrackController

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace Denity.SpatialPartition
{
    public class TrackController : MonoBehaviour
    {
        /// <summary>
        /// トラックが向かってくるスピード
        /// </summary>
        float _trackSpeed;

        /// <summary>
        /// 前のセグメント
        /// </summary>
        Transform _prevSeg;

        /// <summary>
        /// トラックの親オブジェクト
        /// </summary>
        GameObject _trackParent;

        /// <summary>
        /// セグメントの親オブジェクト
        /// </summary>
        Transform _segParent;

        /// <summary>
        /// セグメントのリスト
        /// </summary>
        List<GameObject> _segments;

        /// <summary>
        /// セグメントのスタック
        /// </summary>
        Stack<GameObject> _segStack;

        /// <summary>
        /// 現在位置
        /// </summary>
        Vector3 _currentPosition = new (0, 0, 0);

        [Tooltip("TrackのScriptableObjectを配置")]
        [SerializeField]
        Track track;

        [Tooltip("開始時にロードするセグメントの初期値")]
        [SerializeField]
        int initSegAmount;

        [Tooltip("追加でロードするセグメントの数")]
        [SerializeField]
        int incrSegAmount;

        void Awake()
        {
            // 要素の順番を反転させる
            _segments = Enumerable.Reverse(track.Segments).ToList();
        }

        void Start()
        {
            // 初期化処理
            InitTrack();
        }

        void Update()
        {
            // トラックの親オブジェクトをプレイヤー側に移動させる
            _segParent.transform.Translate(Vector3.back * (_trackSpeed * Time.deltaTime));
        }

        /// <summary>
        /// トラックの初期化処理
        /// </summary>
        void InitTrack()
        {
            // トラックの親オブジェクトの生成
            Destroy(_trackParent);
            _trackParent = Instantiate(Resources.Load("Track", typeof(GameObject))) as GameObject;

            if (_trackParent)
            {
                _segParent = _trackParent.transform.Find("Segments");
            }

            // 最初は前のセグメントは存在しない
            _prevSeg = null;

            // セグメントのリストを新しいスタックのコンテナに注入
            // Spatial Partition Pattern にて,レベルデザイン用のオブジェクトを
            // スタック構造で整理することによって,クエリが実行しやすいようにする
            _segStack = new Stack<GameObject>(_segments);

            LoadSegment(initSegAmount);
        }

        /// <summary>
        /// トラックの読込
        /// </summary>
        /// <param name="amount">ロードするセグメントの数</param>
        void LoadSegment(int amount)
        {
            for (var i = 0; i < amount; i++)
            {
                if (_segStack.Count > 0)
                {
                    // セグメントスタックのポップ処理を通してセグメントを生成
                    var segment = Instantiate(_segStack.Pop(), _segParent.transform);

                    // 前のセグメントが存在しない時は現在位置のzは0
                    if (!_prevSeg)
                    {
                        _currentPosition.z = 0;
                    }

                    // 前のセグメントが存在する時,現在地のzは「前のセグメントのz値 + セグメント空間の長さ」となる
                    if (_prevSeg)
                    {
                        _currentPosition.z = _prevSeg.position.z + track.SegmentLength;
                    }

                    segment.transform.position = _currentPosition;

                    // Segment.TrackController にこのオブジェクトを設定
                    segment.AddComponent<Segment>();
                    segment.GetComponent<Segment>().trackController = this;

                    // 生成したセグメントを「前のセグメント」として設定
                    _prevSeg = segment.transform;
                }
            }
        }

        /// <summary>
        /// 次のセグメントを読み込む
        /// </summary>
        public void LoadNextSegment()
        {
            LoadSegment(incrSegAmount);
        }
    }
}

Segment と SegmentMarker

プレイヤーの後ろを通過したセグメントの破壊方法は本稿では以下のように実装します.

PlayerController を持っているオブジェクトがきた時に Segment を破壊するようにします.

using UnityEngine;

namespace Denity.SpatialPartition
{
    public class SegmentMarker : MonoBehaviour
    {
        void OnTriggerExit(Collider other)
        {
            if (other.GetComponent<PlayerController>())
            {
                Destroy(transform.parent.gameObject);
            }
        }
    }
}

Segment が破壊された時に,次のセグメントを読み込むようにしています.

using UnityEngine;

namespace Denity.SpatialPartition
{
    public class Segment : MonoBehaviour
    {
        public TrackController trackController;

        void OnDestroy()
        {
            if (trackController)
            {
                trackController.LoadNextSegment();
            }
        }
    }
}

Test

Segment のレベルデザインを適当に作ります.
上から Segment 1, Segment 2,End Segment です.

f:id:xrdnk:20210905153954p:plain

f:id:xrdnk:20210905154021p:plain

f:id:xrdnk:20210905154028p:plain

これらを Track (Scriptable Object) に入れます.
Track を Track Controller に格納します.

f:id:xrdnk:20210905154055p:plain

実際にサンプルゲームを起動してみます.
Segments の配下にも着目してみてください.
Segment が追加されたり,消えたりしていることがわかるかと思います.

gyazo.com

Pros

この方法を利用することで,シーン内に生成されるエンティティの数を管理でき,
制御された量のセグメントを特定の間隔で自動的にロード/アンロードする循環型のメカニズムを作ることができます.
理論的には、より安定したフレームレートが得られると思います.
もう一つの利点はゲームプレイに関連していますが,セグメントマーカーがチェックポイントシステムの目印になることです
チェックポイントの概念はいくつかゲームの中で使われます.

Outro

本稿では,Spatial Partition パターンを用いた基本的なレベルエディタを構築する方法を手探りで検討しました.
ここでの目的は,Spatial Partition パターンの標準的な定義に忠実になることではありません.
むしろ,システムを構築するための出発点として利用しています.

生成・破棄の時は Object Pool Pattern を適用した方がもっと最適化が出来ると思いますが,
今回は説明の都合上,割愛します.