🐇

【Unity】InstantiateAsyncの使い方と速度検証

2024/12/07に公開

はじめに

皆さんこんにちは、ambrでCTO / クライアントエンジニアを務めているampです!
この記事ではUnityに追加された新しいオブジェクト生成方法 InstantiateAsync() の使い方と速度検証について取り扱っていきます。

https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.InstantiateAsync.html

概要

これまでUnityのGameObjectの生成方法はInstantiate()のみで、これはメインスレッド上で同期的に実行するAPIであるため、しばしばスパイクの要因となっていました。

Unity 2022.3.20f1 で追加された InstantiateAsync() ではオブジェクトの生成処理の一部がWorkerThreadで実行されるようになり、非同期で実行できるようになりました。
本記事では本関数のオブジェクト生成速度やメインスレッド負荷に焦点を当てて検証してみます。

時間がない人向けの結論

  • InstantiateAsync()はメインスレッドの負荷 = スパイクを抑えながら非同期でオブジェクト生成できる。
  • Instantiate()に比べてPrefabの生成速度は遅いため、シチュエーションを選んで使い分ける。

InstantiateAsync()について

呼び出し方

// 1点生成
AsyncInstantiateOperation<T> handler = InstantiateAsync(prefab, parent);

// 生成を待機
// NOTE: awaitの戻り値はvoidになってしまうので次の行で結果を受け取る
await handler;

T[] objects = handler.Result;  // countを指定しない場合でも配列で返却される
T result = objects[0];
// 同一オブジェクトを複数個生成
AsyncInstantiateOperation<T> handler = InstantiateAsync(prefab, count, parent);

await handler;

T[] objects = handler.Result;

IntegrationTimeMSについて

InstantiateAsync()はオブジェクトの読み込みをWorkerThreadで行い、その後にコンポーネントのAwake()処理をメインスレッドで行います。
Awake()の呼び出しをフレームでスライスする仕組みが備わっており、これにより同時に多数オブジェクトを生成させた場合のスパイクを低減できます。

この呼び出しに当てて良い1フレームあたりの時間の目安を以下の関数で指定できます。
https://docs.unity3d.com/6000.0/Documentation/ScriptReference/AsyncInstantiateOperation.SetIntegrationTimeMS.html

AsyncInstantiateOperation.SetIntegrationTimeMS(float timeMs)

staticメソッドであるため、グローバルなスコープです。場面に応じて設定します。
なるべくカクつかせたくないときは低めに、オブジェクト生成を優先させたいときは高めに設定すると良いでしょう。

IntegrationTimeMSについては以下の公式動画がわかりやすいです。

https://www.youtube.com/watch?v=Q9K-3zkEijQ

検証

検証環境


Unity 6000.29 (IL2CPP / Built-in Render Pipeline)

検証機:Android 10 GalaxyS9+ Snapdragon845

アセット

Instantiateする対象として、以下のアセットを使わせてもらいました。
生成したPrefab内はAnimatorとSkinnedMeshRendererで構成されており、スクリプトコンポーネントはアタッチされていないものになります。
1体につき2048x2048のTextureが1枚割り当てられており、45種類のPrefabを利用しました。

https://assetstore.unity.com/packages/3d/characters/humanoids/yippy-kawaii-88488

検証項目

  • オブジェクトの生成完了に要した時間を計測します。
  • オブジェクト生成中の呼び出し時のスパイク(ラグ)を確認します。
  • 生成処理開始から5秒間のメインスレッドの処理量を計測します。
    • オブジェクト生成の処理がメインスレッドからWorkerThreadに移ったことにより、メインスレッドで実行可能な処理量が増加するという仮説に基づきます。

計測

Prefabを500点生成し、所要時間やスパイクを確認します。
パターンにより生成するPrefab種類数が異なります。

以下に掲載する動画では生成の順番やスパイクをイメージしてもらうためオブジェクトが写っていたり配置処理を入れていますが、計測コードでは配置の計算時間を計測期間から除外していたり、生成過程の描画計算コストを除外するためにカメラの範囲外に配置するようにしてます。

メインスレッドの処理量の計測は、一定時間内に実行できた整数加算回数を計測するようにしました。
いずれのパターンでも5秒間の計測中にオブジェクト生成処理が完了するように生成数を調整しています。

パターン1:オブジェクト生成を行わない

Instantiate系の処理は呼び出しません。

メインスレッドの計算量の比較用です。

パターン2:Instantiate() 45種のPrefab生成

