返回所有文章

如何製作「測量版本」:Unity / UE5 / Godot 適用,正確測量實機效能

在編輯器裡跑得很順的場景,一放到實機上就開始卡頓。反過來,在開發版本裡很重的處理,到了產品版本卻像沒事一樣順暢。明明是同一款遊戲,為什麼在不同環境裡量出來的數字會對不上?

測量環境的三個階段

效能測量的環境大致可以分成三個階段:在編輯器裡做效能分析、在 Development 版本上做效能分析,以及在開啟了 Release / Shipping 等級最佳化的版本上做測量。越靠前的階段越容易拿到數字,但這些數字也離在玩家手中運行的產品版本越遠。

編輯器 Development 版本 測量版本

越往右測量雜訊越少,數字也越接近玩家實際體感到的數值。

在編輯器裡測量,會帶上編輯器特有的負擔。熱重載、資產監看這些編輯器專用系統在背景持續運作;若是 Unity,跑的還是 Mono 而不是 IL2CPP。記憶體也要經過除錯用的配置器,配置、對齊、碎片化傾向、GC 運行的時機都和產品版本不一致。

換成 Development 版本後,編輯器特有的負擔消失了,但除錯負擔仍然還在。斷言檢查、冗長的記錄輸出、效能分析器連線這類除錯功能仍處於開啟狀態,編譯器的最佳化也和正式環境不同:內聯展開被抑制,LTO(連結時最佳化)沒有生效。用 Development 組態打包出來的版本裡還殘留著大量記錄輸出和除錯功能,卻把它的數字當成產品效能來讀,這樣的例子並不少見。

也就是說,前兩個階段觀測到的「卡頓」,很大一部分其實是測量環境本身製造出來的雜訊。

你盯著玩家裝置上根本不存在的成本,去和實機上並不存在的瓶頸較勁。

測量版本的製作方法

第三個階段對應的,就是在與正式環境相同的最佳化條件下運行的「測量版本」。基本方針是:先開啟 Release / Shipping 等級的最佳化,然後只保留測量所需的最少記錄與掛鉤(hook)。不加作弊功能,也不加過多的診斷。目標始終是一個「以和成品相同的速度運行、但仍然取得到數字」的版本。

Unreal Engine:使用 Test 組態

Unreal Engine 有 DebugGame / Development / Test / Shipping 這幾種建置組態。這裡要瞄準的是 Test 組態。

DebugGame Development Test ← 測量版本 Shipping

Development 面向日常迭代,雖然有一定程度的最佳化,但保留了大量除錯功能與檢查。反過來,Shipping 用於產品出貨,連主控台指令和 stat 系列工具都被剔除,很難插入測量掛鉤。

Test 被設計成介於兩者之間。它保持與 Shipping 同等的最佳化,同時保留 stat unit 等診斷指令和最小限度的工具。因為它能同時兼顧效能測量所需的速度特性與可觀測性,所以是測量版本最理所當然的基礎。

不過,Test 並不像 Development、Shipping 那樣能在編輯器的打包選單裡一鍵選擇。可靠的做法是透過 Automation Tool(BuildCookRun),或 Project Launcher 的自訂設定檔來指定。

// MyGame.Build.cs (project module)
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PublicDependencyModuleNames.AddRange(
            new string[] { "Core", "CoreUObject", "Engine" });

        // Measurement-only compile switch, scoped to this module.
        // Module-level PrivateDefinitions leave the shared (installed-
        // engine) build environment untouched, unlike target-level
        // GlobalDefinitions, which would require a unique build
        // environment and abort UBT on Launcher engines.
        if (Target.Configuration == UnrealTargetConfiguration.Test)
        {
            PrivateDefinitions.Add("MEASUREMENT_BUILD=1");
        }
    }
}
RunUAT BuildCookRun -project="C:/Path/MyGame.uproject" -platform=Win64 -clientconfig=Test -cook -stage -pak -package -build

另外,即使在 Test 裡,UE_LOG 也會預設被編譯剔除(其啟用開關 bUseLoggingInShipping 對 Test 和 Shipping 都生效)。不過這個設定在 Launcher 版(installed)引擎上會要求 unique build environment,導致 UBT 中斷,所以最好讓測量鉤子的輸出不依賴 UE_LOG

