3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】音ゲーを作ったので、気を付けたこととか得られた知見を共有する

Last updated at Posted at 2025-12-08

この記事は、名工大AdventCalender2025の10日目の記事です。
 今年の工大祭にて、専用コントローラー付き音ゲー、Ma3XがNITMicによって展示されました。このMa3Xについてリズムゲーム部分とリザルト画面のプログラムを担当したので、今後音ゲーを作ろうとしている方々に、得られた知見や音ゲー制作上役に立つ知識などを共有したいと思い、記事を書かせていただきます。
 こちらの、iiTAiさんの音ゲーコントローラー周りの記事も一緒に読むと楽しめるかもしれません。

 具体的な実装についての話もありますが、当然ながら、ゲームの実装方法は一つではありません。この記事における実装は、あくまで回答の一つ(そのうえ正解とは限らない)ととらえ、自分が作ろうとしているゲームにあった実装を考えるようにしてみてください。

Ma3Xってどんなゲーム?

 上から下にノーツが落ちてくる、よくあるタイプの4鍵音ゲーです。他の音ゲーとの差別化点として、ノーツの色が3色に分かれており、対応した色のボタンを押す必要があります。つまり12鍵盤ということになります。
 想像がつくと思いますが、とっても難しいです。
Ma3X 2025_12_08 22_43_33.png
↑こんな感じ

 色とボタンの対応としては、赤が最上段、白が中段、青が最下段となっています。
画像の配置は、左下を4分間隔で長押ししながら右手で赤白混合を押すという配置になります。
 配置のパターンが無限なので、極悪な配置も簡単に置けてしまいます。

Ma3X 2025_12_08 22_52_32.png
↑ヤバ配置の例1
Ma3X 2025_12_08 22_54_50.png
↑ヤバ配置の例2
Ma3X 2025_12_09 1_35_04.png
?????


 Ma3Xの紹介はここまでにして、本題に入っていきます。

譜面制作ツールについて

 まず、音ゲーの譜面制作ツールについてインターネットで検索すると、NoteEditorというツールが見つかると思います。
https://github.com/setchi/NoteEditor
 これはUnityで作成された、完全無料オープンソースで配布されている汎用的な音ゲー譜面制作ツールです。
 レーン数などは自由に設定できますが、フリックや、その他独自の仕様などには対応していません。これらを入れないシンプルなデザインの音ゲーを作るつもりなら、このNoteEditorを流用するというのが最初の選択肢になると思います。
 他の選択肢として、NoteEditorを改造して作ろうとしている音ゲーに合わせた譜面を作れるようにする、譜面制作ツールを完全新規で自作する、MIDIファイルを使う、BMSやMalodyの譜面フォーマットを流用する、などがあると思います。
ここはやりたいことと手間のバランスを取ってください。

Ma3Xでは?

 Ma3Xでは、NoteEditorを12レーン設定にして4レーンごとに各色へ対応させる、という方法と、私が作成したツールで譜面を作る、という2つの選択肢がありました。
 私が作成したツールはお世辞にも使いやすいものとは言えなかったので、Ma3Xの譜面制作メンバーの大部分はNoteEditorを使用していました。ではきゅうり作ツールは使いづらいだけのNoteEditor下位互換なのかというと、そうではない点もあり、私が作ったツールのみソフランの設定をすることができました。こういった独自仕様を入れたい場合は、自作ツールという選択肢に手を伸ばしてもいいかもしれません。
 この自作ツール周りに関してはあまりにも失敗というか、反省すべきことが多いのですが、ここに書くほどのことでもないので触れないこととします。言えることがあるとすれば、
 皆さんが譜面制作ツールを作る際はUIを親切にしましょう。できれば、作成予定のゲームと見た目を合わせましょう。

どうやってノーツを作る?

 よほど特殊な音ゲーでない限り、音ゲーにはノーツに相当するものがあります。そして、どのタイミングでどのレーンにどの種類のノーツが出るか、という情報の集合体を譜面と呼びます。ここでは、どうやって譜面ファイルを読み、どのようにしてそこから得られた情報を実際のゲーム上の動きに反映するかについて説明します。

