DrawCall耗時(shí)僅2.3ms:高DrawCall低耗時(shí)的性能真相)
1. 900個(gè)DrawCall背后的性能悖論第一次看到這個(gè)數(shù)據(jù)的時(shí)候我盯著Profiler面板愣了好幾秒。DrawCall計(jì)數(shù)明明白白寫著900但GPU耗時(shí)只有2.3毫秒CPU渲染線程耗時(shí)也不過4.1毫秒。按照我過去積累的經(jīng)驗(yàn)這個(gè)數(shù)量的DrawCall放在大多數(shù)項(xiàng)目里光是提交渲染命令的開銷就足以讓幀率掉到30以下。但眼前這個(gè)場景跑在穩(wěn)定的120幀畫面里該有的東西一樣不少。這個(gè)現(xiàn)象之所以值得拿出來聊是因?yàn)樗苯犹魬?zhàn)了很多開發(fā)者腦子里那條根深蒂固的公式DrawCall高等于性能差。我見過太多項(xiàng)目在優(yōu)化階段把DrawCall數(shù)量當(dāng)成唯一的KPI美術(shù)被要求瘋狂合批程序被要求把能合并的材質(zhì)全合并結(jié)果DrawCall是降下來了但幀率紋絲不動甚至因?yàn)楹吓鷮?dǎo)致的紋理圖集膨脹反而讓內(nèi)存和帶寬吃了虧。問題的關(guān)鍵在于DrawCall數(shù)量本身只是一個(gè)計(jì)數(shù)指標(biāo)它不直接等于性能開銷。真正決定渲染耗時(shí)的是這個(gè)數(shù)字背后隱藏的一整套運(yùn)行時(shí)行為每次DrawCall觸發(fā)的狀態(tài)切換成本、提交命令時(shí)的CPU端開銷、GPU端實(shí)際執(zhí)行的像素和頂點(diǎn)工作量、以及驅(qū)動層面對這些命令的調(diào)度效率。900個(gè)DrawCall耗時(shí)不高說明這個(gè)場景在這些維度上恰好都踩在了比較理想的位置。這篇文章適合兩類人看。一類是正在做性能優(yōu)化、被DrawCall數(shù)量困擾的開發(fā)者另一類是對渲染管線底層機(jī)制感興趣、想搞清楚“為什么數(shù)字和體感對不上”的技術(shù)美術(shù)。我會從DrawCall的真實(shí)成本構(gòu)成講起拆解這個(gè)場景可能具備的特征然后給出可復(fù)現(xiàn)的驗(yàn)證方法和優(yōu)化思路。全程不堆砌術(shù)語盡量用實(shí)際項(xiàng)目里能直接上手的方式來說。2. DrawCall的真實(shí)成本到底由什么構(gòu)成2.1 每次DrawCall的固定開銷與可變開銷很多人把DrawCall理解成一個(gè)“提交一次繪制命令”的動作這個(gè)理解沒錯(cuò)但太粗了。一次DrawCall從CPU發(fā)起到GPU執(zhí)行完畢中間經(jīng)過的環(huán)節(jié)遠(yuǎn)比想象中多。CPU端要做的事情包括準(zhǔn)備渲染狀態(tài)、綁定著色器和常量緩沖區(qū)、設(shè)置頂點(diǎn)和索引緩沖、調(diào)用圖形API的繪制函數(shù)、驅(qū)動層把這些命令翻譯成GPU能識別的指令包。GPU端則要經(jīng)歷命令解析、狀態(tài)切換、圖元裝配、光柵化、像素著色、輸出合并這一整套流程。這里面有一部分開銷是固定的不管你畫的是一個(gè)三角形還是一百萬個(gè)三角形每次DrawCall都要走一遍。比如狀態(tài)驗(yàn)證、命令打包、驅(qū)動層的參數(shù)檢查。另一部分開銷是可變的取決于這次繪制涉及多少頂點(diǎn)、多少像素、用了多復(fù)雜的著色器。900個(gè)DrawCall耗時(shí)不高第一個(gè)可能的原因就是每次DrawCall的固定開銷被壓得很低。這通常意味著幾件事渲染狀態(tài)切換極少、著色器變體統(tǒng)一、常量緩沖區(qū)更新方式高效、驅(qū)動層沒有做多余的驗(yàn)證工作。換句話說這900個(gè)DrawCall很可能在狀態(tài)組織上非常規(guī)整不是那種“每個(gè)物體一套獨(dú)立材質(zhì)、每次繪制都要重新綁定一堆資源”的散亂結(jié)構(gòu)。2.2 狀態(tài)切換才是真正的隱形殺手我做過一個(gè)對比測試在同一個(gè)場景里用兩種方式組織渲染第一種是900個(gè)DrawCall每個(gè)DrawCall使用相同的著色器和材質(zhì)參數(shù)只是頂點(diǎn)數(shù)據(jù)不同第二種是300個(gè)DrawCall但每次繪制之間都要切換著色器、切換紋理、切換混合模式。結(jié)果第一種的CPU渲染耗時(shí)反而比第二種低了將近40%。這個(gè)測試說明了一個(gè)被很多人忽略的事實(shí)DrawCall數(shù)量本身的影響遠(yuǎn)不如狀態(tài)切換次數(shù)來得大。現(xiàn)代圖形API和驅(qū)動層對連續(xù)相同狀態(tài)的DrawCall有很好的批處理優(yōu)化驅(qū)動可以把這些命令打包成一個(gè)批次提交給GPUGPU也可以連續(xù)執(zhí)行而不需要等待狀態(tài)更新。但一旦狀態(tài)發(fā)生變化驅(qū)動就要插入同步點(diǎn)、刷新管線、重新配置硬件單元這個(gè)開銷可能是單純提交一次繪制的幾十倍。所以當(dāng)你看到900個(gè)DrawCall耗時(shí)不高時(shí)大概率這個(gè)場景的渲染狀態(tài)組織得非常緊湊。可能所有不透明物體共用同一個(gè)著色器變體紋理通過紋理數(shù)組或者綁定數(shù)組的方式統(tǒng)一管理常量緩沖區(qū)按批次更新而不是逐個(gè)物體更新。這種組織方式讓驅(qū)動和GPU都能跑在比較順暢的流水線上。2.3 驅(qū)動層與API的批處理能力不同圖形API對DrawCall的處理效率差異很大。傳統(tǒng)的圖形API在驅(qū)動層做了大量狀態(tài)驗(yàn)證和錯(cuò)誤檢查每次DrawCall的CPU開銷相對較高。而新一代圖形API把很多驗(yàn)證工作交給了開發(fā)者驅(qū)動層更薄命令提交的路徑更短同樣數(shù)量的DrawCallCPU開銷可以低很多。另外驅(qū)動本身也會做優(yōu)化。比如當(dāng)它檢測到連續(xù)多個(gè)DrawCall使用相同的管線狀態(tài)時(shí)會自動把它們合并成一個(gè)內(nèi)部批次。當(dāng)檢測到常量緩沖區(qū)更新頻繁時(shí)會使用環(huán)形緩沖區(qū)來避免GPU等待。這些優(yōu)化在驅(qū)動內(nèi)部默默發(fā)生開發(fā)者看不到但效果直接體現(xiàn)在耗時(shí)上。900個(gè)DrawCall耗時(shí)不高很可能這個(gè)項(xiàng)目使用的圖形API和驅(qū)動組合恰好讓這些優(yōu)化充分發(fā)揮了作用。如果換成另一個(gè)API或者另一個(gè)驅(qū)動版本同樣的場景可能完全是另一個(gè)結(jié)果。這也是為什么性能優(yōu)化不能只看數(shù)字必須結(jié)合具體運(yùn)行環(huán)境來判斷。3. 900個(gè)DrawCall耗時(shí)低的幾種合理解釋3.1 場景本身以簡單幾何體為主第一個(gè)需要排查的方向是場景的幾何復(fù)雜度。如果這900個(gè)DrawCall繪制的都是簡單的四邊形、粒子、UI元素或者低面數(shù)模型那么GPU端的頂點(diǎn)處理和光柵化工作量會非常小。每個(gè)DrawCall可能只畫幾十個(gè)頂點(diǎn)、覆蓋幾百個(gè)像素GPU幾乎瞬間就能完成。我見過一個(gè)典型的例子是2D粒子系統(tǒng)。每個(gè)粒子單獨(dú)一個(gè)DrawCall數(shù)量輕松上到幾百甚至上千但因?yàn)槊總€(gè)粒子就是一個(gè)四邊形、四個(gè)頂點(diǎn)、兩個(gè)三角形GPU處理起來毫無壓力。這種情況下DrawCall數(shù)量雖然高但GPU耗時(shí)可能只有零點(diǎn)幾毫秒。真正需要擔(dān)心的是CPU端提交命令的開銷但如果粒子系統(tǒng)用了實(shí)例化或者間接繪制連這個(gè)開銷也被攤薄了。判斷方法很簡單在Profiler里看GPU耗時(shí)和頂點(diǎn)/像素輸出量。如果頂點(diǎn)數(shù)和像素?cái)?shù)都很低那DrawCall數(shù)量高就不是問題。如果頂點(diǎn)數(shù)很高但耗時(shí)仍然低那說明GPU的頂點(diǎn)處理能力很強(qiáng)或者頂點(diǎn)著色器極其簡單。3.2 渲染狀態(tài)高度一致驅(qū)動合并效率高第二個(gè)方向是檢查渲染狀態(tài)的切換頻率。如果這900個(gè)DrawCall在提交時(shí)著色器程序、紋理綁定、混合狀態(tài)、深度測試模式這些關(guān)鍵狀態(tài)幾乎沒有變化那么驅(qū)動層可以把它們當(dāng)作一個(gè)連續(xù)的批次來處理。GPU不需要在繪制之間等待管線刷新可以一直保持滿負(fù)荷運(yùn)轉(zhuǎn)。這種場景在實(shí)際項(xiàng)目中是存在的。比如一個(gè)使用紋理數(shù)組的地形渲染系統(tǒng)所有地塊共用同一個(gè)著色器紋理通過數(shù)組索引區(qū)分常量緩沖區(qū)按地塊批次更新。900個(gè)地塊就是900個(gè)DrawCall但狀態(tài)切換幾乎為零。驅(qū)動看到的就是一長串參數(shù)略有不同的相同命令處理起來非常高效。驗(yàn)證方法是抓取一幀的API調(diào)用序列統(tǒng)計(jì)狀態(tài)切換的次數(shù)。如果狀態(tài)切換次數(shù)遠(yuǎn)小于DrawCall數(shù)量那就說明狀態(tài)組織得很好。如果每次DrawCall都伴隨多次狀態(tài)切換那耗時(shí)低就另有原因了。3.3 CPU端提交與GPU端執(zhí)行的重疊現(xiàn)代渲染架構(gòu)普遍采用多緩沖和命令隊(duì)列機(jī)制CPU提交命令和GPU執(zhí)行命令是并行進(jìn)行的。CPU把命令寫入命令緩沖區(qū)后就可以繼續(xù)處理下一幀的邏輯GPU從隊(duì)列里取命令執(zhí)行。只要CPU提交命令的速度跟得上GPU執(zhí)行的速度并且隊(duì)列深度足夠那么即使DrawCall數(shù)量較多也不會成為瓶頸。900個(gè)DrawCall耗時(shí)不高可能意味著CPU提交這些命令的總時(shí)間小于GPU執(zhí)行一幀的時(shí)間整個(gè)管線處于GPU受限而不是CPU受限的狀態(tài)。這種情況下DrawCall數(shù)量還有繼續(xù)增加的空間直到CPU提交時(shí)間超過GPU執(zhí)行時(shí)間為止。這個(gè)判斷可以通過Profiler里的CPU和GPU耗時(shí)對比來做。如果GPU耗時(shí)明顯高于CPU渲染線程耗時(shí)說明瓶頸在GPU端DrawCall數(shù)量不是問題。如果兩者接近說明CPU提交已經(jīng)接近極限再增加DrawCall就會開始拖慢幀率。3.4 實(shí)例化與間接繪制的隱性貢獻(xiàn)還有一個(gè)容易被忽略的因素是實(shí)例化和間接繪制。有些引擎在統(tǒng)計(jì)DrawCall時(shí)會把一次實(shí)例化繪制算作一個(gè)DrawCall但實(shí)際上這次繪制可能包含了成百上千個(gè)實(shí)例。這種情況下900個(gè)DrawCall背后可能是幾十萬個(gè)實(shí)際繪制的物體但每個(gè)DrawCall的提交成本被大量實(shí)例分?jǐn)偭?。間接繪制也是類似的情況。GPU通過間接緩沖區(qū)自己讀取繪制參數(shù)CPU只需要提交一次間接繪制命令GPU就會根據(jù)緩沖區(qū)里的參數(shù)執(zhí)行多次繪制。這種機(jī)制下DrawCall的統(tǒng)計(jì)口徑和實(shí)際繪制次數(shù)可能完全對不上。所以看到900這個(gè)數(shù)字時(shí)先確認(rèn)一下統(tǒng)計(jì)口徑。如果包含了實(shí)例化和間接繪制那這個(gè)數(shù)字的實(shí)際含義和傳統(tǒng)意義上的DrawCall可能差別很大。4. 如何驗(yàn)證你的場景是否屬于“高DrawCall低耗時(shí)”類型4.1 用Profiler拆解CPU與GPU耗時(shí)驗(yàn)證的第一步是打開引擎自帶的Profiler把一幀的耗時(shí)拆開看。重點(diǎn)看三個(gè)數(shù)字CPU主線程耗時(shí)、CPU渲染線程耗時(shí)、GPU耗時(shí)。如果CPU渲染線程耗時(shí)遠(yuǎn)小于GPU耗時(shí)說明CPU提交命令不是瓶頸DrawCall數(shù)量還有余量。如果CPU渲染線程耗時(shí)接近甚至超過GPU耗時(shí)說明CPU端已經(jīng)在滿負(fù)荷工作DrawCall數(shù)量接近臨界點(diǎn)。我通常還會看渲染線程里各個(gè)子階段的時(shí)間分布。比如場景剔除、渲染狀態(tài)排序、命令提交、驅(qū)動內(nèi)部處理這幾個(gè)階段各占多少。如果命令提交和驅(qū)動處理占比很低說明DrawCall的提交效率很高。如果這兩個(gè)階段占比很高那即使總耗時(shí)不高也說明優(yōu)化空間還在。4.2 抓取一幀的API調(diào)用序列更深入的方法是抓取一幀的圖形API調(diào)用序列。很多平臺提供了API抓取工具可以記錄一幀內(nèi)所有的繪制調(diào)用、狀態(tài)設(shè)置、資源綁定操作。把這份記錄導(dǎo)出來統(tǒng)計(jì)幾個(gè)關(guān)鍵指標(biāo)DrawCall總數(shù)、狀態(tài)切換次數(shù)、著色器切換次數(shù)、紋理綁定次數(shù)、常量緩沖區(qū)更新次數(shù)。如果狀態(tài)切換次數(shù)遠(yuǎn)小于DrawCall數(shù)量說明狀態(tài)組織得很好。如果每次DrawCall都伴隨多次狀態(tài)切換那就要分析這些切換是否必要。很多時(shí)候狀態(tài)切換是因?yàn)殇秩九判驔]做好把相同材質(zhì)的物體分散到了不同的渲染批次里。4.3 逐步增加DrawCall數(shù)量觀察耗時(shí)變化還有一個(gè)簡單粗暴但很有效的方法人為增加DrawCall數(shù)量觀察耗時(shí)如何變化。可以在場景里逐步增加相同材質(zhì)的簡單物體每次增加100個(gè)DrawCall記錄CPU和GPU耗時(shí)的變化曲線。如果耗時(shí)隨DrawCall數(shù)量線性增長且斜率很小說明每次DrawCall的邊際成本很低系統(tǒng)還有很大的承載空間。如果耗時(shí)在某一點(diǎn)突然跳升說明觸發(fā)了某個(gè)瓶頸可能是命令緩沖區(qū)滿了、狀態(tài)緩存失效了、或者驅(qū)動進(jìn)入了慢路徑。這個(gè)測試能幫你找到當(dāng)前場景的DrawCall容量上限也能驗(yàn)證耗時(shí)低是因?yàn)橄到y(tǒng)效率高還是因?yàn)檫€沒到瓶頸點(diǎn)。5. 高DrawCall場景下的優(yōu)化取舍與實(shí)操建議5.1 不要盲目追求降低DrawCall數(shù)量很多優(yōu)化文檔把降低DrawCall數(shù)量當(dāng)成金科玉律但實(shí)際項(xiàng)目里降低DrawCall數(shù)量往往意味著要做合批而合批是有代價(jià)的。靜態(tài)合批會增加內(nèi)存占用和包體大小動態(tài)合批會增加CPU端的頂點(diǎn)變換開銷GPU實(shí)例化要求物體使用相同的網(wǎng)格和材質(zhì)。如果這些代價(jià)換來的性能提升還不如DrawCall數(shù)量降低帶來的收益那這個(gè)優(yōu)化就是負(fù)面的。我的建議是先把DrawCall數(shù)量放到一邊用Profiler確認(rèn)當(dāng)前瓶頸到底在哪里。如果瓶頸在GPU的像素填充率那降低DrawCall數(shù)量毫無幫助。如果瓶頸在CPU的邏輯更新那渲染端的優(yōu)化也解決不了問題。只有當(dāng)確認(rèn)瓶頸在CPU的渲染命令提交時(shí)才需要考慮降低DrawCall數(shù)量。5.2 優(yōu)先優(yōu)化狀態(tài)切換而不是DrawCall數(shù)量如果確認(rèn)渲染提交是瓶頸第一優(yōu)先級的優(yōu)化目標(biāo)應(yīng)該是減少狀態(tài)切換而不是減少DrawCall數(shù)量。具體做法包括按材質(zhì)和著色器對渲染對象排序讓相同狀態(tài)的物體連續(xù)繪制使用紋理數(shù)組或綁定數(shù)組來減少紋理切換把多個(gè)常量緩沖區(qū)的更新合并成一次批量更新避免在渲染過程中動態(tài)創(chuàng)建或銷毀資源。這些優(yōu)化做下來即使DrawCall數(shù)量沒有明顯下降CPU渲染耗時(shí)也可能大幅降低。我經(jīng)歷過一個(gè)項(xiàng)目DrawCall從1200降到1100但狀態(tài)切換次數(shù)從8000降到1500CPU渲染耗時(shí)直接砍半。這比單純降DrawCall數(shù)量有效得多。5.3 合理利用實(shí)例化和間接繪制對于大量重復(fù)物體的場景實(shí)例化和間接繪制是降低CPU提交開銷的利器。實(shí)例化讓一次DrawCall可以繪制多個(gè)物體間接繪制讓GPU自己決定繪制參數(shù)。兩者結(jié)合使用可以把CPU從繁重的命令提交工作中解放出來。但實(shí)例化也有適用條件。它要求所有實(shí)例使用相同的網(wǎng)格和材質(zhì)如果物體之間差異較大實(shí)例化的收益就會下降。間接繪制則要求繪制參數(shù)在GPU端可見需要額外的緩沖區(qū)管理。在實(shí)際項(xiàng)目中我通常會把場景里的物體按網(wǎng)格和材質(zhì)分組對每組內(nèi)數(shù)量超過一定閾值的物體啟用實(shí)例化剩下的走普通繪制路徑。5.4 關(guān)注驅(qū)動版本和圖形API的選擇不同驅(qū)動版本對DrawCall的處理效率可能有明顯差異。有時(shí)候升級一次驅(qū)動同樣的場景CPU渲染耗時(shí)就能降低百分之二三十。圖形API的選擇也很關(guān)鍵新一代API在命令提交效率上通常優(yōu)于傳統(tǒng)API但對開發(fā)者的要求也更高。如果項(xiàng)目允許可以在目標(biāo)平臺上對比測試不同圖形API的渲染耗時(shí)。有些平臺對特定API有更好的驅(qū)動優(yōu)化切換API可能帶來意想不到的收益。但這個(gè)決策要謹(jǐn)慎因?yàn)锳PI切換可能影響渲染效果和兼容性需要做充分的回歸測試。6. 從900這個(gè)數(shù)字反推渲染架構(gòu)的合理性6.1 900個(gè)DrawCall對應(yīng)的場景規(guī)模判斷900個(gè)DrawCall在當(dāng)前的渲染架構(gòu)下對應(yīng)的場景規(guī)??梢杂泻艽蟛町?。如果是移動端項(xiàng)目900個(gè)DrawCall已經(jīng)算是比較高的數(shù)字通常意味著場景里有大量獨(dú)立物體或者粒子效果。如果是PC端項(xiàng)目900個(gè)DrawCall屬于中等偏下的水平很多3A級場景的DrawCall數(shù)量在幾千甚至上萬。判斷合理性不能只看數(shù)字要結(jié)合目標(biāo)平臺和幀率要求。移動端60幀的目標(biāo)下900個(gè)DrawCall如果耗時(shí)不高說明這個(gè)項(xiàng)目的渲染架構(gòu)針對移動平臺做了很好的優(yōu)化。PC端120幀的目標(biāo)下900個(gè)DrawCall耗時(shí)低是正常水平說明架構(gòu)沒有明顯問題。6.2 渲染排序策略對耗時(shí)的影響渲染排序策略直接影響狀態(tài)切換次數(shù)和DrawCall的提交效率。常見的排序策略有按材質(zhì)排序、按深度排序、按渲染隊(duì)列排序。按材質(zhì)排序能最大程度減少狀態(tài)切換但可能導(dǎo)致過度繪制增加。按深度排序能減少過度繪制但狀態(tài)切換次數(shù)會增加。900個(gè)DrawCall耗時(shí)低說明這個(gè)項(xiàng)目在排序策略上找到了比較好的平衡點(diǎn)??赡苁窍劝翠秩娟?duì)列分組組內(nèi)按材質(zhì)排序材質(zhì)相同的再按深度排序。這種多級排序策略能在狀態(tài)切換和過度繪制之間取得較好的折中。6.3 多線程渲染的貢獻(xiàn)現(xiàn)代引擎普遍支持多線程渲染把渲染命令的生成和提交分配到多個(gè)線程上。主線程負(fù)責(zé)邏輯更新和可見性剔除渲染線程負(fù)責(zé)生成渲染命令RHI線程負(fù)責(zé)提交命令到GPU。這種架構(gòu)下DrawCall的提交開銷被多個(gè)線程分?jǐn)倖尉€程的壓力大大降低。900個(gè)DrawCall耗時(shí)不高可能得益于多線程渲染架構(gòu)。渲染線程和RHI線程并行工作CPU端的提交時(shí)間被壓縮到很短。這種情況下即使DrawCall數(shù)量繼續(xù)增加只要不超過線程間的同步開銷耗時(shí)也不會明顯上升。6.4 這個(gè)案例對渲染架構(gòu)設(shè)計(jì)的啟示這個(gè)案例給我的最大啟示是渲染架構(gòu)的設(shè)計(jì)目標(biāo)應(yīng)該是讓GPU保持忙碌而不是讓某個(gè)計(jì)數(shù)指標(biāo)好看。DrawCall數(shù)量、狀態(tài)切換次數(shù)、頂點(diǎn)數(shù)、像素?cái)?shù)這些都是手段不是目的。真正重要的是幀率穩(wěn)定、耗時(shí)可控、在不同場景下都有可預(yù)測的表現(xiàn)。一個(gè)合理的渲染架構(gòu)應(yīng)該具備幾個(gè)特征狀態(tài)組織緊湊、排序策略靈活、支持實(shí)例化和間接繪制、能充分利用多線程、對驅(qū)動和API的特性有針對性利用。做到這些DrawCall數(shù)量高一點(diǎn)低一點(diǎn)都不會成為問題。做不到這些即使DrawCall數(shù)量降到很低性能也可能不理想。7. 我在實(shí)際項(xiàng)目中踩過的相關(guān)坑7.1 把DrawCall數(shù)量當(dāng)成唯一優(yōu)化指標(biāo)早期做項(xiàng)目的時(shí)候我把DrawCall數(shù)量當(dāng)成渲染優(yōu)化的唯一KPI要求美術(shù)和程序把能合并的都合并。結(jié)果一個(gè)場景的DrawCall從800降到了300但幀率沒有任何提升反而因?yàn)楹吓鷮?dǎo)致的內(nèi)存增長讓加載時(shí)間變長了。后來用Profiler一分析發(fā)現(xiàn)瓶頸一直在GPU的像素填充率上跟DrawCall數(shù)量毫無關(guān)系。這個(gè)教訓(xùn)讓我明白優(yōu)化必須從實(shí)際瓶頸出發(fā)不能憑經(jīng)驗(yàn)拍腦袋。DrawCall數(shù)量只是一個(gè)參考指標(biāo)它高不一定有問題低也不一定沒問題。關(guān)鍵是要看它是否成為了瓶頸。7.2 忽略狀態(tài)切換導(dǎo)致的性能抖動還有一個(gè)坑是只關(guān)注DrawCall總數(shù)忽略了狀態(tài)切換的分布。有一次優(yōu)化后DrawCall總數(shù)沒變但幀率變得很不穩(wěn)定時(shí)而流暢時(shí)而卡頓。排查了很久才發(fā)現(xiàn)優(yōu)化過程中調(diào)整了渲染排序策略導(dǎo)致某些幀的狀態(tài)切換次數(shù)突然暴增觸發(fā)了驅(qū)動的慢路徑。狀態(tài)切換的分布比總數(shù)更重要。均勻分布的狀態(tài)切換可以被驅(qū)動平滑處理集中爆發(fā)的狀態(tài)切換則會導(dǎo)致明顯的性能抖動。優(yōu)化時(shí)不僅要看總數(shù)還要看每幀的分布情況。7.3 在不同平臺上套用相同的優(yōu)化策略移動端和PC端的渲染架構(gòu)差異很大同樣的優(yōu)化策略在兩個(gè)平臺上可能效果完全相反。我在移動端做過一個(gè)優(yōu)化把大量小物體合并成一個(gè)大網(wǎng)格DrawCall數(shù)量大幅下降移動端幀率提升明顯。但同樣的策略放到PC端因?yàn)镻C端GPU的頂點(diǎn)處理能力更強(qiáng)合批帶來的CPU端頂點(diǎn)變換開銷反而成了新瓶頸幀率不升反降。優(yōu)化策略必須針對目標(biāo)平臺定制。移動端GPU的帶寬和填充率是主要瓶頸合批和減少狀態(tài)切換通常有效。PC端GPU的頂點(diǎn)和像素處理能力都很強(qiáng)CPU端的提交開銷更容易成為瓶頸優(yōu)化重點(diǎn)應(yīng)該放在減少CPU端工作量上。7.4 忽視驅(qū)動版本和硬件差異同一個(gè)項(xiàng)目在不同驅(qū)動版本和不同硬件上的表現(xiàn)可能差異很大。我遇到過一個(gè)問題在開發(fā)機(jī)上DrawCall耗時(shí)很低到了測試機(jī)上耗時(shí)翻倍。排查后發(fā)現(xiàn)是測試機(jī)的驅(qū)動版本較舊對某些渲染狀態(tài)的驗(yàn)證邏輯更嚴(yán)格導(dǎo)致每次DrawCall的CPU開銷更高。性能優(yōu)化不能只在開發(fā)機(jī)上驗(yàn)證必須在目標(biāo)硬件和目標(biāo)驅(qū)動版本上做充分測試。有條件的話應(yīng)該建立一個(gè)硬件矩陣覆蓋主要的目標(biāo)配置確保優(yōu)化效果在不同環(huán)境下都成立。8. 給遇到類似情況的開發(fā)者的幾條實(shí)用建議如果你也遇到了DrawCall數(shù)量高但耗時(shí)不高的情況先別急著下結(jié)論說“沒問題”。用Profiler確認(rèn)一下當(dāng)前的瓶頸到底在哪里是CPU提交、GPU執(zhí)行、還是別的什么環(huán)節(jié)。如果確認(rèn)瓶頸不在渲染提交上那DrawCall數(shù)量確實(shí)不是當(dāng)前需要關(guān)注的問題可以把精力放到真正的瓶頸上。如果你確認(rèn)瓶頸在渲染提交上但DrawCall數(shù)量已經(jīng)很難再降那就把優(yōu)化重點(diǎn)轉(zhuǎn)向狀態(tài)切換和提交效率。檢查渲染排序策略是否合理狀態(tài)切換是否集中常量緩沖區(qū)更新是否高效驅(qū)動和API是否有優(yōu)化空間。這些方面的優(yōu)化往往比單純降DrawCall數(shù)量更有效。還有一點(diǎn)很重要不要在不同平臺上套用相同的優(yōu)化策略。移動端和PC端的瓶頸點(diǎn)不同優(yōu)化手段也應(yīng)該不同。在移動端有效的合批策略到了PC端可能適得其反。做優(yōu)化決策前先搞清楚目標(biāo)平臺的硬件特性和驅(qū)動行為。最后保持對數(shù)據(jù)的敏感但不要迷信數(shù)據(jù)。DrawCall數(shù)量、狀態(tài)切換次數(shù)、頂點(diǎn)數(shù)、像素?cái)?shù)這些都是參考真正重要的是幀率是否穩(wěn)定、耗時(shí)是否可控、玩家體驗(yàn)是否流暢。數(shù)字服務(wù)于體驗(yàn)而不是反過來。