UE4 Shader 編譯以及變種實現

侑虎科技發表於2020-07-30

一、動機

這篇文章主要是我對UE4中Shader編譯過程以及變種的理解,瞭解這一塊還是挺有必要的,畢竟動輒幾千上萬個Shader的編譯在UE裡簡直是家常便飯。瞭解它底層的實現機制後內心踏實一點,如果要去修改,大方向也不會錯。

這部分工作是我之前就做好的,文章裡涉及內部修改的地方都被我閹割掉了。所以這篇文章主要用於知識普及,分享給廣大被UE4中的Shader編譯折磨的碼農們,湊活著看,看完其實應該就瞭解了。


二、UE4中Shader的組織和獲取

在講具體的Shader編譯過程時,先講UE4的渲染過程,渲染過程中是怎麼拿Shader的,最後再講這些Shader是怎麼生成的。

虛幻引擎中講到執行緒主要有三個:遊戲執行緒、渲染執行緒和RHI執行緒。

其中我們平時關心的比較多的就是遊戲執行緒和渲染執行緒了,至於RHI執行緒偏向於底層硬體介面,是甚少關心的,一般情況下也很少有需要改動到RHI執行緒的東西。

1. 渲染執行緒
虛幻引擎在FEngineLoop::PreInit中對渲染執行緒進行初始化。

具體的位置是在StartRenderingThread函式裡面,此時虛幻引擎主視窗是尚未被繪製出來的,渲染執行緒的啟動位於StartRenderingThread函式裡面,這個函式大概做了以下幾件事:

1)通過FRunnableThread::Create函式建立渲染執行緒

UE4 Shader 編譯以及變種實現

2)等待渲染執行緒準備好從自己的TaskGraph取出任務並執行

UE4 Shader 編譯以及變種實現

3)註冊渲染執行緒

UE4 Shader 編譯以及變種實現

4)建立渲染執行緒心跳更新執行緒

UE4 Shader 編譯以及變種實現

2. 渲染執行緒的執行
在UE4的體系中,渲染執行緒的主要執行內容在全域性函式RenderingThreadMain(RenderingThread.cpp)中。

從本質上來講他更像是一個員工,等著老闆給他派任務,老闆塞給他的任務都會放在TaskMap中,他則負責不斷地提取這些任務去執行。

UE4 Shader 編譯以及變種實現

老闆可以通過ENQUEUE_RENDER_COMMAND系列巨集,給員工派發任務(新增到TaskMap中),下圖說明了這個過程:

UE4 Shader 編譯以及變種實現

具體程式碼呼叫例項如下,這個巨集是在遊戲執行緒中呼叫的,有時候遊戲執行緒中有一些資源發生了變動,或者新增了一些新的資源,抑或是因為一些邏輯而要去改到渲染執行緒的一些操作,都需要有一種方法去通知到渲染執行緒,就像是兩艘並行飛馳的船,各自走自己的路,另一艘船上發生了什麼是完全不知道的,而UE4就通過設定一系列巨集為兩艘船之間的通訊提供了方法。

UE4 Shader 編譯以及變種實現

員工執行任務時也不是直接向GPU傳送指令,而是將渲染命令新增到RHICommandList,也就是RHI命令列表中,由RHI執行緒不斷取出指令,向GPU傳送,並阻塞等待結果。

UE4 Shader 編譯以及變種實現

此時RHI執行緒雖然阻塞,但是渲染執行緒依然正常工作,可以繼續處理向RHI命令列表填充指令。

3. 渲染過程中Shader的來源及選擇
明白了上述那些概念我們知道,螢幕結果就像是我們最終要做出來的產品,老闆就像是產品經理,告訴員工這個產品要怎麼做,並交給員工對應的資源,員工根據這些資源,和老闆的命令去完成最終的產品(繪製到螢幕上)。

首先講這些資源在UE4中對應的是什麼,以及員工在完成不同的工作階段(繪製Pass)時是如何從這麼多資源中拿到自己想要的資源的,再去講這些資源的生成。

