- Unity Shader入門精要
- 馮樂樂
- 7852字
- 2019-03-07 12:58:12
2.3 GPU流水線
當GPU從CPU那里得到渲染命令后,就會進行一系列流水線操作,最終把圖元渲染到屏幕上。
2.3.1 概述
在上一節中,我們解釋了在應用階段,CPU是如何和GPU通信,并通過調用Draw Call來命令GPU進行渲染。GPU渲染的過程就是GPU流水線。
對于概念階段的后兩個階段,即幾何階段和光柵化階段,開發者無法擁有絕對的控制權,其實現的載體是GPU。GPU通過實現流水線化,大大加快了渲染速度。雖然我們無法完全控制這兩個階段的實現細節,但GPU向開發者開放了很多控制權。在這一節中,我們將具體了解GPU是如何實現這兩個概念階段的。
幾何階段和光柵化階段可以分成若干更小的流水線階段,這些流水線階段由GPU來實現,每個階段GPU提供了不同的可配置性或可編程性。圖2.6中展示了不同的流水線階段以及它們的可配置性或可編程性。

▲圖2.6 GPU的渲染流水線實現。顏色表示了不同階段的可配置性或可編程性:綠色表示該流水線階段是完全可編程控制的,黃色表示該流水線階段可以配置但不是可編程的,藍色表示該流水線階段是由GPU固定實現的,開發者沒有任何控制權。實線表示該Shader必須由開發者編程實現,虛線表示該Shader是可選的
從圖中可以看出,GPU的渲染流水線接收頂點數據作為輸入。這些頂點數據是由應用階段加載到顯存中,再由Draw Call指定的。這些數據隨后被傳遞給頂點著色器。
頂點著色器(Vertex Shader)是完全可編程的,它通常用于實現頂點的空間變換、頂點著色等功能。曲面細分著色器(Tessellation Shader)是一個可選的著色器,它用于細分圖元。幾何著色器(Geometry Shader)同樣是一個可選的著色器,它可以被用于執行逐圖元(Per-Primitive)的著色操作,或者被用于產生更多的圖元。下一個流水線階段是裁剪(Clipping),這一階段的目的是將那些不在攝像機視野內的頂點裁剪掉,并剔除某些三角圖元的面片。這個階段是可配置的。例如,我們可以使用自定義的裁剪平面來配置裁剪區域,也可以通過指令控制裁剪三角圖元的正面還是背面。幾何概念階段的最后一個流水線階段是屏幕映射(Screen Mapping)。這一階段是不可配置和編程的,它負責把每個圖元的坐標轉換到屏幕坐標系中。
光柵化概念階段中的三角形設置(Triangle Setup)和三角形遍歷(Triangle Traversal)階段也都是固定函數(Fixed-Function)的階段。接下來的片元著色器(Fragment Shader),則是完全可編程的,它用于實現逐片元(Per-Fragment)的著色操作。最后,逐片元操作(Per-Fragment Operations)階段負責執行很多重要的操作,例如修改顏色、深度緩沖、進行混合等,它不是可編程的,但具有很高的可配置性。
接下來,我們會對其中主要的流水線階段進行更加詳細的解釋。
2.3.2 頂點著色器
頂點著色器(Vertex Shader)是流水線的第一個階段,它的輸入來自于CPU。頂點著色器的處理單位是頂點,也就是說,輸入進來的每個頂點都會調用一次頂點著色器。頂點著色器本身不可以創建或者銷毀任何頂點,而且無法得到頂點與頂點之間的關系。例如,我們無法得知兩個頂點是否屬于同一個三角網格。但正是因為這樣的相互獨立性,GPU可以利用本身的特性并行化處理每一個頂點,這意味著這一階段的處理速度會很快。
頂點著色器需要完成的工作主要有:坐標變換和逐頂點光照。當然,除了這兩個主要任務外,頂點著色器還可以輸出后續階段所需的數據。圖2.7展示了在頂點著色器中對頂點位置進行坐標變換并計算頂點顏色的過程。