譜面の形式の話

 譜面制作ツールは、譜面ファイルを出力します。
 NoteEditorは曲自体の情報とノーツの情報が記述されたJson形式で出力します。
 私のツールはノーツ総数とBPMが頭に記述され、続いてノーツの種類とタイミングがスペース区切りで記述されたテキスト形式を出力します。
 どちらもノーツの種類とタイミングが書いてあるということは同じなので、これらの形式は相互変換可能です。Ma3Xではそれぞれを共通の形式に変換し、それを読んでノーツを生成していました。
 jsonを読ませる形式にしなかったのは、私の中にjsonは遅いというイメージがあったためです。これはある程度正しくて、csvなどと比較して構文解析などの分jsonの方がある程度遅いようです。ただ、実際のゲームでは演奏開始前に1秒程度のロード画面を挿入しており、この時間内には十分終わる速度であったので、結果的にはどっちでもよかったことになります。

 ほとんどの音ゲーでは、ノーツ総数はせいぜい2000程度に収まる量だと思います。特殊な音ゲーの特殊な譜面でもない限り、10000ノーツを超えることはないでしょう。
 一般に、現代のコンピュータからすれば10000程度はなんてことない数です。多少コンピュータにとって不親切な形式であっても、1秒かからず読み終えることができます。

なんてことない、というのは$O(n)$や$O(n\log n)$での話です。$O(n^2)$とかになるとちょっと無視できなくなります。

 以上のことから、譜面形式は人間にとって可読性が高い形式にするのがいいでしょう。皆さんが新しく譜面形式を定義する場合、人間が見て読みやすい、と思う形にしたほうが良いかもしれません。

実装について

 譜面を実際にC#で読む実装についてはあえて説明しません。JsonならUnityがJsonを読む機能を提供しているし、他の形式でも読む方法はいくらでもあると思います。
 ここでは、譜面ファイルを読んで得られた情報(ノーツの時間、場所、色、種類)を実際にどうやってゲーム上の動きに反映するかを説明します。

各ノーツが持っている情報

 Ma3Xのノーツクラスは自身の判定タイミング、自身が出るレーン、自身の色、種類の情報を持っています。
 種類についてですが、Ma3Xでは通常ノーツとロングノーツの2種類があります。また、譜面ファイルにおいてソフランの情報は、「色とレーン情報の代わりにソフランの速度が書かれたノーツ」として記述されており、これは他のノーツとは別で扱われます。

ロングノーツは

  • 始点
  • 中間点
  • 終点

というパーツごとに管理しています。よって、ここから先のノーツの種類というのは、

  • 単ノーツ
  • ロング始点
  • ロング中間点
  • ロング終点
    の4種類のことだと思ってください。
     ここは音ゲーによって千差万別なので、あくまでMa3Xの場合として読んでください。

 最初に、読んだノーツの情報(時間、レーン、色、種類、基準座標)を配列に格納します。基準座標は、選択されたノーツスピードとソフラン情報、判定タイミングから書くノーツごとに計算されるものです。
 ただし、ただの配列よりQueueの方が後々都合が良いのでQueueに格納します。ただ、Queueを使用する場合、ノーツのデータが判定時間順にソートされている必要があります。PriorityQueueなどのライブラリを探して使ってもいいかもしれません。
なんでUnityの.NETに標準でPriorityQueueないの
 
 実装は以下のようになりました。

var notesQueue = new Queue<(float time,float position,int type)>[lane][color];

 レーンと色をQueueに持たせるのではなく2次元配列のインデックスとして使っているのは、ロングノーツの実装上都合が良かったためです。
 このQueue[][]をノーツ生成クラスに渡します。

 ノーツ生成クラスは毎フレームQueueの先頭を確認し、ノーツの基準座標と現在経過している時間をもとにノーツの位置を計算した結果、ノーツがプレイヤーから視認できる位置に存在するならばノーツを生成してDequeueします。Queueではなく配列を使用する場合はここでDequeueの代わりに参照しているインデックスを1増やす、という動きになるかと思いますが、Queueの方がシンプルに書けるのでこちらを推奨します。
 ノーツオブジェクトの動きはノーツオブジェクトについたスクリプトにより実行、という形式になりました。