3.1 資源的組織:ShaderMap
那麼螢幕上的畫面究竟是如何呈現的呢?員工是怎麼樣去用這些資源的呢,換句話說就是老闆給員工的資源,員工是怎麼處理成最終能用的資源的?這些資源是怎麼組織的?這裡就涉及到一個名詞:ShaderMap。

用過虛幻4的渲染的都知道,虛幻引擎中的著色器數量是非常龐大的,如果改動一個材質,經常就需要編幾千個甚至上萬個Shader,其實也就是說單個材質會編譯出多個Shader,這一點是非常重要的。

用一個簡單點的概念來理解ShaderMap,可以把它理解成一個三維矩陣,長度為每個材質型別,寬度為每個渲染階段,高度為每個頂點工廠型別,矩陣的每一個方格都對應了一組著色器組合(頂點著色器,畫素著色器),材質也不一定參與全部階段,所以這個三維矩陣中是存在有很多空缺的。

頂點工廠在UE4中的含義是負責抽象頂點資料以供後面的著色器獲取,從而讓著色器能夠忽略由於頂點型別造成的差異,比如說普通的靜態網格物體和使用GPU進行蒙皮的物體,二者的頂點資料不同,但是通過頂點工廠進行抽象後,提供統一的資料獲取介面,供後面的著色器使用。

3.2 資源的選擇:怎麼從ShaderMap中拿到想要的Shader
現在是第二個問題,如何根據當前階段,當前的材質型別,當前頂點工廠型別,從這個三維矩陣中獲得需要的著色器組合。

以一個StaticMesh物體的渲染為例(動態物體不同),對著色器資料選擇的過程如下:

1)渲染執行緒把這個物體新增進場景AddToScene。

UE4 Shader 編譯以及變種實現

2)更新場景的靜態物體繪製列表AddStaticMeshes。

UE4 Shader 編譯以及變種實現

3)呼叫CacheMeshDrawCommands,開始生成當前物體的繪製命令MeshDrawCommands並快取住。

UE4 Shader 編譯以及變種實現

4)遍歷所有的Rendering Pass型別,獲取當前場景的CachedDrawLists生成Drawlistcontext。

UE4 Shader 編譯以及變種實現

5)呼叫不同Pass(以BasePass為例)的AddMeshBatch函式,並將Drawlistcontext作為引數傳入(方便之後把生成的繪製命令快取住)。

UE4 Shader 編譯以及變種實現

6)通過一系列引數判斷該Mesh應不應該在當前Pass(BasePass為例)生成繪製命令,如果驗證通過,那麼呼叫當前Pass的Process函式。

UE4 Shader 編譯以及變種實現

7)獲取該Mesh在當前Pass繪製需要的Shaders,繪製狀態,光柵化狀態,並最終生成該Mesh的繪製命令。

UE4 Shader 編譯以及變種實現

UE4 Shader 編譯以及變種實現

UE4 Shader 編譯以及變種實現

UE4 Shader 編譯以及變種實現

所以到這一步就講清楚了渲染時怎麼去拿Shader的流程,需要去看不同Pass的GetShaders函式,結合之前對ShaderMap的分析來看它的傳入引數,MaterialResource對應它使用的材質資源,VertexFactory的type對應所用到的頂點工廠型別,最後還有用到的頂點和畫素著色器。

最終得到頂點著色器和畫素著色器的呼叫如下(此時材質型別和渲染Pass已經確定):

UE4 Shader 編譯以及變種實現

UE4 Shader 編譯以及變種實現

材質的GetShader函式首先以當前頂點工廠型別的ID為索引,通過GetMeshShaderMap函式從OrderedMeshShaderMaps成員變數中查詢到對應頂點工廠型別的MeshShaderMap,隨後呼叫當前MeshShaderMap的GetShader函式,以當前著色器型別為引數查詢,查詢到實際對應的著色器。

總結如下:實質上獲取一組著色器組合需要的三個變數:渲染Pass、頂點工廠型別和材質型別,這也就不難理解UE4中對資源的組織形式了。


三、UE4中Shader的生成

