1、概述
前面幾篇文章OpenGL ES 3.0(一)綜述 、OpenGL ES 3.0(二)GLSL與著色器 討論到可以為每個頂點添加顏色來增加圖形的細節,從而創建出有趣的圖像。但是,如果想讓圖形看起來更真實,就必須有足夠多的頂點,從而指定足夠多的顏色。這將會產生很多額外開銷,因為每個模型都會需求更多的頂點,每個頂點又需求一個顏色屬性。所以一般來說更多的會使用紋理(Texture)。紋理是一個2D圖片(甚至也有1D和3D的紋理),它可以用來添加物體的細節。可以想象紋理是一張繪有磚塊的紙,無縫折疊貼合到3D的房子上,這樣的房子看起來就像有磚墻外表了。因為可以在一張圖片上插入非常多的細節,這樣就可以讓物體非常精細而不用指定額外的頂點。除了圖像以外,紋理也可以被用來儲存大量的數據,這些數據可以發送到著色器上,但這里對這不做深入討論。
如果將一張圖片貼到一個三角形上面,如下所示:
為了能夠把紋理映射到三角形上,需要指定三角形的每個頂點各自對應紋理的哪個部分。這樣每個頂點就會關聯著一個紋理坐標,用來標明該從紋理圖像的哪個部分采樣即采集片段顏色。之后在圖形的其它片段上進行片段插值。
紋理坐標在x和y軸上,范圍為0到1之間,這邊指2D紋理圖像。使用紋理坐標獲取紋理顏色叫做采樣。有效紋理坐標起始于(0, 0),也就是紋理圖片的左下角,終始于(1, 1),即紋理圖片的右上角。下面的圖片展示了我們是如何把紋理坐標映射到三角形上的。但是這邊要說明的是,紋理坐標的范圍并不只此,當你采樣的坐標大于1或者小于0時,會根據紋理纏繞方式進行擴展,具體下面會進行討論。
為三角形指定了3個紋理坐標點。如上圖所示,希望三角形的左下角對應紋理的左下角,因此把三角形左下角頂點的紋理坐標設置為(0, 0)。三角形的上頂點對應于圖片的上中位置所以把它的紋理坐標設置為(0.5, 1.0);同理右下方的頂點設置為(1, 0)。只要給頂點著色器傳遞這三個紋理坐標就行了,接下來它們會被傳片段著色器中,它會為每個片段進行紋理坐標的插值。紋理坐標如下:
var vertices = floatArrayOf(
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
)
對紋理采樣的解釋非常寬松,它可以采用幾種不同的插值方式。所以需要告訴OpenGL該怎樣對紋理采樣。
2、紋理環繞方式
紋理坐標的范圍通常是從(0, 0)到(1, 1),如果把紋理坐標設置在范圍之外 OpenGL ES默認的行為是重復這個紋理圖像,但OpenGL ES提供了更多的選擇。
環繞方式 | 描述 |
---|---|
GL_REPEAT | 對紋理的默認行為。重復紋理圖像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT一樣,但每次重復圖片是鏡像放置的。 |
GL_CLAMP_TO_EDGE | 紋理坐標會被約束在0到1之間,超出的部分會重復紋理坐標的邊緣,產生一種邊緣被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐標為用戶指定的邊緣顏色。 |
前面提到的每個選項都可以使用glTexParameter()對單獨的一個坐標軸設置(s軸、t軸、r軸)。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一個參數指定了紋理目標,對于2d圖片使用的是2D紋理,因此紋理目標是GL_TEXTURE_2D。第二個參數需要指定設置的選項與應用的紋理軸。配置的是WRAP選項,并且指定S和T軸。最后一個參數需要傳遞一個環繞方式,在這個例子中OpenGL會給當前激活的紋理設定紋理環繞方式為GL_MIRRORED_REPEAT。如果選擇GL_CLAMP_TO_BORDER選項,還需要指定一個邊緣的顏色。這需要使用glTexParameter()的fv后綴形式,用GL_TEXTURE_BORDER_COLOR作為它的選項,并且傳遞一個float數組作為邊緣的顏色值:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
3、紋理過濾
紋理坐標不依賴于分辨率,它可以是任意浮點值,所以OpenGL ES需要知道怎樣將紋理像素映射到紋理坐標。當有一個很大的物體但是紋理的分辨率很低的時候這就變得很重要了。OpenGL ES 也有對于紋理過濾的選項。紋理過濾有很多個選項,這邊討論最重要的兩種:GL_NEAREST和GL_LINEAR。
GL_NEAREST(也叫鄰近過濾,Nearest Neighbor Filtering)是OpenGL ES默認的紋理過濾方式。當設置為GL_NEAREST的時候,OpenGL ES會選擇中心點最接近紋理坐標的那個像素。下圖中可以看到四個像素,加號代表紋理坐標。左上角那個紋理像素的中心距離紋理坐標最近,所以它會被選擇為樣本顏色:
GL_LINEAR(也叫線性過濾,(Bi)linear Filtering)它會基于紋理坐標附近的紋理像素,計算出一個插值,近似出這些紋理像素之間的顏色。一個紋理像素的中心距離紋理坐標越近,那么這個紋理像素的顏色對最終的樣本顏色的貢獻越大。下圖中可以看到返回的顏色是鄰近像素的混合色:
兩種過濾方式各有利弊,首先從效率來講肯定是鄰近過濾更加高效,但從過濾效果來講可能是線性過濾更加平滑。當然也有可能需要一種類似像素風的形式,這樣的話可能臨近過濾會適合些。
從上圖可以看出,GL_NEAREST產生了顆粒狀的圖案,能夠清晰看到組成紋理的像素,而GL_LINEAR能夠產生更平滑的圖案,很難看出單個的紋理像素。當進行放大(Magnify)和縮小(Minify)操作的時候可以設置紋理過濾的選項,比如可以在紋理被縮小的時候使用鄰近過濾,被放大時使用線性過濾。需要使用glTexParameter()為放大和縮小指定過濾方式。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
4、多級漸遠紋理
假設有一個包含著上千物體的大房間,每個物體上都有紋理。有些物體會很遠,但其紋理會擁有與近處物體同樣高的分辨率。由于遠處的物體可能只產生很少的片段,OpenGL ES 從高分辨率紋理中為這些片段獲取正確的顏色值就很困難,因為它需要對一個跨過紋理很大部分的片段只拾取一個紋理顏色。在小物體上這會產生不真實的感覺,更不用說對它們使用高分辨率紋理浪費內存的問題了。OpenGL ES 使用一種叫做多級漸遠紋理(Mipmap)的概念來解決這個問題,它簡單來說就是一系列的紋理圖像,后一個紋理圖像是前一個的二分之一。多級漸遠紋理背后的理念很簡單:距觀察者的距離超過一定的閾值,OpenGL ES會使用不同的多級漸遠紋理,即最適合物體的距離的那個。由于距離遠,解析度不高也不會被用戶注意到。同時,多級漸遠紋理另一加分之處是它的性能非常好。
手工為每個紋理圖像創建一系列多級漸遠紋理很麻煩,OpenGL ES有一個glGenerateMipmaps(),在創建完一個紋理后調用它OpenGL ES就會承擔接下來的所有工作。
在渲染中切換多級漸遠紋理級別時,OpenGL ES在兩個不同級別的多級漸遠紋理層之間會產生不真實的生硬邊界。就像普通的紋理過濾一樣,切換多級漸遠紋理級別時也可以在兩個不同多級漸遠紋理級別之間使用NEAREST和LINEAR過濾。為了指定不同多級漸遠紋理級別之間的過濾方式,可以使用下面四個選項中的一個代替原有的過濾方式:
過濾方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST | 使用最鄰近的多級漸遠紋理來匹配像素大小,并使用鄰近插值進行紋理采樣。 |
GL_LINEAR_MIPMAP_NEAREST | 使用最鄰近的多級漸遠紋理級別,并使用線性插值進行采樣。 |
GL_NEAREST_MIPMAP_LINEAR | 在兩個最匹配像素大小的多級漸遠紋理之間進行線性插值,使用鄰近插值進行采樣。 |
GL_LINEAR_MIPMAP_LINEAR | 在兩個鄰近的多級漸遠紋理之間使用線性插值,并使用線性插值進行采樣。 |
就像紋理過濾一樣,可以使用glTexParameteri將過濾方式設置為前面四種提到的方法之一:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一個常見的錯誤是,將放大過濾的選項設置為多級漸遠紋理過濾選項之一。這樣沒有任何效果,因為多級漸遠紋理主要是使用在紋理被縮小的情況下的:紋理放大不會使用多級漸遠紋理,為放大過濾設置多級漸遠紋理的選項會產生一個GL_INVALID_ENUM錯誤代碼。
5、使用紋理
5.1 加載紋理
使用紋理之前要做的第一件事是把它們加載到應用中。紋理圖像可能被儲存為各種各樣的格式,每種都有自己的數據結構和排列。比較幸運的是Android 已經提供了許多加載圖片的方式,比如說通過將圖片加載成Bitmap,具體可以看我的這篇文章——Android Bitmap及相關概念簡述。這邊做如下操作:
init {
...
val options: BitmapFactory.Options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
// 加載時縮小圖片,防止圖片過大造成內存溢出
options.inSampleSize = 16
val bitmap: Bitmap = BitmapFactory.decodeResource(mContext.resources, R.drawable.wall ,options)
val buf = ByteBuffer.allocate(bitmap.byteCount)
bitmap.copyPixelsToBuffer(buf)
// 將ByteBuffer的寫模式轉成讀模式
buf.flip()
...
}
如上所示,需要指定一下圖片加載的rgb格式這邊采用RGB_565,這個格式需要與后面講到的格式相對應。這邊特別需要提醒一下ARGB_4444這個格式已經廢棄,Bitmap圖片加載時會使用自動轉成ARGB_8888。同時需要注意的是將Bitmap像素值寫入到ByteBuffer后需要將其從寫模式轉成讀模式。
5.2 生成紋理
和之前生成的OpenGL ES對象一樣,紋理也是使用ID引用。
private val textureIds: IntBuffer
init {
...
textureIds = IntBuffer.allocate(2);
GLES30.glGenBuffers(2, textureIds)
...
}
glGenTextures()首先需要輸入生成紋理的數量,然后把它們儲存在第二個參數的IntBuffer數組中,由于之后打算生成兩個紋理,所以這邊申請了兩個紋理id。就像其他對象一樣,需要綁定它,讓之后任何的紋理指令都可以配置當前綁定的紋理:
init {
...
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D,textureIds.get(0))
...
}
紋理綁定之后,可以使用前面載入的圖片數據生成一個紋理。紋理可以通過glTexImage2D()來生成:
init {
...
GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGB, bitmap.width, bitmap.height, 0, GLES30.GL_RGB, GLES30.GL_UNSIGNED_SHORT_5_6_5, buf)
GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D)
...
}
glTexImage2D()函數的參數比較多這邊一一進行講解:
① target:指定了紋理目標。設置為GL_TEXTURE_2D意味著會生成與當前綁定的紋理對象在同一個目標上的紋理(任何綁定到GL_TEXTURE_1D和GL_TEXTURE_3D的紋理不會受到影響)。
② level:為紋理指定多級漸遠紋理的級別。0是最基本的圖像級別,n表示第N級貼圖細化級別。
③ internalformat: 告訴OpenGL ES希望把紋理儲存為何種格式。可選的值有GL_ALPHA,GL_RGB,GL_RGBA,GL_LUMINANCE, GL_LUMINANCE_ALPHA 等幾種。
④ 、⑤ width、height: 參數設置最終的紋理的寬度和高度。紋理圖片至少要支持64個材質像素的寬度。
⑥ border: 應該總是被設為0(歷史遺留的問題)。
⑦ format:定義了源圖的的顏色格式, 不需要和internalformatt取值必須相同。可選的值參考internalformat。
⑧ type: 定義了源圖的數據類型。代表原始數據中的像素數據以什么樣的形式進行理解和讀取。可以使用的值有GL_UNSIGNED_BYTE,GL_UNSIGNED_SHORT_5_6_5,GL_UNSIGNED_SHORT_4_4_4_4,GL_UNSIGNED_SHORT_5_5_5_1。
⑨ pixels:是真正的圖像像素數據。
當調用glTexImage2D()時,當前綁定的紋理對象就會被附加上紋理圖像。然而,目前只有基本級別(Base-level)的紋理圖像被加載了,如果要使用多級漸遠紋理,必須手動設置所有不同的圖像(不斷遞增第二個參數)。或者,直接在生成紋理之后調用glGenerateMipmap()。這會為當前綁定的紋理自動生成所有需要的多級漸遠紋理。
生成一個紋理的過程總的來說應該這樣:
private val textureIds: IntBuffer
init{
...
textureIds = IntBuffer.allocate(2);
GLES30.glGenBuffers(2, textureIds)
...
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_MIRRORED_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)
val options: BitmapFactory.Options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
options.inSampleSize = 16
val bitmap: Bitmap = BitmapFactory.decodeResource(mContext.resources, R.drawable.wall ,options)
val buf = ByteBuffer.allocate(bitmap.byteCount)
bitmap.copyPixelsToBuffer(buf)
buf.flip()
GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGB, bitmap.width, bitmap.height, 0, GLES30.GL_RGB, GLES30.GL_UNSIGNED_SHORT_5_6_5, buf)
GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D)
...
}
5.3 應用紋理
這部分會使用glDrawElements繪制前面文章提到的繪制矩形來進行討論。需要告知OpenGL ES如何采樣紋理,所以必須使用紋理坐標更新頂點數據:
float vertices[] = {
// ---- 位置 ---- ---- 顏色 ---- - 紋理坐標 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};
這時候其實不用再使用顏色坐標了,但為了考慮到文章的前后連貫性,這里不去掉顏色坐標,而是額外再加上一個頂點屬性即紋理坐標,此時緩存中的形式應該如下圖。
init {
...
GLES30.glVertexAttribPointer(2, 2, GLES30.GL_FLOAT, false, vertexStride, 6 * 4)
...
}
fun draw() {
...
GLES30.glEnableVertexAttribArray(2)
...
GLES30.glDisableVertexAttribArray(2)
...
}
companion object {
internal val COORDS_PER_VERTEX =8
...
}
接著需要調整頂點著色器使其能夠接受頂點坐標為一個頂點屬性,并把坐標傳給片段著色器。
private val vertexShaderCode =
"#version 300 es \n" +
" layout (location = 0) in vec3 aPos;" +
"layout (location = 1) in vec3 aColor;" +
"layout (location = 2) in vec2 aTexCoord;" +
"out vec3 ourColor;" +
"out vec2 TexCoord;" +
"void main() {" +
" gl_Position = vec4(aPos, 1.0);" +
" ourColor = aColor;" +
" TexCoord = aTexCoord;" +
"}"
片段著色器接下來會把輸出變量TexCoord作為輸入變量。片段著色器也應該能訪問紋理對象,GLSL有一個供紋理對象使用的內建數據類型,叫做采樣器(Sampler),它以紋理類型作為后綴,比如sampler1D、sampler3D,或在例子中的sampler2D。可以簡單聲明一個uniform sampler2D把一個紋理添加到片段著色器中,稍后會把紋理賦值給這個uniform。
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"in vec3 ourColor; " +
"in vec2 TexCoord; " +
"uniform sampler2D ourTexture;" +
"void main() {" +
" FragColor =texture(ourTexture, TexCoord) ;" +
"}")
使用GLSL內建的texture()來采樣紋理的顏色,它第一個參數是紋理采樣器,第二個參數是對應的紋理坐標。texture()會使用之前設置的紋理參數對相應的顏色值進行采樣。這個片段著色器的輸出就是紋理的(插值)紋理坐標上的(過濾后的)顏色。之后就是在調用glDrawElements()之前要先綁定紋理,它會自動把紋理賦值給片段著色器的采樣器:
fun draw() {
...
GLES30.glUseProgram(mProgram)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glBindVertexArray(VAOids.get(0))
GLES30.glDrawElements(GLES30.GL_TRIANGLES, 6, GLES30.GL_UNSIGNED_INT, 0);
...
}
還可以把得到的紋理顏色與頂點顏色混合,來獲得更有趣的效果。只需把紋理顏色與頂點顏色在片段著色器中相乘來混合二者的顏色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
這樣最終會是頂點顏色和紋理顏色的混合。
5.4 紋理單元
可以給紋理采樣器分配一個位置值,這樣的話能夠在一個片段著色器中設置多個紋理。一個紋理的位置值通常稱為一個紋理單元(Texture Unit)。一個紋理的默認紋理單元是0,它是默認的激活紋理單元,所以前面部分沒有分配一個位置值。
紋理單元的主要目的是讓開發者在著色器中可以使用多于一個的紋理。通過把紋理單元賦值給采樣器,可以一次綁定多個紋理,只要首先激活對應的紋理單元。
fun draw() {
...
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glUniform1i(GLES30.glGetUniformLocation(mProgram, "texture1"), 0)
GLES30.glActiveTexture(GLES30.GL_TEXTURE1)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(1))
GLES30.glUniform1i(GLES30.glGetUniformLocation(mProgram, "texture2"), 1)
...
}
通過上面的操作將紋理單元,紋理緩存空間和紋理屬性相關聯起來。先用glActiveTexture()激活紋理單元。再通過glBindTexture()將此時激活的紋理單元與之前申請的紋理緩存空間相關聯。再通過glUniform1i()將紋理單元,即該函數的第二個參數,與GLSL中的屬性相關聯。這樣這三者就互相聯系上了。而之前沒有使用glActiveTexture()對GL_TEXTURE0進行激活是因為紋理單元GL_TEXTURE0默認總是被激活。OpenGL ES至少保證有16個紋理單元供使用,也就是說可以激活從GL_TEXTURE0到GL_TEXTRUE15。它們都是按順序定義的,所以也可以通過GL_TEXTURE0 + 15的方式獲得GL_TEXTURE15。
此時需要重新編輯片段著色器來接收另一個采樣器。
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"in vec3 ourColor; " +
"in vec2 TexCoord; " +
"uniform sampler2D texture1;" +
"uniform sampler2D texture2;" +
"void main() {" +
" FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);\n" +
"}")
最終輸出顏色現在是兩個紋理的結合。GLSL內建的mix函數需要接受兩個值作為參數,并對它們根據第三個參數進行線性插值。如果第三個值是0.0,它會返回第一個輸入;如果是1.0,會返回第二個輸入值。0.2會返回80%的第一個輸入顏色和20%的第二個輸入顏色,即返回兩個紋理的混合色。