1. 程式人生 > >【一步步學OpenGL 16】 -《紋理貼圖》

【一步步學OpenGL 16】 -《紋理貼圖》

教程16

紋理貼圖基礎

背景

紋理貼圖意思是將任意型別的圖片貼在3d模型的一個或者多個面上。圖片可以是任意的但通常是一種通用的樣式,比如:磚塊、植物、荒蕪的土地等等,可以提高場景的真實性。比較下面兩幅圖片:
紋理示意圖

為了實現紋理貼圖我們需要做三件事:將一張貼圖載入到OpenGL中,提供紋理座標和頂點(將紋理對應匹配到頂點上),並使用紋理座標從紋理中進行取樣操作取得畫素顏色。由於三角形會被縮放、旋轉、平移變換導致最後會以不同的結果投影顯示到螢幕上,而且由於camera的不同操作看上去也會很不一樣。GPU要做的就是讓紋理緊跟三角形圖元頂點的移動使其看上去真實(如果紋理看上去明顯遊離在三角形上產生錯位就不真實了)。為實現這個效果開發者需要為每個頂點提供一系列紋理座標。在GPU光柵化三角形階段,會對紋理座標進行插值計算並覆蓋到整個三角形面上,並且在片段著色器中開發者要將這些座標跟紋理進行匹配。這個操作叫做‘取樣’

,取樣的結果叫做‘紋素’(紋理中的一個畫素)。紋素通常包含一個顏色值用於畫螢幕上對應的一個畫素。後面的教程中我們會看到紋素可以包含不同的資料型別來產生不同的效果。

OpenGL支援幾種不同型別的紋理:1D,2D,3D,立方體等等,可以應用於不同的技術中。現在這裡就先只使用2D紋理。一張2D紋理可以在某些特殊限制下有任意寬度和高度的,寬度和高度相乘可以計算得到紋素的數量。那麼如何確定紋理座標和頂點呢?事實上不是的,座標並不是在紋理中紋素的座標,那樣侷限性太大了,因為這樣如果要用一張寬度高度不一樣的紋理替換一張紋理的話我們得更新所有頂點的座標來匹配新的紋理圖片。理想的方案是要能夠在不改變紋理座標的情況下隨意更換紋理貼圖

。因此,紋理座標是定義在‘紋理空間’的,也就是定義在單位化的[0,1]範圍內。也就是說紋理座標事實上是個分數,紋理的寬度高度乘以相應的比例分數就可以算出紋素在紋理中的座標。例如:如果紋理座標是[0.5,0.1]並且紋理的寬度為320,高度為200,那麼紋素在紋理中的座標為(160,20)(0.5 * 320 = 160 和 0.1 * 200 = 20)。

通常的約定是使用U和V作為紋理空間中的軸線,U對應於2D座標系的X軸,V對應於Y軸。在OpenGL中對UV軸上的值的處理方式為:在U軸上從左往右遞增,V軸上從下往上遞增(原點在左下角)。看下面的示意圖:
UV座標系

這張圖展示了紋理空間座標系,可以看到原點在左下角,U往右遞增,V往上遞增。現在思考一個三角形的紋理座標定義如下圖:
圖片

在使用紋理座標系的時候我們獲得小房子貼圖上面標示的位置,現在三角形經過一系列變化後到了要光柵化的階段它看上去如下:

圖片

可以看到,紋理座標作為核心屬性一樣固定到了頂點上,不會因為變換而發生任何錯位變化。在對紋理座標進行插值時多數的畫素可以獲得原圖片中對應相同的紋理座標(因為它們相對於頂點的相對位置相同),並且當三角形翻轉時貼在三角形上面的紋理貼圖也會跟著翻轉。也就是說,當三角形圖元旋轉、拉伸或者擠壓時,紋理貼圖會不斷地跟著作相應的變換。但注意也有一種技術為了控制移除三角形面上的紋理貼圖而改變紋理座標。

