《OpenGL編程指南(原書第8版)》針對OpenGL4.3版本的各種特性進行了全新闡述,並全面介紹了OpenGL和OpenGL著色語言,第一次將著色器的技術與函數功能為中心的經典技術介紹相結合,呈現最新的OpenGL編程技術。
由於圖形處理器每秒能夠進行數以億計次的計算,它已成為一種性能十分驚人的器件。過去,這種處理器主要被設計用於承擔實時圖形渲染中海量的數學運算。然而,其潛在的計算能力也可用於處理與圖形無關的任務,特別是當無法很好地與固定功能的圖形管線結合的時候。為了使得這種應用成為可能,OpenG引入一種特殊的著色器:計算著色器。計算著色器可以認為是一個只有一級的管線,沒有固定的輸入和輸出,所有默認的輸入通過一組內置變量來傳遞。當需要額外的輸入時,可以通過那些固定的輸入輸出來控制對紋理和緩沖的訪問。所有可見的副作用是圖像存儲,原子操作,以及對原子計數器的訪問。然而加上通用的顯存讀寫操作,這些看上去似乎有限的功能使計算著色器獲得一定程度的靈活性,同時擺脫圖形相關的束縛,以及打開廣闊的應用空間。
OpenGL中的計算著色器和其他著色器很相似。它通過glCreateShader() 函數創建,用glCompilerShader()進行編譯,通過glAttachShader()對程序進行綁定,最後按通用的做法用glLinkProgram()對這些程序進行鏈接。計算著色器使用GLSL編寫,原則上,所有其他圖形著色器(比如頂點著色器,幾何著色器或者片元著色器)能夠使用的功能它都可以使用。當然,這不包括諸如幾何著色器中的EmitVertex()或者EndPrimitive()等功能,以及其他類似的與圖形管線特有的內建變量。另一方面,計算著色器也包含一些獨有的內置變量和函數,這些變量和函數在OpenGL管線的其他地方無法訪問。
正如圖形著色器被置於管線的不同階段用來操作與圖形相關的單元一樣,將計算著色器被有效地放入一個一級的計算管線中,然後處理與計算相關的單元。按照這種類比,頂點著色器作用於每個頂點,幾何著色器作用於每個圖元,而片元著色器則作用於每個片元。圖形硬件主要通過並行來獲得性能,這種並行則通過大量的頂點、圖元和片元流過相應的管線階段而得以實現。而在計算著色器中,這種並行性則顯得更為直接,任務以組為單位進行執行,我們稱為工作組(work group)。擁有鄰居的工作組被稱為本地工作組(local workgroup), 這些組可以組成更大的組,稱為全局工作組(global workgroup),而其通常作為執行命令的一個單位。
計算著色器會被全局工作組中每一個本地工作組中的每一個單元調用一次,工作組的每一個單元稱為工作項(work item),每一次調用稱為一次執行。執行的單元之間可以通過變量和顯存進行通信,且可執行同步操作保持一致性。圖12-1 對這種工作方式進行了說明。在這個簡化的例子中,全局工作組包含16個本地工作組, 而每個本地工作組又包含16個執行單元,排成4*4的網格。每個執行單元擁有一個2維向量表示的索引值。
盡管在圖12-1中,全局和本地工作組都是2維的,而事實上它們是3維的,為了能夠在邏輯上適應1維、2維的任務,只需要把額外的那2維或1維的大小設為0即可。計算著色器的每一個執行單元本質上是相互獨立的,可以並行地在支持OpenGL的GPU硬件上執行。實際中,大部分OpenGL硬件都會把這些執行單元打包成較小的集合(lockstep),然後把這些小集合拼起來組成本地工作組。本地工作組的大小在計算著色器的源代碼中用輸入布局限定符來設置。全局工作組的大小則是本地工作組大小的整數倍。當計算著色器執行的時候,它可以內置變量來知道當前在本地工作組中的相對坐標、本地工作組的大小, 以及本地工作組在全局工作組中的相對坐標。基於這些還能進一步獲得執行單元在全局工作組中的坐標等。著色器根據這些變量來決定應該負責計算任務中的哪些部分,同時也能知道一個工作組中的其他執行單元,以便於共享數據。
圖12-1 計算工作量的圖示
輸入布局限定符在計算著色器中聲明本地工作組的大小,分別使用local_size_x、local_size_y以及local_size_z,它們的默認值都是1。舉例來說如果忽略local_size_z,就會創建N * M的2維組。比如在例子12.1中就聲明了一個本地工作組大小為16 * 16的著色器。
例12.1簡單的本地工作組聲明
盡管例子12.1中的著色器什麼事情也沒做,它仍然是一個“完整”的著色器,可以正常的編譯、鏈接並且在OpenGL硬件中執行。要創建一個計算著色器,只需調用glCreateShader ()函數,將類型設置為GL_COMPUTE_SHADER,並且調用glShaderSource()函數來設置著色器的源代碼, 接著就能按正常編譯了。然後把著色器附加到一個程序上,調用glLinkProgram()。這樣就會產生計算著色器階段需要的可執行程序。例12.2展示了從創建到鏈接一個計算程序(使用“計算程序”來表示使用計算著色器來編譯的程序)的完整步驟。
例12.2 創建,編譯和鏈接計算著色器
一旦像例12.2中那樣創建並鏈接一個計算著色器後,就可以用glUseProgram()函數把它設置為當前要執行的程序,然後用glDispatchCompute()把工作組發送到計算管線上,其原型如下:
Void glDispatchCompute(GLuint num_groups_x, GLuint num_groups_y, GLuint num_groups_z);
在3個維度上分發計算工作組。num_groups_x,num_groups_y和num_groups_z分別設置工作組在X,Y和Z維度上的數量。每個參數都必須大於0,小於或等於一個與設備相關的常量數組GL_MAX_COMPUTE_WORK_GROUP_SIZE的對應元素。
在調用glDispatchCompute()時,OpenGL會創建一個包含大小為num_groups_x * num_groups_y * num_gourps_z的本地工作組的3維數組。注意三個維度中一個或兩個維度可以為1或者glDispatchCompute()的參數的任何值。所以計算著色器中執行單元的總數是這個3維數組的大小乘以著色器代碼中定義的本地工作組的大小。可想而知,這種方法可以為圖像處理器創建非常大規模的工作負載,而通過計算著色器則可以相對容易地獲得並行性。
正如glDrawArraysIndirect()和glDrawArrays()的關系一樣,除了使用glDispatchCompute()之外通過glDispatchComputeIndirect()可以使用存儲在緩沖區對象上的參數來發送計算任務。緩沖區對象被綁定在GL_DISPATCH_INDIRECT_BUFFER上,並且緩沖區中存儲的參數包含三個打包在一起的無符號整數。這三個無符號整數的作用和glDispatchCompute()中的參數是等價的。參考glDispatchComputeIndirect的原型如下:
void glDispatchComputeIndirect(GLintptr indirect);
在三個維度上分發計算工作組,同時使用緩存對象中存儲的參數。indirect表示緩存數據中存儲參數的位置偏移量,使用基本機器單位。緩存中當前偏移位置的參數,是緊密排列的三個無符號整數值,用來表示本地工作組的數量。這些無符號整數值等價於glDispatchCompute()中的num_groups_x,num_groups_y和num_groups_z參數。每個參數都必須大於0,小於或等於一個設備相關的常量數組GL_MAX_COMPUTE_WORK_GROUP_SIZE的對應元素。
綁定在GL_DISPATCH_INDIRECT_BUFFER上的緩沖區數據的來源可以多種多樣,比如由另外一個計算著色器生成。這樣一來,圖形處理器就能夠通過設置緩沖區中的參數來給自身發送任務做計算或繪圖。例12.3中使用glDispatchComputeIndirect()來發送計算任務。
例12.3 分發計算工作量
注意到例12.3簡單地使用glUseProgram()把當前的程序對象指向某個特定計算程序。除了不能訪問圖形管線中的那些固定功能部分(如光柵器或幀緩存),計算著色器及其程序是完全正常的,這意味著你可以用glGetProgramiv()來請求它們的一些屬性(比如有效的uniform常量,或者存儲塊)或者像往常一樣訪問uniform常量。當然,計算著色器可以訪問所有其他著色器能訪問的資源,比如圖像,采樣器,緩沖區,原子計數器,以及常量存儲塊。
計算著色器及其程序還有一些獨有的屬性。比如,獲得本地工作組的大小(在源代碼的布局限定符中設置),調用glGetProgramiv()時將pname設置成GL_MAX_COMPUTE_WORK_GROUP_SIZE以及把param設置成包含三個無符號整型數的數組地址。這數組中的三個數會按順序被賦值為本地工作組在X,Y和Z方向上的大小。
一旦開始執行計算著色器,它就有可能需要對輸出數組的一個或多個單元賦值(比如一副圖像或者一個原子計數器數組),或者需要從一個輸入數組的特定位置讀取數據。為此得知道當前處於本地工作組中的什麼位置,以及在更大范圍的全局工作組中的位置。於是,OpenGL為計算著色器提供一組內置變量。如例12.4所示,這些內置變量被隱含地聲明。
例12.4 計算著色器中的內置變量聲明
這些計算著色器的定義如下:
假設已經知道自己在本地工作組和全局工作組中的位置,則可以利用信息來操作數據。如例12.5所示,加入一個圖像變量使得我們能夠將數據寫入由當前執行單元坐標決定的圖像位置中去,並且可以在計算著色器中更新。
例12.5 數據的操作
例12.5中的著色器把執行單元在本地工作組中的坐標按本地工作組大小進行歸一化, 然後將該結果寫入由全局請求ID確定的圖像位置上去。 圖像結果表達了全局和本地的請求ID的關系,並且展示在計算著色器中定義的矩形的工作組。(本例有32*16個執行單元,圖像如12.2所示)
為了生成如圖12-2的圖像, 在計算著色器寫完數據後,只需簡單地將紋理渲染至一個全屏的三角條帶上即可。
當調用glDispatchCompute()(或者glDispatchComputeIndirect())的時候,圖形處理器的內部將執行大量的工作。圖形處理器會盡可能采取並行的工作方式,並且每個計算著色器的請求都被看作是一個執行某項任務的小隊。我們必然要通過通信來加強團隊之間的合作,所以即使OpenGL並沒有定義執行順序和並行等級的信息,我們還是可以在請求之間建立某種程度的合作關系,以實現變量的共享。此外,我們還可以對一個本地工作組的所有請求進行同步,讓它們在同一時刻同時抵達著色器的某個位置。
圖12-2 全局和本地的請求ID的關系
我們可以使用shared關鍵字來聲明著色器中的變量,其格式與其它的關鍵字,例如uniform、in、out等類似。例12.6給出了一個使用shared關鍵字來進行聲明的示例。
例12.6 聲明共享變量的示例
如果一個變量被聲明為shared,那麼它將被保存到特定的位置,從而對同一個本地工作組內的所有計算著色器請求可見。如果某個計算著色器請求對共享變量進行寫入,那麼這個數據的修改信息將最終通知給同一個本地工作組的所有著色器請求。在這裡我們用了“最終”這個詞,這是因為各個著色器請求的執行順序並沒有定義,就算是同一個本地工作組內也是如此。因此,某個著色器請求寫入共享shared變量的時刻可能與另一個請求讀取該變量的時刻相隔甚遠,無論先寫入後讀取還是先讀取後寫入。為了確保能夠獲得期望的結果,我們需要在代碼中使用某種同步的方法。下一個小節詳細介紹這一問題。
通常訪問共享shared變量的性能會遠遠好於訪問圖像或者著色器存儲緩存(例如主內存)的性能。因為著色器處理器會將共享內存作為局部量處理,並且可以在設備中進行拷貝,所以訪問共享變量可能比使用緩沖區的方法更迅速。因此我們建議,如果你的著色器需要對一處內存進行大量的訪問,尤其是可能需要多個著色器請求訪問同一處內存地址的時候,不妨先將內存拷貝到著色器的共享變量中,然後通過這種方法進行操作,如果有必要,再把結果寫回到主內存中。
因為需要把聲明為shared的變量存儲到圖形處理器的高性能資源環境中,而這樣的資源環境是有限的,所以需要查詢和了解某個計算著色器程序的共享變量的最大數量。要獲取這個限制值,可以調用glGetIntegerv()並設置pname為GL_MAX_COMPUTE_SHARED_MEMORY_SIZE。
如果本地工作組請求的執行順序,以及全局工作組中的所有本地工作組的執行順序都沒有定義,那麼請求執行操作的時機與其他請求就是完全無關的。如果請求之間不需要互相通信,只需完全獨立地執行,那麼這樣並沒有什麼問題。但是,如果請求之間需要進行通信,無論是通過圖像,緩存還是共享內存,那麼我們就有必要對它們的操作進行同步處理了。
同步命令的類型有兩種。首先是運行屏障(execution barrier),可以通過barrier()函數觸發。它與細分控制著色器中的barrier()函數類似,後者可以用來實現控制點處理過程中的請求同步。如果計算著色器的一個請求遇到了barrier(),那麼它會停止運行,並等待同一個本地工作組的所有請求到達為止。當請求從barrier()中斷的地方重新開始運行的時候,我們可以斷定其它所有的請求也已經到達了barrier(),並且在此之前的所有操作均已經完成。barrier()函數在計算著色器中的用法比在細分控制著色器中更為靈活。尤其是,不需要限制在著色器中的main()函數中執行barrier()。但是,必須在統一的流控制過程中調用barrier()。也就是說,如果本地工作組的一個請求執行了barrier()函數,那麼同一工作組的所有請求都必須執行這個函數。這樣是合理的,因為著色器的某個請求不可能知道其它請求的控制流情況,所以只能假設其它請求也能到達屏障的位置,否則將會發生死鎖的情形。
如果在本地工作組內進行請求間的通信,那麼可以在一個請求中寫入共享變量,然後在另一個請求中讀取。但是,我們必須確定目標請求中讀取共享變量的時機,即在源請求已經完成對應的寫入操作之後。為了確保這一點,我們可以在源請求中寫入變量,然後在兩個請求中同時執行barrier()函數。當目標請求從barrier()返回的時候,源請求必然已經執行了同一個函數(也就是完成共享變量的寫入),因此可以安全地讀取變量的值了。
第二種類型的同步叫做內存屏障(memory barrier)。內存屏障的最直接的版本就是memoryBarrier()。如果調用memoryBarrier(),那麼就可以保證著色器請求內存的寫入操作一定是提交到內存端,而不是通過緩沖區(cache)或者調度隊列之類的方式。所有發生在memoryBarrier()之後的操作在讀取同一處內存的時候,都可以使用這些內存寫入的結果,即使是同一個計算著色器的其它請求也是如此。此外,memoryBarrier()還可以給著色器編譯器做出指示,讓它不要對內存操作重排序,以免因此跨越屏障函數。如果你覺得memoryBarrier()的約束過於嚴格,那麼你的感覺很正確。事實上,memoryBarrier()系列中還有其它不同的內存屏障子函數。memoryBarrier()所做的只是簡單地按照某種未定義的順序(這個說法不一定准確)依次調用這些子函數而已。
memoryBarrierAtomicCounter()函數會等待原子計數器更新,然後繼續執行。memoryBarrierBuffer()和memoryBarrierImage()函數會等待緩存和圖像變量的寫入操作完成。memoryBarrierShared()函數會等待帶有shared限定符的變量更新。這些函數可以對不同類型的內存訪問提供更為精細的控制和等待方法。舉例來說,如果正在使用原子計數器來實現緩存變量的訪問,我們可能希望確保原子計數器的更新被通知到著色器的其它請求,但是不需要等待緩存寫入操作本身完成,因為後者可能會花費更長的時間。此外,調用memoryBarrierAtomicCounter()允許著色器編譯器對緩存變量的訪問進行重排序,而不會受到原子計數器操作的邏輯影響。
注意,就算是調用memoryBarrier()或者它的某個子函數,我們依然不能保證所有的請求都到達著色器的同一個位置。為了確保這一點,我們只有調用執行屏障函數barrier(),然後再讀取內存數據,而後者應該是在memoryBarrier()之前被寫入的。
內存屏障的使用,對於單一著色器請求中內存交換順序的確立來說並不是必需的。在著色器的某個請求中讀取變量的值總是會返回最後一次寫入這個變量的結果,無論編譯器是否對它們進行重排序操作。
我們介紹的最後一個函數叫做groupMemoryBarrier(),它等價於memoryBarrier(),但是它只能應用於同一個本地工作組的其它請求。而所有其它的屏障函數都是應用於全局的。也就是說,它們會確保全局工作組中的任何內存寫入請求都會在提交之後,再繼續執行程序。
本文摘自《OpenGL編程指南(原書第8版)》第12章:計算著色器,機械工業出版社出版。
OpenGL編程指南(原書第8版) 中英文PDF 高清晰版 http://www.linuxidc.com/Linux/2015-08/122230.htm
OpenGL編程指南(原書第7版)中文掃描版PDF 下載見 http://www.linuxidc.com/Linux/2012-08/67925.htm
OpenGL超級寶典 第4版 中文版PDF+英文版+源代碼 見 http://www.linuxidc.com/Linux/2013-10/91413.htm
OpenGL 渲染篇 http://www.linuxidc.com/Linux/2011-10/45756.htm
Ubuntu 13.04 安裝 OpenGL http://www.linuxidc.com/Linux/2013-05/84815.htm
OpenGL三維球體數據生成與繪制【附源碼】 http://www.linuxidc.com/Linux/2013-04/83235.htm
Ubuntu下OpenGL編程基礎解析 http://www.linuxidc.com/Linux/2013-03/81675.htm
如何在Ubuntu使用eclipse for c++配置OpenGL http://www.linuxidc.com/Linux/2012-11/74191.htm
更多《OpenGL超級寶典學習筆記》相關知識 見 http://www.linuxidc.com/search.aspx?where=nkey&keyword=34581