ロングノーツの仕様

 ロングノーツについてはもう少しややこしい実装になっており、種類が「ロング始点」ならばその始点を含むロングノーツを構成する部品をその終点まで先読みしてそれぞれ生成、という動きをしています。
だからここでロングを視点から終点まで読むために、Queueを配列で持つ必要があったんですね。
 NotesEditorが出力するファイルにおいて、ロングノーツの始点が他の部品をリストとして保持する形式となっているため、もしNotesEditorの形式をそのまま使用するならこのような実装が楽だと思います。
 あるいは、ロングノーツの長さを計算して単一のオブジェクトとして生成するやり方も考えられます。


 さて、さっきサラッと「演奏開始からの時間」と書きましたが、これはどうやって管理すればいいのでしょう?UnityにはTime.deltaTimeなどのAPIが用意されていますが、これらは不正確なので音ゲーを作るうえでは非推奨です。どのようにして時間を管理するかについては、次の項に譲りたいと思います。

どうやってノーツを動かすの?

 譜面を読み込んでノーツの生成ができたなら、それを動かさなければいけません。ここでは、Ma3Xにおけるノーツの動かし方について説明します。

時間計測の話

class Notes:Monobehavior
{
    void Update()
    {
         transform.position += Vector3.forward*Time.deltaTime;
    }
}

 これではうまくいきません。これをノーツに付けて音楽に合わせてノーツを生成してみると、かなりズレると思います。それも、フレームを重ねるごとにどんどんズレが加算されていくタイプのズレ方です。
 Time.deltaTimeやTime.realTimeSinceStartUpは正確な時間計測を目的としたAPIではないということでしょう。ではどうするか。

class Notes:Monobehavior
{
    AudioSource source;
    Vector3 basePosition;
    void Update()
    {
        transform.position = basePosition + DistanceAt(source.time); 
    }
    static Vector3 DistanceAt(float time)
    {
        //その時間においてどの場所にいるべきか返す関数
    }
}

 こんな感じです。
DistanceAtは時間に対して、その時間における基準の座標を返す純粋関数です。
 Audiosource.timeをもとに取得したその時間における基準の座標と、ノーツごとに設定された基準座標を足した値が、そのフレームでのノーツの位置になります。
 今回出したコードの例ではDistanceAtがNotesクラスについていますが、実際には毎フレーム、そのフレームでのDistanceAtを計算して公開するNotesManagerクラスを用意し、各ノーツはそのクラスへの参照を持つ、という形での実装になりました。

 AudioSource.timeはそのAudioSourceインスタンスの再生位置を秒単位で返します。音ゲーということで、演奏中の曲を使うわけです。
 似たようなのにAudioSettings.dspTimeというのもあります。dspTimeはゲーム起動からの時間、AudioSource.timeは音楽再生からの時間、という違いがあります。
 これらがズレないのかというと、絶対にズレません。音楽の再生位置を返すAPIなので、音楽を止めたら一緒に時間も止まるなど、完璧な挙動を見せてくれます。

 唯一の欠点として、AudioSourceには直接再生終了を検知する方法が用意されていないので、これをゲーム内時間計測に使用する場合、終了検知の方法を工夫する必要があります。AudioSettings.dspTimeを使うならこの心配はいらないのですが、その代わりポーズなどで音楽を止める場合の挙動を工夫する必要があります。

 Ma3Xではポーズ画面の仕様を、音楽の停止ではなく音楽の0倍速再生という形で実装し、曲の終了はAudioSource.isPlayingによって検知していました。再生中だとtrueになるプロパティで、音楽が終了するとこれがfalseになって検知できます。

 この方法の欠点として、曲の終了以外の何らかの理由(たとえば、ゲームのウィンドウが最前面から外れるなど)で音楽が止まってしまうと、曲が終了したと誤判定されるというものがあります。この点は仕方ないとして妥協していました。誤判定されず、ポーズも可能な方法について知っている人がいたらぜひ教えていただきたいです。

 DistanceAtの実装は、ソフランが存在するか否かによって大きく変わると思います。ソフランを実装しない場合、DistanceAtは一次関数になり、明示的に関数を持たなくてもいいレベルでシンプルになります。
 定数倍速ソフランが存在する場合は、どこで何倍速のソフランが開始するか、という情報をもとにした折れ線形の関数になります。Ma3Xではこの実装になっています。
 Ma3Xでは実装していませんが、2次関数に沿って減速、ベジエ曲線に沿って加速、なんてことを実装してしまうと、かなり複雑になると思います。もしやりたい場合は相応の覚悟を持って挑戦してください。


 これにより、晴れてたくさんのノーツを一斉に、ズレなく動かせるようになったわけです。

