1、概述
在上一篇文章OpenGL ES 3.0(一)綜述 中提到,著色器(Shader)是運行在GPU上的小程序。這些小程序為圖形渲染管線的某個特定部分而運行。從本質上來說,著色器只是一種把輸入轉化為輸出的程序。著色器也是一種非常獨立的程序,因為它們之間不能相互通信,它們之間唯一的溝通只有通過輸入和輸出。這篇文章主要討論用一種更加廣泛的形式詳細解釋著色器,特別是OpenGL著色器語言(GLSL)。
2、GLSL
著色器是使用一種叫GLSL的類C語言寫成的。GLSL是為圖形計算量身定制的,它包含一些針對向量和矩陣操作的有用特性。著色器的開頭總是要聲明版本,接著是輸入和輸出變量、uniform和main函數。每個著色器的入口點都是main函數,在這個函數中我們處理所有的輸入變量,并將結果輸出到輸出變量中。后面會進行講解。一個典型的著色器有下面的結構:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
int main()
{
// 處理輸入并進行一些圖形操作
...
// 輸出處理過的結果到輸出變量
out_variable_name = weird_stuff_we_processed;
}
當討論到頂點著色器的時候,每個輸入變量也叫頂點屬性(Vertex Attribute)。能聲明的頂點屬性是有上限的,它一般由硬件來決定。OpenGL ES確保至少有16個包含4分量的頂點屬性可用,但是有些硬件或許允許更多的頂點屬性,可以查詢GL_MAX_VERTEX_ATTRIBS來獲取具體的上限:
var maxVertexAttribute = IntBuffer.allocate(1)
GLES30.glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, maxVertexAttribute)
Log.d(TAG, "maxVertexAttribute:" + maxVertexAttribute.get(0))
3、數據類型
和其他編程語言一樣,GLSL有數據類型可以來指定變量的種類。GLSL中包含C等其它語言大部分的默認基礎數據類型:int、float、double、uint和bool。GLSL也有兩種容器類型,分別是向量(Vector)和矩陣(Matrix),其中矩陣會單獨出一篇文章討論。
GLSL中的向量是一個可以包含有1、2、3或者4個分量的容器,分量的類型可以是前面默認基礎類型的任意一個。它們可以是下面的形式(n代表分量的數量)。
| 類型 | 含義 |
|---|---|
| vecn | 包含n個float分量的默認向量 |
| bvecn | 包含n個bool分量的向量 |
| ivecn | 包含n個int分量的向量 |
| uvecn | 包含n個unsigned int分量的向量 |
| dvecn | 包含n個double分量的向量 |
大多數時候使用vecn,因為float足夠滿足大多數要求了。一個向量的分量可以通過vec.x這種方式獲取,這里x是指這個向量的第一個分量。你可以分別使用.x、.y、.z和.w來獲取它們的第1、2、3、4個分量。GLSL也允許你對顏色使用rgba,或是對紋理坐標使用stpq訪問相同的分量。
向量這一數據類型也允許一些有趣而靈活的分量選擇方式,叫做重組(Swizzling)。重組允許這樣的語法:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
可以使用上面4個字母任意組合來創(chuàng)建一個和原來向量一樣長的(同類型)新向量,只要原來向量有那些分量即可;但是不允許在一個vec2向量中去獲取.z元素。也可以把一個向量作為一個參數傳給不同的向量構造函數,以減少需求參數的數量:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
4、輸入與輸出
雖然著色器是各自獨立的小程序,但是它們都是一個整體的一部分,出于這樣的原因,會希望每個著色器都有輸入和輸出,這樣才能進行數據交流和傳遞。GLSL定義了in和out關鍵字專門來實現這個目的。每個著色器使用這兩個關鍵字設定輸入和輸出,只要一個輸出變量與下一個著色器階段的輸入匹配,它就會傳遞下去。但在頂點和片段著色器中會有點不同。
頂點著色器應該接收的是一種特殊形式的輸入,否則就會效率低下。頂點著色器的輸入特殊在,它從頂點數據中直接接收輸入。為了定義頂點數據該如何管理,使用location這一元數據指定輸入變量,這樣才可以在CPU上配置頂點屬性。在前面的文章已經看過這個了,layout (location = 0)。頂點著色器需要為它的輸入提供一個額外的layout標識,這樣才能把它鏈接到頂點數據。也可以忽略layout (location = 0)標識符,通過在OpenGL ES代碼中使用glGetAttribLocation()查詢屬性位置值(Location),但是在著色器中設置它們,會更容易理解而且節(jié)省工作量。
另一個例外是片段著色器,它需要一個vec4顏色輸出變量,因為片段著色器需要生成一個最終輸出的顏色。如果在片段著色器沒有定義輸出顏色,OpenGL會把物體渲染為黑色(或白色)。所以,如果打算從一個著色器向另一個著色器發(fā)送數據,必須在發(fā)送方著色器中聲明一個輸出,在接收方著色器中聲明一個類似的輸入。當類型和名字都一樣的時候,OpenGL ES就會把兩個變量鏈接到一起,它們之間就能發(fā)送數據了(這是在鏈接程序對象時完成的)。為了展示這是如何工作的,稍微改動一下前面一篇文章里的那個著色器,讓頂點著色器為片段著色器決定顏色。
// 頂點著色器
private val vertexShaderCode =
"#version 300 es \n" +
"out vec4 ourColor;" +
" layout (location = 0) in vec3 aPos;" +
"void main() {" +
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);" +
" ourColor = vec4(0.5, 0.2, 0.1, 1.0);" +
"}"
// 片段著色器
private val fragmentShaderCode =
"#version 300 es \n " +
"#ifdef GL_ES\n"+
"precision highp float;\n"+
"#endif\n"+
"out vec4 FragColor; " +
"in vec4 ourColor; " +
// "uniform vec4 outColor; " +
"void main() {" +
" FragColor = ourColor ;" +
"}"
可以看到在頂點著色器中聲明了一個ourColor變量作為vec4輸出,并在片段著色器中聲明了一個類似的ourColor。由于它們名字相同且類型相同,片段著色器中的ourColor就和頂點著色器中的ourColor鏈接了。就可以通過這樣的方式將頂點著色器中的數據傳遞至片段著色器。下面的圖片展示了輸出結果:

