見出し画像

カラオケアプリの隠れた機能を実現するために Unity開発基礎知識

???「すいちゃんはー?」
今日も可愛い!!!!☄

こんにちは!
カバー株式会社クリエイティブ制作本部技術開発部エンジニアのTです。

皆さんは「カラオケアプリ」というアプリを覚えているでしょうか。リリース日には博衣こよりさんに歌枠にて使用していただいたあのアプリです。

【歌枠】JOYSOUNDさん提供のカラオケアプリが登場⁉すごすぎるうううう!!!歌う!!!【博衣こより/ホロライブ】

このアプリ、リリース日から各種アップデートを続けており、今でもタレントさんの歌枠にて定期的に使われているものになっております。

またこちらの配信も見たことあるでしょうか?

【 歌枠 】カラオケアプリで歌枠してみるにぇ!【ホロライブ/さくらみこ】

さくらみこさんが以前使ってくださったように、カラオケアプリにはカラオケのように歌詞を表示する機能を実装しています。この歌詞の表示非表示には「ObjectPool」という方式を用いています。今回はこちらの「ObjectPool」について説明させていただきます。

と言ってもゲーム開発をしたことのある方々的には常識の範囲内だと思われますので、「ゲームやアプリ開発してみたい!」という方々向けの内容となります。また、今回の内容は基本的にUnityにて解説を行いますが、知識としては別のプラットフォームの開発でも活かせるものになると思います。

生成と破棄

ObjectPoolについて説明する前にC#・Unityにおける生成と破棄について説明させてください。

C#の生成

基本的にこの世のプログラミング言語の大半は変数やオブジェクトを定義する時、それらのサイズ分の領域がメモリ上に確保されます。例えばint型なら4byte、short型なら2byteなどですね。C#でももちろん同様の仕様となります。
C#には値型と参照型が存在し、それぞれ値型はスタックメモリに、参照型はヒープメモリにて確保・生成されます。以下に値型と参照型の区分け図を置いておきます。

スタックメモリ・ヒープメモリはアクセス速度に差があります。簡単に言うとスタックメモリのほうがアクセス速度が早いです。またもう一つ大きな違いとして、スタックメモリはOSによって自動で解放されますが、ヒープメモリはアプリ開発者が自分で解放してやる必要があります。このあたりは破棄の話の際に記述します。
値型と参照型の仕様、スタックとヒープの詳細な違いについては本題から逸れるためここでは話しません。ここでは値型のほうが生成・アクセス速度ともに参照型よりも優れている(厳密にはちょっと違いますが…)と覚えておいてください。

C#の破棄

確保されたメモリは破棄される必要があります。基本的にスタックとヒープともに使える量に上限があるためです。確保したままでいるとメモリが利用できなくなるならまだしも、最悪の場合はメモリが破壊されます。
上限を意識してコード書かなきゃいけないの!?となるかもしれませんが、よっぽど変なことをしない限りはこの上限を意識する必要はありません。
じゃあこの話をしなくてもいいのでは…?となるかもしれないですが、一つだけ気にするべきことがあります。

先ほどスタックとヒープの違いについて話した際に「ヒープメモリは自分で解放してやる必要がある」と言いました。つまりヒープメモリを解放し忘れるといつまでも解放されずに残り続けてしまいます。これが世にいう「メモリリーク」です。アプリ開発者は「メモリリーク」を常に意識してやる必要があります。

といいつつC#、ひいてはUnityにはガベージコレクション(以下GC)という仕組みが存在します。GCは確保されたメモリが使われていないことを検知して自動で解放処理を働かせてくれる優れ者です。そのためC#・Unity開発はメモリリークのことを意識せずに開発ができるというわけですね。(一部例外を除く)
そんな優れ者のGC君も無敵ではないので、使い方によってはこちらに不利益を与えてきます。例えばGCのメモリ解放タイミングはユーザーが一切制御できません。こちらが予期しないタイミングで大量のメモリ解放が発生した場合、アプリの実行中に一瞬フリーズが発生したりする恐れがあるわけです。
他にもありますが、ここで詳細な解説をするつもりはないです。気になる方は「GC Alloc」とか「GC スパイク」などで検索してみてください。


