返回所有文章

Unity 繪製呼叫從 20,001 次降到 153 次,為什麼影格時間幾乎沒變

在一個包含 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 次用於場景中的其他繪製。

下面是三種模式的擷取畫面。疊加資訊顯示的是擷取時某一影格的值,不是表中的中位數。

排成圓盤狀、上下起伏的 20,000 個立方體。左上角的疊加資訊顯示 NAIVE、FPS 125、FRAME TIME 8.01 ms、DRAW CALLS 20,001、OBJECTS 20,000
NAIVE:每個立方體一個 GameObject,擷取畫面中的繪製呼叫為 20,001 次。
同一個立方體圓盤。左上角的疊加資訊顯示 GPU INSTANCING ONLY、FPS 118、FRAME TIME 8.44 ms、DRAW CALLS 153、OBJECTS 20,000
INSTANCED_RENDERER:GPU 實例化將繪製呼叫降到 153 次,仍保留每個立方體的 GameObject。
同一個立方體圓盤。左上角的疊加資訊顯示 RenderMeshInstanced、FPS 654、FRAME TIME 1.53 ms、DRAW CALLS 60、OBJECTS 20,000,下方的橫幅上是 No GameObjects: 60 draw calls - 5x FPS
INSTANCED_DIRECT:移除個別立方體 GameObject 的同時也改變了算繪路徑。橫幅中的「FPS 五倍」描述的是擷取畫面的比較,並非單獨移除 GameObject 的收益。

如何解讀各執行緒的耗時

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 這樣的微小差異視為穩定收益前,應先重複測量基準。

減少繪製呼叫之前,先做三項檢查

  1. 在有代表性的建置中收集有效的主執行緒、算繪執行緒和 GPU 耗時,並檢查影格率限制與等待時間
  2. 在 Profiler 中找出耗時較高的工作。主執行緒耗時高,表示該檢查物件更新,並不表示應該直接刪除 GameObject
  3. 盡量一次只改變一個可能的原因,重複相同工作負載,比較影格時間中位數、較慢的影格和記憶體