ノーツクラスの実装

 Ma3Xにおいて、ノーツは

  • 単ノーツ
  • ロング始点
  • ロング中間点
  • ロング終点

の4種類であることを説明しました。これらはクラスにより設計が分けられています。次のような継承図になっていました。note_継承.png
 ILongは、そのノーツが属するロングノーツの終点を返すプロパティのみを持つインターフェースです。これはロング始点が見かけ上存在する期間を決定したり、判定クラスが現在判定しているロングノーツの情報を知るために利用されます。
 Notesクラスは抽象クラスで、ノーツのレーン、色、判定タイミング、デフォルトの動き方などが定義されています。
 このように、同じような動きをする違うものを複数定義したい場合、積極的に継承を利用するべきだと思います。やりすぎは注意ですが、このゲーム程度の規模なら見通しが良くなることのほうが多いでしょう。

 次は、ノーツを押したときの判定を作ります。

判定はどう作る?

 音ゲーなので、ボタンが押されたらノーツを消し、得点に加算し、クリアゲージを増加させなくてはいけません。どうやってボタンが押されることと、ノーツが判定されることを紐づけるのでしょう?


 再びQueueを使います。今回もレーン数×色のQueueを用意します。
 ノーツがMiss,Good,Perfectのどれになったかを判定しないといけないタイミングは2つあります。
 1つは、ボタンが押された瞬間です。ボタンが押された瞬間のノーツの判定タイミングと、実際の時間のズレにより、そのノーツがどう判定されたか決定されます。
 もう1つは、ノーツが押されないまま判定時間を一定以上過ぎた瞬間です。
判定されないまま一定時間が経過したノーツを消す処理を入れないと、一つ押し忘れただけでそのレーンで大量のBadハマり(このゲームだとGoodハマり)が起きるようになってしまいます。特に、複数個同じレーンで見逃した場合、見逃した数と同じ分余分にたたかないと今来ているノーツが叩けない、という事態になってしまいます。

 この2つのタイミングを判定するため、前者はボタンが押されたときに呼ばれるメソッドで、後者はUpdateで管理していました。後者では、各レーン各色すべてについて遅すぎるノーツの存在を確認する必要があったため、Update内で3×4の2重ループを回していました。
 いずれにせよ、Ma3Xでは最前面に出ているノーツに対してのみ処理を行います。その方がシンプルな実装になるからです。
 一応具体的な実装例を貼っておきます。実際のMa3Xのコードではなく、簡略化したものですが、だいたいこんな感じなんだなという感じで見てください。

Queue<Notes>[][] judgeQueue;
float CurrentTime => TimeManager.Time;
void OnBottunPush(int lane,int color) 
{
    var targetQueue = judgeQueue[lane][color];
    var targetNotes = targetQueue.Peek();
    float timeGap = Mathf.Abs(CurrentTime - targetNotes.JudgeTime);
    //ノーツが保持する判定時間と、実際の時間の差
    //大きいとGoodやMiss、小さいとPerfect
    ...
}

