大福未来研究所

大福フューチャーラボのなんか色々なアレ。プログラミング(C++/DirectX/Unity/UE4あたりやりたい)とか音楽とかの。

【UE4】BlueprintだけでObjectPoolを作ってみた:追記あり・画像差し替えました

スクリーンショットの画質が、アップロード時に劣化していたため
設定を変更して画像の差し替えを行いました。クリックした画像が潰れてしまっていたら、クリックして飛んだ先にある「オリジナルサイズを表示」
をクリックしてみてください。

概要

Unreal Engine 4 (UE4) その2 Advent Calendar 2018 の15日目になります。

多くの場合、ゲームの中ではオブジェクトを作って消しての繰り返しが大量に発生しています。

アクションゲーム等をプレイしていても、エフェクトや敵弾、更には敵自身など、ゲーム中に大量に出現しては消えていくモノばかりです。
弾幕シューティングなど、ものすごい物量のオブジェクトを作っては消し、作っては消しするゲームではより顕著ですね。

この「オブジェクトの生成と削除」をnewとdeleteで行うと、そのたびにメモリを確保したり全体の初期化が走ったりで
コストがどえらいことになるので、ゲーム制作ではオブジェクトプーリングという手法で一度使ったオブジェクトを再利用するようにするケースが多いです。

今回、この仕組みをBlueprintだけで作ってみることにしました。


※この内容は、UE4.20.3で作ったものです。

オブジェクトプーリングとは

端的にいうと、「オブジェクトを前もって作ってプールしておいて、新しく作る代わりにそこから使って、消す代わりにそこへ戻す」というものです。
ゲームもしくはシーンの開始時にオブジェクトを大量に作る下準備をして、ゲーム実行中にそこからオブジェクトを使います。
これによって、オブジェクトを作るたびに新しくメモリを確保したり、消すたびにゴミを溜めたりすることを減らします。
UnityやUnrealでは、ある程度使用済みのメモリがたまるとGCが走るのですが、その際にカクつきが発生するので、
アクションゲームやシューティングゲームなど、ゲーム中にカクつきが走ると致命傷になりうるジャンルのゲームでは、
オブジェクトプーリングはとても有効であると言えます。


下準備

では、プールを作る前にプール用アクタの基底クラスから作りましょう。
先にプーリングしそうなオブジェクトのIDを列挙型で作っておきます。

次に、基底クラスとなるPoolActorBaseを作ります。

このクラスは継承されることが前提なので、イベントグラフを見てもすっからかんです。


変数には
・ActorID(さっきのIDが入る)
・IsActive(アクティブかどうか)
・ManagerRef(あとで作るマネージャへの参照)


関数には
・SetActive(アクティブにするときに呼ばれる)
・SetActiveEnd(アクティブにしたあと、初期化処理等が終わったタイミングで呼ばれる)
・SetDeactive(非アクティブにする際に呼ばれる)
・SelfKill(自滅おまとめセット。プールから自身を非アクティブにする一連の流れをまとめたもの)


をそれぞれ用意しました。


各関数の中身は以下のとおりです。

ObjectPoolManagerを作る

では、実際にオブジェクトプールを持っているObjectPoolManagerを作ります。
まずプール用の構造体ObjPoolStructを用意しておきます。

・ObjectID:前に作ったObjID Enum
・MaxNum:プールに作っておく数
・ActiveArray:アクティブ状態になっているアクタの参照配列
・NotActiveArray:非アクティブ状態になっているアクタの参照配列



ObjectPoolManagerの中身はこうなっています。

・Pool:上記ObjPoolStructの配列(初期値の設定が必要)
・ClassMap:どのIDとクラスを紐づけするMap(初期値の設定が必要)
・IDMap:IDと配列番号を紐づけするMap(空っぽにしておく)


Poolの設定内容の例を挙げておきます。


ClassMapの例はこちらです。


IDMapはプール初期化の際に自動的に作られます。
プールのイベントグラフはこのように作りました。

