1. 程式人生 > >第十一回 Shader的動態組合

第十一回 Shader的動態組合

  Shader是很奇怪的程式碼,它的長度受到限制,它的動態分支能力很弱,它的指令很昂貴,這些都使得你很難使用一個單一的Shader來處理所有的渲染要求.而各種渲染要求的種類如此之多,如果要為每一種渲染型別都寫一段專一的程式碼的話,那會是一件非常吃力的活,假設我們現在要寫一個材質系統,我們希望它能夠支援各種效果.你會發現隨著支援的效果越來越多,需要寫的Shader的數量會急劇上升,比如:  *.一開始我們希望我們的材質系統能夠支援普通光照,我們為這種最簡單的效果寫一個Shader  *.然後我們希望這個材質還可以支援貼圖,我們為它再寫一個Shader,現在有兩個Shader了--[支援普通光照,不支援貼圖]和[支援普通光照,支援貼圖]
  *.然後我們希望為它加上骨骼動畫,我們需要為已有的Shader各寫一個帶骨骼動畫的版本,這樣Shader的個數就變成4個了,分別是:[支援普通光照,不支援貼圖,不支援骨骼動畫][支援普通光照,支援貼圖,支援骨骼動畫][支援普通光照,支援貼圖,不支援骨骼動畫][支援普通光照,不支援貼圖,支援骨骼動畫]  *.然後我們希望能夠加上霧,我們需要為已有的Shader各寫一個支援霧的版本,這樣Shader的個數就變成8個了,分別是:[支援普通光照,不支援貼圖,不支援骨骼動畫,不支援霧][支援普通光照,支援貼圖,支援骨骼動畫,不支援霧][支援普通光照,支援貼圖,不支援骨骼動畫,不支援霧][支援普通光照,不支援貼圖,支援骨骼動畫,不支援霧]
