個人で作っているシューティングゲームで、敵が同時に大量に弾を撃つ演出を実装したところ、FPSが60から40台に落ちました。
原因を調べていくと、毎フレームの Instantiate / Destroy がゴミ(GCアロケーション)を大量に生成していることがわかりました。
この記事では、ObjectPoolパターンで改善したあと、さらにDDD/オニオンアーキテクチャと整合する形に整えた過程をコードとともに紹介します。
何が起きていたか
最初の実装(問題あり)
最初はシンプルに書いていました。
// 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層に切り出す
// Application/Services/IBulletPoolService.cs
// Application層にインターフェースを置く → Infrastructure層への直接依存をなくす
public interface IBulletPoolService
{
GameObject Get(Vector3 position, Quaternion rotation);
void Release(GameObject bullet);
}Step 3: Infrastructure層で実装する
// 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で依存注入する
// 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にすることで、ポーズ時の停止や複数の発射条件の合成が宣言的に書けます。
// 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負荷を減らせます。
// 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 |
|---|---|---|---|
| 平均FPS | 44fps | 59fps | 59fps |
| GC Alloc (1秒間) | 約180KB | 約2KB | 約2KB |
| GC.Collectの発生 | 頻繁 | ほぼなし | ほぼなし |
| Update()実行数/フレーム | 弾丸数分増加 | 弾丸数分増加 | 弾丸数分(Updateは移動のみ) |
FPSの改善はObjectPoolの時点でほぼ達成されますが、DI + R3 + UniTask化によって設計上の密結合が解消され、テストや機能追加がしやすくなりました。
各層の配置まとめ
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で意識している設計手法【オニオンアーキテクチャ】にまとめています。