Unityの生成と破棄

UnityにはGameObjectというUnity特有のオブジェクトが存在します。
UnityでGameObjectを生成するときは、Instantiateという関数を実行することになります。

var go = Instantiate(originalPrefab)

InstantiateはprefabというGameObjectの設計書を引数に取って生成を行います。生成されたGameObjectはランタイムでシーン上に配置されます。GameObjectはクラスなため、生成時はヒープに配置されます。もちろんこのInstantiateするときも生成コストが発生します。

また、Unityの重要な機能として「MonoBehaviours」が存在します。
以前弊社の技術ブログでも解説がありましたね。

UnityではGameObject単体では特に何もせず、コンポーネントをアタッチすることでそれぞれの機能を実現することが出来ます(以下画像参照)。MonoBehavioursはUnityのほとんどのGameObjectにコンポーネントとして存在しています。C#のクラスにMonoBehavioursを継承させることでコンポーネントとして利用できるわけです。

MonoBehavioursにはイベント関数が定義されています。代表的なものだと「Awake」「Start」「Update」などですね。イベント関数の一部は生成時に実行されるものも存在します(例の中だと「Awake」「Start」です)。つまりMonoBehavioursが生成された時にはこれらの関数の動作コストが発生することになります。

イベント関数の実行順序については下記公式ドキュメントを参照してください。
https://docs.unity3d.com/ja/2022.3/Manual/ExecutionOrder.html

動的に生成したいGameObjectはほぼ確実にComponentがアタッチされているので、GameObjectの生成時は生成コストだけでなく、これらの処理コストにも気を配る必要があるわけです。

また破棄についても生成と同様です。MonoBehavioursには破棄時に呼ばれるイベント関数(「OnDestroy」など)が定義されているので、これらの処理コストが発生します。


つまり何を言いたいの?

オブジェクトの生成・破棄には常にコストが発生するということです。これが一つや二つ程度なら大した事ないですが、作りたいゲームやアプリによっては動的に何百もオブジェクトを生成したいことが度々出てきます。
わかりやすい例としてはシューティングゲームです。プレイヤーが弾を打つたびに弾オブジェクトは生成されますし、時間が立つほど敵オブジェクトも生成されます。また、弊社のカラオケアプリも同様です。楽曲によって動的に歌詞文字列オブジェクトをいくつも生成する必要があります。
しかし下手に行うとランタイムで大量なオブジェクト生成だったりFull GCが発生してしまうわけですね。ゲームやアプリを使っている時にちょっとしたフリーズが起きるとユーザーも使いづらいと思ってしまいます。

ではどうするか…?
ここでこの記事の本題の「ObjectPool」の出番となります。

ObjectPool

ObjectPoolとは「Object」を「Pool」する方式です。そのまんまですね。
詳細に言うと、「Objectを事前に使う分を生成しておき(Poolingしておく)、それらを使い回す」ということです。

先ほど「動的に大量にオブジェクトを生成したいことがある」という話をしました。とはいえ、一度に使用したいオブジェクト数には大体上限があります。
例えばシューティングゲーム。プレイヤーが撃った弾が画面上に存在する個数は多くても100個だったりします。画面外に出た弾は破棄してやればいいわけです。
例えばカラオケアプリ。歌詞オブジェクトはルビとかも含めて多くて20個程度です。

ObjectPoolでは事前に使う分だけのオブジェクトを生成しておきます。そして、オブジェクトを要求された時に必要な分のオブジェクトをプールから渡してやり、不要になったらまたプールさせておく、といった処理を行うわけです。これをやることで動的な生成破棄が発生しなくなるため、予期しない処理コストを抑えることができるわけです。

