天天看點

《OpenGL程式設計指南(原書第9版)》——2.8 SPIR-V

SPIR-V是Khronos标準的一種中間語言,這是一種着色器程式分發的替代方案。OpenGL支援GLSL形式的着色器程式,同樣也支援SPIR-V形式的着色器程式。通常來說,我們需要某些離線的處理工具,從GLSL這樣的進階着色語言來生成SPIR-V形式的代碼,進而在使用者程式當中釋出已生成的SPIR-V程式,而不是直接釋出GLSL的源代碼。

SPIR-V的建立、釋出和使用都是采用二進制單元的子產品(module)形式。一個SPIR-V子產品在記憶體中是一段32位詞的内容,或者直接存儲為32位詞的檔案。不過,OpenGL和GLSL都不會直接操作檔案,是以SPIR-V子產品必須是作為記憶體中的32位詞資料指針傳遞到OpenGL中使用的。

每個SPIR-V子產品都可以包含一個或者多個入口點,用來啟動一段着色器程式,并且每個入口點都隸屬于已知的OpenGL流水線階段(pipeline stage)。每個這樣的入口點都會構成一段獨立而完整的OpenGL流水線階段。換句話說,桌面GLSL會儲存多個編譯過的着色器單元,然後将它們組合成一個階段,但是SPIR-V着色器不同。它的編譯過程是在離線狀态下,通過某個前端工具将進階語言翻譯成SPIR-V完成的,是以得到的是一個完整的階段。即使對于同一個階段來說,一個獨立的SPIR-V子產品也可能包含多個入口點。

SPIR-V子產品是可以專有化的,也就是說,我們可以在最後編譯之前實時修改子產品中某些特定的辨別常量。這樣做是為了降低一個着色器的多個(輕微修改後的)版本對應的SPIR-V子產品的數量。

2.8.1 選擇SPIR-V的理由

如果使用者期望釋出SPIR-V形式的着色器,而不是GLSL形式的,那麼可能有以下幾種原因。有些原因可能符合你目前的狀況,有些可能不符合:

更好的可移植性。有一類可移植性問題是因為不同平台的驅動程式對于GLSL的進階文法會有稍微不同的解釋。而進階語言之是以被稱作進階,部分原因就是它們節約了開發者的寶貴時間。但是,這種便利的前提條件有的時候是很難完全确定的,因而導緻了驅動層面的不同結果。SPIR-V更為嚴格,對于文法的表達也更為規範,是以解釋過程中并沒有很大的歧義。是以SPIR-V在不同平台的解釋過程中變數更小,因而提升了可移植性。當然,我們并沒有使用SPIR-V進行編碼,而是繼續使用諸如GLSL這樣的進階語言。但是為了生成SPIR-V程式,需要選擇一個針對全平台的前端工具。也就是說,選擇了一個獨立的GLSL前端之後,我們就消除了因為不同平台的GLSL文法解釋過程而産生的可移植性問題。有些人可能會選擇其他的前端來編寫着色器代碼,這樣也沒有問題。我們真正要關注的重點是:應用程式中的GLSL着色器是否都采用了平台一緻的GLSL解釋方式,進而生成一緻的SPIR-V代碼。

多種源語言支援。SPIR-V可以支援GLSL之外的其他進階語言。隻要最後釋出的SPIR-V是正确格式的,我們就不需要關心它是如何生成的。

減少釋出尺寸。SPIR-V有多種特性來顯著降低着色器釋出後的尺寸。對于獨立的着色器來說,SPIR-V的形式通常比GLSL的形式更大一些,但這兩者生成的最終結果其實都很小。但是如果将相關的着色器集合起來,尺寸就會大得多。而SPIR-V提供了兩種特性來處理這種集合的形式:每個子產品的專有化和多重入口點。專有化可以讓我們延遲修改某些常量數值,而同一個SPIR-V子產品中的多重入口點可以在多個程式段中共享同一個函數體的執行個體。釋出GLSL的時候,需要針對每一個着色器都釋出一份函數體的拷貝,而SPIR-V的釋出隻需要一個拷貝即可。

