見出し画像

2. Object Poolパターンでパフォーマンス向上

ス「解説役のスーだよ」

ミ「相槌担当のミカゼです。」

ス「今回のデザインパターンはObject Poolパターンだよ」

ミ「どのようなパターンなのですか」

ス「生成したオブジェクトを再利用するパターンだよ。オブジェクトの生成と破棄はコストが高い。それを解決するためのもの。アクションやシューティングの動的なゲームの場合、有効になるときがあるよ」

ミ「コストとは何なのですか」

ス「メモリの使用するコストとCPUの処理時間のコストのだね。このObject Poolパターンで有益なのはCPUの処理時間のコストのほうだよ。今後コストって単語を出すときはCPUの処理時間のほうだと思ってね。
繰り返すけど、オブジェクトの生成するときはコストが掛かる。たくさん生成するとスペックの低いPCだったら一瞬画面が止まったり、カクついたりする場合があるよ。」

ミ「そうなのですね」

ス「敵が弾を出すというシチュエーションで説明していくよ。」

ミ「はい」

ス「この敵は永遠と弾を出すものとするよ。」

画像で説明

ス「プログラムでは発射するときに弾の生成を行う。一定時間立つと弾を破棄する。」

画像で説明

ス「弾を生成時にメモリの確保が行われる。これはコストが高い作業。」

ミ「はい」

ス「一定時間立って弾を破棄する。UnityならDestroy()関数で破棄することになるよね。」

ミ「それもコストが掛かるんですか」

ス「すぐには掛からないよ。C#ではGC(ガベージコレクタ)によって管理されていて、どこかのタイミングでメモリが破棄されるよ。それがガベージコレクション。そのときにコストが掛かる」

ミ「ガベージコレクタとガベージコレクションが分からないです。」

画像を出しながら説明

ス「ガベージコレクタは動的に確保したメモリの監視をしていて、プログラムがもう使用していないメモリを自動的に開放するための機能のことだよ。ガベージコレクションはガベージコレクタによってメモリを開放する行為のことだよ」

ミ「なんとなく」

ス「つまり、ガベージコレクタは機能を差し、ガベージコレクションはその機能が行う行為を指すということだよ。」

ミ「便利な機能ですね」

ス「弊害もあるよ。ガベージコレクションのタイミングが分からない。ガベージコレクション自体にコストが掛かるから出来るなら頻度を下げたい。生成と破棄が多いとその頻度があがる。じゃあ、何とかしてやりゃーってのがObject Poolパターンだよ。話が逸れたから戻すよ。」

ミ「はい」

ス「Object Poolパターンのメリットを述べるよ
・一度生成したオブジェクトを再利用できるためコスト減らせる。
・ガベージコレクションの頻度を下げることが出来る。」

ミ「なんだか恩恵が少なく感じます」

ス「そんなことないよ。確かに脱出ゲームのような静的なゲームだったら恩恵は少ないよ。これが画面上に埋まるくらい弾が出る弾幕シューティングだったら恩恵は大きいよ」

ス「次はObject Poolパターンを実装するために必要な2つの要素を説明するよ」

ス「
1. プールするオブジェクトのクラス:これはプールされる具体的なオブジェクトを表すよ。例えば、弾や敵などが該当するね。
2.Object Pool クラス:このクラスは未使用のオブジェクトを保管するリストを持つよ。オブジェクトが必要なときにはプールから取り出し、使用が終わったときにはプールに戻すという機能を提供するよ。

ミ「そろそろどのように実装するかソースが欲しいですね」

ス「わかった。まずは弾を表すBulletクラス」

public class Bullet : MonoBehaviour
{
    public Vector2 Velocity { get; set; }
    public float ExistenceDuration { get; set; }
    private float existenceTime;

    private void OnEnable()
    {
        existenceTime = 0;
    }

    private void Update()
    {
        transform.position += Velocity * Time.deltaTime;
        existenceTime += Time.deltaTime;

        if (existenceTime > ExistenceDuration)
        {
            gameObject.SetActive(false);
        }
    }
}

ミ「弾の実装部分ですね」

ス「そう。指定された方向に弾は進んで行って時間が経過するとSetActiveを使って画面上から消す。」

ミ「 ゲーム上では弾がなくなったように見せるんですね。」

ス「次にObjectPoolクラスを作成。このクラスは未使用のオブジェクトを保管するリストを持ち、オブジェクトが必要なときにはそのプールから取り出す機能を提供するよ。GetBullet()メソッドがプールからの取り出しの実装部分だね」

public class BulletPool : MonoBehaviour
{
    public static BulletPool Instance { get; private set; }
    [SerializeField]
    private Bullet bulletPrefab;
    private int initialSize;
    private List<Bullet> bullets;

    private void Awake()
    {
        initialSize = 10;
        Instance = this;
        bullets = new List<Bullet>(initialSize);

        for (int i = 0; i < initialSize; i++)
        {
            Bullet bullet = Instantiate(bulletPrefab);
            bullet.gameObject.SetActive(false);
            bullets.Add(bullet);
        }
    }

