【Unity C#】弾丸の大量生成でFPSが落ちた → ObjectPool + DI で解決した話

Unity

個人で作っているシューティングゲームで、敵が同時に大量に弾を撃つ演出を実装したところ、FPSが60から40台に落ちました。
原因を調べていくと、毎フレームの Instantiate / Destroy がゴミ(GCアロケーション)を大量に生成していることがわかりました。

この記事では、ObjectPoolパターンで改善したあと、さらにDDD/オニオンアーキテクチャと整合する形に整えた過程をコードとともに紹介します。


何が起きていたか

最初の実装(問題あり)

最初はシンプルに書いていました。

C#
// EnemyShooter.cs(問題のある実装)
public class EnemyShooter : MonoBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private float fireRate = 0.2f;

    private void Start()
    {
        InvokeRepeating(nameof(Fire), 0f, fireRate);
    }

    private void Fire()
    {
        // 毎回生成 → GCアロケーションが積み上がる
        var bullet = Instantiate(bulletPrefab, transform.position, transform.rotation);
        Destroy(bullet, 3f);
    }
}

弾幕シューティングで敵を30体同時に出すと、1秒間に150回のInstantiate + Destroyが走ります。 UnityのProfilerで確認すると GC.Collect が0.5秒おきに走っており、そのタイミングでFPSが一瞬落ちていました。


Step 1: ObjectPoolで生成コストを消す

Unity 2021以降は UnityEngine.Pool 名前空間に公式のプール実装があります。 生成・破棄の代わりに SetActive(true) / SetActive(false) を使うことで、GCアロケーションをほぼゼロにできます。

しかしここで一度立ち止まりました。 よくある実装例として BulletPool.Instance.Get() のようにSingletonで直接参照する方法がありますが、これには問題があります。

  • EnemyPresenter(Presentation層)が BulletPoolService(Infrastructure層)を直接参照する
  • オニオンアーキテクチャの依存方向(外側が内側に依存する)が崩れる
  • テスト時にモックに差し替えられない

自分のプロジェクトではオニオンアーキテクチャ + VContainer によるDIで設計しているため、インターフェースを挟んでDIする形にしました。


Step 2: インターフェースをApplication層に切り出す

C#
// Application/Services/IBulletPoolService.cs
// Application層にインターフェースを置く → Infrastructure層への直接依存をなくす
public interface IBulletPoolService
{
    GameObject Get(Vector3 position, Quaternion rotation);
    void Release(GameObject bullet);
}

Step 3: Infrastructure層で実装する

C#
// Infrastructure/Services/BulletPoolService.cs
using UnityEngine;
using UnityEngine.Pool;

public class BulletPoolService : MonoBehaviour, IBulletPoolService
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private int defaultCapacity = 50;
    [SerializeField] private int maxSize = 200;

    private ObjectPool<GameObject> _pool;

    private void Awake()
    {
        _pool = new ObjectPool<GameObject>(
            createFunc:      () => Instantiate(bulletPrefab),
            actionOnGet:     obj => obj.SetActive(true),
            actionOnRelease: obj => obj.SetActive(false),
            actionOnDestroy: obj => Destroy(obj),
            collectionCheck: false,
            defaultCapacity: defaultCapacity,
            maxSize: maxSize
        );
    }

    public GameObject Get(Vector3 position, Quaternion rotation)
    {
        var bullet = _pool.Get();
        bullet.transform.SetPositionAndRotation(position, rotation);
        return bullet;
    }

    public void Release(GameObject bullet) => _pool.Release(bullet);
}

Step 4: VContainerで依存注入する

C#
// Core/DI/GameSceneLifetimeScope.cs
using VContainer;
using VContainer.Unity;

public class GameSceneLifetimeScope : LifetimeScope
{
    [SerializeField] private BulletPoolService bulletPoolService;

    protected override void Configure(IContainerBuilder builder)
    {
        // MonoBehaviourのServiceはインスタンスをそのまま登録
        builder.RegisterComponent(bulletPoolService).As<IBulletPoolService>();
    }
}

Step 5: Presentation層からDIで受け取る

発射ロジックもR3を使って InvokeRepeating を置き換えます。 R3のObservableにすることで、ポーズ時の停止や複数の発射条件の合成が宣言的に書けます。

C#
// Presentation/Objects/EnemyPresenter.cs
using UnityEngine;
using R3;
using VContainer;

public class EnemyPresenter : MonoBehaviour
{
    private IBulletPoolService _bulletPoolService;

    // VContainerによるコンストラクタインジェクション
    [Inject]
    public void Construct(IBulletPoolService bulletPoolService)
    {
        _bulletPoolService = bulletPoolService;
    }