実装例

以下実装例です。UnityにはUnity2021以降ObjectPoolが搭載されています。今回はこれを使って確かめてみようと思います。

MyObjectPool.cs

using System; using 
UnityEngine.Pool;

public class MyObjectPool<T> : ObjectPool<T> where T : class
{
    public MyObjectPool(
        Func<T> createFunc, 
        Action<T> actionOnGet, 
        Action<T> actionOnRelease,
        Action<T> actionOnDestroy,
        bool collectionCheck = true,
        int defaultCapacity = 10,
        int maxSize = 10000) 
        : base(
            createFunc, 
            actionOnGet, 
            actionOnRelease,
            actionOnDestroy,
            collectionCheck,
            defaultCapacity,
            maxSize)
    {
    }
    // 事前にcount分のオブジェクトだけ生成して非アクティブにする関数    
    public void CreatePoolItems(uint count)  
    {      
        if (this.CountAll != 0) return;       
        var tmpArray = new T[count];        
        for (var i = 0; i < count; i++)       
        {
            tmpArray[i] = base.Get();           
        }

        foreach (var obj in tmpArray)        
        {            
            this.Release(obj);      
        }  
    }

    public new T Get()    
    {       
        if (this.CountAll > 0 && this.CountInactive == 0) throw new IndexOutOfRangeException("生成量を超えて取得することはできません。");        
        return base.Get();   
    }    
}

PoolingObject.cs

using UnityEngine;
// 検証用のため空
public class PoolingObject : MonoBehaviour
{
}

MainScript.cs

using UnityEngine;
using UnityEngine.Profiling;

public class MainScripts : MonoBehaviour
{
    [SerializeField] private PoolingObject _prefab;
    private MyObjectPool<PoolingObject> _pool;
    // Start is called before the first frame update
    private void Awake()
    {
        _pool = new MyObjectPool<PoolingObject>(
            OnCreatePoolObject,
            OnGetFromPool,
            OnReleaseToPool,
            OnDestroyPoolObject
        );
    }

    void Start()
    {
        _pool.CreatePoolItems(10000);
    }

    private bool flg = false;
    private void Update()
    {
        //ここに生成破棄処理を入れる
    }

    private void OnDestroy()
    {
        _pool.Clear();
    }

    private PoolingObject OnCreatePoolObject()
    {
        var obj = Instantiate<PoolingObject>(_prefab);
        return obj;
    }

    private void OnGetFromPool(PoolingObject obj)
    {
        obj.gameObject.SetActive(true);
    }
    
    private void OnReleaseToPool(PoolingObject obj)
    {
        obj.gameObject.SetActive(false);
    }

    private void OnDestroyPoolObject(PoolingObject obj)
    {
        Destroy(obj.gameObject);
    }
}

今回はわかりやすさを重視するためにあえて簡単な実装にしました。事前生成用の関数を用意した以外はUnity標準のものになっております。

では実際に2パターンにて時間計測とProfilerでの計測を行います。

パターン1 大量のオブジェクト同時生成

// ObjectPoolを用いない場合
private void Update()
{
    if (flg) return;
   
    Profiler.BeginSample("NonPoolSample");
    start = Time.realtimeSinceStartup;
    for (var i = 0; i < 10000; i++)
    {
	// Instantiateでのオブジェクト生成
        _poolItems[i] = Instantiate(_prefab);
    }
    end = Time.realtimeSinceStartup;
    Profiler.EndSample();
    Debug.Log($"non pool: {end - start}");

    flg = true;
}
// ObjectPoolを用いる場合
private void Update()
{
    if (flg) return;

    Profiler.BeginSample("PoolSample");
    var start = Time.realtimeSinceStartup;
    for (var i = 0; i < 10000; i++)
    {
	// ObjectPoolでのオブジェクト生成
        _pool.Get();
    }
    var end = Time.realtimeSinceStartup;
    Profiler.EndSample();
    Debug.Log($"pool: {end - start}");

    flg = true;
}