「IDに対応したクラスのアクタを設定されている数だけスポーンし、即非ActiveにしてNotActiveArrayへ突っ込み、最後にIDと配列番号を紐づけしてIDMapへ追加」
という処理を、Poolの数だけ繰り返します。


オブジェクトを生成する際と削除する際には、CreatePoolObj関数とKillPoolObj関数を呼びます。


CreatePoolObj関数



CoreSystemRef変数は今回の内容と関係ないので無視してください。
TempActorはこの関数内のローカル変数で、PoolActorBase型です。
【追記】一部、処理が重くなる箇所がありましたので変更いたしました。最後の追記欄をご覧ください。



KillPoolObj関数

作ってたゲームではKillPoolObjはSelfKill経由で呼び出される想定だったので、ここにはSetDeactiveが用意されていませんでしたが、
この辺の作りはゲームごとに適した感じでやればいいと思います。


継承の例

この基底クラスをもとに敵弾を作るとこうなります。

基底クラスの関数をオーバーライドして、処理を付け加えていきます。
また、弾が寿命を迎えて消えるときにDestroyActorではなくSelfKillを呼び出しているところにも注意してください。

敵がこの敵弾を撃つ際は、CreatePoolObjで生成するようにします。
3Way弾を作るときの例です(きたなくてすまん)


ObjectPoolを使った場合と使わなかった場合の比較

スペックがスペックなので、比較しづらいかもしれませんが、GCが入ったときのカクつきは決定的だったので
それだけでもわかりやすいかとは思います……



■スペック
Core i7-8086K 4.0GHz
・メモリ 64GB
・GeforceGTX1080Ti



■比較内容
毎フレームごとに3Way弾を発射する敵を左右5体ずつ用意し、片側ずつ弾を発射し続けてもらう。
3秒で弾は消滅する。
左側の敵はオブジェクトプールから弾を生成して、弾は消滅時にプールへ戻る。
右側の敵は直接弾をスポーンして、弾はDestroyActorで消滅する。
録画は2分間行う。



左側の敵の弾生成


右側の敵の弾生成


それでは、録画結果をご覧ください。

オブジェクトプールを使わなかった場合

youtu.be

オブジェクトプールを使った場合

youtu.be

録画結果からわかること

オブジェクトプールを使わず逐次スポーン・デストロイを繰り返していると、
約1分ごとにGCによるカクつきが発生しているのがわかります。
オブジェクトプール利用時には、これが発生せず、2分間安定した動作を続けているのがわかります。
また、録画後に5分ほど追加動作させましたが、それでもカクつきは起きませんでした。
実際のゲームでは、弾だけでなくエフェクトや敵自身など、サイズが重いオブジェクトも大量に出現、消滅するので
スポーンとデストロイを繰り返していては、GCによるカクつきは避けられないと思われます。
STGで1分ごとに突然カクつくとかゲームにならないですからね。


また、オブジェクトプール利用時のほうが平均して1~2FPSほど速いのがわかります。
PCのスペックがかなり高めなので差は分かりづらいかもしれませんが、
これがもっと低スペックなPCやAndroid等ならば差は大きくなるのではないかと思います。


まとめ

そういうわけで、Blueprintでもオブジェクトプールを作ることができました。
もっと洗練されたものを作られてる方もたくさんいらっしゃると思うので、
ぜひそういう方々の知見が公開されてくれるといいなと思います。

追記

UnrealのArrayでは、アイテムをRemoveしたときに、
それより先にある全アイテムを一つ前にコピーしてArrayを詰めています。
そのため、Create Pool Objの際に一番最初のオブジェクトから使うのではなく、一番後ろのオブジェクトから使うべきでした。
申し訳ございませんでした。


この記事はここまでです。お疲れ様でした。
明日はka-sさんの
【UE4】ボタンポチっでiOS/AndroidのDevelopment/Shippingの計4つのバイナリを作ってDeploygateに上げる【Jenkins】 - Qiitaです。