保護源代碼。有時候也叫作代碼的混淆,因為很多時候我們并不希望用過于清晰的方式來釋出自己的着色器源代碼。着色器的源碼可能是某種新奇想法或者知識産權,而你不一定願意把這些成果完全透明地釋出給其他人,讓他們随意改動。采用離線編譯源代碼到SPIR-V,然後釋出SPIR-V代碼的方法,就可以避免直接釋出自己的源代碼。這樣其他人就很難了解這樣的着色器代碼是如何工作的。沒錯,這樣的代碼依然可以反編譯成GLSL或其他進階着色語言的形式,或者重新再轉換成SPIR-V的語言。不過,這樣的逆向工程需要得到對應的法律許可,因而也就為釋出者提供了真正的知識産權保護機制。

我們選擇中間語言而非進階語言的另一個理由是為了保證明時編譯器的性能,但是這裡也要注意。高性能的着色器執行過程通常需要對應的排程和寄存器配置設定算法,而實時運作這件事本身是需要消耗時間的。這些後續步驟無法通過可移植的中間語言來消除。而實時編譯器的性能是可以通過多種途徑來提升的。例如,解析進階語言的過程需要花費時間。雖然解析隻是整個編譯過程的一小部分,但是如果着色器代碼中含有大量無用的代碼段,或者我們需要将多段着色器代碼編譯為相同的中間結果的話,這裡的性能損耗還是非常顯著的。在這種情況下,使用SPIR-V可以明顯降低解析所需的時間。同樣,有些進階的優化特性也可以離線完成,但是需要避免使用那些平台相關的優化方法,否則在某些平台上可能會損害性能。舉例來說,是否将所有的函數設定為内聯形式,這就是一個平台相關的特性。

2.8.2 SPIR-V與OpenGL

在OpenGL中使用SPIR-V着色器的方法,與使用GLSL着色器非常類似。正如之前所介紹的,建立了着色器對象之後,我們還需要兩個步驟來關聯SPIR-V的入口點與每個着色器對象。第一步是調用glShaderBinary()來關聯SPIR-V子產品與着色器對象:

void glShaderBinary(GLsizei count, const GLuint shaders, enum binaryformat, const void binary, GLsizei length);

如果binaryformat設定為GL_SHADER_BINARY_FORMAT_SPIR_V_ARB,那麼binary中需要設定SPIR-V子產品所關聯的一組着色器對象。shaders包含一組着色器對象的句柄,大小為count。每個着色器對象句柄對應一個唯一的着色器類型,可以是GL_VERTEX_SHADER、GL_FRAGMENT_SHADER、GL_TESS_CONTROL_SHADER、GL_TESS_EVALUATION_SHADER、GL_GEOMETRY_SHADER或者GL_COMPUTE_SHADER中的一種。binary指向一個合法SPIR-V子產品的第一個位元組,而length包含了SPIR-V子產品的位元組長度。如果我們成功地使用了SPIR-V子產品,那麼shaders中的每個入口都可以從這個SPIR-V子產品中擷取入口點。這些着色器編譯的狀态會被設定為GL_FALSE。

因為SPIR-V通常是由32位的資料流所組成的,是以我們需要将自己的SPIR-V代碼大小轉換成位元組數再傳遞給glShaderBinary()。glShaderBinary()函數也可以用于其他非源碼形式的着色器,是以它是一個通用的函數,而不是專用于SPIR-V的,除非指定SHADER_BINARY_FORMAT_SPIR_V_ARB。

第二步是使用glSpecializeShader()來關聯SPIR-V入口點與着色器對象,如果成功的話,那麼編譯狀态會從glShaderBinary()所設定的GL_FALSE變成GL_TRUE:

void glSpecializeShader(GLuint shader, const char pEntryPoint, GLuint numSpecializationConstants, const uint pConstantIndex, const uint* pConstantValue);

設定SPIR-V子產品中入口點的名字,并設定SPIR-V子產品中專有化常量的值。shader表示與SPIR-V子產品關聯(使用glShaderBinary())的着色器對象的名字。而pEntryPoint是一個UTF-8字元串指針,使用NULL截斷,它表示SPIR-V子產品中name着色器對應的入口點名稱。如果pEntryPoint為空,那麼預設字元串為“main”。

