第四章 開始Unity Shader學習之旅(3)
1. 程序員的煩惱:Debug
調試(debug),大概是所有程序員的噩夢。而不幸的是,對一個Shader進行調試更是噩夢中的噩夢。這也是造成Shader難寫的原因之一——如果發現得到的效果不對,我們就可能花非常多的時間來找到問題所在。造成這種現狀的原因就是在Shader中可以選擇的調試方法非常有限,甚至連簡單的輸出都不行。
2. 最新利器:幀調試器
Unity5除了帶來全新的UI系統外,還為我們帶來了一個新的針對渲染的調試器——幀調試器(Frame Debugger)。與其它調試工具的復雜性相比,Unity原生的幀調試器非常簡單快捷。我們可以用它來看到遊戲圖像的某一幀是如何一步步渲染出來的。
幀調試器可以用於查看渲染該幀時進行的各種渲染事件(event),這些事件包含了Draw Call序列,也包括了類似清空緩存等操作。幀調試器窗口大致可分為3個部分:最上面的區域可以開啟/關閉(單擊Enable按鈕)幀調試功能,當開啟了幀調試時,通過移動窗口最上方的滑動條(或單擊前進和後退按鈕),我們就可以重放這些渲染事件;左側區域顯示了所有事件的樹狀圖,在這個樹狀圖中,每個葉子節點就是一個事件,而每個父節點的右側顯示了該節點下的事件數目。我們可以從事件的名字了解這個事件的操作,例如以Draw開頭的事件通常就是一個Draw Call;當單擊了某個事件時,在右側的窗口就會顯示該事件的細節,例如幾何圖形的細節以及使用了哪個Shader等。同時在Game視圖中我們也可以看到它的效果。如果該事件是一個Draw Call並且對應了場景中的一個GameObject,那麽這個GameObject也會在Hierarchy視圖中被高亮顯示出來,下圖顯示了單擊渲染某個對象的深度圖事件的結果。
如果被選中的Draw Call是一個對渲染紋理(RenderTexture)的渲染操作,那麽這個渲染紋理就會顯示在Game視圖中。而且,此時右側面板上方的工具欄中也會出現更多的選項,例如在Game視圖中單獨顯示R、G、B和A通道。
Unity5提供的幀調試器實際上並沒有實現一個真正的幀拾取(frame capture)的功能,而是僅僅使用了停止渲染的方法來查看渲染事件的結果。例如我們想要查看第4個Draw Call的結果,那麽幀調試器就會在第4個Draw Call調用完畢後停止渲染。這種方法雖然簡單,但得到的信息也有限。
3. 小心渲染平臺的差異
我們以前提到過OpenGL和DirectX的屏幕空間坐標的差異,如下圖所示:
需要註意的是,我們不僅可以把渲染結果輸出到屏幕上,還可以輸出到不同的渲染目標(Render Target)中。這時,我們需要使用渲染紋理(Render Texture)來保存這些渲染結果。我們將在後面學習如何實現這樣的目的。
大多數情況下,這樣的差異並不會對我們造成任何影響。但當我們要使用渲染到紋理技術,把屏幕圖像渲染到一張渲染紋理時,如果不采取任何措施的話,就會出現紋理翻轉的情況。幸運的是,Unity在背後為我們處理了這種翻轉問題——當在DirectX平臺使用渲染到紋理技術時,Unity會為我們翻轉屏幕圖像紋理,以便在不同平臺上達到一致性。
在一種特殊情況下Unity不會為我們進行這個翻轉操作,這種情況就是我們開啟了抗鋸齒(在Edit->Project Setting->Quality->Anti Aliasing中開啟)並在此時使用了渲染到紋理技術。在這種情況下,Unity首先渲染得到屏幕圖像,再由硬件進行抗鋸齒處理後,得到一張渲染紋理來供我們進行後續處理。此時,在DirectX平臺下,我們得到的輸入屏幕圖像並不會被Unity翻轉,也就是說,此時對屏幕圖像的采樣坐標是需要符合DirectX平臺規定的。如果我們的屏幕特效只需要處理一張渲染圖像,我們仍然不需要在意紋理的翻轉問題,這是因為我們在調用Graphics.Blit函數時,Unity已經為我們對屏幕圖像的采樣坐標進行了處理,我們只需要按照正常的采樣過程處理屏幕圖像即可。但如果我們需要同時處理多張渲染圖像(前提是開啟了抗鋸齒),例如需要同時處理屏幕圖像和法線紋理,這些圖像在豎直方向的朝向可能是不同的(只有在DirectX這樣的平臺上才有這樣的問題)。這種時候,我們就需要自己在頂點著色器中翻轉某些渲染紋理(例如深度紋理或其它由腳本傳遞過來的紋理)的縱坐標,使之都符合DirectX平臺的規則。例如:
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0)
uv.y = 1- uv.y;
#endif
其中,UNITY_UV_STARTS_AT_TOP用於判斷當前平臺是否是DirectX類型的平臺,而在這樣的平臺下開啟了抗鋸齒後,主紋理的紋理大小在豎直方向上會變成負值,以方便我們對主紋理進行采樣。因此,我們可以通過判斷_MainTex_TexelSize.y是否小於0來檢驗是否開啟了抗鋸齒。如果是,我們就需要對主紋理外的其他紋理的采樣進行豎直方向上的翻轉。
4. Shader的整潔之道
4.1 float、half還是fixed
我們使用Cg/HLSL來編寫UnityShader中的代碼。而在Cg/HLSL中,有三種精度的數值類型:float,half和fixed。這些精度將決定計算結果的數值範圍。下表給出了這3種精度在通常情況下的數值範圍。
上面的精度範圍並不是絕對正確的,尤其是在不同平臺和GPU上,它們的實際精度可能和上面給出的範圍不一致。通常來講:
(1)大多數現代的桌面GPU會把所有計算都按最高的浮點精度進行計算,也就是說,float、half、fixed在這些平臺上實際是等價的。這意味著,我們在PC上很難看出因為half和fixed精度而帶來的不同。
(2)但在移動平臺GPU上,它們的確會有不同的精度範圍,而且不同精度的浮點值的運算速度也會有所差異。因此,我們應該確保在真正的移動平臺上驗證我們的Shader。
(3)fixed精度實際上只在一些較舊的移動平臺上有用,在現在大多數現代的GPU上,它們內部把fixed和half當成同精度來對待。
盡管由上面的不同,但一個基本的建議是,盡可能使用精度較低的類型,是因為這可以優化Shader的性能,這一點在移動平臺上尤為重要。從它們大體的值域範圍來看,我們可以使用fixed類型來存儲顏色和單位矢量,如果要存儲更大範圍的數據可以選擇half類型,最差的情況下再選擇float。如果我們的目標平臺是移動平臺,一定要確保在真實的手機上測試我們的Shader,這一點非常重要。
4.2 避免不必要的計算
如果我們毫不節制的在Shader(尤其是片元著色器)中進行了大量計算,那麽我們可能很快收到Unity的錯誤提示:
temporary register limit of 8 exceeded
或
Arithmetic instruction limit of 64 exceeded;65 arithmetic instructions needed to compile program
出現這些錯誤信息大多是因為我們在Shader中進行了過多的運算,使得需要的臨時寄存器數目或指令數目超過了當前可支持的數目。讀者需要知道,不同的Shader Target、不同的著色器階段,我們可以使用的臨時寄存器和指令數目都是不同的。
通常,我們可以通過制定更高級的Shader Target來消除這些錯誤。下表給出了Unity目前支持的一些
Shader Target。
需要註意的是,由於Unity版本的不同,Unity支持的Shader Target種類也不同,讀者可以在官方手冊上找到更為詳細的介紹。
讀者:什麽是Shader Model呢?
我們:Shader Model是由微軟提出的一套規範,通俗的理解就是它們決定了Shader中各個特性(feature)的能力(capability)。這些特性和能力體現在Shader能使用的運算指令數目、寄存器數目等各個方面。Shader Model等級越高,Shader的能力越大。
雖然更高等級的Shader Target可以讓我們使用更多的臨時寄存器和運算指令,但一個更好的方法是盡可能減少Shader中的運算,或者通過預計算的方式來提供更多的數據。
4.3 慎用分支和循環語句
在最開始,GPU是不支持在頂點著色器和片元著色器中使用流程控制語句的。隨著GPU的發展,我們現在已經可以使用if-else、for和while這種流程控制指令了。大體來說,GPU使用了不同於CPU的技術來實現分支語句,在最壞的情況下,我們花在一個分支語句的時間相當於運行了所有分支語句的時間。因此,我們不鼓勵在SubShader中使用流程控制語句,因為它們會降低GPU的並行處理操作。
如果我們在Shader中使用了大量的流程控制語句,那麽這個Shader的性能會成倍下降。一個解決方法是,我們應該盡量把計算向流水線上方移動,例如把放在片元著色器中的計算放到頂點著色器中去,或者直接在CPU中進行預計算,再把結果傳遞給Shader。當然,有時我們不可避免的要使用分支語句來進行計算,那麽一些建議是:
(1)分支判斷語句中使用的條件變量最好是常數,即在Shader運行過程中不會發生變化;
(2)每個分支中包含的操作指令數盡可能少;
(3)分支的嵌套層數盡可能少。
第四章 開始Unity Shader學習之旅(3)