階實(shí)戰(zhàn):從藍(lán)圖到C++與渲染管線深度解析)
1. 從能跑藍(lán)圖到敢改引擎UE實(shí)戰(zhàn)的分水嶺在哪里很多人學(xué)Unreal Engine的路徑都差不多先跟著教程拖幾個(gè)Actor連幾根藍(lán)圖線做個(gè)能走能跳的小人然后覺(jué)得自己會(huì)UE了。但真正進(jìn)項(xiàng)目之后才發(fā)現(xiàn)藍(lán)圖能解決的問(wèn)題其實(shí)很有限——性能瓶頸、GC卡頓、網(wǎng)絡(luò)同步錯(cuò)亂、打包后行為不一致這些問(wèn)題幾乎都指向同一個(gè)方向你得往下走一層去碰C和引擎本身的機(jī)制。這篇內(nèi)容面向的是已經(jīng)能獨(dú)立用藍(lán)圖搭出完整玩法原型、但一遇到性能或架構(gòu)問(wèn)題就卡住的開(kāi)發(fā)者。我會(huì)圍繞Gameplay框架的C落地、渲染管線的可干預(yù)點(diǎn)、以及幾個(gè)高級(jí)主題GAS、網(wǎng)絡(luò)同步、資源加載展開(kāi)重點(diǎn)不是羅列API而是講清楚為什么這么設(shè)計(jì)以及實(shí)際項(xiàng)目里怎么用才不出事。關(guān)鍵詞里的UE、Unreal Engine、C、Gameplay框架、渲染管線基本就是這條進(jìn)階路線的骨架。先說(shuō)一個(gè)我自己的判斷標(biāo)準(zhǔn)如果你寫(xiě)的藍(lán)圖里單個(gè)Event Graph的節(jié)點(diǎn)數(shù)超過(guò)80個(gè)或者一個(gè)Blueprint的Tick里做了超過(guò)3件有實(shí)際邏輯的事那這個(gè)項(xiàng)目遲早要出問(wèn)題。這不是藍(lán)圖本身的錯(cuò)而是藍(lán)圖的可維護(hù)性和性能邊界決定的。C不是用來(lái)替代藍(lán)圖的而是用來(lái)把那些高頻、底層、需要精確控制的邏輯從藍(lán)圖里搬出來(lái)讓藍(lán)圖回歸它最擅長(zhǎng)的事——快速迭代玩法表現(xiàn)。2. Gameplay框架的C落地AActor、UObject與組件化的真實(shí)邊界2.1 為什么UObject的GC機(jī)制決定了你的代碼結(jié)構(gòu)Unreal的垃圾回收不是C那種RAII而是基于UObject的引用追蹤。所有繼承自UObject的對(duì)象都由引擎的GC系統(tǒng)管理GC會(huì)定期掃描根集合Root Set和對(duì)象之間的引用關(guān)系把沒(méi)有引用的對(duì)象回收掉。這個(gè)機(jī)制直接決定了你寫(xiě)C類(lèi)時(shí)的幾個(gè)硬性規(guī)則。第一任何你想讓GC追蹤的UObject指針必須用UPROPERTY()宏標(biāo)記。我見(jiàn)過(guò)太多人寫(xiě)了一個(gè)裸指針成員變量運(yùn)行時(shí)對(duì)象莫名其妙被回收然后崩潰在訪問(wèn)空指針上。原因就是GC掃描時(shí)看不到這個(gè)引用認(rèn)為這個(gè)對(duì)象沒(méi)人用了。UPROPERTY()不只是給編輯器暴露變量用的它同時(shí)是GC的可見(jiàn)性標(biāo)記。第二非UObject的普通C對(duì)象比如你自己寫(xiě)的struct或純C類(lèi)不受GC管理你得自己管生命周期。常見(jiàn)做法是用TSharedPtr/TWeakPtr或者把生命周期綁定到某個(gè)UObject上。我一般建議如果這個(gè)數(shù)據(jù)需要被藍(lán)圖訪問(wèn)、需要網(wǎng)絡(luò)復(fù)制、需要序列化那就做成UObject或USTRUCT如果只是臨時(shí)的計(jì)算中間結(jié)果用普通C類(lèi)型就行別什么都往UObject上套GC壓力會(huì)很大。第三AActor的銷(xiāo)毀不是立即的。調(diào)用Destroy()之后Actor會(huì)被標(biāo)記為Pending Kill真正銷(xiāo)毀發(fā)生在當(dāng)前幀結(jié)束后的GC階段。這意味著你在Destroy之后同一幀內(nèi)還可能拿到這個(gè)Actor的指針訪問(wèn)它可能不會(huì)立刻崩潰但行為是未定義的。正確做法是用IsValid()檢查而不是簡(jiǎn)單的! nullptr。2.2 組件化設(shè)計(jì)什么時(shí)候該拆組件什么時(shí)候該繼承UE的組件化UActorComponent/USceneComponent是Gameplay框架的核心設(shè)計(jì)之一。但組件化這個(gè)詞被濫用了很多人把什么邏輯都往組件里塞結(jié)果組件之間互相依賴(lài)比繼承還亂。我的經(jīng)驗(yàn)判斷法則是如果一個(gè)功能滿(mǎn)足可以被多個(gè)不同類(lèi)型的Actor復(fù)用且有獨(dú)立的狀態(tài)和生命周期那它適合做成組件。比如生命值、背包、交互能力這些做成組件很合理。但如果一個(gè)功能只服務(wù)于一種Actor且和Actor的核心邏輯強(qiáng)耦合那放在Actor本身或者用繼承更清晰。舉個(gè)實(shí)際例子。我做過(guò)一個(gè)載具系統(tǒng)最初把駕駛控制、武器掛載、損傷表現(xiàn)全做成了組件。結(jié)果發(fā)現(xiàn)駕駛控制和載具的物理模擬強(qiáng)綁定武器掛載需要訪問(wèn)駕駛狀態(tài)損傷表現(xiàn)又要監(jiān)聽(tīng)前兩者的數(shù)據(jù)。三個(gè)組件之間互相GetOwner()-FindComponentByClass調(diào)用鏈繞來(lái)繞去調(diào)試極其痛苦。后來(lái)重構(gòu)把駕駛和損傷合并進(jìn)載具基類(lèi)武器掛載獨(dú)立成組件代碼立刻清爽了。組件之間的通信優(yōu)先用委托Delegate而不是直接互相引用。UE的動(dòng)態(tài)委托DECLARE_DYNAMIC_MULTICAST_DELEGATE可以在藍(lán)圖中綁定非常適合組件間解耦。靜態(tài)委托性能更好但不能在藍(lán)圖用純C內(nèi)部通信可以用。2.3 Gameplay框架里那些看起來(lái)多余的設(shè)計(jì)其實(shí)都有原因剛接觸UE C的人經(jīng)常吐槽為什么要有GameMode、GameState、PlayerController、PlayerState這么多類(lèi)一個(gè)玩家而已搞這么復(fù)雜。這套設(shè)計(jì)是為了支持網(wǎng)絡(luò)多人游戲。在客戶(hù)端-服務(wù)器模型下有些數(shù)據(jù)只存在于服務(wù)器比如GameMode它決定游戲規(guī)則有些數(shù)據(jù)需要同步到所有客戶(hù)端比如GameState它保存當(dāng)前比分、游戲階段有些數(shù)據(jù)屬于單個(gè)玩家但需要同步PlayerState保存玩家分?jǐn)?shù)、名字而PlayerController是玩家在服務(wù)器上的代理負(fù)責(zé)接收輸入并轉(zhuǎn)發(fā)。即使你做的是單機(jī)游戲這套結(jié)構(gòu)也在運(yùn)行只是服務(wù)器和客戶(hù)端在同一臺(tái)機(jī)器上。理解這一點(diǎn)很重要因?yàn)楫?dāng)你想做多人游戲時(shí)不需要重新設(shè)計(jì)架構(gòu)只需要把該同步的屬性標(biāo)記Replicated該寫(xiě)的RPC寫(xiě)好就行。我建議從一開(kāi)始就按這套結(jié)構(gòu)組織代碼哪怕當(dāng)前是單機(jī)后續(xù)擴(kuò)展會(huì)省很多事。3. 渲染管線從改材質(zhì)到干預(yù)渲染流程的進(jìn)階路徑3.1 延遲渲染管線的基本階段與可干預(yù)點(diǎn)UE默認(rèn)使用延遲渲染Deferred Rendering它的管線大致分為深度預(yù)pass、Base Pass寫(xiě)入GBuffer、光照階段、透明物體前向渲染、后處理。每個(gè)階段都有你可以介入的地方。Base Pass階段每個(gè)物體通過(guò)它的材質(zhì)把信息寫(xiě)入GBuffer。GBuffer包含多個(gè)渲染目標(biāo)RT分別存儲(chǔ)BaseColor、Metallic、Specular、Roughness、World Normal等。你能控制的是材質(zhì)里輸出的這些值。但如果你想改變GBuffer本身的格式比如增加一個(gè)自定義通道那就需要改引擎的shader文件和渲染管線代碼這是比較深度的修改。光照階段引擎根據(jù)GBuffer里的信息計(jì)算直接光照和間接光照。你可以通過(guò)自定義光照函數(shù)Custom Lighting Function來(lái)改變光照計(jì)算方式比如做卡通渲染的階梯光照。這個(gè)在材質(zhì)里就能做不需要改引擎。后處理階段是最容易介入的。你可以寫(xiě)自定義的后處理材質(zhì)Post Process Material在場(chǎng)景渲染完成后對(duì)畫(huà)面做處理。Bloom、DOF、Color Grading這些都是后處理。如果你想做全屏的描邊、色調(diào)映射、或者自定義的屏幕特效后處理材質(zhì)是首選。3.2 自定義Shader的三種落地方式與選型建議在UE里寫(xiě)自定義shader主要有三條路材質(zhì)編輯器里的Custom節(jié)點(diǎn)、全局ShaderGlobal Shader、以及修改引擎的Shader文件。三者的靈活度和成本差異很大。Custom節(jié)點(diǎn)最簡(jiǎn)單在材質(zhì)里直接寫(xiě)HLSL代碼適合做小范圍的計(jì)算比如自定義的噪聲函數(shù)、特殊的UV變換。但它有局限不能訪問(wèn)GBuffer的其他通道不能做跨像素的操作而且代碼是內(nèi)聯(lián)在材質(zhì)里的復(fù)用性差。全局Shader適合做與場(chǎng)景渲染無(wú)關(guān)的計(jì)算比如GPU上的粒子模擬、地形生成、后處理。你需要寫(xiě)一個(gè).usf文件然后在C里通過(guò)FGlobalShaderMap來(lái)調(diào)度。這種方式靈活度高但需要理解UE的RHIRender Hardware Interface抽象層調(diào)試也相對(duì)麻煩。修改引擎Shader文件是最深度的方式適合做管線級(jí)別的修改比如增加新的GBuffer通道、改變光照模型。但代價(jià)是升級(jí)引擎版本時(shí)合并沖突會(huì)很痛苦而且不同平臺(tái)的兼容性需要自己保證。我的建議是能用材質(zhì)解決的不用全局Shader能用全局Shader解決的不要改引擎。3.3 性能分析用GPU Profiler定位渲染瓶頸渲染優(yōu)化最怕的是憑感覺(jué)優(yōu)化。UE提供了幾個(gè)工具Stat GPU可以看各個(gè)渲染階段的耗時(shí)RenderDoc可以抓幀分析每個(gè)Draw CallUnreal Insights可以做更細(xì)粒度的CPU/GPU分析。我常用的流程是先用Stat GPU看哪個(gè)階段耗時(shí)最高。如果是Base Pass高可能是材質(zhì)太復(fù)雜或者Draw Call太多如果是光照階段高可能是動(dòng)態(tài)光源太多或者陰影設(shè)置太重如果是后處理高檢查后處理材質(zhì)的復(fù)雜度。一個(gè)常見(jiàn)的坑是很多人看到Draw Call高就拼命合并Mesh但實(shí)際上現(xiàn)代GPU對(duì)Draw Call的容忍度比想象中高真正的瓶頸往往是Overdraw像素被重復(fù)繪制。用Quad Overdraw視圖模式可以直觀看到哪些區(qū)域Overdraw嚴(yán)重通常透明物體和粒子是重災(zāi)區(qū)。4. 高級(jí)主題實(shí)戰(zhàn)GAS、網(wǎng)絡(luò)同步與資源加載的坑與解法4.1 Gameplay Ability System強(qiáng)大但陡峭的學(xué)習(xí)曲線GASGameplay Ability System是UE官方提供的一套技能和屬性框架適合做復(fù)雜的RPG、MOBA類(lèi)游戲。它提供了Ability技能、Attribute屬性、Effect效果、Tag標(biāo)簽等概念能處理技能冷卻、屬性修改、Buff/Debuff、網(wǎng)絡(luò)同步等復(fù)雜需求。但GAS的學(xué)習(xí)曲線非常陡。我第一次用GAS的時(shí)候光是搞清楚GameplayEffect的Duration、Period、Modifier之間的關(guān)系就花了兩天。而且GAS的文檔相對(duì)零散很多細(xì)節(jié)要靠讀源碼。我的建議是如果你的項(xiàng)目技能系統(tǒng)比較簡(jiǎn)單比如就幾種固定技能沒(méi)有復(fù)雜的Buff交互不要上GAS自己寫(xiě)一套輕量的技能系統(tǒng)更快。但如果你的項(xiàng)目有大量技能、需要處理技能之間的相互作用、需要網(wǎng)絡(luò)同步、需要做屬性計(jì)算那GAS的前期投入是值得的。用GAS有幾個(gè)必須注意的點(diǎn)。第一Attribute的修改必須通過(guò)GameplayEffect不能直接改。直接改會(huì)導(dǎo)致網(wǎng)絡(luò)同步和預(yù)測(cè)出問(wèn)題。第二Ability的激活要處理好Local Predicted和Server Only的區(qū)別前者在客戶(hù)端預(yù)測(cè)執(zhí)行后者只在服務(wù)器執(zhí)行。第三GameplayTag的命名要有規(guī)范否則項(xiàng)目大了之后標(biāo)簽管理會(huì)失控。4.2 網(wǎng)絡(luò)同步屬性復(fù)制與RPC的正確使用姿勢(shì)UE的網(wǎng)絡(luò)同步基于屬性復(fù)制Property Replication和RPCRemote Procedure Call。屬性復(fù)制是服務(wù)器主動(dòng)把標(biāo)記了Replicated的屬性同步給客戶(hù)端RPC是客戶(hù)端或服務(wù)器主動(dòng)調(diào)用對(duì)方的方法。屬性復(fù)制的關(guān)鍵點(diǎn)是只有服務(wù)器能修改Replicated屬性客戶(hù)端修改會(huì)被服務(wù)器覆蓋。如果你想讓客戶(hù)端也能影響某個(gè)值要用Server RPC把請(qǐng)求發(fā)給服務(wù)器服務(wù)器修改后再同步回來(lái)。這個(gè)流程在單機(jī)時(shí)看不出問(wèn)題一到多人就會(huì)暴露。RPC有三種Server RPC客戶(hù)端調(diào)用服務(wù)器執(zhí)行、Client RPC服務(wù)器調(diào)用客戶(hù)端執(zhí)行、Multicast RPC服務(wù)器調(diào)用所有客戶(hù)端執(zhí)行。注意Multicast只能由服務(wù)器發(fā)起客戶(hù)端調(diào)用會(huì)被忽略。一個(gè)常見(jiàn)的坑是在Actor的構(gòu)造函數(shù)里設(shè)置bReplicates true但忘了在BeginPlay之后檢查GetLocalRole()導(dǎo)致客戶(hù)端也執(zhí)行了本該只在服務(wù)器執(zhí)行的邏輯。我一般會(huì)在關(guān)鍵邏輯入口加一個(gè)if (GetLocalRole() ROLE_Authority)的判斷確保只在服務(wù)器執(zhí)行。4.3 資源加載同步加載、異步加載與流式加載的取舍UE的資源加載方式主要有三種同步加載LoadObject/StaticLoadObject、異步加載StreamableManager、以及流式加載Level Streaming。同步加載最簡(jiǎn)單但會(huì)阻塞游戲線程導(dǎo)致卡頓。只適合在游戲啟動(dòng)時(shí)加載必要的資源或者加載很小的資源。我見(jiàn)過(guò)有人在Tick里同步加載資源結(jié)果幀率直接掉到個(gè)位數(shù)。異步加載通過(guò)FStreamableManager在后臺(tái)線程加載資源加載完成后回調(diào)。這是運(yùn)行時(shí)加載資源的標(biāo)準(zhǔn)做法。但要注意異步加載的回調(diào)可能在任意線程執(zhí)行如果要操作UObject需要切回游戲線程。流式加載適合大世界的場(chǎng)景切換通過(guò)Level Streaming可以把世界分成多個(gè)子關(guān)卡按需加載和卸載。World Composition和World Partition是UE提供的兩套大世界方案前者適合傳統(tǒng)的大地圖后者適合超大規(guī)模開(kāi)放世界。資源加載的一個(gè)核心原則是預(yù)加載比即時(shí)加載好異步比同步好。在關(guān)卡切換或游戲階段轉(zhuǎn)換時(shí)提前把需要的資源加載好玩家就感覺(jué)不到加載的存在。5. 那些文檔不會(huì)告訴你的實(shí)戰(zhàn)經(jīng)驗(yàn)5.1 藍(lán)圖與C的邊界劃分我的三條硬規(guī)則關(guān)于藍(lán)圖和C怎么分工爭(zhēng)論一直很多。我自己的三條規(guī)則是第一Tick里執(zhí)行的邏輯如果超過(guò)簡(jiǎn)單的狀態(tài)檢查用C。藍(lán)圖的Tick有額外的開(kāi)銷(xiāo)而且節(jié)點(diǎn)多了之后很難優(yōu)化。第二需要被大量實(shí)例化的Actor核心邏輯用C。比如場(chǎng)景里有幾百個(gè)敵人每個(gè)敵人的AI邏輯用藍(lán)圖寫(xiě)性能會(huì)明顯下降。第三頻繁修改、需要快速迭代的玩法表現(xiàn)用藍(lán)圖。比如技能的特效表現(xiàn)、UI的交互邏輯這些用藍(lán)圖改起來(lái)快不需要重新編譯。C暴露給藍(lán)圖的函數(shù)用UFUNCTION(BlueprintCallable)標(biāo)記。但不要暴露太多細(xì)粒度的函數(shù)否則藍(lán)圖里會(huì)變得很亂。我一般會(huì)暴露一些高層的、語(yǔ)義明確的函數(shù)比如嘗試激活技能而不是設(shè)置技能冷卻時(shí)間。5.2 編譯與熱重載那些讓人抓狂的報(bào)錯(cuò)怎么排查UE C的編譯報(bào)錯(cuò)有時(shí)候很迷惑。最常見(jiàn)的是無(wú)法解析的外部符號(hào)這通常是某個(gè)函數(shù)聲明了但沒(méi)實(shí)現(xiàn)或者模塊依賴(lài)沒(méi)配好。檢查Build.cs里的PublicDependencyModuleNames和PrivateDependencyModuleNames確保用到的模塊都加進(jìn)去了。熱重載Live Coding在UE5里改進(jìn)了很多但仍然不是萬(wàn)能的。改頭文件里的類(lèi)布局比如增刪成員變量通常需要完全重啟編輯器。改函數(shù)實(shí)現(xiàn)一般可以熱重載。我的習(xí)慣是小改用熱重載大改直接關(guān)編輯器重新編譯省得遇到奇怪的狀態(tài)不一致。還有一個(gè)坑是熱重載后已經(jīng)存在的藍(lán)圖實(shí)例可能不會(huì)自動(dòng)更新到新的C類(lèi)。如果發(fā)現(xiàn)改了C但藍(lán)圖行為沒(méi)變?cè)囋囍匦戮幾g藍(lán)圖或者重啟編輯器。5.3 從個(gè)人項(xiàng)目到團(tuán)隊(duì)協(xié)作代碼規(guī)范與版本管理的實(shí)際建議個(gè)人做項(xiàng)目時(shí)怎么爽怎么來(lái)但一旦進(jìn)入團(tuán)隊(duì)代碼規(guī)范就很重要了。UE有自己的代碼規(guī)范Epic C Coding Standard核心幾點(diǎn)類(lèi)名用前綴U for UObject, A for AActor, F for struct成員變量不加前綴但用駝峰函數(shù)用動(dòng)詞開(kāi)頭。版本管理方面UE項(xiàng)目有幾個(gè)特殊文件需要注意。.uasset和.umap是二進(jìn)制文件不能手動(dòng)合并所以團(tuán)隊(duì)協(xié)作時(shí)要避免多人同時(shí)修改同一個(gè)資源。Config文件夾里的.ini文件要納入版本管理但有些本地設(shè)置比如編輯器布局不應(yīng)該提交。Binaries、Intermediate、Saved這些文件夾應(yīng)該加入.gitignore。我踩過(guò)最大的坑是團(tuán)隊(duì)里有人提交了編譯產(chǎn)物Binaries文件夾導(dǎo)致其他人拉取后編譯沖突。后來(lái)我們?cè)?gitignore里嚴(yán)格排除了這些目錄只提交源碼和資源。6. 進(jìn)階路上的一些個(gè)人體會(huì)寫(xiě)UE的C代碼最大的轉(zhuǎn)變不是語(yǔ)法而是思維方式。你要理解引擎的框架設(shè)計(jì)意圖順著它的思路走而不是跟它對(duì)著干。比如引擎讓你用組件就用組件讓你用GameplayEffect改屬性就用GameplayEffect強(qiáng)行繞過(guò)框架往往會(huì)在后期付出更大代價(jià)。另一個(gè)體會(huì)是不要過(guò)早優(yōu)化。我見(jiàn)過(guò)太多項(xiàng)目在早期就糾結(jié)渲染管線怎么改、網(wǎng)絡(luò)同步怎么設(shè)計(jì)結(jié)果玩法還沒(méi)驗(yàn)證就跑偏了。先用藍(lán)圖快速驗(yàn)證核心玩法確認(rèn)方向?qū)α嗽僦鸩桨研阅苊舾泻图軜?gòu)關(guān)鍵的部分遷移到C。最后說(shuō)一個(gè)實(shí)際的小技巧UE的源碼是最好的學(xué)習(xí)資料。遇到不懂的機(jī)制直接去Engine/Source里搜相關(guān)類(lèi)看引擎自己是怎么用的。比如你想知道某個(gè)函數(shù)該怎么調(diào)用搜一下引擎里哪里調(diào)用了它比看文檔快得多。源碼里的注釋雖然不多但命名和結(jié)構(gòu)本身就是很好的說(shuō)明。