在一個包含 20,000 個移動立方體的 Unity 場景中,我們把繪製呼叫從 20,001 次降到了 153 次。影格時間中位數卻只從 8.948 ms 降到 8.709 ms,改善了 0.24 ms。
第三種模式不再為每個立方體建立 GameObject,而是用 Graphics.RenderMeshInstanced 直接繪製,影格時間降到了 1.678 ms。比較各執行緒的耗時,可以解釋為什麼只開啟 GPU 實例化效果不大。不過,直接繪製模式的改善也不能只歸因於繪製呼叫減少。
比較的三種繪製模式
三種模式使用同一個場景:20,000 個立方體排成圓盤,隨行進的正弦波上下起伏,相機在周圍緩慢旋轉。每個影格都會更新所有立方體的位置。同一建置類型內,三種模式使用相同的執行檔,只透過啟動參數切換。
- NAIVE:每個立方體有一個 GameObject 和一個 MeshRenderer,材質關閉 GPU 實例化
- INSTANCED_RENDERER:保留相同的 GameObject 結構,開啟材質的 GPU 實例化
- INSTANCED_DIRECT:不為每個立方體建立 GameObject,由一個指令碼透過
Graphics.RenderMeshInstanced繪製
測量環境如下:
- Unity 6000.4.11f1、Built-in Render Pipeline 前向算繪、Mono、D3D12
- RTX 4070 Ti、Core i5-13500、1920x1080 解析度
- 所有模式均關閉 vSync 和陰影
- 關閉動態批次處理和靜態批次處理,並確認各自的批次計數器均為 0
每種模式先暖機 15 秒,再測量 120 秒。耗時取自 Release 建置,繪製計數器取自 Development 建置,避免 Development 建置的效能分析負擔影響耗時比較。因此,下面兩張表來自不同的執行,並不是對同一批影格的同步測量。
除非另有標註,數值均為中位數 p50。影格時間的 p95 表示 95% 的受測影格耗時不超過該值;p95 越高,分布中較慢的影格耗時越長。
在 Release 建置中使用 FrameTimingManager 測量時,請開啟 Player Settings 中的 Frame Timing Stats,詳見 Unity 的設定說明。數值缺失或為 0,並不代表這項工作沒有負擔。
影格時間與繪製計數器
| 模式 | FPS | 影格時間 | 影格時間 p95 | 遊戲執行緒 | 算繪執行緒 | GPU 時間 | 記憶體 |
|---|---|---|---|---|---|---|---|
| NAIVE | 111.8 | 8.948 ms | 10.644 ms | 8.920 ms | 0.480 ms | 0.909 ms | 169.0 MB |
| INSTANCED_RENDERER | 114.8 | 8.709 ms | 12.919 ms | 8.680 ms | 0.519 ms | 0.703 ms | 176.6 MB |
| INSTANCED_DIRECT | 596.0 | 1.678 ms | 2.774 ms | 1.668 ms | 0.091 ms | 0.232 ms | 56.6 MB |
只開啟 GPU 實例化,影格時間中位數幾乎沒有變化。其他指標也沒有一致改善:平均 FPS 從 111.2 降到 109.0,影格時間 p95 從 10.644 ms 升到 12.919 ms,記憶體從 169.0 MB 增加到 176.6 MB。不能只憑 p50 的小幅改善,就認定效能穩定提升了。
| 模式 | 標準繪製呼叫 | 實例化繪製呼叫 | 實例批次 | SetPass Calls | 三角形 |
|---|---|---|---|---|---|
| NAIVE | 20,001 | 0 | 0 | 153 | 240,002 |
| INSTANCED_RENDERER | 1 | 152 | 149 | 153 | 240,002 |
| INSTANCED_DIRECT | 20 | 40 | 59 | 2 | 240,002 |
本文的「繪製呼叫」是標準繪製呼叫與實例化繪製呼叫之和:NAIVE 為 20,001 次,INSTANCED_RENDERER 為 1 + 152 = 153 次,INSTANCED_DIRECT 為 20 + 40 = 60 次。SetPass Calls 是另一項指標,即使數值同為 153,也不能混為一談。NAIVE 的 20,001 次呼叫中,20,000 次用於立方體,剩下 1 次用於場景中的其他繪製。
下面是三種模式的擷取畫面。疊加資訊顯示的是擷取時某一影格的值,不是表中的中位數。
如何解讀各執行緒的耗時
NAIVE 的遊戲執行緒耗時為 8.920 ms,幾乎等於 8.948 ms 的影格時間;算繪執行緒為 0.480 ms,GPU 為 0.909 ms。這裡的「遊戲執行緒」指 Unity 主執行緒。這些時間會重疊,不能相加來計算影格時間;各指標的 p50 也不代表某一個影格的耗時明細。
這組比較顯示,應該先檢查主執行緒。INSTANCED_RENDERER 的結果也支持這個方向:繪製呼叫大幅減少後,遊戲執行緒仍需 8.680 ms。算繪執行緒從 0.480 ms 變成 0.519 ms,都在 0.5 ms 左右。GPU 時間從 0.909 ms 降到 0.703 ms,卻沒有帶來相應幅度的影格時間改善。
在自己的場景中,可以用這些時間來選擇下一步檢查的位置:
- 主執行緒時間接近影格時間:檢查指令碼、Transform 更新,以及主執行緒上的算繪準備工作
- 算繪執行緒時間接近影格時間:檢查繪製命令提交和算繪狀態切換
- GPU 時間接近影格時間:檢查著色器、過度繪製和算繪階段等 GPU 工作
這些是排查方向,不能只看最大值就下診斷。影格率限制、畫面呈現等待和執行緒同步都會影響判斷。時間欄位的定義見 Unity 的 FrameTiming 文件,具體負擔還要在 Profiler 的 Timeline 中確認。主執行緒耗時並不全是遊戲邏輯,減少繪製呼叫也可能影響多個執行緒。
移除個別 GameObject 後,哪些工作變了
INSTANCED_DIRECT 不再為每個立方體建立 GameObject、Transform 和 MeshRenderer,而是由一個指令碼每個影格更新矩陣陣列並提交繪製。這個基準測試把 20,000 個矩陣按每組 1,023 個拆分,因此每個影格呼叫 C# API 20 次。API 呼叫次數與表中的繪製計數器並不一一對應。
private void RenderInstanced()
{
for (int start = 0; start < _objectCount; start += MaxInstancesPerCall)
{
int chunk = Mathf.Min(MaxInstancesPerCall, _objectCount - start);
Graphics.RenderMeshInstanced(_renderParams, _cubeMesh, 0, _matrices, chunk, start);
}
}
1,023 是本測試使用的分組大小,不是適用於所有組態的批次處理規則。Unity 給出的上限為 1,023 個實例,但實際限制取決於實例資料和著色器設定;預設的雙矩陣配置允許 511 個實例。採用這段程式碼前,應查閱 RenderMeshInstanced API 文件。
遊戲執行緒耗時降到了 1.668 ms,而 NAIVE 為 8.920 ms,INSTANCED_RENDERER 為 8.680 ms。這表示值得進一步檢查個別立方體物件的維護和更新成本。元件處理、Transform 同步和剔除都是候選,但這些彙總耗時無法測出各項分別貢獻了多少。
直接繪製模式同時改變了算繪路徑,因此本實驗不能單獨算出「移除 GameObject 節省的時間」。SetPass Calls 從 153 次降到 2 次,GPU 時間從 NAIVE 的 0.909 ms 降到 0.232 ms。結果反映的是物件表示方式與繪製方式共同變化的效果。
記憶體從 169.0 MB 降到 56.6 MB,減少約 112 MB。將差值除以 20,000,約為每個立方體 5.6 KB,但這不是一個 GameObject 的通用記憶體成本。SDK 使用的 Profiler.GetTotalAllocatedMemoryLong 回傳 Unity 內部配置器正在使用的記憶體,不是整個行程的記憶體用量。
直接繪製模式仍為 20,000 個 Matrix4x4 配置了約 1.28 MB。三種模式都繪製 240,002 個三角形,因此記憶體下降並不需要減少畫面中的幾何體。不過,單憑三角形數量相同,也無法確定究竟減少了哪些記憶體配置。
用 Framedash 比較執行結果
在本次實驗中,Framedash 負責收集耗時並比較執行結果。Unity SDK 在 perf_heartbeat 事件中傳送 frame_time_ms、game_thread_ms、render_thread_ms 和 gpu_time_ms。我們為每種模式設定不同的 BeginAutomatedSession build_id,再用 CLI 比較 NAIVE 與 INSTANCED_DIRECT。
執行前,將兩個變數設定為對應的建置 ID:
framedash perf-diff --baseline "$NAIVE_BUILD_ID" --candidate "$INSTANCED_DIRECT_BUILD_ID" \
--threshold 5 --fail-on-regression
記錄的輸出如下:
- 影格時間 p50:9.011 ms → 1.701 ms(-81.12%)
- GPU 時間:0.9257 ms → 0.2406 ms(-74.00%)
- 記憶體:175.7 MB → 59.4 MB(-66.20%)
- 每種模式 133 個樣本,判定為「No performance regression beyond 5%」
這些數值與表格不同,因為彙總的樣本不同。暖機後的 120 秒自動工作階段中,伺服器端合併了每秒傳送的事件和每 10 秒傳送的 perf_heartbeat,每種模式共 133 個樣本。表格使用的則是逐影格樣本。上面的數值精度保留了當時的伺服器輸出。
發布前也可以從 CI 執行這個比較,但取樣資料的 p50 不能代替逐影格的尾端延遲分析。判定只針對所比較的指標和門檻,不會確定瓶頸,也不代表每一個影格都變快了。
這個實驗能說明什麼
這是在一種軟硬體組態上執行的一種工作負載,而且每個立方體每個影格都在移動。它說明:在本測試中,只開啟 GPU 實例化對影格時間中位數幫助很小,而直接繪製模式明顯更快。它不能預測大部分物件靜止,或算繪成本不同的場景能改善多少。
行動裝置、其他圖形 API、使用 SRP Batcher 的 URP 或 HDRP、IL2CPP,以及陰影,都可能改變耗時比例。繪製計數器的逐影格波動也很大,因此這裡報告中位數,不依賴最小值或最大值。現有結果沒有給出重複執行之間的波動範圍;在把 0.24 ms 這樣的微小差異視為穩定收益前,應先重複測量基準。
減少繪製呼叫之前,先做三項檢查
- 在有代表性的建置中收集有效的主執行緒、算繪執行緒和 GPU 耗時,並檢查影格率限制與等待時間
- 在 Profiler 中找出耗時較高的工作。主執行緒耗時高,表示該檢查物件更新,並不表示應該直接刪除 GameObject
- 盡量一次只改變一個可能的原因,重複相同工作負載,比較影格時間中位數、較慢的影格和記憶體
- 指標定義:資料模型
- SDK 導入步驟:Unity SDK 指南