少々強引ですがUpdateにて一回だけ10000個のオブジェクトを生成する処理です。普通にObjectPoolを用いる場合と、Instantiateで生成する場合の2パターンで試します。
※Updateにて毎フレーム動かすとすごいことになるのでやらないようにしましょう。

時間計測結果

  • ObjectPoolを用いない場合

  • ObjectPoolを用いる場合


とりあえず3回ほど計測した結果です。このあと10回くらいは回しましたが全部同じでしたね。実に約7~8倍の結果となりました。

Profiler計測結果

  • ObjectPoolを用いない場合

  • ObjectPoolを用いる場合


CPU Usageにて計測を行いました。こちらも歴然ですね。ObjectPoolを用いた場合GCAllocが発生していないのも評価が高いです。


パターン2 大量のオブジェクト生成破棄

// ObjectPoolを用いない場合
private void Update()
{
    if (flg) return;

    Profiler.BeginSample("NonPoolSample");
    start = Time.realtimeSinceStartup;
    for (var i = 0; i < 10000; i++)
    {
        // Instantiate-Destroyでのオブジェクト生成破棄
        var obj = Instantiate(_prefab);
        Destroy(obj.gameObject);
    }
    end = Time.realtimeSinceStartup;
    Profiler.EndSample();
    Debug.Log($"non pool: {end - start}");

    flg = true;
}
// ObjectPoolを用いる場合
private void Start()
{
    _pool.CreatePoolItems(10);
}

private void Update()
{
    if (flg) return;

    Profiler.BeginSample("PoolSample");
    var start = Time.realtimeSinceStartup;
    for (var i = 0; i < 10000; i++)
    {
        // ObjectPoolでのオブジェクト生成破棄
        var obj = _pool.Get();
        _pool.Release(obj);
    }
    var end = Time.realtimeSinceStartup;
    Profiler.EndSample();
    Debug.Log($"pool: {end - start}");

    flg = true;
}

こちらは破棄も含めて10000回ほど回した形です。またこのパターンで計測する場合、事前生成は10000個もいらないので10個とかに押さえておきます。こちらでも確認してみましょう。
※実際1フレーム内でこんな大量のオブジェクトの生成破棄を回すことなど殆どありません。大げさに測ってみている状態です。

時間計測結果

  • ObjectPoolを用いない場合

  • ObjectPoolを用いる場合

こちらでも約5倍ほどの差が出てますね。

Profiler計測結果

  • ObjectPoolを用いない場合

  • ObjectPoolを用いる場合

こちらも同様です。やはり大幅に差がありますね。そしてここでも同様ですがObjectPoolを使った場合はGCAllocが発生していません

まとめ

今回は、いつもと違って弊社で利用している仕組みというよりは開発に用いる設計方式の基礎知識を紹介させていただきました。できるだけわかりやすく書いたつもりですのでこの記事を参考に開発など行ってくださると幸いです。
また、今回の計測結果的にはObjectPoolを使えばいいように見えますが、実際はObjectPoolにも欠点はあります。実際に開発を行う際は自分が利用したい技術や仕組みが適切であるかどうかの検討を行いましょう。(個人開発でやってみたい!とかなら自由ですが…)
カラオケアプリも今はまだ最低限の機能のみとなっていますが、これからアップデートを重ねて配信を盛り上げるような機能を盛り込む予定です。まだまだ知名度は低いですがこれからもよろしくお願いいたします。

カラオケ…歌…といえば来月はhololive SUPER EXPO 2024 及び hololive 5th fesが開催されます!是非皆さんも参加してタレントの方々と一緒に楽しんでいただければと思います!
https://hololivesuperexpo2024.hololivepro.com/
ではまた来月!