最快的程式碼,是不執行的程式碼

默白發表於2016-03-22

前言

當效能是一項特性時,緩慢就是一個 Bug。找到緩慢的源頭就像是追蹤 Bug,一旦你找到緩慢程式碼,通常有三種方式可以加速程式碼:

  • 讓內部迴圈執行更快。這包括了快取優化、減少分支和優化SIMD。
  • 用更多的處理器執行內部迴圈。在多個處理器的多個核心,或是多臺機器上共同執行。
  • 減少內部迴圈執行次數。這包括了從早期測試到演算法革新的一系列手段來改善計算複雜度(big-O)。

故事是關於內部迴圈已經高度優化,並且也同步執行了,但是一些列嚴重的設計缺陷使得系統執行遠慢於預期。

背景

2014年初,我在箭頭遊戲工作室工作,當時我們打算重新啟動經典的街機遊戲Gauntlet。事件發生在我們打算首次公佈該遊戲的前一週。我們想要讓玩家可以任意選擇遊戲關卡,最後一週裡大家都忙於修復漏洞、改善遊戲設定,以及提升視覺效果。看起來一切順利,但是最後一週出現了一些情況:幀率從預期的60FPS下降到了20-25FPS的龜速。這引起了極大的恐慌。哪裡出錯了?我們還有時間修復嗎?

調查

我們馬上把懷疑方向轉向了三維場景的光照。近期剛增加了大量的全方位陰影投射燈,光影效果的開銷從來都是比較大的。

Gauntlet的開發利用了第三方BitSquid引擎(原名叫StingRay)。我們用陰影貼圖來使用一個延遲著色管線。這篇部落格就不詳細闡述陰影貼圖的工作原理了,不過這裡是一個簡短的概述,可以看出在BitSquid中如何完成全方面的陰影對映:

每個全向光模擬為六個聚光燈走向的正負x、y、z軸。對於每個“虛擬聚光燈”,引擎會把附近的幾何圖形呈現在陰影貼圖上,這是一個螢幕外的緩衝區,包含了從燈光到最近幾何圖形的距離。這些陰影貼圖之後會被用來確定場景中的各個畫素是否需要被陰影投射所遮擋。

關掉陰影上的所有投射燈之後確實讓幀率重回60FPSl ,但是這也破壞了這個遊戲的整體感覺。在最後的兩天裡,我決定追查問題的來源。

  我開始嘗試改動陰影貼圖設定。尤其是,我將解析度從1024² 大幅地調整到 了16²。這不會對幀率速度造成任何影響。這一點提醒了我,有一些地方出了大錯。在加強遊戲內部探查器之後我發現了罪魁禍首:陰影投射的剔除。

問題

在繪製陰影貼圖的時候,我們不能只是簡單地把所有的幾何圖形傳送給渲染器,因為這可能會導致陰影貼圖渲染速度非常低。相反的,渲染器會首先剔除掉幾何圖形。就是這種剔除過程耗時太長。事實上,這會耗費長達25毫秒!我們為一個幀率穩定在60 FPS 的遊戲,每幀的預算為16ms。這16毫秒裡需要做所有事:遊戲邏輯、物理模擬、陰影渲染、場景渲染、場景照明和後期處理等。16毫秒來做這一切,而現在光做一件事就需要25毫秒。該死的!

這是在週三發現的。週五我們就要讓最終版本開始執行。在週四早上我做出了一個大膽的承諾:今天結束的時候,幀率會加倍。然後我就去上班了。

值得慶幸的是,我們擁有BitSquid的原始碼許可證,我習慣於優化引擎並修復漏洞。看了這些程式碼之後發現BitSquid單純地把所有的幾何圖形在整體水平上進行剔除,在每一次的陰影投射聚光燈和每六次的全方位燈光時進行。此外,這種剔除是通過耗時的OBB(面向定界框)和錐測試的鬥爭中完成的。這就意味著在水平上有N個幾何圖形和L個全方位燈,就會有N*L*6個OBB-錐測試。就是這些測試花費了25毫秒。很顯然,BitSquid已經有人意識到這部分程式碼可能變成一個瓶頸,因為這樣的OBB-錐測試是SIMD優化的,並且在數個工作路線中並行處理。這也意味著我不可以讓這些程式碼執行的更快,或是用更多的處理器去執行它。所以我只剩下了唯一一條路:讓程式碼少執行幾次。

解決方案

我只剩下一天時間來提高效能了,所以我採取的方法都對引擎(我只是大概瞭解的)產生了最小的改變。

早期

BitSquid的所有幾何圖形都有一個已經預先計算好的OBB包圍盒,但是OBB測試總是很慢。我決定在每一個幾何圖形和介面上增加一個很粗糙、但是執行更快的原始定界框:一個球體。測試彼此排斥的兩個球體開銷要小得多,這在很大程度上讓我們免於耗時的OBB測試,也節省了大量時間。然而,我還沒有時間去修改引擎和所有的工具,來為每個幾何圖形增加一個預先計算好的最小包圍球。相反的,我決定來計算從OBB出來的最小包圍半徑。這個長寬高分別為W、H、D的包圍盒外包圍球的最小半徑是 √((W/2)² + (H/2)² + (D/2)²), 但是這個平方根有點大。所以我決定讓這個包圍球稍微大一點,計算後得出半徑為√3/2 * max(W, H, D)( 其中√3/2可以重複使用)。

我還計算出所有燈光的包圍球,這對於全方位投影燈來說當然微不足道,但是對聚光燈來說這也稍顯複雜,但是我想到了一個快速逼近的辦法,不過還沒有想出具體的細節。

預先計算一組潛在的陰影投射

把遙遠的幾何圖形送去剔除完全是在浪費時間。大多數的遊戲引擎都在某種形式的層次結構(如BSP)中儲存遊戲關卡,這使得引擎能大段大段地遠距離剔除。BitSquid卻沒有這樣的功能,我也沒有時間在一天之內加上這樣一個結構。所以我在幀的開頭新增了額外的步驟,這樣我就可以在所有相交的相機陰影投射燈周圍加上邊界框。這形成了一個包含一切的邊界框,這就可以在我們的視野範圍內投射一個介面。然後,我就可以預先剔除擁有這個邊界框場景中的一切,從而選出一組潛在的陰影投射。這就意味著,在之後的剔除過程中,我們只需要測試該場景中的幾何圖形,而不是所有的。

把全向光看作一個整體

如前所述,BitSquid把每個全向光看做六個聚光燈,並且單獨為每個聚光燈進行剔除。我增加了一個預處理,在那我剔除潛在陰影投射中每一個有包圍球的泛光燈,從而挑選出靠近泛光燈的幾何圖形。只有通過這項粗略測試之後,我才會把這些幾何圖形傳送去進一步測試,也就是使用原始的OBB-錐測試程式碼對六個虛擬聚光燈進行的測試。

結果

在引擎上加上所有這些步驟之後,我成功把剔除過程從25毫秒減到了2毫秒左右,我們的幀率也順利穩定在了60FPS。任務完成!第二天我們釋出了預覽版。

教訓

  • 擁有你現在正在使用的所有中間軟體的原始碼是十分必要的。它不僅會幫助你發現問題所在,你也可以通過它來修復問題。
  • 如果你想要加速程式碼執行,首先退一步,想一想這個程式碼是否可以不執行。

相關文章