▲圖2.7 GPU在每個輸入的網格頂點上都會調用頂點著色器。頂點著色器必須迚行頂點的坐標變換,需要時還可以計算和輸出頂點的顏色。例如,我們可能需要迚行逐頂點的光照
·坐標變換。顧名思義,就是對頂點的坐標(即位置)進行某種變換。頂點著色器可以在這一步中改變頂點的位置,這在頂點動畫中是非常有用的。例如,我們可以通過改變頂點位置來模擬水面、布料等。但需要注意的是,無論我們在頂點著色器中怎樣改變頂點的位置,一個最基本的頂點著色器必須完成的一個工作是,把頂點坐標從模型空間轉換到齊次裁剪空間。想想看,我們在頂點著色器中是不是會看到類似下面的代碼:
o.pos = mul(UNITY_MVP, v.position);
類似上面這句代碼的功能,就是把頂點坐標轉換到齊次裁剪坐標系下,接著通常再由硬件做透視除法后,最終得到歸一化的設備坐標(Normalized Device Coordinates , NDC)。具體數學上的實現細節我們會在第4章中講到。圖2.8展示了這樣的一個轉換過程。

▲圖2.8 頂點著色器會將模型頂點的位置變換到齊次裁剪坐標空間下,迚行輸出后再由硬件做透視除法得到NDC下的坐標
需要注意的是,圖2.8給出的坐標范圍是OpenGL同時也是Unity使用的NDC,它的z分量范圍在[-1, 1]之間,而在DirectX中,NDC的z分量范圍是[0, 1]。頂點著色器可以有不同的輸出方式。最常見的輸出路徑是經光柵化后交給片元著色器進行處理。而在現代的Shader Model中,它還可以把數據發送給曲面細分著色器或幾何著色器,感興趣的讀者可以自行了解。
2.3.3 裁剪
由于我們的場景可能會很大,而攝像機的視野范圍很有可能不會覆蓋所有的場景物體,一個很自然的想法就是,那些不在攝像機視野范圍的物體不需要被處理。而裁剪(Clipping)就是為了完成這個目的而被提出來的。
一個圖元和攝像機視野的關系有3種:完全在視野內、部分在視野內、完全在視野外。完全在視野內的圖元就繼續傳遞給下一個流水線階段,完全在視野外的圖元不會繼續向下傳遞,因為它們不需要被渲染。而那些部分在視野內的圖元需要進行一個處理,這就是裁剪。例如,一條線段的一個頂點在視野內,而另一個頂點不在視野內,那么在視野外部的頂點應該使用一個新的頂點來代替,這個新的頂點位于這條線段和視野邊界的交點處。
由于我們已知在NDC下的頂點位置,即頂點位置在一個立方體內,因此裁剪就變得很簡單:只需要將圖元裁剪到單位立方體內。圖2.9展示了這樣的一個過程。

▲圖2.9 只有在單位立方體的圖元才需要被繼續處理。因此,完全在單位立方體外部的圖元(紅色三角形)被舍棄,完全在單位立方體內部的圖元(綠色三角形)將被保留。和單位立方體相交的圖元(黃色三角形)會被裁剪,新的頂點會被生成,原來在外部的頂點會被舍棄
和頂點著色器不同,這一步是不可編程的,即我們無法通過編程來控制裁剪的過程,而是硬件上的固定操作,但我們可以自定義一個裁剪操作來對這一步進行配置。
2.3.4 屏幕映射
這一步輸入的坐標仍然是三維坐標系下的坐標(范圍在單位立方體內)。屏幕映射(Screen Mapping)的任務是把每個圖元的x和y坐標轉換到屏幕坐標系(Screen Coordinates)下。屏幕坐標系是一個二維坐標系,它和我們用于顯示畫面的分辨率有很大關系。
假設,我們需要把場景渲染到一個窗口上,窗口的范圍是從最小的窗口坐標(x1, y1)到最大的窗口坐標(x2, y2),其中x1< x2且y1< y2。由于我們輸入的坐標范圍在-1到1,因此可以想象到,這個過程實際是一個縮放的過程,如圖2.10所示。你可能會問,那么輸入的z坐標會怎么樣呢?屏幕映射不會對輸入的z坐標做任何處理。實際上,屏幕坐標系和z坐標一起構成了一個坐標系,叫做窗口坐標系(Window Coordinates)。這些值會一起被傳遞到光柵化階段。

