記事一覧へ戻る

Unity のロード時間はどこまで測る? シーンの読み込みから操作可能になるまで

ロード画面が消えるまで待った時間と、LoadSceneAsync が完了するまでの時間は、同じとは限りません。 シーンを読み込んだ後も、セーブデータの適用やプレイヤーの生成、入力の有効化が残ることがあります。 ロードを短くしたいなら、まず 計測を止める条件 を決めます。

この記事では、ロード要求から操作可能になるまでを全体の指標とし、その途中を診断用の時刻で分けます。 Unity 6.4 の API と Framedash Unity SDK 0.1.8 の仕様に沿った計測設計です。

進捗 0.9 と操作可能を分ける

allowSceneActivation = false にすると、Unity は進捗を 0.9 で止め、isDone を false のままにします。 これはシーンを有効化する前の待機点で、読み込んだバイト数の 90% や CPU 処理時間の 90% を意味しません。 有効化を許可するまで、他の AsyncOperation が待たされる場合もあります。 Unity の allowSceneActivation の説明を踏まえ、計測のためだけにこの待機を追加しないでください。

また、sceneLoaded は OnEnable の後、Start の前に呼ばれます。 その通知を操作可能の印にすると、Start や後続の非同期初期化が計測から外れます。 Unity のコールバック順序と、ゲーム側の準備完了を別々に扱います。

同じ時計で 4 つの時刻を記録する

ロード要求、有効化の許可、シーン操作完了、操作可能の順に区間を分けた図
  • T0:ロード要求。ローダーを呼び出す直前
  • T1:有効化の許可。既存ローダーが有効化を止めている場合に、allowSceneActivation を true にする直前
  • T2:シーン操作の完了を観測。LoadSceneAsync の isDone が true になったことを確認した時点
  • T3:操作可能。必要な初期化が終わり、ロード画面を外して入力を受け付けるという、ゲーム側で決めた条件が成立した時点

T0 から T1 には、読み込みだけでなく、演出や「続ける」ボタンを待つ時間も入り得ます。 T1 から T2 も、有効化の純粋な CPU 時間ではありません。 メインスレッドの別の処理や、コルーチンが次に確認するまでの時間を含む経過時間です。 原因を処理単位で調べる段階では Unity Profiler を併用します。

有効化を手動で止めていないローダーには、T1 を設ける必要はありません。 T0 から T2 と T2 から T3 を記録するだけでも、シーン操作の後に待たせている時間を分けられます。 準備完了が T2 より先に通知され得る構成では、その状態を呼び出し側で保持し、T2 を観測した後に TryReportPlayable を呼び直します。 この補助クラスは早い通知を保持しません。T3 は、両方の条件がそろったことを観測して報告する時点です。

Framedash には全体を 1 件、内訳を別イベントで残す

map_load に記録する区間は、比較するビルド間でそろえます。 ここでは T0 から T3 を 1 件のロードとして記録します。 前のビルドでは T2、新しいビルドでは T3 で終了すると、処理が同じでも計測値が増えてしまいます。

Framedash のロード計測 APIには BeginMapLoad / EndMapLoad と ReportMapLoad があります。 開始と終了を SDK に任せる場合は前者、既存ローダーで測ったミリ秒を渡す場合は後者を使います。 BeginMapLoad を重ねて呼ぶと保留中の計測が置き換わるため、同時ロードを別々に測る場合は、それぞれの時計と ReportMapLoad を使います。

次の補助クラスは後者の例です。 初期化済み SDK を渡し、T0 でインスタンスを作ります。 既存ローダーから T1 で任意の MarkActivationRequested、T2 で MarkOperationDone、T3 で TryReportPlayable を呼びます。 シーン切り替えで破棄されないローダーが、このインスタンスを保持する想定です。

サンプルの仕様は SDK 0.1.8 の実装と照合しています。 呼び出しは Unity のメインスレッドで行います。 バックグラウンド処理の完了時は、ゲームのメインスレッドへ戻してから報告してください。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Framedash;

// One instance per load. Call every method on Unity's main thread.
public sealed class LoadProbe
{
    private readonly TelemetrySDK sdk;
    private readonly string mapName;
    private readonly Stopwatch clock = Stopwatch.StartNew();
    private double? activationRequestedMs;
    private double? operationDoneMs;
    private bool reported;

    public LoadProbe(TelemetrySDK initializedSdk, string mapName)
    {
        if (initializedSdk == null)
            throw new ArgumentNullException(nameof(initializedSdk));
        if (string.IsNullOrWhiteSpace(mapName))
            throw new ArgumentException("A stable map name is required.", nameof(mapName));
        sdk = initializedSdk;
        this.mapName = mapName;
    }

