在編輯器裡跑得很順的場景,一放到實機上就開始卡頓。反過來,在開發版本裡很重的處理,到了產品版本卻像沒事一樣順暢。明明是同一款遊戲,為什麼在不同環境裡量出來的數字會對不上?
測量環境的三個階段
效能測量的環境大致可以分成三個階段:在編輯器裡做效能分析、在 Development 版本上做效能分析,以及在開啟了 Release / Shipping 等級最佳化的版本上做測量。越靠前的階段越容易拿到數字,但這些數字也離在玩家手中運行的產品版本越遠。
越往右測量雜訊越少,數字也越接近玩家實際體感到的數值。
在編輯器裡測量,會帶上編輯器特有的負擔。熱重載、資產監看這些編輯器專用系統在背景持續運作;若是 Unity,跑的還是 Mono 而不是 IL2CPP。記憶體也要經過除錯用的配置器,配置、對齊、碎片化傾向、GC 運行的時機都和產品版本不一致。
換成 Development 版本後,編輯器特有的負擔消失了,但除錯負擔仍然還在。斷言檢查、冗長的記錄輸出、效能分析器連線這類除錯功能仍處於開啟狀態,編譯器的最佳化也和正式環境不同:內聯展開被抑制,LTO(連結時最佳化)沒有生效。用 Development 組態打包出來的版本裡還殘留著大量記錄輸出和除錯功能,卻把它的數字當成產品效能來讀,這樣的例子並不少見。
也就是說,前兩個階段觀測到的「卡頓」,很大一部分其實是測量環境本身製造出來的雜訊。
你盯著玩家裝置上根本不存在的成本,去和實機上並不存在的瓶頸較勁。
測量版本的製作方法
第三個階段對應的,就是在與正式環境相同的最佳化條件下運行的「測量版本」。基本方針是:先開啟 Release / Shipping 等級的最佳化,然後只保留測量所需的最少記錄與掛鉤(hook)。不加作弊功能,也不加過多的診斷。目標始終是一個「以和成品相同的速度運行、但仍然取得到數字」的版本。
Unreal Engine:使用 Test 組態
Unreal Engine 有 DebugGame / Development / Test / Shipping 這幾種建置組態。這裡要瞄準的是 Test 組態。
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=yes 是 use_static_cpp=yes debug_symbols=no lto=auto 的別名(Godot 4.x);C# 專案使用的範本必須加上 module_mono_enabled=yes。支援 C# 的範本僅憑這一行還無法完成,還需要產生 mono glue 並建置 GodotSharp 受控組件(步驟請參閱官方文件)。
測量版本要測的指標:影格時間、記憶體、載入時間、儲存 IO
準備好測量版本之後,就透過真實的遊玩場次來蒐集數字。應該關注的指標如下:
而最重要的,是一定要為這些數字附上情境脈絡。具體來說,就是當下的玩家座標、攝影機朝向、畫面上顯示的地圖 / 場景、建置 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 分析器)
到這裡,「為什麼」就浮現出來了。是哪個系統在吃掉那一影格,是哪個資產一直抓著記憶體不放。分工很簡單。
這種兩段式的組合,構成了一個不靠臆測的最佳化迴圈。
用 Framedash 自動化測量迴圈
上面這套「蒐集 → 彙整 → 找異常」的流程,如果全靠手動來跑,維運成本會越堆越高。Framedash 正是負責把這個迴圈自動化的工具。
它提供一張效能熱圖,把 FPS、影格時間、GPU 時間、記憶體使用量以儲存格為單位疊加在遊戲地圖上。可以依裝置或建置設定檔篩選,因此哪種硬體配置、在哪裡比較重,一眼就能看清。
你可以為每一個遙測事件附上玩家座標和攝影機朝向(偏航 yaw、俯仰 pitch)。攝影機朝向由 SDK 自動採集(可選擇退出),所以無需額外實作,就能把本文所講的「帶情境脈絡的測量」直接付諸實踐。也可以附上任意的自訂指標或屬性。各引擎的 SDK 整合步驟,可參閱 Unity、UE5、Godot (C#) 的文件。
此外,它還具備依 build_id 比較影格時間、GPU 時間與記憶體的版本回歸分析,超過門檻值的回歸可以透過 Slack、電子郵件、Webhook 通知你。這套機制能讓你在發行前就發現「這個版本的 P95 變差了」。