文章目錄
- 紋理坐標
- 紋理環繞方式
- 紋理過濾——處理紋理分辨率低的情況
- 多級漸遠紋理Mipmap——處理紋理分辨率高的情況
- 加載與創建紋理 ( <stb_image.h> )
- 生成紋理
- 應用紋理
- 紋理單元
- 練習1
- 練習2
- 練習3
- 練習4
通過上一篇著色部分的學習,我們可以為每個頂點添加顏色來增加圖形的細節,從而創建出有趣的圖像。當需要給圖形賦予真實顏色的時候,不大可能為每一個頂點指定一個顏色,通常會采用紋理貼圖。
本篇開始學習使用紋理(Texture)來添加物體的細節。我們要做的工作是告訴OpenGL該怎樣對紋理采樣。
紋理相關原理參考:GAMES101學習筆記(五):Texture 紋理(紋理映射、重心坐標、紋理貼圖)
紋理坐標
為了能夠把紋理映射(Map)到三角形上,我們需要指定三角形的每個頂點各自對應紋理的哪個部分。
每個頂點就會關聯著一個紋理坐標(Texture Coordinate),用來標明該從紋理圖像的哪個部分采樣(采集片段顏色)。之后在圖形的其它片段上進行片段插值(Fragment Interpolation)。
紋理坐標在x和y軸上,范圍為0到1之間(2D紋理圖像)。使用紋理坐標獲取紋理顏色叫做采樣(Sampling)。
紋理坐標的原點(0, 0)在紋理圖片的左下角,終止于(1, 1),即紋理圖片的右上角。
float texCoords[] = {0.0f, 0.0f, // 左下角1.0f, 0.0f, // 右下角0.5f, 1.0f // 上中
};
下面的圖片展示了如何把紋理坐標映射到三角形上的:
頂點結構將更新為如下:
float vertices[] = {// positions // colors // texCoords0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, // bottom right-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom left0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.5f, 1.0f // top
};
由于我們添加了一個額外的頂點屬性,我們必須告訴OpenGL我們新的頂點格式:
紋理環繞方式
紋理坐標的范圍通常是從(0, 0)到(1, 1),那如果紋理坐標設置在范圍之外會發生什么?
OpenGL默認的行為是重復這個紋理圖像(我們基本上忽略浮點紋理坐標的整數部分)OpenGL也提供了更多的選擇
環繞方式 | 描述 |
---|---|
GL_REPEAT | 對紋理的默認行為。重復紋理圖像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT 一樣,但每次重復圖片是鏡像放置的。 |
GL_CLAMP_TO_EDGE | 紋理坐標會被約束在0到1之間,超出的部分會重復紋理坐標的邊緣,產生一種邊緣被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐標為用戶指定的邊緣顏色。 |
當紋理坐標超出默認范圍時,每個選項都有不同的視覺效果輸出:
可以使用glTexParameter
函數對單獨的一個坐標軸設置(s
、t
(如果是使用3D紋理那么還有一個r
)它們和x
、y
、z
是等價的):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
- 第一個參數指定了紋理目標;我們使用的是2D紋理,因此紋理目標是
GL_TEXTURE_2D
。 - 第二個參數需要我們指定設置的選項與應用的紋理軸。我們打算配置的是WRAP選項,并且指定S和T軸。
- 最后一個參數需要我們傳遞一個環繞方式(Wrapping),即上面表格中的4種方式。
如果我們選擇GL_CLAMP_TO_BORDER
選項,我們還需要指定一個邊緣的顏色。這需要使用glTexParameter
函數的fv
后綴形式:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
紋理過濾——處理紋理分辨率低的情況
OpenGL需要知道怎樣將紋理像素(Texture Pixel,也叫Texel)映射到紋理坐標。但
- 紋理坐標的精度是無限的,可以是任意浮點值。(紋理坐標不依賴于分辨率Resolution)
- 紋理像素是有限的(圖片分辨率)
當物體很大但是紋理的分辨率很低的時候,就會出現鋸齒現象。
在GAMES101課程中我們了解了雙線性插值和雙三次插值的方式來做抗鋸齒。
OpenGL有對于紋理過濾(Texture Filtering)的選項。紋理過濾有很多個選項,但是現在我們只討論最重要的兩種:
-
GL_NEAREST
(也叫鄰近過濾,Nearest Neighbor Filtering)
OpenGL默認的紋理過濾方式。當設置為GL_NEAREST的時候,OpenGL會選擇中心點最接近紋理坐標的那個像素。 -
GL_LINEAR
(也叫線性過濾,(Bi)linear Filtering)
它會基于紋理坐標附近的紋理像素,計算出一個插值,近似出這些紋理像素之間的顏色。一個紋理像素的中心距離紋理坐標越近,那么這個紋理像素的顏色對最終的樣本顏色的貢獻越大。 -
GL_LINEAR
可以產生更真實的輸出,但有些開發者更喜歡8-bit風格,所以他們會用GL_NEAREST
選項
當進行放大(Magnify)和縮小(Minify)操作的時候可以設置紋理過濾的選項,比如你可以在紋理被縮小的時候使用鄰近過濾,被放大時使用線性過濾。我們需要使用glTexParameter
函數為放大和縮小指定過濾方式。這段代碼看起來會和紋理環繞方式的設置很相似:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多級漸遠紋理Mipmap——處理紋理分辨率高的情況
想象一下,假設我們有一個包含著上千物體的大房間,每個物體上都有紋理。有些物體會很遠,但其紋理會擁有與近處物體同樣高的分辨率。由于遠處的物體可能只產生很少的片段,OpenGL從高分辨率紋理中為這些片段獲取正確的顏色值就很困難,因為它需要對一個跨過紋理很大部分的片段只拾取一個紋理顏色。在小物體上這會產生不真實的感覺,更不用說對它們使用高分辨率紋理浪費內存的問題了。
OpenGL使用一種叫做 多級漸遠紋理(Mipmap) 的概念來解決這個問題,它簡單來說就是一系列的紋理圖像,后一個紋理圖像是前一個的二分之一。多級漸遠紋理背后的理念很簡單:距觀察者的距離超過一定的閾值,OpenGL會使用不同的多級漸遠紋理,即最適合物體的距離的那個。由于距離遠,解析度不高也不會被用戶注意到。同時,多級漸遠紋理另一加分之處是它的性能非常好。讓我們看一下多級漸遠紋理是什么樣子的:
使用glGenerateMipmap
函數,在創建完一個紋理后調用它OpenGL就會生成Mipmap。后面的教程中會看到該如何使用它。
在渲染中切換多級漸遠紋理級別(Level)時,OpenGL在兩個不同級別的多級漸遠紋理層之間會產生不真實的生硬邊界。就像普通的紋理過濾一樣,切換多級漸遠紋理級別時你也可以在兩個不同多級漸遠紋理級別之間使用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
錯誤代碼。
加載與創建紋理 ( <stb_image.h> )
接下來我們加載本地圖片在OpenGL中創建紋理,這里我們使用一個支持多種流行格式的圖像加載庫stb_image.h
庫。
stb_image.h
是Sean Barrett的一個非常流行的單頭文件圖像加載庫,它能夠加載大部分流行的文件格式,并且能夠很簡單得整合到你的工程之中。stb_image.h
可以在這里下載。
在工程中包含該庫時,需要定義宏:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
使用stb_image.h
的stbi_load
函數加載一張木箱的圖片:
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
stbi_load
函數原型:
unsigned char *stbi_load(char const *filename, int *x, int *y, int *comp, int req_comp)
首先接受一個圖像文件的路徑作為輸入。接下來它需要三個int作為它的第二、第三和第四個參數,stb_image.h
將會用圖像的寬度、高度和顏色通道的個數填充這三個變量。我們之后生成紋理的時候會用到的圖像的寬度和高度。
生成紋理
和之前生成的其他OpenGL對象一樣,紋理也是使用ID引用的。
使用glGenTextures
創建紋理:
unsigned int texture;
glGenTextures(1, &texture);
- 第一個參數:生成紋理的數量
- 第二個參數:一個
unsigned int
數組,用于存儲指定數量的紋理
像其他對象一樣,我們需要綁定它,讓之后任何的紋理指令都可以配置當前綁定的紋理:
glBindTexture(GL_TEXTURE_2D, texture);
紋理綁定之后,我們可以使用前面載入的圖片數據生成一個紋理了。
通過glTexImage2D
來生成:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
- 第一個參數指定了紋理目標(Target)
設置為GL_TEXTURE_2D
意味著會生成與當前綁定的紋理對象在同一個目標上的紋理(任何綁定到GL_TEXTURE_1D
和GL_TEXTURE_3D
的紋理不會受到影響)。 - 第二個參數為紋理指定多級漸遠紋理的級別,如果你希望單獨手動設置每個多級漸遠紋理的級別的話。這里我們填0,也就是基本級別。
- 第三個參數指定紋理儲存格式。我們的圖像只有RGB值,因此我們也把紋理儲存為RGB值。
- 第四個參數指定最終的紋理的寬度。
- 第五個參數指定最終的紋理的高度。
- 第六個參數應該總是被設為0(歷史遺留的問題)
- 第七個參數定義了源圖的格式。使用RGB值加載這個圖像
- 第八個參數定義了源圖的數據類型。把它們儲存為char(byte)數組,我們將會傳入對應值。
- 最后一個參數是真正的圖像數據。
當調用glTexImage2D
時,當前綁定的紋理對象就會被附加上紋理圖像數據。目前只加載基本級別(Base-level)的紋理圖像。
如果要使用多級漸遠紋理,在生成紋理之后還要調用glGenerateMipmap
,這會為當前綁定的紋理自動生成所有需要的多級漸遠紋理。
綁定好紋理之后,就可以使用stbi_image_free
釋放圖像內存了。
生成一個紋理的完整過程:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 為當前綁定的紋理對象設置環繞、過濾方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加載并生成紋理
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);glGenerateMipmap(GL_TEXTURE_2D);
}
else
{std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);
應用紋理
接下來,我們會使用之前繪制的矩形來應用紋理,更新頂點數據:
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 // 左上
};
由于我們添加了一個額外的頂點屬性,我們必須告訴OpenGL我們新的頂點格式:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
注意,我們同樣需要調整前面兩個頂點屬性的步長參數為
8 * sizeof(float)
調整頂點著色器,使其能夠接受頂點坐標aTexCoord
為一個頂點屬性,并把坐標傳給片段著色器:
#version 330 core
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。
#version 330 core
out vec4 FragColor;in vec3 ourColor;
in vec2 TexCoord;uniform sampler2D ourTexture;void main()
{FragColor = texture(ourTexture, TexCoord);
}
使用GLSL內建的texture
函數來采樣紋理的顏色,它第一個參數是紋理采樣器,第二個參數是對應的紋理坐標。texture
函數會使用之前設置的紋理參數對相應的顏色值進行采樣。這個片段著色器的輸出就是紋理的(插值)紋理坐標上的(過濾后的)顏色。
現在只剩下在調用glDrawElements
之前綁定紋理了,它會自動把紋理賦值給片段著色器的采樣器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
效果如下圖: (完整源碼參考)
紋理單元
sampler2D
變量是個uniform
,但我們沒有用glUniform
給它賦值。
實際上可以使用glUniform1i
給紋理采樣器分配一個位置值,這樣的話我們能夠在一個片段著色器中設置多個紋理。
一個紋理的這個位置值通常稱為一個紋理單元(Texture Unit)。
紋理的默認紋理單元是0(默認的激活狀態),所以前面部分我們沒有分配一個位置值。但并非所有圖形驅動程序都指定了默認紋理單元。
紋理單元的主要目的是讓我們在著色器中可以使用多于一個的紋理。通過把紋理單元賦值給采樣器,我們可以一次綁定多個紋理,只要我們首先激活對應的紋理單元。就像glBindTexture
一樣,我們可以使用glActiveTexture
激活紋理單元,傳入我們需要使用的紋理單元:
glActiveTexture(GL_TEXTURE0); // 在綁定紋理之前先激活紋理單元
glBindTexture(GL_TEXTURE_2D, texture);
激活紋理單元之后,接下來的glBindTexture
函數調用會綁定這個紋理到當前激活的紋理單元,紋理單元GL_TEXTURE0
默認總是被激活,所以我們在前面的例子里當我們使用glBindTexture
的時候,無需激活任何紋理單元。但當我們想要應用多個紋理時,就要先激活對應的紋理單元,
OpenGL至少保證有16個紋理單元供你使用,也就是說你可以激活從
GL_TEXTURE0
到GL_TEXTRUE15
。它們都是按順序定義的,所以我們也可以通過GL_TEXTURE0 + 8
的方式獲得GL_TEXTURE8
,這在當我們需要循環一些紋理單元的時候會很有用。
編輯片段著色器來接收另一個采樣器,最終輸出顏色是兩個紋理的結合:
#version 330 core
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);
}
GLSL內建的mix
函數需要接受兩個值作為參數,并對它們根據第三個參數進行線性插值。如果第三個值是0.0,它會返回第一個輸入;如果是1.0,會返回第二個輸入值。0.2會返回80%的第一個輸入顏色和20%的第二個輸入顏色,即返回兩個紋理的混合色。
現在載入并創建另一個紋理,第二個紋理我們使用一張笑臉表情圖片:
unsigned char *data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
if (data)
{glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);glGenerateMipmap(GL_TEXTURE_2D);
}
注意,我們現在要讀取一張包含alpha(透明度)通道的
.png
圖片,這意味著我們現在需要使用GL_RGBA
參數,指定該圖片數據包含了alpha通道;否則OpenGL將無法正確解析圖片數據。
為了使用第二個紋理(以及第一個),我們必須改變一點渲染流程:
- 先綁定兩個紋理到對應的紋理單元
- 然后定義哪個uniform采樣器對應哪個紋理單元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
我們還要通過使用glUniform1i
設置每個采樣器的方式告訴OpenGL每個著色器采樣器屬于哪個紋理單元。我們只需要設置一次即可,所以這個會放在渲染循環的前面:
ourShader.use(); // 不要忘記在設置uniform變量之前激活著色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手動設置
ourShader.setInt("texture2", 1); // 或者使用著色器類設置while(...)
{[...]
}
通過使用glUniform1i
設置采樣器,我們保證了每個uniform采樣器對應著正確的紋理單元。你應該能得到下面的結果:
紋理上下顛倒了!這是因為OpenGL要求y軸0.0坐標是在圖片的底部的,但是圖片的y軸0.0坐標通常在頂部。
stb_image.h
能夠在圖像加載時幫助我們翻轉y軸,只需要在加載任何圖像前加入以下語句即可:
stbi_set_flip_vertically_on_load(true);
(完整代碼參考)
練習1
修改片段著色器,僅讓笑臉圖案朝另一個方向看
//FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);// 修改片段著色器,僅讓笑臉圖案朝另一個方向看
FragColor = mix(texture(texture1, TexCoord), texture(texture2, vec2(1.0 - TexCoord.x, TexCoord.y)), 0.2);
}
練習2
嘗試用不同的紋理環繞方式,設定一個從0.0f到2.0f范圍內的(而不是原來的0.0f到1.0f)紋理坐標。
試試看能不能在箱子的角落放置4個笑臉:參考解答
練習3
嘗試在矩形上只顯示紋理圖像的中間一部分,修改紋理坐標,達到能看見單個的像素的效果。嘗試使用GL_NEAREST的紋理過濾方式讓像素顯示得更清晰:參考解答
練習4
使用一個uniform變量作為mix函數的第三個參數來改變兩個紋理可見度,使用上和下鍵來改變箱子或笑臉的可見度:參考解答
處理鍵盤輸入:
void processInput(GLFWwindow *window)
{if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)glfwSetWindowShouldClose(window, true);if (glfwGetKey(window, GLFW_KEY_UP) == GLFW_PRESS){mixValue += 0.001f; // change this value accordingly (might be too slow or too fast based on system hardware)if(mixValue >= 1.0f)mixValue = 1.0f;}if (glfwGetKey(window, GLFW_KEY_DOWN) == GLFW_PRESS){mixValue -= 0.001f; // change this value accordingly (might be too slow or too fast based on system hardware)if (mixValue <= 0.0f)mixValue = 0.0f;}
}
修改片段著色器:
#version 330 core
out vec4 FragColor;in vec3 ourColor;
in vec2 TexCoord;uniform float mixValue;// texture samplers
uniform sampler2D texture1;
uniform sampler2D texture2;void main()
{// linearly interpolate between both texturesFragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), mixValue);
}
在渲染循環中,實時修改mixValue的值
ourShader.setFloat("mixValue", mixValue);