UnityとDXライブラリの弾発射の限界数の検証
初めに
新しいシューティングシステムの弾幕システムを作るにあたって速度面の検証を行いました。ガバガバエンジニアの検証なので、間違っている箇所も多いかもしれませんが、参考程度に読んで頂ければ幸いです。
検証内容
検証内容
60FPSが保てる範囲でどこまで弾幕を発射できるか、弾の発射数を比較します。
プレイヤーとの当たり判定は行わず、弾の発射と弾の移動だけを行います。
検証を行ったPCの性能は以下の通りです。
CPU:i7-9700K
メモリ:32GB
GPU:NVIDIA GeForceRTX 2070SUPER
Unity:2021.3.10f1
DXライブラリ:3.23a
検証結果
詳細は後述しますが、以下の結果になりました。
まだまだ処理速度向上させれる点があるかもしれませんが、個人的には現状が限界です。
Unity
①弾発射毎にInstantiate(2500発)
②弾画像をアトラス化(4500発)
③弾をプーリング+弾画像をアトラス化(6000発)
④monoビルド(8000発)
④' IL2CPPビルド(11000発)
④'' URP monoビルド(13000発)
④''' URP IL2CPPビルド(15000発)
DXライブラリ
⑤弾をプーリング(37,000発)
⑥弾をプーリング+弾画像をアトラス化(187,000発 )
①弾発射毎にInstantiate (2500発)
一番遅いと思われる手法です。Instantiateは非常に遅いらしいですね。
LauncherInit.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LauncherInit : MonoBehaviour
{
[SerializeField] int shotBulletPerFrame = 25;
[SerializeField] Bullet shotBullet;
void FixedUpdate()
{
for (int ii = 0; ii < shotBulletPerFrame; ii++)
{
Bullet createObj = Instantiate(shotBullet, transform);
createObj.Init(transform.position, Random.Range(0.0f, 360.0f), Random.Range(2.0f, 10.0f) / 100.0f, Random.Range(0, 15));
}
}
}
Bullet.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Bullet : MonoBehaviour
{
// 弾画像群
[SerializeField] List<Sprite> bulletImageBlue;
// 子要素(弾画像)
[SerializeField] GameObject m_childBulletImage;
private SpriteRenderer m_childSpriteRender;
private float speed = 0.0f;
private float angle = 0.0f;
private Vector3 velocity;
Vector3 direction;
// 指定された角度( 0 ~ 360 )をベクトルに変換して返す
static public Vector3 GetDirection(float angle)
{
return new Vector3(Mathf.Cos(angle * Mathf.Deg2Rad),
Mathf.Sin(angle * Mathf.Deg2Rad),
0);
}
// 弾を発射する時に初期化するための関数
public void Init(Vector3 pos, float angle, float speed, int bulletImageIndex)
{
this.speed = speed;
this.angle = angle;
direction = GetDirection(this.angle);
velocity = direction * this.speed;
Vector3 tempPos = pos;
this.transform.position = tempPos;
this.transform.rotation = Quaternion.Euler(0, 0, 0);
Quaternion q = this.transform.rotation;
float zzz = Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg - 90.0f;
Quaternion q2 = Quaternion.Euler(q.x, q.y, zzz);
this.transform.rotation = q2;
m_childSpriteRender = m_childBulletImage.GetComponent<SpriteRenderer>();
m_childSpriteRender.sprite = bulletImageBlue[bulletImageIndex];
}
public void SetSortingOrder(int order)
{
m_childSpriteRender.sortingOrder = order;
}
public void FixedUpdate()
{
Quaternion q = this.transform.rotation;
float zzz = Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg - 90.0f;
Quaternion q2 = Quaternion.Euler(q.x, q.y, zzz);
this.transform.rotation = q2;
this.transform.position += velocity;
if(transform.position.x < -5.0f ||
transform.position.x > 5.0f ||
transform.position.y < -5.0f ||
transform.position.y > 5.0f)
{
//this.gameObject.SetActive(false);
Destroy(this.gameObject);
}
}
}
②弾画像をアトラス化 (4500発)
弾画像を1つの画像に結合するアトラス化を実施するだけで、約倍になりました。プログラムを変更せずにただただUnityのアトラス化機能を使うだけで良いので、非常にコスパが良いと思います。
アトラス化により、BatchesとSetPass calls(GPUへの転送回数?)が劇的に減っているのが分かります。
③弾をプーリング+弾画像をアトラス化(6000発)
プーリング・・・実行時にInstantiateを纏めて行い、弾の生成/削除はGameObjectのアクティブ、非アクティブ操作で行う。
ただ単純にプーリングすると配列を線形検索する処理が遅いので、少し工夫したプーリングを実装。
BulletSystem.csで事前に弾を生成してます。
弾を追加する時は、非アクティブな弾を探し、それをアクティブにして疑似的に弾を生成します。
非アクティブな弾を探す処理が非常に時間が掛かるので、2点工夫しています。
①後述の弾を削除するタイミングで、その弾のインデックスを覚えておき、そのインデックスがあれば、そのインデックスを使用する
②弾を追加したインデックスを記憶しておき、そこから検索する
また、弾を追加するときに、GetComponentを呼ばない工夫も入れています。
BulletSystemArgo2.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class BulletSystemArgo2 : MonoBehaviour
{
static public BulletSystemArgo2 instance;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Init()
{
instance = null;
}
void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
// --------------------------------------------------------
[SerializeField] TextMeshProUGUI text;
[SerializeField] TextMeshProUGUI text2;
const int BulletMax = 6500;
[SerializeField] GameObject BaseBulletA;
GameObject[] Bullets;
BulletArgo2[] BulletsSc;
private Stack<int> bulletEmeptyList = new Stack<int>();
public void SetEmptyList(int ii)
{
bulletEmeptyList.Push(ii);
}
public int GetEmptyList()
{
if (bulletEmeptyList.Count > 0)
{
return bulletEmeptyList.Pop();
}
return -1;
}
[SerializeField] float spawnZ = 0.0f;
private int m_shotBulletNum = 1;
void Start()
{
Bullets = new GameObject[BulletMax];
BulletsSc = new BulletArgo2[BulletMax];
for (int ii = 0; ii < BulletMax; ii++)
{
var bu = Instantiate(BaseBulletA);
bu.transform.position = new Vector3(0, 0, spawnZ);
BulletArgo2 sc = bu.GetComponent<BulletArgo2>();
BulletsSc[ii] = sc;
bu.transform.parent = transform;
sc.m_childSpriteRender = sc.GetComponent<SpriteRenderer>();
bu.SetActive(false);
Bullets[ii] = bu;
}
}
int itr = 0;
public BulletArgo2 CreateBullet(Vector3 pos, float speed, float angle, int bulletImageIndex)
{
UnityEngine.Profiling.Profiler.BeginSample("CreateBullet");
int emptyIndex = GetEmptyList();
if (emptyIndex != -1)
{
var bu = Bullets[emptyIndex];
if (!bu.activeSelf)
{
BulletArgo2 sc = BulletsSc[emptyIndex];
if (sc)
{
pos.z = spawnZ;
sc.Init(emptyIndex, pos, angle, speed, bulletImageIndex);
bu.SetActive(true);
sc.SetSortingOrder(m_shotBulletNum);
m_shotBulletNum++;
if (m_shotBulletNum > 10000) m_shotBulletNum = 0;
itr++;
if (itr >= BulletMax) itr = 0;
return sc;
}
}
}
for (int ii = itr; ii < BulletMax; ii++)
{
var bu = Bullets[ii];
if (bu.activeSelf) continue;
BulletArgo2 sc = BulletsSc[ii];
if (!sc) continue;
pos.z = spawnZ;
sc.Init(ii, pos, angle, speed, bulletImageIndex);
bu.SetActive(true);
sc.SetSortingOrder(m_shotBulletNum);
m_shotBulletNum++;
if (m_shotBulletNum > 10000) m_shotBulletNum = 0;
itr++;
if (itr >= BulletMax) itr = 0;
return sc;
}
for (int ii = 0; ii < itr; ii++)
{
var bu = Bullets[ii];
if (bu.activeSelf) continue;
BulletArgo2 sc = BulletsSc[ii];
if (!sc) continue;
pos.z = spawnZ;
sc.Init(ii, pos, angle, speed, bulletImageIndex);
bu.SetActive(true);
sc.SetSortingOrder(m_shotBulletNum);
m_shotBulletNum++;
if (m_shotBulletNum > 10000) m_shotBulletNum = 0;
itr++;
if (itr >= BulletMax) itr = 0;
return sc;
}
return null;
}
// 変数
int frameCount;
float prevTime;
float fps;
private void Update()
{
frameCount++;
float time = Time.realtimeSinceStartup - prevTime;
if (time >= 0.5f)
{
fps = frameCount / time;
frameCount = 0;
prevTime = Time.realtimeSinceStartup;
}
text2.text = $"FPS({fps})";
}
private void FixedUpdate()
{
int count = 0;
for (int ii = 0; ii < BulletMax; ii++)
{
var bu = Bullets[ii];
if (!bu.activeSelf) continue;
BulletArgo2 sc = BulletsSc[ii];
if (!sc) continue;
sc.MyFixedUpdate();
count++;
}
text.text = $"total {count} bullet. ";
}
}
LauncherArgo2.cs
using UnityEngine;
public class LauncherArgo2 : MonoBehaviour
{
[SerializeField] int shotBulletPerFrame = 70;
[SerializeField] BulletSystemArgo2 bulletSystem;
void FixedUpdate()
{
for (int ii = 0; ii < shotBulletPerFrame; ii++)
{
bulletSystem.CreateBullet(transform.position, Random.Range(2.0f, 10.0f) / 100.0f, Random.Range(0.0f, 360.0f), Random.Range(0, 15));
}
}
}
BulletArgo2.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Profiling;
public class BulletArgo2 : MonoBehaviour
{
// 弾画像群
[SerializeField] List<Sprite> bulletImageBlue;
// 子要素(弾画像)
[SerializeField] GameObject m_childBulletImage;
public SpriteRenderer m_childSpriteRender;
private float speed = 0.0f;
private float angle = 0.0f;
private Vector3 velocity; // 速度
Vector3 direction;
int index = 0;
// 指定された角度( 0 ~ 360 )をベクトルに変換して返す
static public Vector3 GetDirection(float angle)
{
return new Vector3(Mathf.Cos(angle * Mathf.Deg2Rad),
Mathf.Sin(angle * Mathf.Deg2Rad),
0);
}
// 弾を発射する時に初期化するための関数
public void Init(int index, Vector3 pos, float angle, float speed, int bulletImageIndex)
{
this.index = index;
this.speed = speed;
this.angle = angle;
direction = GetDirection(this.angle);
velocity = direction * this.speed; // 発射角度と速さから速度を求める
this.transform.position = pos;
this.transform.rotation = Quaternion.Euler(0, 0, 0);
Quaternion q = this.transform.rotation;
float zzz = Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg - 90.0f;
Quaternion q2 = Quaternion.Euler(q.x, q.y, zzz);
this.transform.rotation = q2;
m_childSpriteRender.sprite = bulletImageBlue[bulletImageIndex];
}
public void SetSortingOrder(int order)
{
m_childSpriteRender.sortingOrder = order;
}
//[BurstCompile]
public void MyFixedUpdate()
{
Quaternion q = this.transform.rotation;
float zzz = Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg - 90.0f;
Quaternion q2 = Quaternion.Euler(q.x, q.y, zzz);
this.transform.rotation = q2;
this.transform.position += velocity;
if(transform.position.x < -5.0f ||
transform.position.x > 5.0f ||
transform.position.y < -5.0f ||
transform.position.y > 5.0f)
{
this.gameObject.SetActive(false);
BulletSystemArgo2.instance.SetEmptyList(index);
}
}
}
④ビルド
③の弾の上限を9000発に変更後、ビルドし実行した結果。環境ごとに結果が異なったので、それぞれ検証しました。
URPやIL2CPPだと早くなるようですね
monoビルド(8000発)
IL2CPPビルド(11000発)
URP monoビルド(13000発)
URP IL2CPPビルド(15000発)
補足
transform.positionにアクセスするのは遅いらしいです。(ひづきさん情報ありがとうございます!)
[transformに直接アクセスする方法]よりも[transformを一旦ローカル変数に保存する方法]が早いです。
エディタでの実行結果
[transformに直接アクセスする方法] 45FPS
[transformを一旦ローカル変数に保存する方法] 85FPS
monoビルドでの結果(monoビルドだと負荷が足らないので負荷を追加して実行)
[transformに直接アクセスする方法] 50FPS
[transformを一旦ローカル変数に保存する方法] 60FPS
transformに直接アクセスする方法
public void MyFixedUpdate()
{
this.transform.position += velocity;
if(transform.position.x < -5.0f ||
transform.position.x > 5.0f ||
transform.position.y < -5.0f ||
transform.position.y > 5.0f)
{
this.gameObject.SetActive(false);
BulletSystemArgo2.instance.SetEmptyList(index);
}
}
transformを一旦ローカル変数に保存する方法
public void MyFixedUpdate()
{
var tf = this.transform;
var pos = tf.localPosition;
pos += velocity;
if(pos.x < -5.0f || pos.x > 5.0f || pos.y < -5.0f || pos.y > 5.0f)
{
this.gameObject.SetActive(false);
BulletSystemArgo2.instance.SetEmptyList(index);
} else {
tf.localPosition = pos
}
}
⑤(DXライブラリ)弾をプーリング(37,000発)
Unityと同様の処理をDXライブラリで書いてみました。
超雑プログラムなので中身は気にしないで下さい。
main.cpp
#include "DxLib.h"
#include <math.h>
#include <string>
#include <vector>
int bulletImage[20];
struct CBaseBullet {
bool m_used;
double m_x;
double m_y;
double m_vel_x;
double m_vel_y;
double m_angle;
int m_image;
};
constexpr int BULLETNUM = 200000;
CBaseBullet m_bullet[BULLETNUM];
int order = 0;
void AddBullet(double x, double y, double speed, double angle, int image) {
for (int ii = order; ii < BULLETNUM; ii++) {
if (!m_bullet[ii].m_used) {
m_bullet[ii].m_used = true;
m_bullet[ii].m_x = x;
m_bullet[ii].m_y = y;
m_bullet[ii].m_angle = angle / 57.29;
m_bullet[ii].m_vel_x = cos(angle)* speed;
m_bullet[ii].m_vel_y = sin(angle) * speed;
m_bullet[ii].m_image = image;
order = ii;
return;
}
}
for (int ii = 0; ii < order; ii++) {
if (!m_bullet[ii].m_used) {
m_bullet[ii].m_used = true;
m_bullet[ii].m_x = x;
m_bullet[ii].m_y = y;
m_bullet[ii].m_angle = angle / 57.29;
m_bullet[ii].m_vel_x = cos(angle) * speed;
m_bullet[ii].m_vel_y = sin(angle) * speed;
m_bullet[ii].m_image = image;
order = ii;
return;
}
}
}
int count = 0;
void ActionBullet() {
count = 0;
for (int ii = 0; ii < BULLETNUM; ii++) {
if (m_bullet[ii].m_used) {
m_bullet[ii].m_x += m_bullet[ii].m_vel_x;
m_bullet[ii].m_y += m_bullet[ii].m_vel_y;
count++;
if (m_bullet[ii].m_x < (640) - (640 * 0.5) ||
m_bullet[ii].m_x > (640) + (640 * 0.5) ||
m_bullet[ii].m_y < (360) - (640* 0.5) ||
m_bullet[ii].m_y > (360) + (640 * 0.5)) {
m_bullet[ii].m_used = false;
continue;
}
DrawRotaGraphF(m_bullet[ii].m_x, m_bullet[ii].m_y, 0.5, m_bullet[ii].m_angle - (90.0 / 57.29), bulletImage[m_bullet[ii].m_image], TRUE);
}
}
}
template <typename ... Args> std::string MyFormat(const std::string& fmt, Args ... args)
{
size_t len = std::snprintf(nullptr, 0, fmt.c_str(), args ...);
std::vector<char> buf(len + 1);
std::snprintf(&buf[0], len + 1, fmt.c_str(), args ...);
return std::string(&buf[0], &buf[0] + len);
}
// https://dixq.net/g/03_14.html を参考に
class CFPSManager2 {
int m_startTime; //測定開始時刻
int m_count; //カウンタ
double m_fps; //fps
static const int SAMPLING = 60;//平均を取るサンプル数
static const int FPS = 60; //設定したFPS
public:
CFPSManager2();
bool Start();
double GetFPS();
void End();
};
CFPSManager2::CFPSManager2() {
m_startTime = 0;
m_count = 0;
m_fps = 0;
}
bool CFPSManager2::Start() {
if (m_count == 0) { //1フレーム目なら時刻を記憶
m_startTime = GetNowCount();
}
if (m_count == SAMPLING) { //60フレーム目なら平均を計算する
int t = GetNowCount();
m_fps = 1000.f / ((t - m_startTime) / (float)SAMPLING);
m_count = 0;
m_startTime = t;
}
m_count++;
return true;
}
double CFPSManager2::GetFPS() {
return m_fps;
}
void CFPSManager2::End() {
int tookTime = GetNowCount() - m_startTime; //かかった時間
int waitTime = m_count * 1000 / FPS - tookTime; //待つべき時間
if (waitTime > 0) {
WaitTimer(waitTime); //待機
}
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
SetGraphMode(1280, 720, 32);
ChangeWindowMode(TRUE);
if (DxLib_Init() == -1)
{
return -1;
}
SetDrawScreen(DX_SCREEN_BACK);
SetDrawMode(DX_DRAWMODE_BILINEAR);
std::vector<std::string> filenames;
for (int ii = 0; ii < 20; ii++) {
std::string format1 = MyFormat("Image\\b (%d).png", ii+1);
bulletImage[ii] = LoadGraph(format1.c_str());
}
//LoadDivGraph("Image\\bbb.png", 20, 2, 10, 70, 70, bulletImage);
SetFontSize(64);
int peakGetDrawCallCount = 0;
CFPSManager2 m_fpsManager;
//int frameshotnum = 900;
int frameshotnum = 180;
while (!ProcessMessage()) {
m_fpsManager.Start(); //fpsStart管理
ClearDrawScreen();
for (int ii = 0; ii < frameshotnum; ii++) {
AddBullet(1280 / 2, 720 / 2, 1.0 + (rand() % 1000 / 300), (rand() % (360 * 100)), rand() % 15);
}
ActionBullet();
int Color = GetColor(255, 255, 255);
DrawFormatString(0, 0, Color, "弾数 %d ", count);
DrawFormatString(0, 60, Color, "FPS %2.2f", m_fpsManager.GetFPS());
int drawcallcount = GetDrawCallCount();
if (drawcallcount > peakGetDrawCallCount) {
peakGetDrawCallCount = drawcallcount;
}
ScreenFlip();
clsDx();
m_fpsManager.End();
}
DxLib_End();
return 0;
}
⑥(DXライブラリ)弾をプーリング+弾画像をアトラス化(187,000発)
DXライブラリでも別々に画像を読み込むより、1枚の画像から弾画像を表示した方が描画回数が少なくなります。
描画回数はGetDrawCallCountで確認できます。
[バラバラの画像を読み込む]のような画像を1枚1枚を読み込むのではなく、[1枚の画像を読み込む]のような1枚の画像から弾を読み込むとアトラス化になります。
// 一枚ずつ読み込む
std::vector<std::string> filenames;
for (int ii = 0; ii < 20; ii++) {
std::string format1 = MyFormat("Image\\b (%d).png", ii+1);
bulletImage[ii] = LoadGraph(format1.c_str());
}
// 一枚の画像を一度に読み込む(アトラス化)
LoadDivGraph("Image\\bbb.png", 20, 2, 10, 70, 70, bulletImage);
まとめ
弾の発射の速度について、いろいろ検証してみました。
UnityだとBurstやIJobを使って並列処理をすればもっと早くなると思います。
DXライブラリは十分早そうですね!