和紋理對映相關的另一個重要概念是‘過濾’。我們已經討論了怎樣將紋理座標(這是個0到1之間的分數!)對映到紋素上,紋理貼圖中紋素的座標總是以整數定義的,但是如果紋理座標對映到紋素上的座標為(152.34,745.14)怎麼辦?不明智的方案是將這個座標捨去小數變為(152,745)。這種方法雖然可以有效果但是在某些情形下效果會很差。一個更好的辦法是選取該座標周圍紋素2x2的4個座標 ( (152,745), (153,745), (152,744) 和 (153,744) ) 並根據他們的顏色做線性插值。線性插值必須體現出座標(152.34,745.14)和四個座標的相對距離來。距離近的點的顏色對最終紋素的顏色值影響大,越遠影響越小,這樣就比開始的方法簡單多了。

選擇最終紋素的方法叫做’過濾‘,最簡單的實現小數紋理座標取整的方法叫做‘最鄰近過濾’,更復雜一點的方法叫做‘線性過濾’,另外一種可能遇到的最鄰近過濾方法叫做‘點過濾’。OpenGL支援幾種不同型別的過濾方式可以選擇。通常效果好的過濾方式需要GPU更強大計算能力並且可能對幀率產生影響,選擇不同的過濾方式就需要權衡對過濾效果的需求和對目標裝置能力的要求了。

知道了紋理座標的概念之後現在可以看一下紋理對映在OpenGL中實現的方式了。OpenGL中的紋理貼圖意味著要操作四個概念之間錯綜複雜的聯絡:著色器中的紋理物件,紋理單元,取樣器物件和取樣器一致變數
紋理物件包含著紋理貼圖自身的資料,比如:紋素。貼圖可以是不同型別不同維度的圖片(1D,2D等),圖片內在格式可能有多種像RGB或者RGBA等。OpenGL提供了一種方式來定義元資料在記憶體中的起始地址以及上面所有的屬性同時將這些資料載入到GPU。很多像過濾型別這樣的引數都是可以控制的,和頂點緩衝物件類似紋理物件也繫結在一個引用控制代碼上。建立控制代碼並載入紋理資料和引數之後你可以通過將不同的控制代碼繫結到OpenGL狀體上來簡單切換紋理貼圖,不需要反覆載入同一個紋理資料。從現在開始就需要OpenGL驅動器來確保資料在開始渲染之前已經載入到GPU了。

紋理物件並不是直接繫結到shader上的(事實上是取樣階段發生的地方),而是繫結到‘紋理單元’上,‘紋理單元’的索引會被傳到shader中,因此shader是通過紋理單元得到紋理物件的。一般可以同時有多個可用的紋理單元,數量上限取決於顯示卡的容量。為了將一個紋理物件A繫結到紋理單元0上,首先你需要啟用紋理單元0然後繫結紋理物件A。你現在可以啟用紋理單元1然後繫結一個不同的(甚至是和紋理單元0繫結的相同的)紋理物件到上面,而紋理單元0還是繫結的紋理物件A。

事實上每個紋理單元可以同時繫結幾個紋理物件,紋理又有不同的型別,型別叫做紋理物件的‘目標’。當你繫結一個紋理物件到一個紋理單元的時候,需要定義這個目標(1D,2D等等)。所以你可以將紋理物件A繫結到1D的目標上,同時紋理物件B繫結到同一個紋理單元的2D目標上。

取樣操作通常發生在片段著色器中並通過一個特殊的函式來完成。取樣函式需要知道從哪個紋理單元取樣,因為在片斷著色器中是可以從多個紋理單元中取樣的,對此有一組特殊的一致變數(取樣器一致變數),針對紋理目標:’sampler1D’, ‘sampler2D’, ‘sampler3D’, ‘samplerCube’等等。你可以建立任意數量的這種取樣器一致變數,並在應用中將紋理單元的值賦給他們。對某個取樣器一致變數呼叫取樣函式時相應的紋理單元(和紋理物件)都會被用到。

最後一個概念是取樣器物件。不要和取樣器一致變數混淆,他們是不同的東西。紋理物件是同時包含有紋理資料和配置取樣操作的引數的,這些引數是取樣狀態的一部分。然而,你也可以建立一個取樣物件,用取樣狀態來配置它並繫結到紋理單元上,這樣取樣器物件將覆蓋紋理物件中定義的所有取樣狀態。不過不用擔心,目前我們還根本用不到取樣器物件,先了解一下它也好。

下面的圖概括了上面紋理的概念之間的關係:

圖片

原始碼詳解