    private void Start()
    {
        // R3: 0.2秒ごとに発火するObservableに変換(InvokeRepeatingの代替)
        Observable.Interval(System.TimeSpan.FromSeconds(0.2f))
            .Subscribe(_ => Fire())
            .AddTo(this);  // MonoBehaviour破棄時に自動Dispose
    }

    private void Fire()
    {
        _bulletPoolService.Get(transform.position, transform.rotation);
    }
}

Step 6: Bullet側の返却処理もUniTaskに変える

Update() でタイマーを回す代わりに、UniTaskで寿命管理するとMonoBehaviourのUpdate負荷を減らせます。

C#
// Presentation/Objects/BulletView.cs
using UnityEngine;
using Cysharp.Threading.Tasks;
using VContainer;

public class BulletView : MonoBehaviour
{
    [SerializeField] private float speed = 15f;
    [SerializeField] private float lifetime = 3f;

    private IBulletPoolService _poolService;

    [Inject]
    public void Construct(IBulletPoolService poolService)
    {
        _poolService = poolService;
    }

    private void OnEnable()
    {
        // プールから借りたタイミングで寿命タイマーを開始
        ReturnAfterLifetime(this.GetCancellationTokenOnDestroy()).Forget();
    }

    private async UniTaskVoid ReturnAfterLifetime(System.Threading.CancellationToken ct)
    {
        // lifetime秒後にプールに返す(キャンセル時は何もしない)
        var result = await UniTask.Delay(
            System.TimeSpan.FromSeconds(lifetime),
            cancellationToken: ct
        ).SuppressCancellationThrow();

        if (!result.IsCanceled)
        {
            _poolService.Release(gameObject);
        }
    }

    private void Update()
    {
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
    }

    private void OnTriggerEnter(Collider other)
    {
        _poolService.Release(gameObject);
    }
}

実測した結果

Profilerで計測した数値です(敵30体・発射レート5発/秒の条件)。

指標改善前ObjectPool導入後DI + R3 + UniTask
平均FPS44fps59fps59fps
GC Alloc (1秒間)約180KB約2KB約2KB
GC.Collectの発生頻繁ほぼなしほぼなし
Update()実行数/フレーム弾丸数分増加弾丸数分増加弾丸数分(Updateは移動のみ)

FPSの改善はObjectPoolの時点でほぼ達成されますが、DI + R3 + UniTask化によって設計上の密結合が解消され、テストや機能追加がしやすくなりました。


各層の配置まとめ

Plaintext
Assets/Scripts/
├── Application/
│   └── Services/
│       └── IBulletPoolService.cs   # インターフェース(依存の向きの基点)
├── Core/
│   └── DI/
│       └── GameSceneLifetimeScope.cs  # VContainer登録
├── Infrastructure/
│   └── Services/
│       └── BulletPoolService.cs    # ObjectPool実装
└── Presentation/
    └── Objects/
        ├── EnemyPresenter.cs       # 発射ロジック(R3 Observable)
        └── BulletView.cs           # 弾丸の移動・寿命管理(UniTask)

やってみて気づいた注意点

OnEnable または UniTask でリセット処理を入れること

プールから借りたオブジェクトは前回の状態を引き継いでいます。

UniTaskを使う場合は OnEnable でタスクを再起動し、前回のCancellationTokenが残らないよう注意してください。

defaultCapacity の設定

最初から十分な数を確保しておかないと、ゲーム開始直後に一気にInstantiateが走ってカクつきます。 想定される同時出現数の1.2〜1.5倍を目安に設定するとスムーズです。

VContainerのMonoBehaviour登録

BulletPoolService のように MonoBehaviour をInfrastructureとして使う場合は、builder.RegisterComponent() でインスタンスをそのまま登録します。

builder.Register<T>() では MonoBehaviour は生成できないため注意してください。


まとめ

  • Instantiate / Destroy を繰り返すとGCアロケーションが積み上がり、FPSスパイクが起きる
  • UnityEngine.Pool.ObjectPool<T> でGCアロケーションをほぼゼロにできる
  • IBulletPoolService をApplication層に置き、VContainerでDIすることでオニオンアーキテクチャと整合する
  • InvokeRepeating は R3 の Observable.Interval に、Updateタイマーは UniTask に置き換えるとコードが宣言的になる
  • 設計と最適化は同時に達成できる

設計の詳細は【DDD/UCDD】Unityで意識している設計手法【オニオンアーキテクチャ】にまとめています。

タイトルとURLをコピーしました