その他
    ホーム 技術発信 DoRuby 【Unity】オブジェクトプーリングでオブジェクト節約術
    【Unity】オブジェクトプーリングでオブジェクト節約術
     

    【Unity】オブジェクトプーリングでオブジェクト節約術

    この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。

    オブジェクトを生成・破棄する際にかかるコストを、簡単なオブジェクトプーリングの仕組みを実装して軽減してみます。

    まえがき

    こんにちは。17新卒エンジニアのinoooooocchiです。
    今まではUnity入門的なネットに溢れかえってるような記事をここ(DoRuby)に投稿していましたが、そろそろ多少は実用的な記事を書かなければいけないと気を引き締め、今回はオブジェクトの生成・破棄と”オブジェクトプーリング“について書いていきます。これを皮切りに、これから少しずつ技術的な話を書いていきたいと思います。(願望)

    Unityにおけるオブジェクトの生成・破棄

    Unityでは、オブジェクトを新たに生成したり、使い終わったオブジェクトを破棄したりする際、それぞれ Object.Instantiate や Object.Destroy を用いて行います。
    一般的に、オブジェクトをスクリプトで生成する場合、UnityのPrefab機能を用いてオブジェクトのPrefabを作っておき、それをスクリプトに渡して Instantiate することで生成します。

    この Instantiate・Destroy はゲームを動かす上で何百、何千、何万回も呼ばれるであろう関数ですが、処理が重いことで知られています。
    (→ Debug.Log() や Instantiate() などの速度を計測してみる (外部サイト))

    スマートフォン向けにゲームを作ってみよう!と思い立ち、いざUnityを立ち上げてゲームを作ってみると、意外とこの Instantiate・Destroy の負担は目に見えて現れます。
    弊社で運営しているようなRPGベースのゲームでは頻繁に Instantiate と Destroy を繰り返すことは少ないですが、アクションゲームやシューティングゲームなどでは必然的に敵や弾などのオブジェクトの数が多くなるため、Instantiate や Destroy を行う回数は相当なものです。
    特に筆者の好きな弾幕系シューティングゲームでは物によっては合計数百〜千程度の弾オブジェクトが生成されては消えを繰り返すため、スペック次第では処理落ちが発生し、プレイヤーの操作感を損なうことに繋がってしまいます。
    (コルーチンならフレームを跨いで処理が行えるため、処理落ちを多少は回避できそうですが、今回は置いておきます)

    オブジェクトプーリングしよう

    そこで登場するのが、 “オブジェクトプーリング” という手法です。
    オブジェクトプーリングとは、一定数のオブジェクトを溜めておく(poolしておく)ための貯蔵庫のような物を作り、オブジェクトが必要になったら取り出し、使い終わったら戻すことで、なるべく少ない数のオブジェクトを使い回してオブジェクト数及び生成・破棄のコストを抑えようというものです。
    リサイクルみたいなもんです。

    今回は、受け取ったPrefabを元にGameObjectを最初に一定数まとめてInstantiateで生成し、それらを使い回すといった簡単なオブジェクトプーリングを実装してみます。
    また、最初に生成した数では足りなくなった時は、逐次生成してプールしているリストに加えます。

    実装

    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    
    public class ObjectPool : MonoBehaviour {
        private List<GameObject> _poolObjList;
        private GameObject _poolObj;
    
        // オブジェクトプールを作成
        public void CreatePool(GameObject obj, int maxCount){
            _poolObj = obj;
            _poolObjList = new List<GameObject>();
            for (int i = 0; i < maxCount; i++) {
                var newObj = CreateNewObject();
                newObj.SetActive(false);
                _poolObjList.Add(newObj);
            }
        }
    
        public GameObject GetObject(){
            // 使用中でないものを探して返す
            foreach (var obj in _poolObjList) {
                if (obj.activeSelf == false) {
                    obj.SetActive(true);
                    return obj;
                }
            }
    
            // 全て使用中だったら新しく作って返す
            var newObj = CreateNewObject();
            newObj.SetActive(true);
            _poolObjList.Add(newObj);
    
            return newObj;
        }
    
        private GameObject CreateNewObject(){
            var newObj = Instantiate(_poolObj);
            newObj.name = _poolObj.name + (_poolObjList.Count + 1);
    
            return newObj;
        }
    
    }
    

    オブジェクトを管理するオブジェクト(例: 大砲の弾を管理する大砲オブジェクト)でObjectPoolクラスのCreatePool関数にオブジェクトのPrefabと最初にまとめて生成する個数を渡すことで、オブジェクトプールがリストとして作成されます。
    生成されたオブジェクトはSetActive(false)で非アクティブ状態にしておきます。
    今回の場合、オブジェクトが使用中かどうかをアクティブかどうかで判断しているためです。
    管理オブジェクトからGetObject関数が呼ばれた場合、リストの中から使用されていないものを探して返します。
    リスト内のオブジェクトが全て使われていた場合、新たに1個だけオブジェクトを生成し、それを返します。

    それでは、実際にプールするオブジェクトと管理オブジェクトを作り、動作を確認してみます。

    オブジェクト管理クラス

    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    
    [RequireComponent(typeof(ObjectPool))]
    public class BulletCreator : MonoBehaviour {
    
        [SerializeField] private GameObject _bulletPrefab;
        private const int BULLET_MAX = 50;
        private int _state = 0;
        private float _originDirection = 0.0f;
        private bool _isReverse = false;
        private ObjectPool _pool;
    
        private void Awake () {
            _pool = GetComponent<ObjectPool>();
            _pool.CreatePool(_bulletPrefab, BULLET_MAX);
        }
    
        // Update is called once per frame
        void Update () {
            if (_state % 30 == 0) {
                var way = 16; // 16方向
                for (int i = 0; i < way; i++) {
                    var bullet = _pool.GetObject();
                    if (bullet != null) {
                        bullet.GetComponent<Bullet>().Initialize(_originDirection + i * (360.0f / way), _isReverse);
                    }
                }
                // 発射のたびに10度ずつ回転し、弾の回転方向を反転
                _originDirection = (_originDirection + 10) % 360;
                _isReverse = !_isReverse;
            }
            _state++;
        }
    }
    

    オブジェクト

    using UnityEngine;
    using System.Collections;
    
    public class Bullet : MonoBehaviour {
    
        private float _speed = 4.0f;
        private float _direction;
        private bool _isReverse;
        private Rigidbody2D _rigidBody;
    
        public void Initialize(float direction, bool isReverse){
            _direction = direction;
            _isReverse = isReverse;
            _rigidBody = GetComponent<Rigidbody2D>();
    
            if (_isReverse) {
                GetComponent<SpriteRenderer>().color = new Color(1.0f, 0.3f, 0.3f); // 赤っぽい色
            } else {
                GetComponent<SpriteRenderer>().color = Color.white;
            }
    
            SetDirection();
        }
    
        public void Update(){
            if (_isReverse) {
                _direction = (_direction - 1) % 360;
            } else {
                _direction = (_direction + 1) % 360;
            }
            SetDirection();
        }
    
        public void OnBecameInvisible(){
            gameObject.SetActive(false);
            ResetPosition();
        }
    
        private void SetDirection(){
            Vector2 v;
            v.x = Mathf.Cos(Mathf.Deg2Rad * _direction) * _speed;
            v.y = Mathf.Sin(Mathf.Deg2Rad * _direction) * _speed;
    
            _rigidBody.velocity = v;
        }
    
        private void ResetPosition(){
            _rigidBody.velocity = Vector2.zero;
            transform.localPosition = Vector3.zero;
        }
    }
    
    

    動作確認

    enter image description here

    画像サイズ上限の都合上短いですが、上記ソースコードでは最初に生成するオブジェクトプールを50個に設定しており、数が足らなくなったことで追加で2個生成されています。
    管理オブジェクトは、30フレームに一度、16個の弾を発射するようになっています。
    画像は管理オブジェクトが弾を発射する様子を5秒程度切り取ったものなので、本来なら160個のオブジェクトが必要なところ、オブジェクトプーリングを導入することで52個のオブジェクトで済むようになっています。
    これが1分2分と続いていくとしても、弾が定期的かつ規則的に発射される限り、52個前後のオブジェクトで本来なら数千個必要なオブジェクトを賄えるということになります。
    すなわち、数千回分のInstantiate・Destroyを削減出来たわけです。

    最後に

    今回の実装は、オブジェクトプーリングの形だけを作りましたが、実際にゲームで運用するためには幾つか改善すべき点があります。

    1. ObjectPoolがオブジェクト管理オブジェクトと1:1の関係になっているため、同じオブジェクトを生成する管理オブジェクトが複数ある場合、ObjectPoolを共有できず無駄が多い
    2. 最初に生成しておくオブジェクトの数を大きくし過ぎると、最初だけ処理が重くなる  →描画コストなどとの兼ね合いですが、基本的には逐次生成が良さそうです

    実際はゲームの種類やシステム、オブジェクトの特徴などによって、どのような形式のオブジェクトプールを使うのか考える必要がありますね。
    また、オブジェクトプーリングの考え方はUIにも応用出来ます。
    例えば何らかのオブジェクトを一覧表示してスクロールさせるUIでは、最初に幾つかオブジェクトを生成しておき、スクロールに応じて中身を入れ替えることで擬似的にたくさんのオブジェクトが並んでいることを表現できそうです。

    以上、「オブジェクトプーリングでオブジェクト節約術」でした。

    記事を共有