おなじみの生成関数。同期的な読み込み処理なので呼び出し時にスパイクが発生します。

InstantiateAsyncLab_2_1.gif

備考:キャプチャの挙動でスパイクの様子が分かりづらいですが、画面下のCubeの動きが飛んだ分だけスパイクが発生したとみてください(実際の画面では画面が止まった後にCubeが移動)


// 呼び出し処理
Transform[] transforms = instantiateAsyncTest.InstantiateObject(objectCount);

// 定義
public Transform[] InstantiateObject(int count)
{
    Transform[] transforms = new Transform[count];

    for (int i = 0; i < count; i++)
    {
        Transform transform = Instantiate(GetPrefab(i));
        transforms[i] = transform;
    }

    return transforms;
}

private Transform GetPrefab(int index)
{
    return prefabs[index % prefabs.Length];
}

パターン3. InstantiateAsync() 45種のPrefab、1点生成を500回リクエスト

count = 1のInstantiateAsync()を500回実行しています。
任意のPrefabを指定できるので各Prefabの生成を指定。Instantiate()を呼び出す使用感とほぼ同じです。

呼び出し時にスパイクはあるものの、パターン2 Instantiate()より抑えられている印象です。

InstantiateAsyncLab_5_1.gif
↑ IntegrationTimeMS = 2.0 で実行。

InstantiateAsyncLab_5_IT5_1.gif
↑IntegrationTimeMS = 5.0 で実行。
2.0のときより生成速度が速いが、生成中のFPSは低下。
生成数が多かったり生成負荷が高いとより明確に違いが出る。

// 呼び出し処理
 List<UniTask<Transform>> tasks = new List<UniTask<Transform>>();
 
 for (int i = 0; i < objectCount; i++)
 {
     tasks.Add(instantiateAsyncTest.InstantiateObjectAsync(i));
 }
 
 Transform[] transforms = await UniTask.WhenAll(tasks);

// 定義
public async UniTask<Transform> InstantiateObjectAsync(int prefabIndex)
{
    var handler = InstantiateAsync(GetPrefab(prefabIndex));

    await handler;

    Transform[] transformArray = handler.Result;
    Transform result = transformArray[0];

    return result;
}

パターン4. InstantiateAsync() 同一Prefab、countで全数リクエスト

InstantiateAsync()を使用。countに500を与えて1回だけInstantiateAsync()を呼び出しています。
1種類のPrefabを500点生成しています。

この方法だと意外にもスパイクが発生しました。

InstantiateAsyncLab_3_1.gif

// 呼び出し処理
Transform[] transforms = await instantiateAsyncTest.InstantiateObjectsAsync(objectCount);

// 定義
public async UniTask<Transform[]> InstantiateObjectsAsync(int count)
{
    var handler = InstantiateAsync(GetPrefab(0), count);  // 45種のPrefabのうち0要素目を選択して生成

    await handler;

    Transform[] transformArray = handler.Result;

    return transformArray;
}

パターン5. InstantiateAsync() 同一Prefab、30点生成ずつに分けてリクエスト


count = 30を16回とcount = 20を1回のInstantiateAsync()の実行に分けて
1種類のPrefabを合計500点の生成要求を行っています。

全パターンの中で最もスパイクが少ない、体感ではラグ0の結果になりました。

InstantiateAsyncLab_4_1.gif

// 呼び出し処理
Transform[] transforms = await instantiateAsyncTest.InstantiateObjectsSeparateAsync(objectCount, 30);

// 定義
public async UniTask<Transform[]> InstantiateObjectsSeparateAsync(int count, int separate)
{
    int separateLoopCount = count / separate;
    int mod = count % separate;

    List<AsyncInstantiateOperation<Transform>> opList = new List<AsyncInstantiateOperation<Transform>>();
    List<Transform> transformList = new List<Transform>(count);

    for (int i = 0; i < separateLoopCount; i++)
    {
        opList.Add(InstantiateAsync(GetPrefab(0), separate));
    }

    if (mod >= 1) opList.Add(InstantiateAsync(GetPrefab(0), mod));

    // 全てのオブジェクトの生成完了を待機する。WhenAll相当
    for (int i = 0; i < opList.Count; i++)
    {
        var op = opList[i];

        await op;

        foreach (Transform _transform in op.Result)
        {
            transformList.Add(_transform);
        }
    }

    return transformList.ToArray();
}

フレームレートのグラフ表示は以下のアセットを利用させてもらいました。
https://assetstore.unity.com/packages/tools/gui/graphy-ultimate-fps-counter-stats-monitor-debugger-105778