▲圖2.10 屏幕映射將x、y坐標從(-1, 1)范圍轉換到屏幕坐標系中
屏幕映射得到的屏幕坐標決定了這個頂點對應屏幕上哪個像素以及距離這個像素有多遠。
有一個需要引起注意的地方是,屏幕坐標系在OpenGL和DirectX之間的差異問題。OpenGL把屏幕的左下角當成最小的窗口坐標值,而DirectX則定義了屏幕的左上角為最小的窗口坐標值。圖2.11顯示了這樣的差異。

▲圖2.11 OpenGL和DirectX的屏幕坐標系差異。對于一張512*512大小的圖像,在OpenGL中其(0, 0)點在左下角,而在DirectX中其(0, 0)點在左上角
產生這種差異的原因是,微軟的窗口都使用了這樣的坐標系統,因為這和我們的閱讀方式是一致的:從左到右、從上到下,并且很多圖像文件也是按照這樣的格式進行存儲的。
不管原因如何,差異就這么造成了。留給我們開發者的就是,要時刻小心這樣的差異,如果你發現得到的圖像是倒轉的,那么很有可能就是這個原因造成的。
2.3.5 三角形設置
由這一步開始就進入了光柵化階段。從上一個階段輸出的信息是屏幕坐標系下的頂點位置以及和它們相關的額外信息,如深度值(z坐標)、法線方向、視角方向等。光柵化階段有兩個最重要的目標:計算每個圖元覆蓋了哪些像素,以及為這些像素計算它們的顏色。
光柵化的第一個流水線階段是三角形設置(Triangle Setup)。這個階段會計算光柵化一個三角網格所需的信息。具體來說,上一個階段輸出的都是三角網格的頂點,即我們得到的是三角網格每條邊的兩個端點。但如果要得到整個三角網格對像素的覆蓋情況,我們就必須計算每條邊上的像素坐標。為了能夠計算邊界像素的坐標信息,我們就需要得到三角形邊界的表示方式。這樣一個計算三角網格表示數據的過程就叫做三角形設置。它的輸出是為了給下一個階段做準備。
2.3.6 三角形遍歷
三角形遍歷(Triangle Traversal)階段將會檢查每個像素是否被一個三角網格所覆蓋。如果被覆蓋的話,就會生成一個片元(fragment)。而這樣一個找到哪些像素被三角網格覆蓋的過程就是三角形遍歷,這個階段也被稱為掃描變換(Scan Conversion)。
三角形遍歷階段會根據上一個階段的計算結果來判斷一個三角網格覆蓋了哪些像素,并使用三角網格3個頂點的頂點信息對整個覆蓋區域的像素進行插值。圖2.12展示了三角形遍歷階段的簡化計算過程。

▲圖2.12 三角形遍歷的過程。根據幾何階段輸出的頂點信息,最終得到該三角網格覆蓋的像素位置。對應像素會生成一個片元,而片元中的狀態是對3個頂點的信息迚行插值得到的。例如,對圖2.12中3個頂點的深度迚行插值得到其重心位置對應的片元的深度值為-10.0
這一步的輸出就是得到一個片元序列。需要注意的是,一個片元并不是真正意義上的像素,而是包含了很多狀態的集合,這些狀態用于計算每個像素的最終顏色。這些狀態包括了(但不限于)它的屏幕坐標、深度信息,以及其他從幾何階段輸出的頂點信息,例如法線、紋理坐標等。
2.3.7 片元著色器
片元著色器(Fragment Shader)是另一個非常重要的可編程著色器階段。在DirectX中,片元著色器被稱為像素著色器(Pixel Shader),但片元著色器是一個更合適的名字,因為此時的片元并不是一個真正意義上的像素。
前面的光柵化階段實際上并不會影響屏幕上每個像素的顏色值,而是會產生一系列的數據信息,用來表述一個三角網格是怎樣覆蓋每個像素的。而每個片元就負責存儲這樣一系列數據。真正會對像素產生影響的階段是下一個流水線階段——逐片元操作(Per-Fragment Operations)。我們隨后就會講到。
片元著色器的輸入是上一個階段對頂點信息插值得到的結果,更具體來說,是根據那些從頂點著色器中輸出的數據插值得到的。而它的輸出是一個或者多個顏色值。圖2.13顯示了這樣一個過程。

