返回所有文章

在 Unity 裡把繪製呼叫從 20,001 降到 153,卻沒有變快的原因

「減少繪製呼叫就會變快」,是 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 次是立方體以外的繪製。

三種模式的畫面並排放在下面。

排成圓盤狀、上下起伏的 20,000 個立方體。左上角的疊加資訊顯示 NAIVE、FPS 125、FRAME TIME 8.01 ms、DRAW CALLS 20,001、OBJECTS 20,000
NAIVE。每一個立方體各帶一個遊戲物件,繪製呼叫是 20,001 次。
同一個立方體圓盤。左上角的疊加資訊顯示 GPU INSTANCING ONLY、FPS 118、FRAME TIME 8.44 ms、DRAW CALLS 153、OBJECTS 20,000
INSTANCED_RENDERER。GPU 實例化把繪製呼叫減到了 153 次,但 FPS 和 NAIVE 幾乎一樣。
同一個立方體圓盤。左上角的疊加資訊顯示 RenderMeshInstanced、FPS 654、FRAME TIME 1.53 ms、DRAW CALLS 60、OBJECTS 20,000,下方的橫幅上是 No GameObjects: 60 draw calls - 5x FPS
INSTANCED_DIRECT。下方的橫幅可以讀作「不用遊戲物件、60 次繪製呼叫、FPS 五倍」,但這個模式在拿掉遊戲物件的同時也換了繪製路徑。FPS 的差距,並不只是拿掉遊戲物件帶來的結果。

疊加資訊裡的數字,是擷取畫面那一瞬間某一個影格的值,和表格裡的 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_msgame_thread_msrender_thread_msgpu_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。再把影格時間和三項明細排在一起,看哪一個最接近。削減是這之後的事。