    public Bullet GetBullet()
    {
        foreach (Bullet bullet in bullets)
        {
            if (!bullet.gameObject.activeInHierarchy)
            {
                bullet.gameObject.SetActive(true);
                return bullet;
            }
        }

        Bullet newBullet = Instantiate(bulletPrefab);
        bullets.Add(newBullet);
        return newBullet;
    }
}

ミ「Awake()で何かしていますね」

ス「Awake()では10個の弾をプールしておく。10個のBulletオブジェクトを生成してListに保存しておくことで実装している。これを事前プール、もしくはウォームアップと呼ぶらしいよ」

ミ「その下のGetBullet()は名前を見る限り弾を取得するメソッドっぽいですね」

ス「呼び出し元は弾を取得するという認識であっているよ。実装部分は最初のループ部分でさっきプールした弾が非アクティブのものが残っていればアイクティブに変えて弾オブジェクトを返す。」

ミ「なるほど。11発以上発射されるとforeachの下に描いてあるInstantiate()で弾を生成してプールに入れて弾を返すということですね」

ス「その通りだよ」

ス「最後に敵クラスを表すEnemyクラス。弾オブジェクトを必要とする箇所でObjectPoolから取得。弾を発射する場合、以下のようにObjectPoolから弾を取得する」

public class Enemy : MonoBehaviour
{
    private float bulletSpeed = 10f;
    private float bulletDuration = 5f;

    private void Update()
    {
        if (ShouldFireBullet())
        {
            Bullet bullet = BulletPool.Instance.GetBullet();
            bullet.Velocity = new Vector2(0, bulletSpeed);
            bullet.ExistenceDuration = bulletDuration;
        }
    }

    private bool ShouldFireBullet()
    {
        return Time.time % 1f < 0.01f;
    }
}

ミ「Enemyと書いてありますがライフの概念がないので無敵状態ですね」

ス「ソースが見にくくなるから極力不要な要素は排除するよ。Enemyクラスでは一秒毎に弾を討つロジックになっているよ。ShouldFireBullet()メソッドが該当ロジック。後、弾の生存時間の設定をbulletDurationという変数を通してしているよ。一番の注目するべきところは
Bullet bullet = BulletPool.Instance.GetBullet();
プールから弾のオブジェクトを貰っている箇所だね。

ス「このEnemyを画面上に配置すると1秒感に1発弾をY軸方向に10のスピードで弾が移動するロジックになっているよ」

ミ「何となく分かりましたが恩恵が分かりづらいですね」

ス「ならば1秒間に10発弾を出すことにするよ。そうすると事前プールで貯めておいたオブジェクトが一気に使用されるから、次の1秒の1フレームの間で10個のオブジェクトを生成しないといけない。10発くらいだと今どきのPCは影響ないかもしれないかもしれないから、影響が出るまで100発、1000発と増やして試してみると良いよ」

ミ「ゲームがカクついたりしたら、生成で時間がかかっていることになりますね。」

ス「その場合は事前プールのオブジェクトの数を適宜増やすとカクつかなくなる。」

ス「これがObject Poolパターンなのだよ」

ミ「デメリットは無いのですか?」

ス「単純にプールにメモリを確保しておかないといけないから、メモリが少ない環境ではメモリが足りなくなるかもしれないね」

ミ「ゲームの仕様や想定するハードとの相談ということですね」

ス「そだよ。世の中にはメモリもCPUも低スペックな場合で何とかやりくりしないと行けないこともあるからね。」

ミ「そのようなときはどうすれば良いのですか」

ス「C言語だったら弾の表示される数は全て把握して、配列に入れて使い回す感じになるかな」

ミ「一言で言うなら うまいことやれ ってことですね」

ス「そうだね。話しを戻すよ。事前プールはオブジェクトをプールしすぎるとその分ロードが長くなるってデメリットがあるから注意だよ。
事前プールを用意しない場合は初回時にオブジェクトを生成する形でプールされる。始めは弾は少ないけど徐々に弾が多くなるのならこういう形でも良いかもしれないね。」

ミ「事前プールを使わない場合と使う場合のObject Poolパターンがあるということですね。使わない場合のときは何か名前があるのですか?」

ス「呼び名はないよ。つけるとしたら遅延生成(Lazy Generation)のObjectPoolパターンかな」

ミ「Web用語で似たよう名前のものありますね。」

ス「それは遅延読み込み(Lazy Load)だね。」

ミ「それです!」

ス「オブジェクトが必要になったときに生成されてプールしておく。また使うときは作ったものを再利用する。それが遅延生成Object Poolパターン。って勝手に名付けるよ。一般的には事前プールしておくのがObjectPoolパターンだと思うけどね」

ミ「まとめてください」

ス「事前にオブジェクトを複数生成する。複数生成したオブジェクトをどこかにプールしておいて、使うときにプールから取得する。使い終わったらプールに戻す。これがObject Pool パターン!」

拍手SE

ミ「ソースは後々Noteに書いておきます。URLは概要欄に置いておきます。」

ス「締めるよ」

ミ・ス「ご視聴ありがとうございました」


この記事が気に入ったらサポートをしてみませんか?