▲圖2.13 根據上一步插值后的片元信息,片元著色器計算該片元的輸出顏色
這一階段可以完成很多重要的渲染技術,其中最重要的技術之一就是紋理采樣。為了在片元著色器中進行紋理采樣,我們通常會在頂點著色器階段輸出每個頂點對應的紋理坐標,然后經過光柵化階段對三角網格的3個頂點對應的紋理坐標進行插值后,就可以得到其覆蓋的片元的紋理坐標了。
雖然片元著色器可以完成很多重要效果,但它的局限在于,它僅可以影響單個片元。也就是說,當執行片元著色器時,它不可以將自己的任何結果直接發送給它的鄰居們。有一個情況例外,就是片元著色器可以訪問到導數信息(gradient,或者說是derivative)。有興趣的讀者可以參考本章的擴展閱讀部分。
2.3.8 逐片元操作
終于到了渲染流水線的最后一步。逐片元操作(Per-Fragment Operations)是OpenGL中的說法,在DirectX中,這一階段被稱為輸出合并階段(Output-Merger)。Merger這個詞可能更容易讓讀者明白這一步驟的目的:合并。而OpenGL中的名字可以讓讀者明白這個階段的操作單位,即是對每一個片元進行一些操作。那么問題來了,要合并哪些數據?又要進行哪些操作呢?
這一階段有幾個主要任務。
(1)決定每個片元的可見性。這涉及了很多測試工作,例如深度測試、模板測試等。
(2)如果一個片元通過了所有的測試,就需要把這個片元的顏色值和已經存儲在顏色緩沖區中的顏色進行合并,或者說是混合。
需要指明的是,逐片元操作階段是高度可配置性的,即我們可以設置每一步的操作細節。這在后面會講到。
這個階段首先需要解決每個片元的可見性問題。這需要進行一系列測試。這就好比考試,一個片元只有通過了所有的考試,才能最終獲得和GPU談判的資格,這個資格指的是它可以和顏色緩沖區進行合并。如果它沒有通過其中的某一個測試,那么對不起,之前為了產生這個片元所做的所有工作都是白費的,因為這個片元會被舍棄掉。Poor fragment!圖2.14給出了簡化后的逐片元操作所做的操作。

▲圖2.14 逐片元操作階段所做的操作。只有通過了所有的測試后,新生成的片元才能和顏色緩沖區中已經存在的像素顏色迚行混合,最后再寫入顏色緩沖區中
測試的過程實際上是個比較復雜的過程,而且不同的圖形接口(例如OpenGL和DirectX)的實現細節也不盡相同。這里給出兩個最基本的測試——深度測試和模板測試的實現過程。能否理解這些測試過程將關乎讀者是否可以理解本書后面章節中提到的渲染隊列,尤其是處理透明效果時出現的問題。圖2.15給出了深度測試和模板測試的簡化流程圖。