OpenGL知道怎樣從記憶體中載入不同格式的紋理資料,但是沒有提供方法將像PNG或JPG格式的影象檔案載入到記憶體當中。這裡我們將使用一個外部第三方庫來實現,這個有多種選擇而我們使用這個庫,這是一個免費的庫支援多種影象格式並且可以很方便的跨多個作業系統。安裝的時候可以看到這個庫的更多的介紹。
對紋理多數的操作都封裝在下面的類中:
(ogldev_texture.h:27)

class Texture
{
public:
   Texture(GLenum TextureTarget, const std::string& FileName);

   bool Load();

   void Bind(GLenum TextureUnit);
};

建立一個紋理物件的時候需要定義一個紋理目標型別(這裡使用GL_TEXTURE_2D)和檔名,然後要呼叫Load()函式。載入可能會有失敗的情況,比如檔案不存在或者ImageMagick遇到一些其他錯誤。當你想要定義一個紋理例項的時候你需要將它繫結到一個紋理單元上。

(ogldev_texture.cpp:31)

try {
   m_pImage = new Magick::Image(m_fileName);
   m_pImage->write(&m_blob, "RGBA");
}
catch (Magick::Error& Error) {
   std::cout << "Error loading texture '" << m_fileName << "': " << Error.what() << std::endl;
   return false;
}

這段程式碼展示了我們如何使用ImageMagick從檔案載入紋理並將它儲存到記憶體中供OpenGL載入使用。我們開始先使用檔名引數用Magick::Image類定義例項化一個物件,這樣紋理會被載入到一段記憶體中,但對ImageMagick類是私有的成員變數,OpenGL還不可以直接使用。然後我們將影象以RGBA格式寫入一個Magick::Blob物件中。BLOB (Binary Large Object)是一種在記憶體中儲存編碼後的影象並可以被外部程式使用的很有用的機制。如果出現任何錯誤它會丟擲異常,所以我們要對異常情況做處理。

(ogldev_texture.cpp:40)
glGenTextures(1, &m_textureObj);

這個OpenGL函式和我們已經熟悉的glGenBuffers()函式類似,產生指定數量的紋理物件,並將他們的引用控制代碼放到GLuint陣列指標(第二個引數)中。這裡只需要一個紋理物件。

(ogldev_texture.cpp:41)
glBindTexture(m_textureTarget, m_textureObj);

我們將使用幾個和頂點緩衝器類似模式的紋理相關的函式呼叫。OpenGL需要知道要操作什麼紋理物件,這就是glBindTexture()這個函式的作用,它告訴OpenGL在後面所有和紋理相關呼叫中我們所引用的是這個繫結的紋理物件,直到有新的紋理物件被繫結。函式中除了第二個引數那個物件控制代碼我們還要定義目標紋理,紋理可以是GL_TEXTURE_1D, GL_TEXTURE_2D等等。可以有不同的紋理物件同時繫結到每一個目標紋理上。在我們的程式碼實現中目標紋理作為建構函式的一個引數傳入(並且我們使用GL_TEXTURE_2D紋理)。

(ogldev_texture.cpp:42)
glTexImage2D(m_textureTarget, 0, GL_RGBA, m_pImage->columns(), m_pImage->rows(), 0, GL_RGBA, GL_UNSIGNED_BYTE, m_blob.data());

這個複雜的函式是用來載入紋理物件的主要部分的,也就是紋理資料本身。有很多個glTexImage*這種字首命名的函式,每一個函式各自應用於幾種目標紋理。目標紋理總是引數中的第一個;第二個引數是LOD(Level-Of-Detail),一個紋理物件可以包含在不同解析度下的相同的紋理。有個概念叫做‘多級漸遠紋理(Mip-mapping)’每張mip-map有一個不同的LOD索引,最好解析度時索引為0,並隨著解析度降低索引值增大。目前我們只有一張mip-map所以索引值暫時傳遞0;下一個引數是OpenGL儲存紋理的內部格式,比如你可以使用完整的四通道(紅,綠,藍,透明度)傳遞紋理。但是如果引數是GL_RED的話,你將只得到紋理的紅色通道,看上去有點…呵呵,紅色的!可以試一下~。這裡我們使用GL_RGBA來獲取顏色完整的紋理圖片;後面兩個引數是紋理的紋素單位寬度和高度。ImageMagick中儲存了紋理的這些資訊,我們可以使用Image::columns()/rows()函式很方便的獲取這些引數;第五個引數是邊界,現在先置0;最後的三個引數定義了匯入的紋理資料的來源,引數是格式、型別和記憶體地址。格式引數告訴我們通道個數,需要和記憶體中的BLOB匹配。型別引數每個通道的基本儲存資料型別,OpenGL支援很多資料型別,但是在ImageMagick BLOB中每個通道我們只有一個byte,所以我們使用GL_UNSIGNED_BYTE。最後就是真實資料的記憶體地址了,這個地址我們使用Blob::data()函式從BLOB中得到。