[支援普通光照,不支援貼圖,不支援骨骼動畫,支援霧][支援普通光照,支援貼圖,支援骨骼動畫,支援霧][支援普通光照,支援貼圖,不支援骨骼動畫,支援霧][支援普通光照,不支援貼圖,支援骨骼動畫,支援霧]  你可以注意到,當我們每加入一種新的功能的時候,Shader的個數就要翻倍.當我們還要不停的加入新功能時,這個數字很快就會上升到不能容忍的地步了.手工去寫這每一個Shader是會讓人崩潰的.  有一種解決方法是,我們可以寫一個什麼都支援的Shader,[支援普通光照,支援貼圖,支援骨骼動畫,支援霧],然後通過設入不同的引數來達到正確的表現效果,比如我們不需要貼圖,我們可以設入一張白色的貼圖,如果不需要骨骼動畫,我們可以設入一個identity的矩陣陣列,如果不需要霧,我們可以把霧的起始距離設到一個很大的值.這樣我們只需要寫一個功能強大的單一Shader就可以了,但是上面說過,Shader程式碼有種種限制,這樣一個單一Shader隨著功能越加越多,會變得非常緩慢,如果這個緩慢的shader的確完成了許多功能,那也可以接受,但當它完成的只是全部功能的一個很小的子集而仍然要耗費同等的時間時,這就無法接受了.據說新一代的顯示卡和runtime在這方面做了很大的提升,不過我沒有用過,不知道會怎麼樣,我覺得仍然夠嗆.不管怎麼樣,我們的engine是面向dx9的,必須尋找另一種方法來解決這個問題.
  使用預定義的巨集可以比較好的解決這個問題,我們可以寫一個長長的容納各種功能的Shader檔案,然後在裡面用大量#ifdef/#else/#endif來將各個功能分隔開來,然後在使用Shader時,我們通過指定不同的預定巨集的組合來編譯這個Shader檔案,以得到一個功能被正確裁剪的Shader.既然我們無法在Shader內部裡完成動態分支,我們只能讓編譯器幫我們把各個分支組合編譯成一個個靜態的Shader了.  在我們的engine裡,使用了非常類似的方法,下面介紹一下具體的實現:  首先要說說我對Shader計算的理解.Shader程式碼雖然有種種惡劣的品質,但有一點比較好,就是它的最終計算結果是簡單的,大多數情況下只是一個float4的顏色而已,這個最終結果可以由一些中間結果計算得到,而這些中間結果又可以由更次一級的中間結果計算得到,我把最終的計算結果稱為Output,中間結果稱為Factor,計算過程稱為Formular,那麼一個典型的shader可以用下面的形式描述出來
  下面說說具體的實現:  *.首先我們有一個Shader Library Project的概念,有點類似於vc的一個project,它由若干個檔案組成,它的Build結果是一個Shader庫.

   

  *.一個Shader庫不是一個shader,它包含了很多各種功能的Shader,我們可以根據各種功能的組合,從這個庫中生成對應的Shader.  *.一個Shader Library Project裡必須並且僅能包含一個模板檔案,模板檔案中定義了這個庫中會用到的所有Factor(注意是所有的Factor),以及一些預設的Formula,如下圖:   注意這張圖裡沒有包含ShaderConstant,每個Formula都可以自由的訪問所有的ShaderConstant,所以我就不把它畫出來了  *.除了模板以外,專案裡還包括若干個功能(Feature)檔案.一個Feature代表了Shader要實現的一種功能,比如上面說的支援貼圖,支援骨骼動畫等,一個Feature裡面定義了一個或多個Formula,它們都是模板檔案裡定義過的Formula的過載版本.比如:

  功能DirLight過載了一個formula :

   

  功能DiffuseMap過載了一個formula:

   

  功能Bones 過載了兩個formula:

   

  功能Fog過載了兩個formula

   

  *.有了模板檔案和功能檔案,我們就可以來組合Shader了,首先我們指定我們需要的功能組合,然後我們只需要簡單的將功能檔案過載的formula替換模板檔案裡的對應版本就行了,我們會得到一個新的計算流程,如下圖:

  

  然後我們根據這個流程來生成對應的shader程式碼,交給d3dx編譯(目前我們使用d3dx的effect系統來使用shader),就可以得到一個組合後的shader了  一些說明:  *. 上面的圖形化表示方式其實只是一個示意圖,方便讀者理解,目前engine中所有的模板檔案和Feature檔案都是用指令碼寫的,對它們的處理牽涉到很多語法分析和字串拼接的工作,並沒有一個專門的圖形化編輯器,我希望將來有機會能更新到一個真正的圖形化的編輯方式.  *. 出於一些效能上的考慮,目前所有的Feature都定義在C++標頭檔案裡的,每一個Feature用一位表示,我們用一個unsigned int64來表示各種Feature的組合.也就是說我們最多可以定義64種不同的Feature,(在當初設計這套系統的時候還是覺得足夠的,不過目前有用完的趨勢,需要一些trick來處理這個問題.)  *. 有一些Feature是不能共存的,比如目前關於蒙皮的Feature有四個:Bones1,Bones2,Bones3,Bones4,分別對應於同一頂點受不同骨骼數量影響的情況,它們是不能共存的,我們在模板檔案裡添加了Conflict Group的支援,可以把彼此互斥的Feature放到同一個Conflict Group中,我們在組合Shader的時候會根據ConflictGroup來檢測Feature衝突的情況.  *. 可能會出現多個可以共存的Feature過載了同一個Formula的情況,我們通過Feature在過載Formula的時候需要指定一個優先順序的方法來解決這個問題,多個Feature通過這個優先順序來競爭同一個Formula.  *.使用這套系統只是在一定程度上方便了寫shader的過程,設計一個模板檔案仍然是很複雜的工作,需要對shader程式設計有足夠的經驗,以及總體把握的能力.  使用這套系統來進行Shader的動態組裝在本質上和使用#ifdef/#else/#endif並沒有區別,但它有一些好處:  *.精簡的主流程,模板檔案裡記錄的只是整個計算流程裡的一些關鍵節點,它不會變得很長,使用#ifdef/#else/#endif很容易導致寫一個超大的檔案,而主的計算流程會被淹沒在各種細節中  *.同一個Feature的程式碼可以集中寫在同一個檔案裡,而不用分散在一個大檔案的各個地方,易於維護  *.Feature檔案可以被多個Shader專案共用,只要這些專案的模板裡定義了相同意義的Formula.  *.由於Shader的組合是由我們自己完成的,我們可以把組合的結果(一個標準的hlsl檔案)展示給使用者,便於檢查.我不知道d3dx提供的hlsl編譯器是不是有類似的功能.  Shader的動態組合包括hlsl程式碼的組合(這部分由我們完成)以及hlsl程式碼的編譯(這部分由d3dx完成),是比較慢的過程,我們顯然不能在每次使用某個shader時都去重複這一個過程,我們會把每一個組合好的Shader都儲存在一個cache中,然後用Shader庫的名稱+64位的Feature組合碼作為hash key來索引它.這個cache會被儲存在一個檔案中,每次遊戲執行時會進行載入.  關於Shader的動態組合就說到這,Shader的具體使用上還有些亂七八糟的事,這個下回再說.