▲圖2.15 模板測試和深度測試的簡化流程圖
我們先來看模板測試(Stencil Test)。與之相關的是模板緩沖(Stencil Buffer)。實際上,模板緩沖和我們經常聽到的顏色緩沖、深度緩沖幾乎是一類東西。如果開啟了模板測試,GPU會首先讀取(使用讀取掩碼)模板緩沖區中該片元位置的模板值,然后將該值和讀?。ㄊ褂米x取掩碼)到的參考值(reference value)進行比較,這個比較函數可以是由開發者指定的,例如小于時舍棄該片元,或者大于等于時舍棄該片元。如果這個片元沒有通過這個測試,該片元就會被舍棄。不管一個片元有沒有通過模板測試,我們都可以根據模板測試和下面的深度測試結果來修改模板緩沖區,這個修改操作也是由開發者指定的。開發者可以設置不同結果下的修改操作,例如,在失敗時模板緩沖區保持不變,通過時將模板緩沖區中對應位置的值加1等。模板測試通常用于限制渲染的區域。另外,模板測試還有一些更高級的用法,如渲染陰影、輪廓渲染等。
如果一個片元幸運地通過了模板測試,那么它會進行下一個測試——深度測試(Depth Test)。相信很多讀者都聽到過這個測試。這個測試同樣是可以高度配置的。如果開啟了深度測試,GPU會把該片元的深度值和已經存在于深度緩沖區中的深度值進行比較。這個比較函數也是可由開發者設置的,例如小于時舍棄該片元,或者大于等于時舍棄該片元。通常這個比較函數是小于等于的關系,即如果這個片元的深度值大于等于當前深度緩沖區中的值,那么就會舍棄它。這是因為,我們總想只顯示出離攝像機最近的物體,而那些被其他物體遮擋的就不需要出現在屏幕上。如果這個片元沒有通過這個測試,該片元就會被舍棄。和模板測試有些不同的是,如果一個片元沒有通過深度測試,它就沒有權利更改深度緩沖區中的值。而如果它通過了測試,開發者還可以指定是否要用這個片元的深度值覆蓋掉原有的深度值,這是通過開啟/關閉深度寫入來做到的。我們在后面的學習中會發現,透明效果和深度測試以及深度寫入的關系非常密切。
如果一個幸運的片元通過了上面的所有測試,它就可以自豪地來到合并功能的面前。
為什么需要合并?我們要知道,這里所討論的渲染過程是一個物體接著一個物體畫到屏幕上的。而每個像素的顏色信息被存儲在一個名為顏色緩沖的地方。因此,當我們執行這次渲染時,顏色緩沖中往往已經有了上次渲染之后的顏色結果,那么,我們是使用這次渲染得到的顏色完全覆蓋掉之前的結果,還是進行其他處理?這就是合并需要解決的問題。
對于不透明物體,開發者可以關閉混合(Blend)操作。這樣片元著色器計算得到的顏色值就會直接覆蓋掉顏色緩沖區中的像素值。但對于半透明物體,我們就需要使用混合操作來讓這個物體看起來是透明的。圖2.16展示了一個簡化版的混合操作的流程圖。

▲圖2.16 混合操作的簡化流程圖
從流程圖中我們可以發現,混合操作也是可以高度配置的:開發者可以選擇開啟/關閉混合功能。如果沒有開啟混合功能,就會直接使用片元的顏色覆蓋掉顏色緩沖區中的顏色,而這也是很多初學者發現無法得到透明效果的原因(沒有開啟混合功能)。如果開啟了混合,GPU會取出源顏色和目標顏色,將兩種顏色進行混合。源顏色指的是片元著色器得到的顏色值,而目標顏色則是已經存在于顏色緩沖區中的顏色值。之后,就會使用一個混合函數來進行混合操作。這個混合函數通常和透明通道息息相關,例如根據透明通道的值進行相加、相減、相乘等?;旌虾芟馪hotoshop中對圖層的操作:每一層圖層可以選擇混合模式,混合模式決定了該圖層和下層圖層的混合結果,而我們看到的圖片就是混合后的圖片。
上面給出的測試順序并不是唯一的,而且雖然從邏輯上來說這些測試是在片元著色器之后進行的,但對于大多數GPU來說,它們會盡可能在執行片元著色器之前就進行這些測試。這是可以理解的,想象一下,當GPU在片元著色器階段花了很大力氣終于計算出片元的顏色后,卻發現這個片元根本沒有通過這些檢驗,也就是說這個片元還是被舍棄了,那之前花費的計算成本全都浪費了!圖2.17給出了這樣一個場景。