const float TooLate = 0.1f;
void Update()
{
    for(int lane = 0;lane < 4;lane++)
    {
        for(int color = 0;color < 3;color++)
        {
            var targetQueue = judgeQueue[lane][color];
            var targetNotes = targetQueue.Peek();
            float timeGap = Mathf.Abs(CurrentTime - targetNotes.JudgeTime);
            bool late = CurrentTime > targetNotes.JudgeTime;
            if(timeGap > TooLate && late) 
            ...
        }
    }
}

 ここの実装方法は各音ゲーによって異なると思います。チュウニズムなんかはタップ判定が最前面ではないノーツに吸われるような現象が起きたりするので、おそらくこのような実装にはなっていないだろうなーというような想像もできたりします。

UI周りについて

 ボタンが押された、あるいは遅Missによって判定が確定した場合、UI周りの処理を呼ぶ必要があります。スコア表示を更新したり、コンボ数表示を変えたりしなくてはいけません。
 判定クラスがUIクラスと密結合になるのはよくないため、判定クラスは判定した後に呼ぶデリゲートを公開しておくくらいが良いと思います。

 以上が判定にまつわる話でした。ロングノーツの判定などについては長くなるうえに面白くもなく、応用もできなさそうなので割愛します。


 ここから先は、Ma3Xがどうやって計算負荷を軽減しようとしていたかの話になります。ある程度音ゲーの実装が分かっていて、さらに処理を軽くしたいという人向けの話になります。(といってもある程度音ゲーの実装が分かっている人なら以下のことは釈迦に説法かもしれませんが。)

オブジェクトプールについて

 Instantiateは結構重い処理です。Destroyもそれなりに重い処理です。ノーツ一つ一つにこれらを行っていると、処理落ちするかもしれません。そこでオブジェクトプールを利用します。有名なデザインパターンで、多くのゲームで利用されています。ゲームを作ろうとしている人で、この言葉に耳なじみがない場合は覚えて帰ってください。
 あちこちで説明されていることなので、知らない場合は他の記事の説明を見た方が良いと思います。一応簡単に説明すると、

使い終わったオブジェクトをDestroyするのではなく、SetActive(false)して透明かつ動かない状態で適当に保管し、再度使う際には新規でInstantiateせずに在庫を再利用する

 というやり方です。
 これがどう役に立つか、具体的に説明します。
 例えば1秒当たり10個、寿命が10秒のオブジェクトを生成するとします。
 オブジェクトプールをしない場合、1分間あたり、$60秒 \times 10個/秒 = 600個$分のInstantiateを実行することになります。
 一方オブジェクトプールをする場合、1分間(というより何時間でも)で必要なInstantiate回数は100で足ります。なぜなら、一度アクティブになったオブジェクトは10秒で在庫に戻ってきて、再利用可能になるからです。必要なInstantiateは最初の10秒での合計100回のみです。
 しかも、「100個」という個数は寿命と生産頻度から予測が可能である点もポイントです。最初に100個まとめて作り、その間の処理落ちを暗転で隠してしまえば、実質Instantiateによる処理落ちをゼロにできます。

 ここでは簡略化して100回としましたが、最近のコンピュータは頑丈なのでこれくらいで暗転が必要なほど処理落ちはしないと思います。オブジェクトプールはオブジェクト数が多ければ多いほど威力を発揮します。

 今回のケースでは、最初に譜面を読むので、そのときに瞬間最大ノーツ密度を計算することができます。ここでいうノーツ密度は、一画面に入るノーツの数(=Instantiateが必要な最大値)という意味です。
 この密度計算は、

  • $n := $ ノーツの総数
  • $pos_i := $ 時間0におけるi番目のノーツの場所
  • $x := $ ノーツの出現座標と消失座標の差
  • count$(l,r) := l < pos_i < r$なるノーツの総数

とすると、
$i = 1,2,...n$に対してcount$(pos_i,pos_i+x)$を計算してその最大値をとることで算出できます。

 この方法は$O(n^2)$の計算時間が必要ではあるものの、ノーツの現実的な密度と数を考えれば十分高速です。しかし、もっと速く、軽量化できます。どうすればいいのでしょう?
 この問題は尺取り法により$O(n)$で解くことができます。尺取り法の$l,r$を