(ogldev_texture.cpp:43)

glTexParameterf(m_textureTarget, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(m_textureTarget, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

通用函式glTexParameterf可以控制紋理取樣操作的很多方面,這些操作的方面也是紋理取樣狀態的一部分。這裡我們定義用於放大和縮小的過濾器。每一張紋理都會有寬度和高度這兩個維度資訊,但是紋理很少會以原尺寸貼在三角形圖元上,多數情況下三角形要麼略大要麼略小,這時過濾器的型別就決定了如何縮放操作來匹配三角形的比例尺寸。如果柵格化的三角形比紋理大(比如離camera很近的時候),這時候可能會有相同的紋素覆蓋在幾個畫素點上(放大)。相反如果三角形很小(離camera很遠)的時候可能就會有多個紋素重疊覆蓋在同一個畫素點上(縮小)。這裡我們都選擇線性過濾插值的方法。就像我們之前看到的那樣,線性插值根據實際紋素的位置將周圍2x2四個紋素的顏色值混合插值可以得到看上去比較不錯的效果(通過縮放紋理座標來計算)。

(ogldev_texture.cpp:49)

void Texture::Bind(GLenum TextureUnit)
{
   glActiveTexture(TextureUnit);
   glBindTexture(m_textureTarget, m_textureObj);
}

現在3d應用已經越來越複雜,我們可能要在渲染迴圈中的很多繪製回撥中使用很多不同的紋理。在每次繪製回撥之前我們需要將我們需要的紋理物件繫結到一個紋理單元上,從而可以在片段著色器中進行取樣。這裡這個繫結函式使用紋理單元的列舉作為一個引數(GL_TEXTURE0, GL_TEXTURE1等等)。使用glActiveTexture()函式啟用紋理單元然後將紋理物件繫結到上面。這個紋理物件將一直繫結到這個紋理單換上直到下一次呼叫Texture::Bind()這個函式並繫結同一個紋理單元。

(shader.vs)

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;

uniform mat4 gWVP;

out vec2 TexCoord0;

void main()
{
   gl_Position = gWVP * vec4(Position, 1.0);
   TexCoord0 = TexCoord;
};

這裡頂點著色器程式碼要更新了,這裡有一個新的輸入引數叫做:紋理座標TexCoord(一個2d向量)。同時這裡輸出的不是顏色了而是將紋理座標從頂點緩衝器傳遞到片段著色器。柵格器會對紋理座標在整個三角形面上進行插值,並且每一個片段著色器都會和其特有的紋理座標一起起作用。

(shader.fs)

in vec2 TexCoord0;

out vec4 FragColor;

uniform sampler2D gSampler;

void main()
{
    FragColor = texture2D(gSampler, TexCoord0.st);
};

這是新的片段著色器程式碼,有一個叫做TexCoord0的輸入引數,引數裡有從頂點著色器得到的插值後的紋理座標。還有一個新的叫做gSampler的一致性變數,型別為sampler2D。這是取樣器一致變數的一個例子。應用必須要將紋理單元的值設定到這個變數中這樣片段著色器才能獲取使用到該紋理。主要的作用就一個:它使用內部的texture2D函式來對紋理取樣。第一個引數是取樣器一致變數,第二個引數是紋理座標,返回的就是取樣並經過過濾後的紋素值(這裡是顏色值),這也是本教程最終畫素的顏色值了。在後面的教程中我會將看到光線只是根據光線引數簡單的改變縮放那個顏色值而已。

(tutorial16.cpp:128)

Vertex Vertices[4] = { 
    Vertex(Vector3f(-1.0f, -1.0f, 0.5773f), Vector2f(0.0f, 0.0f)),
    Vertex(Vector3f(0.0f, -1.0f, -1.15475), Vector2f(0.5f, 0.0f)),
    Vertex(Vector3f(1.0f, -1.0f, 0.5773f), Vector2f(1.0f, 0.0f)),
    Vertex(Vector3f(0.0f, 1.0f, 0.0f), Vector2f(0.5f, 1.0f)) };

到此教程為止我們的頂點緩衝器只是簡單地一系列的含有位置資訊的Vector3f結構,現在這裡我們的頂點結構既包含位置資料同時又包含Vector2f型別的紋理座標。

(tutorial16.cpp:80)

...
glEnableVertexAttribArray(1);
...
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12);
...
pTexture->Bind(GL_TEXTURE0);
...
glDisableVertexAttribArray(1);

主渲染迴圈中的程式碼有所添改,在之前啟用的用於頂點位置的頂點屬性0之後,這裡我們啟動一個頂點屬性1用於紋理座標。這個和頂點著色器中的layout變數宣告是一一對應的。然後我們呼叫glVertexAttribPointer函式來定義頂點緩衝器中紋理座標的位置。紋理座標包含兩個浮點數數值,分別和第二個、第三個引數對應。注意第五個引數,這個是包含頂點位置和紋理座標的頂點結構的size資料大小。這個引數稱作‘頂點跨度vertex stride’,它會告訴OpenGL一個頂點屬性的開始位置到下一個頂點的相同屬性開始位置之間間隔的bytes數目。我們這個例子中緩衝器包含:pos0,紋理coords0,pos1,紋理coords1等等。之前的教程中我們只有位置資訊所以將引數設定為0或者sizeof(Vector3f)。現在我們這個頂點結構包含了不止一個屬性,因此頂點跨度應該為這個符合的頂點結構的byte數。最後一個引數是從頂點結構的起始位置到裡面紋理屬性之間的byte數偏移值,而且還必須要轉為函式需要的GLvoid*型別。在繪製回撥之前我們還要將我們需要用的的紋理繫結到紋理單元上。這裡我們只有一個紋理所以任何一個紋理單元都可以。我們唯一要保證相同的紋理單元要設定在shader中(見下面)。繪製回撥結束後記得關掉頂點屬性陣列。

(tutorial16.cpp:253)

glFrontFace(GL_CW);
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);