5、Uniform
Uniform是一種從CPU中的應用向GPU中的著色器發(fā)送數據的方式,但uniform和頂點屬性有些不同。首先,uniform是全局的(Global)。全局意味著uniform變量必須在每個著色器程序對象中都是獨一無二的,而且它可以被著色器程序的任意著色器在任意階段訪問。第二,無論把uniform值設置成什么,uniform會一直保存它們的數據,直到它們被重置或更新。
可以在一個著色器中添加uniform關鍵字至類型和變量名前來聲明一個GLSL的uniform。從此處開始就可以在著色器中使用新聲明的uniform了。通過uniform設置三角形的顏色:
private val fragmentShaderCode =
"#version 300 es \n " +
"#ifdef GL_ES\n"+
"precision mediump float;\n"+
"#endif\n"+
"out vec4 FragColor; " +
//"in vec4 ourColor; " +
"uniform vec4 outColor; " +
"void main() {" +
" FragColor = ourColor ;" +
"}"
在片段著色器中聲明了一個uniform vec4的ourColor,并把片段著色器的輸出顏色設置為uniform值的內容。因為uniform是全局變量,可以在任何著色器中定義它們,而無需通過頂點著色器作為中介。頂點著色器中不需要這個uniform,所以不用在那里定義它。如果聲明了一個uniform卻在GLSL代碼中沒用過,編譯器會靜默移除這個變量,導致最后編譯出的版本中并不會包含它。
這個uniform現在還是空的,還沒有給它添加任何數據。接下來首先需要找到著色器中uniform屬性的索引/位置值。當得到uniform的索引/位置值后,就可以更新它的值了。這次不去給像素傳遞單獨一個顏色,而是讓它隨著時間改變顏色:
// Triangle.kt
fun draw() {
...
val timeValue = System.currentTimeMillis()
val greenValue = Math.sin((timeValue / 300 % 50).toDouble()) / 2 + 0.5
GLES30.glUseProgram(mProgram)
val vertexColorLocation = GLES30.glGetUniformLocation(mProgram, "ourColor")
...
}
// MyGLSurfaceView.kt
init {
...
//renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
首先通過System.currentTimeMillis()當前時間。然后使用sin函數讓顏色在0.0到1.0之間改變,最后將結果儲存到greenValue里。接著,用glGetUniformLocation()查詢uniform ourColor的位置值。為查詢函數提供著色器程序和uniform的名字,這時候獲得的是查詢的屬性的位置值。如果glGetUniformLocation返回-1就代表沒有找到這個位置值。最后,可以通過glUniform4f()設置uniform值。注意,查詢uniform地址不要求之前使用過著色器程序,但是更新一個uniform之前必須先使用程序,即調用glUseProgram(),因為它是在當前激活的著色器程序中設置uniform的。
因為OpenGL ES在其核心是一個C庫,所以它不支持類型重載,在函數參數不同的時候就要為其定義新的函數;glUniform是一個典型例子。這個函數有一個特定的后綴,標識設定的uniform的類型??赡艿暮缶Y有:
| 后綴 | 含義 |
|---|---|
| f | 函數需要一個float作為它的值 |
| i | 函數需要一個int作為它的值 |
| ui | 函數需要一個unsigned int作為它的值 |
| 3f | 函數需要3個float作為它的值 |
| fv | 函數需要一個float向量/數組作為它的值 |
每當打算配置一個OpenGL ES的選項時就可以簡單地根據這些規(guī)則選擇適合的數據類型的重載函數。在例子里,希望分別設定uniform的4個float值,所以通過glUniform4f傳遞數據。
這邊要注意下需要將MyGLSurfaceView.kt里面的renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY 這個模式注釋掉,不然不會自動刷新數據。最終效果如下:

6、更多屬性
在前面的文章中,提到了如何填充VBO、配置頂點屬性指針以及如何把它們都儲存到一個VAO里。這次,同樣打算把顏色數據加進頂點數據中。將把顏色數據添加為3個float值至vertices數組。將把三角形的三個角分別指定為紅色、綠色和藍色:
internal var vertices = floatArrayOf(// 按逆時針順序
// 位置 // 顏色
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 頂部
)
由于現在有更多的數據要發(fā)送到頂點著色器,有必要去調整一下頂點著色器,使它能夠接收顏色值作為一個頂點屬性輸入。需要注意的是用layout標識符來把aColor屬性的位置值設置為1:
private val vertexShaderCode =
"#version 300 es \n" +
" layout (location = 0) in vec3 aPos;" +
"layout (location = 1) in vec3 aColor;" +
"out vec3 ourColor;" +
"void main() {" +
" gl_Position = vec4(aPos, 1.0);" +
" ourColor = aColor;" +
"}"
由于不再使用uniform來傳遞片段的顏色了,現在使用ourColor輸出變量,必須再修改一下片段著色器:
private val fragmentShaderCode =
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"in vec3 ourColor; " +
"void main() {" +
" FragColor = vec4(ourColor, 1.0) ;" +
"}"
因為添加了另一個頂點屬性,并且更新了VBO的內存,就必須重新配置頂點屬性指針。更新后的VBO內存中的數據現在看起來像這樣:

此時就需要使用glVertexAttribPointer()更新頂點格式,并且啟用索引為1的頂點數組:
init {
GLES30.glVertexAttribPointer(0,3, GLES30.GL_FLOAT, false, vertexStride, 0)
GLES30.glVertexAttribPointer(1, 3, GLES30.GL_FLOAT, false, vertexStride, 3*4)
}
fun draw() {
GLES30.glEnableVertexAttribArray(0);
GLES30.glEnableVertexAttribArray(1);
GLES30.glUseProgram(mProgram)
GLES30.glBindVertexArray(VAOids.get(0))
GLES30.glDrawElements(GLES30.GL_TRIANGLES, 3, GLES30.GL_UNSIGNED_INT, 0);
GLES30.glDisableVertexAttribArray(0)
GLES30.glDisableVertexAttribArray(1);
}
companion object {
internal val COORDS_PER_VERTEX = 6
internal val vertexStride = COORDS_PER_VERTEX * 4
internal var indices = intArrayOf(// 按逆時針順序
0, 1, 2
)
internal var vertices = floatArrayOf(// 按逆時針順序
// 位置 // 顏色
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 頂部
)
}
glVertexAttribPointer()的前幾個參數比較明了。這次配置屬性位置值為1的頂點屬性。每個顏色值占3個float,并且數據就是標準化的不需要再次標準化。
由于現在有了兩個頂點屬性,需要重新計算步長值。為獲得數據隊列中下一個屬性值(比如位置向量的下個x分量)必須向右移動6個float,其中3個是位置值,另外3個是顏色值。所以步長值為6乘以float的字節(jié)數(=24字節(jié))。
同樣,這次必須指定一個偏移量。對于每個頂點來說,位置頂點屬性在前,所以它的偏移量是0。顏色屬性緊隨位置數據之后,所以偏移量就是3 * float的字節(jié)數,用字節(jié)來計算就是12字節(jié)。
運行程序結果如下:

雖然只提供了3個顏色,但出現的效果確實一個大調色板。這是在片段著色器中進行的所謂片段插值(Fragment Interpolation)的結果。當渲染一個三角形時,光柵化(Rasterization)階段通常會造成比原指定頂點更多的片段。光柵會根據每個片段在三角形形狀上所處相對位置決定這些片段的位置。
基于這些位置,它會插值(Interpolate)所有片段著色器的輸入變量。比如說,有一個線段,上面的端點是綠色的,下面的端點是藍色的。如果一個片段著色器在線段的70%的位置運行,它的顏色輸入屬性就會是一個綠色和藍色的線性結合;更精確地說就是30%藍 + 70%綠。
這個三角形也是如此,有3個頂點,和相應的3個顏色,片段著色器為這三個點圍起來的這些像素進行插值顏色。
Tips:在最前面的OpenGL ES 2.0 顯示圖形(上)這篇文章中對于GLSL中的輸入輸出并并不是用關鍵字in 和 out來表示的而是用vary、atrribute。其實這兩者對應與一個意思。會出現不一樣 這是由于GLSL的版本不同,在OpenGL ES 3.0版本中已經將vary、atrribute廢棄而使用in和out。由于OpenGL ES 2.0 的GLSL還是使用老版本,這也是我在這個系列中使用3.0為例子而不是2.0的其中一個原因。