計測結果


IntegrationTime = 2.0 で検証(デフォルト値)

生成処理時間(秒)
小さいほど良い
FPS
実行後5フレ
MainThread処理量
大きいほど良い
1. 生成なし
(比較用)
- 60, 61, 60, 60, 60 6272 (141%)
2. Instantiate
45種のPrefabを
1000点生成
0.28 (100%) 3, 60, 30, 60, 60 4448 (100%)
3. InstantiateAsync
45種のPrefabを
1点生成を
500回リクエスト
2.71 (968%) 9, 60, 60, 60, 60 4672 (105%)
4. InstantiateAsync
同一Prefab
500点生成を一括リクエスト
0.30 (107%) 60, 60, 60, 60, 4

※6フレ以降は安定
していたので省略
4416 (99%)
5. InstantiateAsync
同一Prefab
30点生成ずつに分けて
リクエスト
0.33 (118%) 30, 60, 60, 60, 60 4512 (101%)


IntegrationTime = 5.0 で検証

生成処理時間(秒)
小さいほど良い
FPS
実行後5フレ
MainThread処理量
大きいほど良い
1. 生成なし
(比較用)
- 60, 61, 60, 60, 60 6272 (141%)
2. Instantiate
45種のPrefabを
1000点生成
0.28 (100%) 3, 60, 30, 60, 60 4448 (100%)
3. InstantiateAsync
45種のPrefabを
1点生成を
500回リクエスト
0.93 (332%) 9, 60, 60, 60, 60 4448 (100%)
4. InstantiateAsync
同一Prefab
500点生成を一括リクエスト
0.29 (104%) 60, 60, 60, 60, 4 4448 (100%)
5. InstantiateAsync
同一Prefab
30点生成ずつに分けて
リクエスト
0.33 (118%) 30, 60, 60, 60, 60 4544 (102%)

※オブジェクト生成、FPSはメインスレッド負荷(処理量の計測)をかけない状態で計測。
※割合は2番を基準としている。

考察

2番

  • スパイクは発生するものの、オブジェクト生成速度は最も速い。

3番

  • オブジェクト生成は遅いが、異なるオブジェクトを生成できて処理としても使いやすい中で、
    2番に比べてスパイクを抑制できる。
    2番に比べてメインスレッドの総合負荷に優れる(IntegrationTime 2.0のとき)。
  • 今回は同一フレームで500件実行したが、フレームごとに実行をスライスするとよりスパイクを抑えられる。

4番

  • 2番と同等のスパイクが発生してしまい、使い所が見えない。
    • 初期化処理が5フレーム目で集中的に実行されているためにスパイクが発生しており、count = 500で実行した場合はIntegrationTimeMSの値が無視されているように見える。
      InstantiateAsync()のcountを分割して実行した場合に、IntegrationTimeMSの設定値に従って初期化処理のフレームスライスが実行されるものと推測される。

5番

  • 同一Prefab生成で良いならオブジェクト生成速度は2番にやや劣るものの
    スパイクが回避でき、生成速度重視ではない状況なら優秀。
    • 4, 5番の結果より、InstantiateAsync()のcountは大量に指定するよりある程度小分け(本検証では30を指定)に実行したほうが良いことがわかる。

その他

  • メインスレッドの処理量は今回の検証ではInstantiateAsync()のほうが若干高い結果が見られたものの小さな差に留まった。
    オブジェクト生成の過程ではAwake()などメインスレッド処理の処理量がそれなりに占める模様。
    アセットサイズがより大きい場合では異なる結果が出る可能性がある。

利用に適したシチュエーション

  • 時間を要しても構わないのでfpsを保ちながら生成したいとき。
    例)優先度の低い演出用小物、パーティクルの生成。
  • バックグラウンドでゆっくり読み込みを行いたいとき。

まとめ

InstantiateAsync()の登場によりスパイクを抑えながらオブジェクト生成処理ができるようになりました。

オブジェクト生成速度はInstantiate()が速いですが、
生成完了を急いでいない場合はcount = 1でInstantiateAsync()を雑に呼んだ場合でもスパイク低減につながることがわかりました。
特に同一のPrefabを大量に生成する場面では、countを分割してInstantiateAsync()を実行することで高速かつスパイクなく実行できます。

InstantiateAsync()は呼び出し方によって生成速度とスパイク抑制を両立できるので、
上手に使い分けることでより良い体験を実現できるようになりますので、ぜひ活用を検討してみてください。

ambr Tech Blog

Discussion