此外,Launcher 版引擎預設不包含用於 Test 的引擎二進位檔(installed build 的 GameConfigurations 預設為 Shipping、Development、DebugGame)。如果無法用 Test 打包,就使用原始碼建置的引擎,或包含 Test 一起建置的自訂 installed build。

這套設定在 UE5(以及 UE4)上通用。

Unity:測量用的建置設定檔(Unity 6)

Unity 6 引入了名為 Build Profiles(建置設定檔)的機制,它可以把各平台的建置設定儲存為具名設定檔並在其間切換。為測量單獨切出一個專用設定檔,依以下方針設定:

  • 關掉 Development Build 選項(不背上效能分析器自動連線與指令碼除錯的額外負擔)
  • 指令碼後端選擇 IL2CPP,透過與成品相同的原生程式碼路徑來測量
  • 用 Scripting Define Symbols 只啟用測量用的最小記錄,其餘冗長記錄一律關閉

具體步驟是這樣:從 File > Build Profiles 新建一個測量用設定檔,取消勾選 Development Build。在 Player Settings 裡,把 Scripting Backend 設為 IL2CPP、C++ Compiler Configuration 設為 Release。測量用的 MEASUREMENT 定義,加入該設定檔的 Build Data > Scripting Defines 裡(設定檔的定義會疊加在專案與平台的定義之上)。只在這個定義有效的建置裡,對測量程式碼的呼叫才會保留在編譯結果中([Conditional] 移除的是呼叫點,方法本體仍留在組件裡):

using System.Diagnostics;

public static class Measure
{
    // Call sites are stripped at compile time unless the MEASUREMENT
    // define is active for the current build profile. The method body
    // itself stays in the assembly.
    [Conditional("MEASUREMENT")]
    public static void Mark(string label)
    {
        UnityEngine.Debug.Log($"[measure] {label} @ {UnityEngine.Time.frameCount}");
    }
}

建議把 Release 當成測量的預設;如果工作室以 Master 出貨,就在 Master 上測量。[Conditional] 這種切換本身,在 Unity 6 之前也能用,走下文所說的 BuildPipeline 路線即可。

Unity 6 之前的版本沒有 Build Profiles。這種情況下,就用一段能組出等效發行組態的建置指令碼來替代(透過 BuildPipeline,不設定 Development 旗標,指定 IL2CPP 與最小記錄的符號)。

Godot:發佈匯出加自訂功能標籤

在 Godot 裡,承擔這個角色的是匯出預設。匯出時關掉「Export With Debug」(也就是使用發佈版匯出範本),就能得到剝離了遠端偵錯器、效能分析器連線等偵錯機制的最佳化二進位檔。

測量用途下,複製一份預設,並為它加上自訂功能標籤(例如 measurement)。這個標籤寫在匯出預設的設定區塊裡:

# export_presets.cfg (measurement preset section)
custom_features="measurement"

程式碼端據此分支,因此只在這個版本裡啟用測量所需的最小日誌:

// Enable minimal measurement logging only in the measurement build.
if (OS.HasFeature("measurement"))
{
    GD.Print($"[measure] frame={Engine.GetFramesDrawn()} fps={Engine.GetFramesPerSecond()}");
}

自訂功能標籤只在匯出後的版本裡才會解析:從編輯器執行時 OS.HasFeature("measurement") 回傳 false,所以在匯出之前這個分支看起來像是無效程式碼。

如果想進一步精簡二進位檔,也可以換成用 production=yes 編譯的自訂匯出範本。自訂範本的建置指令是 scons platform=windows target=template_release production=yes module_mono_enabled=yes,其中 production=yesuse_static_cpp=yes debug_symbols=no lto=auto 的別名(Godot 4.x);C# 專案使用的範本必須加上 module_mono_enabled=yes。支援 C# 的範本僅憑這一行還無法完成,還需要產生 mono glue 並建置 GodotSharp 受控組件(步驟請參閱官方文件)。

測量版本要測的指標:影格時間、記憶體、載入時間、儲存 IO

準備好測量版本之後,就透過真實的遊玩場次來蒐集數字。應該關注的指標如下:

FPS / 影格時間不只看平均值,還要看 P95。因為即使平均值很平滑,P95 的尖峰也常常在破壞遊玩體驗
記憶體使用量峰值,以及隨時間經過的成長趨勢
地圖 / 關卡的載入時間由於會隨著著色器編譯與快取狀態而變化,因此也記錄是否為首次載入
儲存 IO由串流阻塞引起的 hitch(卡頓)大多會在這裡顯現

而最重要的,是一定要為這些數字附上情境脈絡。具體來說,就是當下的玩家座標、攝影機朝向、畫面上顯示的地圖 / 場景、建置 ID,以及裝置 / 平台。

一筆沒有脈絡、只寫著「影格時間跳了一下」的記錄,事後根本無從追查。反過來,如果記錄的粒度是「build 1042 的 desert_ruins,座標 (X, Y),攝影機朝北時 P95 會跳」,那麼這個異常就成了可重現的調查對象。

正是脈絡,才是把一個普通的數字變成「可以著手調查的異常」的關鍵。

餘裕就是預算

測量的目的,不只是修好慢的地方。如果在目標硬體上,CPU、GPU、記憶體、IO 都還留有餘裕(headroom),那這份餘裕就是可以直接動用的「預算」。

比方說,如果渲染執行緒還有餘力,你就可以刻意把這份預算投向更豐富的 VFX、更多的同畫面物件、更遠的串流距離。最佳化既是「做減法」的工作,同時也是一項能告訴你「表現可以做到多有企圖心」的工作。

只要透過測量版本掌握了準確的餘裕,這類判斷就能靠數字而不是憑感覺來下。是還有餘裕,還是已經貼著上限了?唯有搞清楚這一點,你才能有意識地去設計遊戲玩法的密度。

從異常,到根因

測量版本的職責,是定位問題「在哪裡、在何時」發生。原因本身,在這一步是看不出來的。

比方說,測量版本報出了「在特定座標出現影格尖峰」「某次載入之後記憶體抬高了一階、再也沒降回來」這樣的異常。下一步,就是在 Development 版本裡精確重現那個情形,用詳細的效能分析器深入挖掘。

  • Unity 就用 Unity Profiler 和 Memory Profiler
  • Unreal Engine 就用 Unreal Insights
  • Godot 就用內建分析器與監視器(腳本計時僅支援 GDScript,深入分析 C# 程式碼時請搭配 .NET 分析器)

到這裡,「為什麼」就浮現出來了。是哪個系統在吃掉那一影格,是哪個資產一直抓著記憶體不放。分工很簡單。

測量版本WHERE / WHEN定位問題在哪裡、在何時發生
開發版本的效能分析器WHY重現那個情形,並解釋根因

這種兩段式的組合,構成了一個不靠臆測的最佳化迴圈。

用 Framedash 自動化測量迴圈

上面這套「蒐集 → 彙整 → 找異常」的流程,如果全靠手動來跑,維運成本會越堆越高。Framedash 正是負責把這個迴圈自動化的工具。

它提供一張效能熱圖,把 FPS、影格時間、GPU 時間、記憶體使用量以儲存格為單位疊加在遊戲地圖上。可以依裝置或建置設定檔篩選,因此哪種硬體配置、在哪裡比較重,一眼就能看清。

你可以為每一個遙測事件附上玩家座標和攝影機朝向(偏航 yaw、俯仰 pitch)。攝影機朝向由 SDK 自動採集(可選擇退出),所以無需額外實作,就能把本文所講的「帶情境脈絡的測量」直接付諸實踐。也可以附上任意的自訂指標或屬性。各引擎的 SDK 整合步驟,可參閱 UnityUE5Godot (C#) 的文件。

此外,它還具備依 build_id 比較影格時間、GPU 時間與記憶體的版本回歸分析,超過門檻值的回歸可以透過 Slack、電子郵件、Webhook 通知你。這套機制能讓你在發行前就發現「這個版本的 P95 變差了」。

SDK 整合只需幾行程式碼,最快 5 分鐘 支援的引擎為 Unity、UE5、Godot (C#)。無需信用卡即可開始。
免費開始 UnityUE5Godot (C#)