▲圖2.17 圖示場景中包含了兩個對象:球和長方體,繪制順序是先繪制球(在屏幕上顯示為圓),再繪制長方體(在屏幕上顯示為長方形)。如果深度測試在片元著色器之后執行,那么在渲染長方體時,雖然它的大部分區域都被遮擋在球的后面,即它所覆蓋的絕大部分片元根本無法通過深度測試,但是我們仍然需要對這些片元執行片元著色器,造成了很大的性能浪費
作為一個想充分提高性能的GPU,它會希望盡可能早地知道哪些片元是會被舍棄的,對于這些片元就不需要再使用片元著色器來計算它們的顏色。在Unity給出的渲染流水線中,我們也可以發現它給出的深度測試是在片元著色器之前。這種將深度測試提前執行的技術通常也被稱為Early-Z技術。希望讀者看到這里時不會因此感到困惑。在本書后面的章節中,我們還會繼續討論這個問題。
但是,如果將這些測試提前的話,其檢驗結果可能會與片元著色器中的一些操作沖突。例如,如果我們在片元著色器進行了透明度測試(我們將在8.3節中具體講到),而這個片元沒有通過透明度測試,我們會在著色器中調用API(例如clip函數)來手動將其舍棄掉。這就導致GPU無法提前執行各種測試。因此,現代的GPU會判斷片元著色器中的操作是否和提前測試發生沖突,如果有沖突,就會禁用提前測試。但是,這樣也會造成性能上的下降,因為有更多片元需要被處理了。這也是透明度測試會導致性能下降的原因。
當模型的圖元經過了上面層層計算和測試后,就會顯示到我們的屏幕上。我們的屏幕顯示的就是顏色緩沖區中的顏色值。但是,為了避免我們看到那些正在進行光柵化的圖元,GPU會使用雙重緩沖(Double Buffering)的策略。這意味著,對場景的渲染是在幕后發生的,即在后置緩沖(Back Buffer)中。一旦場景已經被渲染到了后置緩沖中,GPU就會交換后置緩沖區和前置緩沖(Front Buffer)中的內容,而前置緩沖區是之前顯示在屏幕上的圖像。由此,保證了我們看到的圖像總是連續的。
2.3.9 總結
雖然我們上面講了很多,但其真正的實現過程遠比上面講到的要復雜。需要注意的是,讀者可能會發現這里給出的流水線名稱、順序可能和在一些資料上看到的不同。一個原因是由于圖像編程接口(如OpenGL和DirectX)的實現不盡相同,另一個原因是GPU在底層可能做了很多優化,例如上面提到的會在片元著色器之前就進行深度測試,似避免無謂的計算。
雖然渲染流水線比較復雜,但Unity作為一個非常出色的平臺為我們封裝了很多功能。更多時候,我們只需要在一個Unity Shader設置一些輸入、編寫頂點著色器和片元著色器、設置一些狀態就可以達到大部分常見的屏幕效果。這是Unity吸引人的魅力之處,但這樣的缺點在于,封裝性會導致編程自由度下降,使很多初學者迷失方向,無法掌握其背后的原理,并在出現問題時,往往無法找到錯誤原因,這是在學習Unity Shader時普遍的遭遇。
渲染流水線幾乎和本書所有章節都息息相關,如果讀者此時仍然無法完全理解渲染流水線,仍可以繼續學習下去。但如果讀者在學習過程中發現有些設置或代碼無法理解,可以不斷查閱本章內容,相信會有更深的理解。
- .NET之美:.NET關鍵技術深入解析
- 潮流:UI設計必修課
- R語言經典實例(原書第2版)
- Python自然語言處理實戰:核心技術與算法
- C語言程序設計實訓教程
- Visual Basic程序設計教程
- SQL Server 2016數據庫應用與開發習題解答與上機指導
- PostgreSQL Replication(Second Edition)
- Python:Master the Art of Design Patterns
- Java Web程序設計任務教程
- Protocol-Oriented Programming with Swift
- Mastering Gephi Network Visualization
- INSTANT Apache ServiceMix How-to
- 零基礎學C++(升級版)
- Java Hibernate Cookbook