架構(gòu)深度拆解:從剔除調(diào)度到資源管理實(shí)踐)
系列第一篇寫完評(píng)論區(qū)一直有人催更渲染這塊。確實(shí)渲染系統(tǒng)是整個(gè)引擎里最“外露”的東西——玩家不會(huì)夸你物理算得有多準(zhǔn)、網(wǎng)絡(luò)同步有多穩(wěn)但渲染卡一幀、陰影閃一下、遠(yuǎn)處貼圖糊一片那都是一眼就能看見的事。這篇我就把渲染系統(tǒng)架構(gòu)從整體設(shè)計(jì)到落地細(xì)節(jié)完整拆一遍會(huì)涉及一個(gè)自研引擎實(shí)際開發(fā)過程中的大量取舍記錄和踩坑實(shí)錄。無論你是打算從頭寫一個(gè)引擎還是想搞懂商業(yè)引擎內(nèi)部渲染模塊的運(yùn)作邏輯這篇都值得看完。1. 渲染系統(tǒng)架構(gòu)的第一性問題它到底管什么1.1 職責(zé)邊界先畫清楚很多初學(xué)者拿到引擎源碼第一反應(yīng)是去找DrawCall在哪、Shader在哪。其實(shí)渲染系統(tǒng)的架構(gòu)設(shè)計(jì)最先要搞清楚的根本不是畫什么、怎么畫而是邊界。渲染系統(tǒng)要管的不是“把三角形畫出來”這么簡(jiǎn)單。完整職責(zé)范圍包括場(chǎng)景數(shù)據(jù)組織哪些物體存在、在哪、可見性判定攝像機(jī)能看到誰、資源生命周期頂點(diǎn)緩沖、紋理、材質(zhì)上傳下載的時(shí)機(jī)、渲染狀態(tài)切換深度測(cè)試、混合模式、視口、光照和陰影的計(jì)算組織、后處理鏈路拼接以及最底層與GPU驅(qū)動(dòng)打交道時(shí)提交命令和同步的方式。這些職責(zé)如果全部塞到一套代碼里前期開發(fā)確實(shí)快但越往后越痛苦。我第一版原型就是這么干的所有渲染調(diào)用直接在游戲邏輯里寫死繪制狀態(tài)隨手改資源加載堵塞主線程。結(jié)果就是調(diào)試任何一條渲染鏈路都要在幾千行業(yè)務(wù)邏輯和底層API調(diào)用中間來回跳轉(zhuǎn)。后來重構(gòu)時(shí)第一件事就是把渲染系統(tǒng)的邊界畫清楚邏輯層只負(fù)責(zé)“要畫的東西和它們的屬性”渲染層只負(fù)責(zé)“怎么高效地畫出來”。這個(gè)切割是整個(gè)架構(gòu)調(diào)整中性價(jià)比最高的一步。1.2 幀循環(huán)與線程模型是地基渲染系統(tǒng)架構(gòu)里最容易被低估的部分是幀循環(huán)和線程模型。一個(gè)幀里邏輯要更新、物理要計(jì)算、動(dòng)畫要采樣、渲染要提交它們的時(shí)間關(guān)系怎么排直接決定你后續(xù)所有功能好不好做。最常見的組織方式是三線程模型主線程游戲邏輯、渲染線程命令錄制與提交、GPU異步執(zhí)行。主線程和渲染線程之間通過命令緩沖Command Buffer通信。主線程把這一幀要畫的內(nèi)容打包成命令寫進(jìn)緩沖渲染線程讀出來翻譯成底層API調(diào)用提交給驅(qū)動(dòng)GPU實(shí)際執(zhí)行。兩個(gè)線程流水線并行主線程跑到第N2幀時(shí)渲染線程可能還在處理第N1幀GPU執(zhí)行的是第N幀。這個(gè)錯(cuò)位設(shè)計(jì)換來的是CPU可支配時(shí)間的大幅提升代價(jià)是畫面顯示會(huì)有一個(gè)固定的幾幀延遲。這個(gè)延遲對(duì)單機(jī)玩家、對(duì)輸入響應(yīng)敏感的對(duì)戰(zhàn)游戲來說處理方式完全不同。我們當(dāng)時(shí)的取舍是主線程提前預(yù)測(cè)一幀的相機(jī)狀態(tài)來渲染減少操作延遲感同時(shí)保留一個(gè)可配置的幀延遲開關(guān)方便調(diào)試時(shí)候改成同步模式。這個(gè)開關(guān)后來幫了大忙很多渲染bug在同步模式下幾秒鐘就能定位異步模式下得靠一堆日志和時(shí)間戳去猜。1.3 架構(gòu)選型的根本矛盾只有一組渲染系統(tǒng)面向的硬件能力差異極大。集顯機(jī)器上可能只有幾十個(gè)DrawCall的預(yù)算而獨(dú)顯機(jī)器上千個(gè)都不帶喘的。這個(gè)矛盾決定了架構(gòu)選型的核心你沒法一套邏輯吃遍所有設(shè)備必須在“保表現(xiàn)”和“保幀率”之間做動(dòng)態(tài)平衡。業(yè)界普遍的解法是渲染質(zhì)量分級(jí)Scalability定義一組從低到高的渲染特性檔位比如陰影分辨率、動(dòng)態(tài)光源數(shù)量、抗鋸齒方案、體積霧開關(guān)、貼圖最大分辨率等。每個(gè)檔位對(duì)應(yīng)一套參數(shù)組合。運(yùn)行時(shí)讀設(shè)備信息給出一個(gè)建議檔位玩家可以手動(dòng)調(diào)。架構(gòu)上要保證這些檔位的切換在運(yùn)行時(shí)是可熱切換的不要走初始化大重置的路子。這個(gè)設(shè)計(jì)最容易被忽略的是不同檔位之間的狀態(tài)一致性。你從高畫質(zhì)切到低畫質(zhì)場(chǎng)景里所有物體的材質(zhì)實(shí)例、燈光陰影的渲染目標(biāo)和紋理池都要跟著換。如果資源系統(tǒng)沒有做好引用計(jì)數(shù)和版本控制熱切換必然出現(xiàn)紋理黑塊、陰影閃爍之類的怪毛病。我們第一版切檔位直接崩了就是因?yàn)榕f資源還沒釋放、新資源已經(jīng)綁定了同一個(gè)槽位。后來統(tǒng)一在渲染資源管理器里加了一層代際編號(hào)Generation老資源延遲到GPU讀完當(dāng)前幀再回收問題才根治。2. 場(chǎng)景數(shù)據(jù)組織與剔除系統(tǒng)決定渲染架構(gòu)的上限2.1 場(chǎng)景結(jié)構(gòu)別一上來就做場(chǎng)景圖很多人一說場(chǎng)景管理第一反應(yīng)是“場(chǎng)景圖Scene Graph”——游戲?qū)ο髵煸趯蛹?jí)節(jié)點(diǎn)上父節(jié)點(diǎn)移動(dòng)子節(jié)點(diǎn)跟著動(dòng)。這個(gè)概念用在編輯器里很舒服但用在渲染系統(tǒng)里如果你直接把場(chǎng)景圖當(dāng)渲染數(shù)據(jù)源用后面遲早要重構(gòu)。原因在于場(chǎng)景圖是面向編輯的層級(jí)結(jié)構(gòu)渲染系統(tǒng)要的是面向查詢的扁平結(jié)構(gòu)。渲染時(shí)最頻繁的操作是“給我視錐體里所有帶MeshRenderer的物體”而不是“從根節(jié)點(diǎn)遞歸遍歷找所有葉子”。如果渲染每幀都去遞歸遍歷場(chǎng)景圖層級(jí)深一點(diǎn)、節(jié)點(diǎn)多一點(diǎn)CPU時(shí)間就全燒在遍歷上了GPU早就閑著等了。我們最終采用的方案是雙結(jié)構(gòu)場(chǎng)景圖服務(wù)編輯器、邏輯和動(dòng)畫系統(tǒng)渲染系統(tǒng)維護(hù)一份獨(dú)立的扁平渲染列表Render List。這個(gè)列表按材質(zhì)、Mesh、渲染隊(duì)列排序組織成適合批處理和剔除的緊湊結(jié)構(gòu)。每次場(chǎng)景圖發(fā)生了空間變化移動(dòng)、增刪通過事件系統(tǒng)通知渲染層更新對(duì)應(yīng)條目而不是每幀全量同步。這個(gè)雙結(jié)構(gòu)的代價(jià)是兩套數(shù)據(jù)要維護(hù)一致性但換來的是渲染側(cè)查詢和剔除的高效完全值得。2.2 視錐體剔除之外必須上遮擋剔除場(chǎng)景里的物體不可能全在畫面里第一步能做的是視錐體剔除把六個(gè)裁剪面算出來凡是包圍盒完全在六個(gè)面之外的物體直接不畫。這個(gè)是渲染系統(tǒng)的基礎(chǔ)操作絕大多數(shù)引擎都能做到。但視錐體剔除只解決“不在畫面里”的問題解決不了“在畫面里但被墻擋住”的問題。一棟樓后面藏著一整條街的物體視錐體全都在但像素一個(gè)都看不見——大量性能就浪費(fèi)在看不到的地方。遮擋剔除的方案有好幾種硬件遮擋查詢Occlusion Query、軟件光柵化遮擋剔除、基于PVS潛在可見集的預(yù)計(jì)算剔除。實(shí)際項(xiàng)目里我推薦先用軟件遮擋剔除配合層級(jí)Z緩沖Hierarchical Z-Buffer。做法是每幀先用極低分辨率把場(chǎng)景里的大型遮擋物比如建筑、地形、大體積的墻光柵化出一個(gè)深度圖然后用這個(gè)深度圖去測(cè)試其他物體包圍盒的投影是否被完全遮擋完全被遮擋的直接剔除。這個(gè)方案對(duì)城市、室內(nèi)場(chǎng)景收益極其明顯。我們做的一個(gè)比較復(fù)雜的開放關(guān)卡純視錐體剔除后DrawCall大概兩千多加上軟件遮擋剔除后直接壓到五百以下幀時(shí)間從22毫秒降到11毫秒畫面一點(diǎn)沒變。遮擋剔除也是分層做不要對(duì)每個(gè)小物件都做精確測(cè)試先把場(chǎng)景按區(qū)域劃分區(qū)域級(jí)別的遮擋測(cè)試結(jié)果直接決定整塊區(qū)域是否丟棄。2.3 剔除結(jié)果要變成渲染任務(wù)而不是逐物體繪制剔除系統(tǒng)輸出的是“這幀要畫哪些物體”的清單但這份清單不能直接拿去畫。直接逐物體繪制意味著每個(gè)物體都要單獨(dú)提交一次綁定Shader、設(shè)置紋理、設(shè)置變換矩陣的流程DrawCall爆炸不說狀態(tài)切換也炸。正確的做法是把可見物體清單交給渲染隊(duì)列Render Queue由隊(duì)列管理器按照“減少狀態(tài)切換、最大化合批”的原則重排。排序鍵Sort Key通常是一個(gè)64位整數(shù)高位是渲染通道不透明、透明、天空盒、后處理等中間是材質(zhì)ID低位是物體在場(chǎng)景里的深度或距離。這樣排序一次同材質(zhì)、同通道的物體自然被排到一起批處理的機(jī)會(huì)就來了。批處理Batching分靜態(tài)和動(dòng)態(tài)靜態(tài)合批針對(duì)不動(dòng)的物體在加載階段把共享同一材質(zhì)的多個(gè)物體合并成一個(gè)大的頂點(diǎn)緩沖徹底消掉它們之間的DrawCall動(dòng)態(tài)合批針對(duì)小物體每幀嘗試把符合條件的幾個(gè)物體打包到一個(gè)DrawCall里。這里有個(gè)坑動(dòng)態(tài)合批不是穩(wěn)賺不賠的合批時(shí)要拷貝頂點(diǎn)數(shù)據(jù)到臨時(shí)緩沖這個(gè)拷貝本身有CPU開銷物體頂點(diǎn)多了或者合批數(shù)量上去了省下的DrawCall開銷還不一定夠拷貝的。我們?cè)陧?xiàng)目里實(shí)際測(cè)試頂點(diǎn)數(shù)超過某個(gè)閾值的Mesh動(dòng)態(tài)合批反而更慢。所以合批策略一定要有數(shù)據(jù)支撐的開關(guān)控制不能“開了合批就萬事大吉”。3. 命令緩沖與資源管理渲染系統(tǒng)的質(zhì)感和底氣3.1 命令緩沖不是簡(jiǎn)單的命令列表前面提到主線程和渲染線程通過命令緩沖通信。聽著簡(jiǎn)單實(shí)際做起來很多細(xì)節(jié)。命令緩沖的本質(zhì)是一塊環(huán)形分配的內(nèi)存池里面裝著一條條命令每一條由一個(gè)命令頭命令類型、數(shù)據(jù)長(zhǎng)度和一段載荷數(shù)據(jù)組成。主線程寫入渲染線程讀取兩者通過幀同步點(diǎn)協(xié)調(diào)不能出現(xiàn)渲染線程讀了一半主線程又回頭覆寫這塊內(nèi)存的情況。常用做法是幀內(nèi)雙緩沖或三緩沖。主線程和渲染線程各自持有這一幀的寫指針和讀指針靠幀序號(hào)來同步。幀序號(hào)有點(diǎn)像一個(gè)“信號(hào)燈”渲染線程處理完第N幀的命令后告訴內(nèi)存分配器第N幀的緩沖塊可以回收了主線程這才允許覆蓋那塊的寫入。這個(gè)機(jī)制寫起來不復(fù)雜但一旦出問題就是極其惡性的隨機(jī)崩潰數(shù)據(jù)一半新一半舊查都沒法查。我們后來給每一塊緩沖頭部都加了一個(gè)magic number和幀ID內(nèi)存回收前做校驗(yàn)定位過好幾回這類bug?,F(xiàn)代引擎進(jìn)一步把命令緩沖抽象成Render Graph把整幀的渲染過程描述成一張有依賴關(guān)系的異步任務(wù)圖。每個(gè)渲染操作Pass聲明自己讀哪個(gè)資源、寫哪個(gè)目標(biāo)系統(tǒng)根據(jù)依賴自動(dòng)安排執(zhí)行順序自動(dòng)合并或跳過無效的Pass還能自動(dòng)優(yōu)化資源生命周期某個(gè)臨時(shí)RT只在兩個(gè)Pass之間用到系統(tǒng)自動(dòng)復(fù)用內(nèi)存。這套方案把引擎渲染架構(gòu)的復(fù)雜度提升了一個(gè)量級(jí)但換來的效率和可控性確實(shí)好。第一版不用從Render Graph開始從普通命令緩沖起步按需演進(jìn)這是我個(gè)人的經(jīng)驗(yàn)。3.2 GPU資源管理生命周期比繪制本身更容易出事渲染系統(tǒng)里80%的麻煩不在繪制邏輯在資源生命周期。紋理、頂點(diǎn)緩沖、索引緩沖、渲染目標(biāo)、Shader程序這些都是GPU資源都有創(chuàng)建、上傳、綁定、釋放的過程。資源管理架構(gòu)設(shè)計(jì)不好的話跑起來就是紋理花屏、顯存泄漏、設(shè)備丟失。我見過最離譜的問題是顯存泄漏——每幀都創(chuàng)建一個(gè)小紋理但從不釋放跑二十分鐘顯存爆了整個(gè)進(jìn)程被驅(qū)動(dòng)殺穿。設(shè)計(jì)資源管理系統(tǒng)核心是三條原則所有權(quán)清晰每個(gè)GPU資源有且只有一個(gè)owner通常是資源緩存的某一項(xiàng)、引用計(jì)數(shù)完整GPU資源使用方必須登記引用包含渲染線程和主線程兩邊的引用、延遲回收標(biāo)記刪除的資源等到GPU確定不再引用它時(shí)才真正釋放。紋理流送Texture Streaming是個(gè)重要課題。一張4K的紋理可能在顯存里占近一個(gè)G但畫面上可能只占幾百個(gè)像素沒必要全部駐留。紋理流送系統(tǒng)按相機(jī)距離和屏幕占比動(dòng)態(tài)加載高分辨率mipmap或降級(jí)成低分辨率版本同時(shí)控制異步上傳不要卡主線程。實(shí)現(xiàn)時(shí)有一個(gè)關(guān)鍵點(diǎn)流送異步加載完成的回調(diào)和渲染幀的同步。我們踩過紋理閃成粉紅色的坑原因就是低分辨率mip還沒加載完渲染線程就把材質(zhì)對(duì)象標(biāo)記成“已就緒”。后面加了一個(gè)“就緒狀態(tài)按版本校驗(yàn)”的邏輯只有GPU確認(rèn)用上當(dāng)前版本后渲染才切換到新狀態(tài)。3.3 材質(zhì)與Shader系統(tǒng)別把參數(shù)管理變成災(zāi)難材質(zhì)系統(tǒng)負(fù)責(zé)把Shader程序和一組參數(shù)顏色值、紋理、浮點(diǎn)數(shù)、矩陣組合成一個(gè)可渲染的實(shí)例。架構(gòu)上的難點(diǎn)有兩個(gè)一是參數(shù)怎么高效地上傳到GPU二是Shader變體Variant怎么管理避免爆炸。參數(shù)上傳要區(qū)分常變化參數(shù)每一幀都可能變比如世界矩陣、時(shí)間、相機(jī)位置和材質(zhì)級(jí)參數(shù)同一個(gè)材質(zhì)的所有物體共享比如反照率、粗糙度。材質(zhì)級(jí)參數(shù)打包成常量緩沖Constant Buffer一幀只需要上傳一次甚至只在創(chuàng)建時(shí)上傳一次而常變化參數(shù)按對(duì)象上傳。如果這兩類混淆會(huì)造成大量的冗余上傳驅(qū)動(dòng)層會(huì)瘋狂反復(fù)傳相同的常量GPU雖然能扛住但CPU開銷白白浪費(fèi)。Shader變體的管理是另一個(gè)隱藏的坑。一個(gè)材質(zhì)Shader光照模型開關(guān)、陰影開關(guān)、霧效開關(guān)、貼圖數(shù)量組合起來輕則幾十個(gè)變體重則上萬個(gè)。每個(gè)變體編譯一次加載時(shí)間會(huì)被拖慢到讓你懷疑人生首幀出現(xiàn)掉幀和卡頓基本都跟這有關(guān)。我們處理方式是盡量讓Shader程序在運(yùn)行時(shí)通過統(tǒng)一的參數(shù)接口處理可選功能而不是每個(gè)功能都開一個(gè)編譯宏分支。編譯宏控制在個(gè)位數(shù)所有引擎內(nèi)置Shader預(yù)編譯加載階段只綁定Program不觸發(fā)即時(shí)編譯。遇到剛啟動(dòng)時(shí)“瓷磚般的卡頓”基本都是沒做預(yù)編譯。4. 光照、陰影和后處理表現(xiàn)力的架構(gòu)支撐4.1 前向與延遲渲染的抉擇不是技術(shù)偏好是產(chǎn)品取向渲染架構(gòu)繞不開前向渲染和延遲渲染的選擇。前向渲染逐物體計(jì)算光照光源多了DrawCall和計(jì)算量線性上漲延遲渲染先把物體的幾何信息位置、法線、反照率、粗糙度等寫進(jìn)多張G-Buffer紋理然后統(tǒng)一對(duì)屏幕上的每個(gè)像素算光照光源數(shù)量和像素發(fā)生了關(guān)系而不是物體光源再多只要在屏幕空間算一次。手游和VR強(qiáng)烈偏愛前向渲染因?yàn)镚-Buffer的多渲染目標(biāo)MRT在移動(dòng)端帶寬開銷很肉痛。PC上的大型場(chǎng)景則更多考慮延遲渲染因?yàn)閯?dòng)態(tài)光源數(shù)量可能幾十上百個(gè)。但延遲渲染也有代價(jià)MSAA很難直接用于邊緣抗鋸齒、透明物體沒法正常走G-Buffer流程透明的半透明效果依然得用前向渲染補(bǔ)一道、帶寬開銷在低配機(jī)上撐不住。我們最后的架構(gòu)是混合管線的可配置方案不透明物體默認(rèn)走延遲渲染主光路透明物體和特殊效果水面、粒子、貼花走前向渲染的補(bǔ)充Pass。整個(gè)管線在引擎初始化時(shí)根據(jù)當(dāng)前設(shè)備特性和用戶畫質(zhì)檔位決定走哪條路徑核心光照計(jì)算代碼共用同一套光照模型函數(shù)避免兩套光照算法養(yǎng)出兩套效果不一致的“陰陽臉”。這個(gè)結(jié)構(gòu)維護(hù)成本高一些但保證了跨平臺(tái)表現(xiàn)的可控性。4.2 陰影系統(tǒng)架構(gòu)實(shí)時(shí)陰影是塊硬骨頭陰影做得好的引擎給人的第一感受是“畫面立體了”。但實(shí)時(shí)陰影系統(tǒng)架構(gòu)牽扯的東西遠(yuǎn)比多畫一張深度圖復(fù)雜。主流方案是陰影貼圖Shadow Map制從光源視角渲染場(chǎng)景得到深度圖然后主渲染時(shí)比較像素的深度和陰影貼圖深度判斷是否在陰影里。這個(gè)方案簡(jiǎn)單可靠但陰影鋸齒、暗側(cè)閃爍Shadow Acne、彼得潘現(xiàn)象Peter Panning都是需要系統(tǒng)處理的。陰影架構(gòu)真正難的在于大世界里的陰影裁剪與精度分配。一個(gè)巨大的場(chǎng)景一張2048分辨率的陰影貼圖要覆蓋整個(gè)場(chǎng)景那每像素對(duì)應(yīng)的世界尺寸會(huì)非常大近處陰影全是鋸齒和浮動(dòng)。業(yè)界普遍的方案是級(jí)聯(lián)陰影貼圖CSM把視錐體從近到遠(yuǎn)切成幾段每段分配一張獨(dú)立陰影貼圖近處段分辨率高、遠(yuǎn)處段分辨率低。級(jí)聯(lián)數(shù)量一般3到4級(jí)每級(jí)一張深度圖級(jí)別之間的交界處要做過渡否則能看到明顯的“陰影邊界條”。實(shí)現(xiàn)CSM時(shí)最容易出問題的是跟光相關(guān)的抖動(dòng)和過渡。我們第一版里陰影在相機(jī)移動(dòng)時(shí)產(chǎn)生高頻抖動(dòng)排查半天發(fā)現(xiàn)是因?yàn)殛幱百N圖的相機(jī)矩陣每一幀都會(huì)漂移導(dǎo)致陰影紋樣跟著跳。解決辦法是把光源在場(chǎng)景空間的位置按陰影貼圖紋素尺寸對(duì)齊Texel Snap讓光源移動(dòng)時(shí)陰影波動(dòng)以“整像素”為單位發(fā)生變化。這個(gè)細(xì)節(jié)在后來的項(xiàng)目里幫了大忙值得專門記下。4.3 后處理鏈路設(shè)計(jì)與Render Graph天然的緣分現(xiàn)在游戲的畫面表現(xiàn)一半靠后處理色調(diào)映射Tonemapping、泛光Bloom、景深Depth of Field、環(huán)境光遮蔽Ambient Occlusion、動(dòng)態(tài)模糊Motion Blur、抗鋸齒TAA一條鏈路串起來。后處理架構(gòu)設(shè)計(jì)最容易造成的坑是每加一個(gè)后處理效果就多一次全屏RT切換。全屏RT切換開銷其實(shí)是很大的更高分辨率下一次Copy和Clear的代價(jià)甚至比整場(chǎng)景渲染還高。解決思路是把后處理組織成一條鏈上一個(gè)效果的輸出直接作為下一個(gè)效果的輸入避免中間反復(fù)拷貝回主幀緩沖。更進(jìn)一步多個(gè)效果可以上Merge到同一個(gè)Pass里比如Bloom的閾值提取和降采樣可以在一次計(jì)算里完成AO和深度重建可以共享中間數(shù)據(jù)。這就是為什么我說Render Graph后處理和它天生契合。渲染圖能在啟動(dòng)時(shí)把整幀的Pass依賴打通自動(dòng)把全屏讀寫的節(jié)點(diǎn)連接成一條高效的鏈路甚至能檢測(cè)到某個(gè)Pass沒有實(shí)際效果就讓系統(tǒng)自動(dòng)跳過。如果嫌Render Graph太重退而求其次也要實(shí)現(xiàn)一個(gè)輕量級(jí)的后處理Pass調(diào)度器把上面的依賴關(guān)系用結(jié)構(gòu)體表示出來運(yùn)行時(shí)按序執(zhí)行不要用硬編碼if-else把鏈路寫死在代碼里。5. 渲染系統(tǒng)的排障心得與防崩設(shè)計(jì)5.1 幀時(shí)間數(shù)據(jù)拆解定位瓶頸的唯一標(biāo)準(zhǔn)答案渲染系統(tǒng)出性能問題最怕的是靠肉眼猜。一說是“場(chǎng)景太復(fù)雜”一說是“Shader太貴”最后發(fā)現(xiàn)都不是。我處理性能問題時(shí)第一件事永遠(yuǎn)是把幀時(shí)間拆開CPU主線程耗時(shí)、渲染線程耗時(shí)、GPU耗時(shí)分別統(tǒng)計(jì)。大多數(shù)引擎Debug工具都能做到但重要的是數(shù)據(jù)期望。如果主線程耗時(shí)遠(yuǎn)高于GPU那瓶頸在邏輯層而不是渲染加再?gòu)?qiáng)的顯卡都沒用。如果GPU時(shí)間高但主線程閑得很那不是換CPU能解決的。拆解之后進(jìn)一步把GPU時(shí)間細(xì)分到Pass級(jí)別天空盒用了多少、場(chǎng)景主體多少、后處理多少、陰影幾個(gè)級(jí)聯(lián)各多少。我們遇到過反直覺的情況大量時(shí)間不在畫場(chǎng)景上而在全屏后處理的Bloom降采樣上。因?yàn)閳?chǎng)景面數(shù)很低反而沒事干后處理把全屏反復(fù)掃了好幾遍。把Bloom閾值和降采樣次數(shù)調(diào)優(yōu)以后整體幀率直接提升了20%。沒有這層數(shù)據(jù)拆解這個(gè)瓶頸靠肉眼幾乎不可能找到。推薦一套做法引擎內(nèi)置一個(gè)幀調(diào)試覆蓋層熱鍵打開后直接在畫面上顯示每個(gè)Pass的耗時(shí)柱狀圖能把“哪些Pass占了時(shí)間”一目了然地呈現(xiàn)。調(diào)試這類東西讓開發(fā)者“肉眼可見”比任何日志都高效。5.2 資源生命周期崩潰的防御性設(shè)計(jì)渲染系統(tǒng)的崩潰絕大多數(shù)逃不出幾個(gè)類型GPU資源被釋放后還在用、渲染線程訪問了主線程管理的被銷毀對(duì)象、命令緩沖被覆蓋讀取。這三類問題都有共性時(shí)序競(jìng)爭(zhēng)。調(diào)試競(jìng)爭(zhēng)問題沒法純靠看代碼解決必須在架構(gòu)層面做防御。第一道防線是所有GPU資源對(duì)象都用Handle句柄而不是裸指針。Handle是一個(gè)全局資源表里的索引釋放時(shí)把表項(xiàng)標(biāo)記為“懸空”任何訪問到懸空項(xiàng)的操作都在Debug模式下立即報(bào)錯(cuò)而不是等到野指針導(dǎo)致隨機(jī)崩潰。第二道防線是渲染資源必須上引用計(jì)數(shù)并且主線程和渲染線程各持一份引用。線程間轉(zhuǎn)移資源所有權(quán)時(shí)必須顯式調(diào)用傳遞接口禁止裸地從一個(gè)線程往另一個(gè)線程塞資源指針。第三道防線是強(qiáng)制打斷點(diǎn)在特定幀號(hào)配置一個(gè)“在第X幀后讓渲染系統(tǒng)進(jìn)入半同步模式”讓問題出現(xiàn)時(shí)可以穩(wěn)定復(fù)現(xiàn)而不是碰運(yùn)氣。這些防御聽起來瑣碎但渲染系統(tǒng)后期開發(fā)能不能睡得著覺全看這些。越到后面大家比的不是誰畫面好而是誰在改大功能時(shí)不容易炸。架構(gòu)護(hù)住的是長(zhǎng)期迭代的穩(wěn)定性這比短期性能更重要。5.3 視覺驗(yàn)證方法不止是“看著沒問題”渲染系統(tǒng)寫起來最可怕的bug是那種“看著沒大問題但就是哪里不對(duì)勁”。比如陰影有一幀延遲、法線方向有微小偏差導(dǎo)致高光點(diǎn)不對(duì)、色調(diào)映射在暗部產(chǎn)生色偏這些主觀層面的問題無法用數(shù)字?jǐn)嘌詠碜詣?dòng)檢測(cè)。我的經(jīng)驗(yàn)是建立一套視覺參考基線。做法很簡(jiǎn)單在固定場(chǎng)景、固定相機(jī)、固定畫質(zhì)設(shè)置的條件下每一版改動(dòng)都輸出一張參考截圖或者一段固定時(shí)長(zhǎng)的視頻放進(jìn)版本控制系統(tǒng)里。改動(dòng)渲染相關(guān)代碼時(shí)自動(dòng)跑一遍基線場(chǎng)景用像素差異比對(duì)工具檢測(cè)差異。任何意外的差異都會(huì)在提交前暴露出來而不是等美術(shù)同事在編輯器里發(fā)現(xiàn)“燈光怎么變得怪怪的”。另外一個(gè)針對(duì)渲染的實(shí)用工具是調(diào)試可視化模式線框模式、僅深度模式、僅法線模式、僅基礎(chǔ)色模式、熱力圖模式顯示DrawCall數(shù)量或過繪制程度的色彩疊加。調(diào)試復(fù)雜渲染問題時(shí)能有辦法把“看到的畫面”分解成“自己?jiǎn)为?dú)的一層輸出”來查看定位問題的效率會(huì)翻倍上升。比如顯示霧效熱力圖能一眼看出某個(gè)區(qū)域霧濃度異常是采樣點(diǎn)分布引起的還是算法錯(cuò)誤引起的。這些“開關(guān)式”調(diào)試可視化做起來成本不高但幾乎是渲染系統(tǒng)架構(gòu)里最值回報(bào)的一個(gè)功能。最后說一個(gè)我個(gè)人繞了很多彎路才想明白的體會(huì)渲染系統(tǒng)的架構(gòu)永遠(yuǎn)是“先跑通再飛”。第一版就想著一次性把Render Graph、延遲管線、級(jí)聯(lián)陰影、紋理流送全部做齊跨越太高結(jié)果調(diào)試時(shí)排查問題非常痛苦。更可行的路徑是用最小的管線先把全流程跑通從畫一個(gè)三角形開始到畫場(chǎng)景到加后處理每一步都保持整個(gè)鏈路是通的、可見的、可調(diào)試的然后逐層疊加復(fù)雜度。渲染系統(tǒng)這東西看起來是“只要最后效果好就行”但其實(shí)每一步的中間狀態(tài)都需要肉眼嚴(yán)格驗(yàn)證否則疊加到最后你分辨不出是哪一層出現(xiàn)了問題。架構(gòu)設(shè)計(jì)先解決“讓錯(cuò)誤無處藏身”再談“讓性能翻倍”順序別反。如果后續(xù)有機(jī)會(huì)我打算接著寫一篇文章聊渲染系統(tǒng)之外的資源管理和場(chǎng)景加載架構(gòu)有具體疑問的歡迎留言交流。