「減少繪製呼叫就會變快」,是 Unity 最佳化裡最先被提起的一句話。我在一個讓 20,000 個立方體上下起伏的場景裡,把它真的做了一遍。繪製呼叫從 20,001 次減到了 153 次。可是影格時間只從 8.948 ms 變成 8.709 ms,p50 上差 0.24 ms。差到這個程度,跑起來也看不出來。
把同樣的畫面改成不為每個立方體建立遊戲物件的畫法,影格時間變成了 1.678 ms。換成 FPS 就是從 111.8 到 596.0。這時的繪製呼叫是 60 次。
153 次和 60 次,從 20,001 次的角度看都一樣小。可是一邊停在 8.709 ms,另一邊變成了 1.678 ms。造成這個落差的,不是繪製呼叫的次數。
測量的三種模式
場景是同一個。20,000 個立方體排成圓盤狀,疊上一道行進的正弦波。每個影格改寫全部 20,000 個的位置,攝影機繞著它慢慢轉。不同的只有兩點:立方體的畫法,以及是否為每個立方體各帶一個遊戲物件。三種模式的執行檔相同,只改啟動參數。
- NAIVE:每個立方體各帶一個遊戲物件和一個 MeshRenderer,材質的 GPU 實例化關閉
- INSTANCED_RENDERER:遊戲物件的構成和 NAIVE 相同,只有材質的 GPU 實例化是開啟的
- INSTANCED_DIRECT:不為每個立方體建立遊戲物件,從一個指令碼用
Graphics.RenderMeshInstanced繪製的方式
測量環境是 Unity 6000.4.11f1、Built-in Render Pipeline(前向算繪),指令碼後端是 Mono,圖形 API 是 D3D12。GPU 是 RTX 4070 Ti,CPU 是 Core i5-13500,解析度 1920x1080,vSync 關閉,陰影在所有模式下都關掉。動態批次處理和靜態批次處理都明確關閉,並確認批次的計數器為 0。每種模式都先暖機 15 秒再測量 120 秒。下面的數值若無特別說明都是 p50。
在 Unity 裡,如果不在 Player Settings 中打開 Frame Timing Stats,算繪執行緒和 GPU 時間就一直是 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 |
繪製計數器的明細是這樣的。
| 模式 | 標準繪製呼叫 | 實例化繪製呼叫 | 實例批次 | 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 次是立方體以外的繪製。
三種模式的畫面並排放在下面。
疊加資訊裡的數字,是擷取畫面那一瞬間某一個影格的值,和表格裡的 p50 對不上。
關於 INSTANCED_RENDERER,不好看的數字也一併列出來。FPS 在 p50 上從 111.8 漲到了 114.8,看平均值卻是從 111.2 降到 109.0。影格時間的 p95 從 10.644 ms 惡化到 12.919 ms。記憶體也從 169.0 MB 增加到 176.6 MB。把繪製呼叫從 20,001 次減到 153 次換來的,是 p50 上的 0.24 ms。
瓶頸不是算繪執行緒,而是遊戲執行緒
答案從一開始就寫在 NAIVE 的明細裡。影格時間是 8.948 ms。與之相對,並行執行的三項明細是:遊戲執行緒 8.920 ms,算繪執行緒 0.480 ms,GPU 時間 0.909 ms。這裡說的遊戲執行緒,就是在 Unity 的 Profiler 裡顯示為主執行緒的那一條。堆繪製呼叫是算繪執行緒的工作。就算能把算繪執行緒壓到 0 ms,影格時間也還是由遊戲執行緒的 8.920 ms 決定。
這份明細的讀法很簡單。最接近影格時間的那個值,就是決定這一個影格的地方。
- 遊戲執行緒和影格時間幾乎相等:瓶頸在 CPU 的邏輯這一側
- 算繪執行緒逼近影格時間:瓶頸在 CPU 的繪製這一側
- GPU 時間逼近影格時間:瓶頸在 GPU
削減繪製呼叫最容易見效的是第二種。這個場景屬於第一種,遊戲執行緒的 8.920 ms 和影格時間的 8.948 ms 幾乎一致。
INSTANCED_RENDERER 就是為了驗證這種讀法才測的模式。標準繪製呼叫從 20,001 次掉到 1 次,取而代之的是 152 次實例化繪製呼叫和 149 個實例批次。算繪執行緒則從 0.480 ms 變成 0.519 ms,反而略有增加。保留遊戲物件的兩種模式裡,算繪執行緒都在 0.5 ms 上下沒有動過。在 D3D12 的這個組態下,發出繪製呼叫還不是能影響到影格時間的負擔。GPU 時間那邊則從 0.909 ms 降到了 0.703 ms。它確實起了作用,但 GPU 本來就不是瓶頸,所以反映不到影格時間上。
真正起作用的,是不再建立 20,000 個遊戲物件
INSTANCED_DIRECT 改的不只是繪製呼叫的發出方式,還有場景的構成本身。它不為每個立方體建立遊戲物件、Transform 和 MeshRenderer,而是每個影格改寫一個矩陣陣列,從一個指令碼裡畫出來。Graphics.RenderMeshInstanced 一次呼叫最多只能畫 1,023 個實例,所以要把 20,000 個切成每份 1,023 個再傳進去。呼叫會變成 20 次,但計數器上的明細和呼叫次數並不是一對一的。
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);
}
}
遊戲執行緒在 NAIVE 裡是 8.920 ms,在 INSTANCED_RENDERER 裡也有 8.680 ms。它變成了 1.668 ms。消失的是那 20,000 個遊戲物件每個影格都要索取的 CPU 時間。元件的更新、按遊戲物件進行的剔除、Transform 的同步,就是它的內容。
不過,這個模式同時改了兩件事。遊戲執行緒從 8.680 ms 掉到 1.668 ms 的原因,我們主要讀作遊戲物件那一側的貢獻,因為保留遊戲物件、只換繪製路徑的 INSTANCED_RENDERER 並沒有讓遊戲執行緒動起來。話雖如此,僅憑這三種模式還不能把兩個因素完全分開。SetPass Calls 從 153 次降到 2 次、GPU 時間從 NAIVE 的 0.909 ms 降到 0.232 ms,是繪製路徑那一側的效果,和有沒有遊戲物件是兩回事。
記憶體的減少,同樣是不再建立遊戲物件的結果。從 169.0 MB 到 56.6 MB,少了 112 MB。換算下來一個遊戲物件約 5.6 KB,但這個值只是把差值除以個數得到的。SDK 送出的是整個行程的已配置記憶體(Profiler.GetTotalAllocatedMemoryLong)。INSTANCED_DIRECT 這一側還為 20,000 個 Matrix4x4 新配置了約 1.28 MB。即便如此,這 112 MB 也不是網格、頂點這類繪製資料減少的部分。三角形在三種模式下都還是 240,002 個。
分辨自己的場景屬於哪一種
要看的只有影格時間和三項明細。確認遊戲執行緒、算繪執行緒、GPU 時間裡哪一個最接近影格時間。Framedash 的 Unity SDK 會把這四個值當作 perf_heartbeat 自動送出(frame_time_ms、game_thread_ms、render_thread_ms、gpu_time_ms)。
這次測量裡,Framedash 負責的就是這份收集和比較。依模式改變 BeginAutomatedSession 的 build_id,把三次執行區分開,再用 CLI 取差分。
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%」
和上面表格裡的 p50 稍有出入,是因為彙總的樣本不同。伺服器端彙總的是暖機之後的 120 秒自動工作階段裡每秒送出的事件,加上 SDK 每 10 秒送出的 perf_heartbeat。這樣每種模式就是 133 個樣本。表格裡的 p50 則是逐個影格取到的值。上面照搬了伺服器端的輸出,所以小數位數也和表格不同。從 CI 下同一道命令,就能在發布前以這個粒度確認建置之間的差異。
這次的結果適用到什麼範圍
到這裡為止的數值,都來自一個組態上測到的一種工作負載。D3D12、Built-in Render Pipeline、Mono、桌機 GPU 這些前提一變,明細的比例也會跟著變。在驅動程式開銷更大的行動平台圖形 API、SRP Batcher 生效的 URP 或 HDRP、IL2CPP 建置上,算繪執行緒的佔比不保證和這次一樣。打開陰影會多出算繪階段,一次繪製呼叫的份量也會變。
測量的做法本身也有幾點要注意。繪製計數器的值取自 Development 建置,時間的值取自 Release 建置,為的是不把效能分析器的開銷混進時間那一側。計數器逐個影格的波動很大,最小值和最大值不可靠。計數器只放了 p50,就是這個原因。這還是一個每個影格改寫全部 20,000 個位置的工作負載,往自己的場景上套之前也請先確認這一點。
先測量,再削減
削減繪製呼叫最容易見效的時候,是算繪執行緒逼近影格時間的時候。先測過,就能在動手之前判斷:削減繪製呼叫這件事,是不是像這次一樣只值 0.24 ms。如果遊戲執行緒和影格時間幾乎相同,下一個該懷疑的就是遊戲物件的數量。
順序是這樣的。先打開 Frame Timing Stats。再把影格時間和三項明細排在一起,看哪一個最接近。削減是這之後的事。
- 測量中用到的指標定義整理在資料模型裡。
- SDK 的導入步驟在 Unity SDK 指南裡。