numSpecializationConstants表示本次調用過程中專有化常量的數量。pConstantIndex表示一個數組的指針,它包含了numSpecializationConstants個無符号整型資料。pConstantValue中對應的資料即被用來設定專有化常量的值,其索引位置由pConstantIndex中的資料決定。雖然這個數組是無符号整型資料組成的,但是每個數值都是根據子產品中設定的類型來進行按位轉換的。是以,我們也可以在pConstantValue數組中使用浮點數常量,并采用IEEE-754标準的表示方法。pConstantIndex中沒有引用的專有化常量在SPIR-V子產品中依然保留原有的數值。當着色器的專有化完成之後,着色器的編譯狀态将會設定為GL_TRUE。如果失敗的話,着色器的編譯狀态會設定為GL_FALSE,同時我們可以在着色器編譯日志中找到相關的失敗資訊。

我們将會在本節後面的部分讨論GLSL專有化的方法。

完成這兩步之後,我們就可以使用glAttachShader()和glLinkProgram()了,這和我們以前使用glShaderSource()來編寫GLSL代碼的過程是一樣的,其他的工作流程也完全一緻。

2.8.3 使用GLSL在OpenGL中生成SPIR-V

OpenGL對于生成SPIR-V的方法并沒有要求,隻需要SPIR-V本身完整即可。這對于很多進階語言的支援,以及建立SPIR-V的本地工具來說是很好的特性,并且也可以友善我們編寫和交換标準進階語言格式的着色器。為了輔助這一點,Khronos對于GLSL建立SPIR-V的過程進行了标準化。

GLSL有兩種建立SPIR-V形式的着色器的方法:一種是建立Vulkan對應的SPIR-V(通過KHR_glsl_vulkan擴充);另一種是建立OpenGL對應的SPIR-V(通過ARB_gl_spirv擴充)。當然,這裡會着重讨論OpenGL對應的SPIR-V在GLSL中的生成過程。這裡所說的GLSL也就是标準GLSL,但是會有少量的增加和少量的删減,以及一部分更改。總體上來說,它的所有輸入和輸出都需要設定一個location,而I/O與SSO模型的用法是類似的。其他方面則與本章所介紹的GLSL完全相同。

驗證SPIR-V

OpenGL驅動并不會完全支援SPIR-V的實時驗證,因為SPIR-V在離線狀态下生成對于系統性能來說更有利。OpenGL隻需要正确執行經過完整驗證的SPIR-V資料即可。也就是說,如果SPIR-V無效,那麼得到的結果也是無法預知的。Khronos已經開發了一個SPIR-V的驗證工具,以及其他一些工具,可以在下面的位址下載下傳:

<a href="https://github.com/KhronosGroup/SPIRV-Tools">https://github.com/KhronosGroup/SPIRV-Tools</a>

它是離線的,可以確定你要釋出的SPIR-V是可用的。這個工具需要內建到你自己的離線工具鍊當中,以便最大限度地符合着色器的可移植性需求。

GLSL中針對SPIR-V生成的增補項

OpenGL GLSL中針對SPIR-V的核心增補項就是專有化。專有化常量可以很大程度上降低着色器中可變量的數量。是以着色器的常量可以延遲發生改變,而不需要重新生成着色器。

總體上來說,如果在編譯階段就知道哪些數值是常量,那麼我們就可以優化并生成更快的可執行代碼(否則系統可能會通路一直保持不變的數值)。循環語句執行的次數是可知的,是以計算量也可以簡化。因為常量具有這些益處,GLSL着色器通常會通過預編譯宏或者某些自動生成的代碼來進行此類參數化的工作。然後就會因為參數數值的差别而生産成多組不同的着色器代碼。如果使用專有化常量,這樣的參數就會被特别标注出來,并給定一個預設值,并且被當作一個常量對待(雖然它的數值在最終運作時編譯的時候還是可以發生變化)。是以,我們可以隻建立一個着色器,然後使用專有化常量釋出,之後在運作時設定正确的常量數值。在GLSL中可以這樣書寫:

《OpenGL程式設計指南(原書第9版)》——2.8 SPIR-V

這裡我們聲明param是一個專有化常量(通過constant_id),預設值為8。數值17表示param在運作時的辨別,如果使用者程式想通過OpenGL API(也就是之前的glSpecializeShader())來改變預設值,就需要引用這個數值。

編譯SPIR-V的時候,SPIR-V着色器會把param作為一個專有化常量進行追蹤。如果要為這個着色器建立一個渲染流水線,那麼SPIR-V着色器中會給出正确的常量值并且針對它進行優化。是以,我們就不需要為了常量的多個變化值而修改同一個着色器對象了。

