返回所有文章

Unity 載入時間應該量到哪裡?從場景載入到可以操作

LoadSceneAsync 完成後,載入畫面可能還沒有消失。 存檔還原、玩家生成或輸入初始化可能仍在進行。 要縮短載入時間,先定義 何時停止計時。

本文把載入請求到可以操作的時間當作整體指標,再用中間時點協助找出等待發生在哪裡。 量測設計依據 Unity 6.4 API 與 Framedash Unity SDK 0.1.8 的行為。

進度 0.9 不等於可以操作

當 allowSceneActivation = false 時,Unity 會將進度停在 0.9,並保持 isDone 為 false。 這是場景啟用前的等待點,不代表位元組數或 CPU 工作量已完成 90%。 阻止啟用還可能讓其他 AsyncOperation 操作等待。 參閱 Unity 啟用文件,不要只為了量測,就替原本不等待的載入器加入這一步。

sceneLoaded 也是另一個獨立訊號。 Unity 在 OnEnable 之後、Start 之前呼叫它。 如果將它當作可以操作的時點,Start 和後續非同步初始化就會落在量測區間之外。 應將 引擎回呼順序與遊戲的準備完成條件分開。

用同一個時鐘記錄四個時點

從載入請求,經選用的啟用許可與場景操作完成,到可以操作的時間軸
  • 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 是觀察到兩個條件都已成立並進行回報的時點。

記錄一次總時間,另用事件記錄分段

比較組建時,保持 map_load 的起訖定義一致。 這裡將 T0 到 T3 記錄為一次載入。 如果基準組建在 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,只表示輔助類別執行了一次回報流程,不代表伺服器已收到事件。 失敗或取消的嘗試不要呼叫它。 如需記錄失敗,使用獨立事件,避免混入成功載入統計。

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 等較早的工作可能使 T2 本身往後移
  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 未作修改,文章範例的測試場景檢查通過,Player 在 SDK 關閉處理完成後正常結束。

這次版本驗證在本機解碼並確認接收了 map_load 和 load_phases 的實際傳送資料。 失敗與取消的嘗試未作為成功載入傳送。 另一次 Framedash 正式環境連線測試透過回讀查詢,確認兩種事件均已儲存。 這些結果只確認了受測的嘗試,不保證所有事件都能送達。

驗證範圍是本文範例,不涵蓋 SDK 的完整測試套件或客戶遊戲的效能。 可見的互動式視窗以及 Android / iOS 實機仍未驗證。 整合至自己的遊戲時,除了上述檢查,也應在目標裝置上確認實際行為。

先固定 T0 到 T3 的定義,再調查較長的區間。 這樣就能用相同指標判斷,改動縮短的是場景載入工作,還是玩家開始操作前的等待。