1. MaterialShader的編譯
在第二部分的內容中已經說清楚了UE4中Shader的組織形式以及具體是怎麼去獲取,那麼接下來的問題就是如何去生成這些Shader,及材質如何編譯,產生ShaderMap並快取起來。

當HLSL程式碼生成後就需要進入到真正的著色器編譯階段。材質節點圖生成的HLSL程式碼只是一批函式,並不具備完整的著色器資訊,這些程式碼會鑲嵌到真正的著色器編譯環境中(FShaderCompilerEnvironment),重新編譯成最終的ShaderMap中每一個著色器,主要流程如下:

1)儲存材質並編譯當前材質,觸發Shader編譯,呼叫FMaterial::BeginCompileShaderMap()。

2)新建一個ShaderMap例項,呼叫HLSLTranslator把材質節點翻譯成HLSL程式碼。

UE4 Shader 編譯以及變種實現

UE4 Shader 編譯以及變種實現

3)初始化著色器編譯環境,FShaderCompilerEnvironment通過MaterialTraslator::GetMaterialEnvironment初始化例項,主要就是去設定巨集。

3.1)根據當前Material的各種屬性,初始化各種著色器巨集定義,從而控制編譯過程中的各種巨集開關是否啟動。

UE4 Shader 編譯以及變種實現

3.2)根據FHLSLMaterialTranslator在解析過程中得出當前的引數集合,新增引數定義到環境中。

UE4 Shader 編譯以及變種實現

4)開始實際的編譯工作

4.1)呼叫NewShaderMap的Compile函式:
a. 呼叫FMaterial::SetupMaterialEnvironment函式,設定當前的編譯環境,這裡面也會去設定各種巨集定義。

UE4 Shader 編譯以及變種實現

b. 獲取所有頂點工廠型別,對於每一種頂點工廠型別,檢視該型別對應的ShaderMap是不是已經被使用,如果被使用就去BeginCompile。

UE4 Shader 編譯以及變種實現

c. BeginCompile函式中會去遍歷所有的ShaderType,中間會調到例項類的ModifyCompilationEnvironment,最終呼叫全域性函式GlobalBeginCompileShader,這個全域性函式會去填充FShaderCompileJob,包括設定shader格式、usf路徑、注入巨集等等。

UE4 Shader 編譯以及變種實現

d. 真正執行編譯任務的是把所有FShaderCompileJob交給FShaderCompilingManager,並且讓其馬上執行編譯並返回。

UE4 Shader 編譯以及變種實現

2. 如何實現Shader變種?
FMeshMaterialShaderType繼承自FShaderType,他存有模板類的兩個靜態函式指標:ModifyCompilationEnvironment和ShouldCompilePermutation,因此每次遍歷我們都可以訪問到這兩個函式。

上文中的C階段會先呼叫ShouldCompilePermutation詢問TMobileBasePassPS是否為當前Template、VertexFactory、Material組合編譯Shader。

如果需要編譯,則呼叫ModifyCompilationEnvironment注入該當前模板確定的巨集,以此實現Shader的變種。

3. GlobalShader的編譯
在使用編輯器的時候,經常會有需要改動到Shader檔案,並且需要在編輯器中檢視效果的需求,與材質編輯器中的材質Shader不一樣,材質編輯器提供了編譯按鈕,對材質的改動都可以儲存並編譯出Shader儲存到ShaderMap中,所以如果改動了目錄下的Shader檔案怎麼告訴引擎去幫我們編譯修改後的Shader。

虛幻針對這個功能已經提供了相應的指令:

recompileshaders changed ,recompileshaders global,recompileshaders material ,recompileshaders all,recompileshaders

如果不知道這些指令,一個比較死的辦法自然是重啟編輯器,讓它重編改動過的Shader,當然也可以不重啟編輯器來重編這些改動過的Shader,比如使用Recompileshaders changed,這裡首先講通過指令重編的方法,它的具體流程是怎樣?

3.1 動態重編Shader 不需要重開編輯器
1)修改Shader檔案,儲存,在控制檯輸入Recompileshaders changed。