$pos_{l} + x < pos_r < pos_{l+1} + x $
を満たすように動かせばokです。
 尺取り法を知らない場合、以下の記事などを参考になります。
しゃくとり法 (尺取り法) の解説と、それを用いる問題のまとめ
実際のコードを簡略化したものを貼っておきます。

const float x = 10; 
public static int DensityCalc(List<float> positions)
{
    //尺取り法
    int right = 0;
    int currentDensity = 0;
    int maxDensity = 0;
    for (int left = 0; left < positions.Count; left++)
    {
        while (right < positions.Count && positions[right] - positions[left] < x)
        {
            currentDensity++;
            maxDensity = Math.Max(maxDensity, currentDensity);
            right++;
        }
        currentDensity--;
    }
    return maxDensity;
}

やっていることはrightとleftを0からn-1まで動かしているだけですが、ちゃんと最大密度を計算できています。
実際にはノーツの大きさが無視できないことなどによる誤差があるので、$x$は余裕をもって大きめにとっています。
 以上の方法により、Ma3Xではゲーム中でのノーツのInstantiateはほぼ0に抑えられています。
尺取り法は競技プログラミングなどでも有用な技術ですが、こんな感じで実用することもできるのです。
 競プロの知識は競プロ以外にも応用できるよという話でした。
この尺取り法による在庫計算の高速化は結構応用が利くので、生産数が事前にわかる場合のオブジェクトプールを使うなら習得しておくべきだと思います。

スコアの表示はどう更新する?

 だんだんニッチになってきました。こんなもん

TextMeshPro.text = score.ToString();

でいいじゃんかよ!

 良くないことが一つあります。このやり方だと更新のたびに文字列オブジェクトがヒープに確保されてしまいます。
 長くない文字列なので一回当たり数バイト程度の割り当てでしょう。しかし、1000ノーツあれば数キロバイトになります。しかも数字はスコアだけでなく判定数やコンボ数もあります。
 なんとかしてこれらのアロケーションを失くせないでしょうか?
 簡単な方法として、SetTextのフォーマット機能を使うという方法があります。
こんな感じです。

void SetScore(int score)
{
    tmPro.SetText("score:{0}",score);
}

 これで、

  • score:1000

のように表示されます。"score:{0}"の部分に関しては使いまわし可能なので、メンバとして持っておけばヒープ確保は最初の一回のみになります。
 ところが、実装中の私はこの機能を知らなかったため、いらない苦労を強いられていました。次のようなことをしていたのです。

TextMeshProUGUI.SetCharArray(char[])

 これは、文字配列を文字列として扱い、TMProで表示するというメソッドです。ポイントとして、stringは不変なため再利用不可能ですが、char[]は配列なので当然可変であり、中身を書き換えることができます。つまり、一度char[]を確保してしまえばそれを使いまわして何度でもスコアの再表示ができます。
 C#側でのヒープ確保は、最初のたった一度のchar[]確保を除けば0です。
 スコア(int)をchar[]に反映させるメソッドを自前で用意する必要があります。
 SetTextのフォーマット機能があるので普通は必要ないと思いますが、一応実際に使用したコードを貼っておきます。

public static char[] MakeCharArray(int value, char[] buf)
{
    for (int i = 0; i < buf.Length; i++)
    {
        buf[i] = '\0';
    }

    return value.TryFormat(buf, out int size) ? buf : throw new ArgumentException("桁あふれです");
}

public static char[] MakeCharArray(int value, char[] buf, int formatSize)
{
    if (buf.Length < formatSize) throw new ArgumentException($"buf.Length:{buf.Length} < formatSize:{formatSize}");
    for (int i = 0; i < buf.Length; i++)
    {
        buf[i] = '0';
    }

    if (value == 0) return buf;
    if (!value.TryFormat(buf, out int size)) throw new ArgumentException("桁あふれです");

    if (size == formatSize) return buf;
    for (int i = 0; i < size; i++)
    {
        //右詰めに変換している
        buf[formatSize - i - 1] = buf[size - i - 1];
        buf[size - i - 1] = '0';
    }

    for (int i = formatSize; i < buf.Length; i++)
    {
        buf[i] = '\0';
    }

    return buf;
}

 一つ目のMakeCharArrayは数字をchar[]に左詰めで入れ、埋まらなかった部分には\0を入れて返します。
 桁に対してchar[]の長さが足りない場合は例外を出します。
 二つ目のMakeCharArrayはformatSizeをもとに右詰めをし、\0ではなく$0$で埋めて返します。