這些OpenGL回撥函式的呼叫並不是真正和紋理相關的,這裡新增只是為了看上去效果更好(可以嘗試去掉…)。這些函式可以開啟面的剔除,一種常用的優化方法,在柵格化之前將一些不需要的三角形丟棄掉,這樣做的主要原因是經常一個物體表面有大約一半的面是相對我們隱藏的(人、房子、車等的背面)。glFrontFace()函式告訴OpenGL三角形的頂點是按照順時針順序定義的,也就是當你看向三角形的正面時,會發現緩衝器中的三角形頂點是順時針順序排列的。glCullFace()告訴GPU剔除三角形的背面,也就是物體的內表面不需要渲染,只渲染外表面。最後是開啟面剔除本身(預設是關閉的)。注意這個教程中我將三角形底部的頂點順序顛倒了,使三角形看上去好像面對金字塔的裡面(見原始碼tutorial16.cpp第170行)。

(tutorial16.cpp:262)
glUniform1i(gSampler, 0);

這裡我們將我們將要使用的紋理單元的索引放到shader中的取樣器一致變數裡。‘gSampler’是之前使用glGetUniformLocation()函式獲取的一個一致變數。這裡要注意的是紋理單元的實際索引是在此處使用的,而不是那個OpenGL列舉GL_TEXTURE0,值是不一樣的。

(tutorial16.cpp:264)

pTexture = new Texture(GL_TEXTURE_2D, "test.png");

if (!pTexture->Load()) {
    return 1;
}

這裡我們建立紋理物件並載入它。’test.png’這張圖片在教程的原始碼工程裡有,而ImageMagick實際上是可以處理幾乎任何型別的檔案的。
練習:如果你執行這個教程的示例程式碼你會發現金字塔的面並不是一致的,想想為什麼會這樣,需要怎樣改變才能使他們一致起來。