2)呼叫RecompileShaders,根據指令的內容進入不同的分支,先去匹配具體的命令內容。

UE4 Shader 編譯以及變種實現

3)尋找過期的Shader檔案(改動過的Shader)。

UE4 Shader 編譯以及變種實現

4)如果當前對Shader檔案(.usf)沒有任何改動,直接返回No Shader changes found,如果有改動,呼叫BeginRecompileGlobalShaders。
a. 呼叫FlushRenderingCommands,等待渲染執行緒執行完所有掛起的渲染命令。

b. 根據當前平臺得到GlobalShaderMap,GetGlobalShaderMap(ShaderPlatform),這裡也可以看出來不同的ShaderType是存在不同的ShaderMap中的。

UE4 Shader 編譯以及變種實現

c. 從ShaderMap中移除過期的CurrentGlobalShaderType和ShaderPipline(頂點還是畫素著色器等等..)的Shader。

d. 呼叫VerifyGlobalShaders重編ShaderMap中的Shader。

5)完成GlobalShader的重編,呼叫FinishRecompileGlobalShaders(),該函式會阻塞直到所有的Global Shaders被編譯和處理完畢。

3.2 重開編輯器
1)在引擎的preinit函式中呼叫CompileGlobalShaderMap。

2)新建一個GlobalShaderMap例項。

3)檢視Shader快取DDC中的內容與設定的KeyString是否一致,如果不一致說明快取中對應部分的內容已經失效了,UE就會去重編這部分內容(對應最開始說到的重編Shader問題),並且去重新生成這部分的DDC。

UE4 Shader 編譯以及變種實現

4)從DDC中反序列化出來GlobalShaderMap例項的內容。

UE4 Shader 編譯以及變種實現

5)接下來就是一些Shader資源的初始化操作。

3.3 UE4中材質Cook儲存的是什麼
所謂的Cook是指把平臺無關的編輯向資料轉化為特定平臺執行時所需的資料,對於材質來說就是把上述的usf檔案和材質連線編譯成安卓執行時需要的GLSL原始碼。

1)Cook Commandlet會首先呼叫一個Package裡面所有的UObject的BeginCacheForCookedPlatformData(const ITargetPlatform *TargetPlatform)方法,該方法由各個UObject派生類各自實現,目的是生成特定所需資料並快取下來,對於材質來講就是UMaterial的BeginCacheForCookedPlatformData。

UE4 Shader 編譯以及變種實現

a. 開始為目標平臺快取著色器,並將正在編譯的材質資源儲存到CachedMaterialResourcesForCooking中。

UE4 Shader 編譯以及變種實現

b. 為當前ShaderFormat/FeatureLevel、QualityLevel生成一個FMaterialResource陣列,並呼叫CacheShadersForResources填充其內容。

UE4 Shader 編譯以及變種實現

2)之後Cook Commandlet會儲存該Package,也就是是去執行到UMaterial裡面的Serialize方法。
實際上前面部分提到的usf檔案和材質連線都通過CacheShadersForResources被轉化成了一個個FMaterialResource,所以FMaterialResource到底是什麼東西?

在UMaterial能找到如下成員:

UE4 Shader 編譯以及變種實現

結合之前的分析,不難得出UMaterial持有QualityLevelNum * FeatureLevelNum個FMaterialResource,可以通過QualityLevel和FeatureLevel索引到FMaterialResource。

FMaterialResource裡有一個關鍵的成員FMaterialShaderMap,FMaterialShaderMap可以通過FVertexFactoryType::GetId()來索引到FMeshMaterialShaderMap;而FMeshMaterialShaderMap可以通過FShaderType來索引FShader。

因此FMaterialResource裡面存放的實際上是FShader的集合,而FShader裡面存放的就是最終使用的Shader程式碼了。

UE4 Shader 編譯以及變種實現

文末,再次感謝YiQiu的分享,如果您有任何獨到的見解或者發現也歡迎聯絡我們,一起探討。(QQ群:793972859)

作者主頁:https://www.zhihu.com/people/xie-jie-62-1,作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!

相關文章