例えば、

  • value = 123
  • buf = char[7]
  • formatSize = 6

で呼ぶと、buf $ = (0,0,0,1,2,3,\texttt{\0})$として返されます。
 \0はnull文字というやつで、c言語の文字列の終端についてるアレです。Unity上では表示されず、この例だと6桁の右詰めのように見えるはずです。
 返ってきたbufをSetCharArrayの引数としてやれば文字列が表示されます。

演出の表示について

 ノーツを叩いた後は、光ったり、fast/lateの表示が出たり、どの判定として処理されたかの文字列が出たりします。
 これらも当然オブジェクトです。出すたびにInstantiateするわけにはいかないので、これらもプールします。
 ノーツを叩いた際のエフェクトについては、ParticleSystemを使用しました。これについては私も理解していないので各自調べてください。

 判定表示については、DOTweenで動かしていました。が、これは失敗だったと思っています。透明度を上げながら直線運動して消える、などの単純な動きしかしないので、DOTweenのような高機能なツールは必要ありませんでした。
 より軽量なTweenを使うか、自作をするべきだったと思います。私がDOTweenを使ったことがあったから使用したのですが、良くなかったなという感じです。ハンマーを持つものはすべてが釘に見えるとはよく言ったものです。
 皆さんは必要なライブラリを選べるようになりましょう。

最強のGC対策

 ここまでオブジェクトプールやSetCharArrayによる負荷軽減をしてきましたが、これらを超える最強のGC対策があります。それは、
 GCを止める
 です。Unityでは、GCModeによりGCを手動に切り替えることができ、こうすると自動的にはGCは動かなくなります。
 ここで、GCを止めたらメモリが爆発しないか心配になるかもしれませんが、大丈夫です。
 なぜなら、これまでの対策により、プレイ中に動的に行われるヒープ確保は十分に減っており、ゲーム全体を通して100kBに満たない量となっているからです。
 実際に一曲放置した場合のメモリ確保量は以下の通りとなっています。単位はバイトです。
log_shot.jpg
 開始10秒時点でのメモリ確保量を基準にしているのは、開始直後のプールの在庫確保などを含まないようにするためです。
 一曲通しての動的メモリ確保が80KB程度に収まっているのが分かります。
 おそらくここからさらに削ることも出来るのですが、開発期間の都合上余裕がなかったため、このあたりで妥協しています。
 いずれにせよ、80KBは現代のコンピュータのメモリからすれば屁でもない量です。それでもGCは止めない限り動いてしまうので、曲の開始時に手動でGCを回したうえでGCを停止し、シーンを出るときにGCを復活させれば、インゲームを高パフォーマンスで動かすことができるのです。
 もちろん、これはプールなど、負荷軽減のためのアレコレができたうえでの話です。また、異常終了の場合でもGCが止まりっぱなしにならないよう気を配る必要があります。(止まりっぱなしになるとメモリが爆発します。)そのうえでなら、GCを一時停止するというのは有効な手段だと思います。

ただし、実際のゲームでGCを止めていたことが実際にFPS向上に貢献したかというと割と微妙です。80KB程度では大規模なGCは起きないので。
安全性とかもあるので、GC停止までやるべきかは判断によるところだと思います。

終わりに

ここまで読んでくださりありがとうございました。長い内容を分割せずに書いたので結構な量になってしまいました。
インターネット上に文章を上げるのは初めてなので、読みにくかったと思いますが、ご容赦ください。
続きは多分ありませんが、気分次第で何か書くかもしれません。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?