    // Optional: only if the existing loader controls activation.
    public void MarkActivationRequested()
    {
        if (!reported && !activationRequestedMs.HasValue && !operationDoneMs.HasValue)
            activationRequestedMs = clock.Elapsed.TotalMilliseconds;
    }

    public void MarkOperationDone()
    {
        if (!reported && !operationDoneMs.HasValue)
            operationDoneMs = clock.Elapsed.TotalMilliseconds;
    }

    // Call when both readiness and isDone have been observed.
    // An early readiness call is not cached; call again after MarkOperationDone.
    public bool TryReportPlayable()
    {
        if (reported || !operationDoneMs.HasValue) return false;
        double totalMs = clock.Elapsed.TotalMilliseconds;
        double doneMs = operationDoneMs.Value;
        var metrics = new Dictionary<string, float>
        {
            { "scene_operation_ms", (float)doneMs },
            { "after_operation_ms", (float)(totalMs - doneMs) }
        };
        if (activationRequestedMs.HasValue)
        {
            double activationMs = activationRequestedMs.Value;
            metrics["before_activation_ms"] = (float)activationMs;
            metrics["activation_window_ms"] = (float)(doneMs - activationMs);
        }
        reported = true;
        sdk.ReportMapLoad(mapName, totalMs);
        sdk.Track("load_phases", attributes: new Dictionary<string, string>
            { { "map_name", mapName } }, metrics: metrics);
        return true;
    }
}

TryReportPlayable の true は、この補助クラスが報告処理を 1 回行ったという意味です。 サーバーへの到達確認ではありません。 失敗やキャンセルでは呼ばず、その試行を成功ロードの集計に入れないでください。 必要なら別の失敗イベントを用意します。

load_phases とそのメトリクス名は、この記事で定義したカスタムイベントです。 専用の区間グラフや回帰ゲートが自動で作られるという意味ではありません。 map_load の map_id は空で、マップ名は attributes["map_name"]、時間は metrics["load_time_ms"] に入ります。 このイベントだけでは空間ヒートマップの点になりません。

どちらのイベントもサンプリングの影響を受けます。 検証時に全件を保持する設定が必要なら、初期化後に次を設定します。 ただし、レート 1 でも通信障害やバッファの制約による欠落までは防げません。

TelemetrySDK.Instance.SetEventSamplingRate("map_load", 1f);
TelemetrySDK.Instance.SetEventSamplingRate("load_phases", 1f);

比較前に、終了条件と失敗ケースを確かめる

自分のローダーへ組み込むときは、次の点を確認してください。

  1. 同じシーン、同じビルド設定で T0、T2、T3 の順序と単位を確認する
  2. 有効化を止める既存フローでは、意図した待ち時間が T0 から T1 に含まれることを確認する
  3. MarkOperationDone の後に非同期の準備完了を意図的に遅らせ、T2 から T3 が長くなることを確かめる。Awake などを遅らせるテストとは分ける
  4. 失敗、キャンセル、二重完了、同時ロードで、成功件数の水増しや別ロードへの混入がないことを確認する
  5. コールド起動と再ロードを分け、端末、ストレージ、キャッシュ条件、ビルド ID を記録する

サンプルの確認範囲

この補助クラスは、Unity 6000.3.25f1(6.3 LTS)と 6000.6.4f1、Framedash Unity SDK 0.1.8 で動作を確認しています。 同じ Windows x64 ホストで、Mono / IL2CPP の両方を、非 batchmode の D3D12 非表示ウィンドウと -batchmode -nographics で実行しました。 SDK と LoadProbe に変更を加えず、記事サンプル用の合成シーンテストが成功し、SDK の終了処理後に Player が正常終了しています。

このバージョン検証では、map_load と load_phases の実際の送信データをローカルでデコードして受信を確認しました。 失敗・キャンセルした試行は成功ロードとして送信されていません。 本番 Framedash への疎通は別の試行で確認しており、保存済みの両イベントを読取クエリで取得できました。 いずれも確認した試行の結果で、すべてのイベントの到達を保証するものではありません。

検証対象はこの記事のサンプルです。SDK 全体のテストスイート、顧客実ゲームの性能、可視の対話ウィンドウ、Android / iOS 実機まで確認したものではありません。 自分のゲームでは、上のチェック項目に加えて対象端末での動作を確かめてください。

まず T0 から T3 の定義を固定し、次に長い区間を調べます。 その順序なら、読み込み処理を短くしたのか、プレイヤーが待つ時間を短くしたのかを、同じ指標で確かめられます。