SPIR-V中移除的GLSL特性

有些GLSL的傳統特性并不受到SPIR-V的支援。我們将這些特性列在這裡,并且給出建議的替代方案。

子程式(subroutine):OpenGL GLSL的子程式特性在SPIR-V中無法使用。我們可以使用GLSL的其他方法來替代這個功能,比如switch語句以及函數調用。舉例來說:

《OpenGL程式設計指南(原書第9版)》——2.8 SPIR-V

過時特性:過時的特性本來就應當避免,其中有一些會被SPIR-V完全忽略掉。其中包括一些過時的紋理函數,例如texture2D(),它無法使用的原因是texture2D現在已經被保留為類型關鍵字,用來生成不用獨立采樣和2D紋理的sampler2D。它的替代者是texture,這個新版本的内置函數被用來執行紋理查找的操作。

相容模式(compatibility prof?ile):總體上來說,凡是隻屬于相容模式的特性都不會被SPIR-V所支援,并且相容模式的GLSL也不允許用來生成SPIR-V。你需要設定着色器使用核心模式(core prof?ile)的特性,包括我們之前提到的,專用于GLSL中的SPIR-V的特性。

gl_DepthRangeParameters():SPIR-V沒有為深度範圍參數設定内置的變量。如果使用者希望在着色器中使用此類資訊,可以直接聲明自己的uniform變量,并且通過API顯式設定它們的數值。

SPIR-V中變更的GLSL特性

gl_FragColor廣播:直接使用GLSL而不通過SPIR-V的時候,寫入到gl_FragColor相當于對所有的顔色輸出附件(color-output attachment)統一寫入。但是SPIR-V不支援這個特性。理想情況下,我們需要聲明想要寫入的輸出變量,并且顯式地進行寫入。如果依然使用gl_FragColor的話,那麼寫入它的數值相當于隻寫入到位置0的那一個顔色輸出附件。

2.8.4 Glslang

Khronos Group提供了一個GLSL的參考前端工具,可以用來從GLSL生成SPIR-V,并且支援OpenGL和Vulkan。要注意的是,你必須指定你生成的SPIR-V是對應哪個API的,它們對應的特性不同,GLSL語義也不同。雖然這是Khronos提供的驗證GLSL正确性的前端工具,但它隻是一個SPIR-V編譯器的示例程式而已,并不是唯一能夠做這件事的工具。

Glslang是GitHub上維護的一個開源項目,位址為:

注意,glslang是一個Khronos提供的參考工具,可以驗證OpenGL GLSL或者OpenGL ES的ESSL的語義正确性。不過目前它還沒有被Khronos認可為SPIR-V生成所用的檢測工具,隻是一個示例性質的實作而已。

2.8.5 SPIR-V中包含了什麼

SPIR-V采用簡單的純二進制格式,可以表達為一種進階的中間語言。它采用簡單的32位詞的簡單線性隊列進行存儲。如果你要從一個離線的編譯器擷取結果,或者将結果設定給API,那麼它會被表達為一個32位詞的資料流(但是你需要把尺寸乘以4,以便得到glShaderBinary()所期望的位元組數)。它采用自包含的形式,字元串詞并沒有進行進一步的封裝,而是直接從檔案中讀寫原始詞序列,或者設定給API的入口點。在序列當中,前幾個字段的資料提供了對後面資料的可用性檢查功能,包括資料伊始的SPIR-V魔法數字(magic number),它應當是0x07230203。如果你得到的結果在位元組上是反向的,那麼你取得的可能不是一個完整的32位詞,也可能你的大小端(endianness)設定與檔案本身相反。

一個用進階語言編寫的着色器轉換到SPIR-V之後,幾乎不會丢失資訊。它可以保留緊湊的控制特性和其他進階的結構、GLSL自有的類型,以及内置變量資料,是以進行更高性能的優化時不會導緻目标平台上的結果丢失資訊。

對于SPIR-V更多内部細節的講解超出了本書的範疇,我們隻是希望告訴使用者如何使用GLSL來生成SPIR-V,并且在應用程式中釋出它,而不涉及自己編寫SPIR-V的方法。

繼續閱讀