OpenGL紋理對映--基礎篇
紋理對映意思就是把圖片(或者說紋理)對映到3D模型的一個或多個面上。紋理可以是任何圖片,使用紋理對映可以增加3D物體的真實感,我們常見的紋理有磚,植物葉子等等。
下圖中是使用紋理對映和沒有使用紋理對映四面體的比較。
要使用紋理對映,我們必須做以下三件事情:在OpenGL中裝入紋理,為頂點提供紋理座標(為了把紋理對映到頂點),用紋理座標在紋理上執行一個取樣操作,得到一個畫素顏色。
三維空間中的物體經過縮放,旋轉,平移,最終投影到螢幕上,依賴於攝像機位置和方位的不同,最終呈現的形式可能千差萬別,但根據紋理座標,GPU會保證最終的紋理對映結果是正確的。在光柵化階段,GPU也會插值紋理座標,這樣,每個片元都有一個對應紋理座標。在片元shader中,片元(或畫素)會根據紋理座標,取樣得到最終的紋理單元顏色,並把這些顏色和當點片元的顏色或者根據光照計算的顏色混合,從而輸出畫素的最終顏色。下面的教程中,我們將看到,紋理單元能夠包含不同的資料,實現很多特效。
OpenGL支援 1D, 2D, 3D, cube等等多種紋理,這些紋理在不同的技術中使用。我們首先來學習2D紋理,2D紋理通常來說就是一塊有高度和寬度的surface(表面),寬度乘以高度的結果就是紋理單元的數目。那麼如何指定頂點的紋理座標呢?其實頂點的紋理座標並不是頂點在紋理surface上的座標,否則的話,那受限制就太大了,因為我們的三維物體表面是變化的,有的大,有的小,這樣的話,意味著我們要不斷更新紋理座標,這顯然很難做到。因此,存在紋理座標空間,每維的紋理座標範圍都是[0,1],所以紋理座標通常都是[0,1]之間的一個浮點數,我們用紋理座標乘以紋理的高度或寬度,就可以得到頂點在紋理上對應的紋理單元位置,例如:如果紋理位置是 [0.5,0.1],紋理寬度是320,紋理高度是200,那個對應紋理單元位置就是 (160,20) (0.5 * 320 = 160 和 0.1 * 200 = 20)。
通常紋理空間又叫UV空間,U對應2維笛卡爾座標的x軸,V對應y軸,OpenGL中,U軸方向從左到右,V軸方向從下到上,如下圖所示,可以看到(0,0)位置在左下角,向上V增加,向右U增加:
下圖中的三角形被指定紋理座標:
當三角形做了各種變化後,它的紋理座標保持不變,假設三角形光柵化前,它的位置如下。
紋理座標是三角形頂點的屬性,無論三角形怎麼變化,對於頂點來說,紋理座標相對位置都不變,當然也可以在頂點shader中,動態改變紋理座標,這個主要用於實現一些特殊的效果,比如水面效果等等。在本教程中,我們將保持紋理座標不變。
另一個和紋理對映相關的概念是“濾波”,前面我們討論了通過一個紋理座標,得到相應的紋理單元,由於紋理座標是[0,1]之間的浮點數,它乘以紋理高度寬度,可能得到一個浮點的對映座標,比如我們把紋理座標對映到紋理單元 (152.34,745.14),此時怎麼得到紋理單元呢?最簡單的方法,我們可以四捨五入,得到 (152,745),這種方法,可以工作,但是在一些情況下,效果並不是很好,一個更佳的方案是:得到一個2×2的quad紋理單元, ( (152,745), (153,745), (152,744) 和(153,744) ),然後在這些紋理單元顏色之間進行線性插值操作,線性插值和該紋理單元到(152.34,745.14)的距離有關,越接近這個座標,影響就越大,越遠,影響越小,這個效果要比四射五入直接選取紋理單元要好。
決定最終哪一個紋理單元被選擇的方法就稱作“濾波”,最簡單的方法就是前面說的四捨五入方法,這種濾波方式又叫nearst濾波 (nearest filtering),這是一種點取樣的濾波方式,將使用座標離畫素中心最近的紋素,這可能導致鋸齒現象(有時很嚴重),這種濾波方法可用於放大和縮小。如果濾波方法為GL_LINEAR,將使用離畫素中心最近的2X2紋素陣列(對於三維紋理,為2X2X2紋素陣列;對於一維紋理,為2個紋素)的加權線性平均值;同樣這種濾波方法也可用於放大縮小 。OpenGL提供多種取樣方式,你可以選擇其中任意一種,通常,更好的濾波效果需要更高的GPU運算能力,這有可能影響幀率。選擇更好的效果和更流暢的畫面是個balance問題。
下面我們看看OpenGL中如何實現紋理對映:在OpenGL中使用紋理,我們首先要學習四個概念:紋理物件,紋理單元,取樣物件以及shader中的取樣uniform變數。
紋理物件本身包括紋理需要的資料,比如影象資料。根據儲存的資料格式(RGB,RGBA等等),紋理可分為1維紋理,2維紋理,3維紋理等等,OpenGL提供了一種方便的函式,只要指定資料的起始地址,以及資料格式屬性,就可以很方便的把資料裝入GPU,紋理通常就是通過這種方法裝入video memory,在裝入紋理時候,可以指定多個引數,比如濾波方式等等。類似頂點緩衝資料,我們也可以把紋理和控制代碼關聯起來。當建立控制代碼,裝入紋理後,我們能夠實時切換紋理,和不同的OpenGL控制代碼進行繫結,而不需要再次裝入資料,此時,OpenGL的driver會保證,渲染前,紋理資料被裝入video memory。
紋理物件並不直接和shader(紋理取樣實際上在shader中實施)打交道,而是通過一個紋理單元(texture unit),該紋理單元的索引被傳遞到shader中。這樣,shader就能通過該紋理單元訪問紋理物件。通常我們可以使用多個紋理單元(數目和具體gpu有關), 為了把一個紋理物件A繫結到紋理單元0,我們首先要啟用紋理單元0,然後才能繫結到紋理物件A,此時,如果要使用第二個紋理物件,可以啟用紋理單元1,然後繫結到相應的紋理物件。
實際的情況可能有點複雜,一個紋理單元其實可以同時繫結多個紋理物件,只要這些紋理物件的型別不同,這型別是紋理物件的target,比如1D,2D等等,繫結紋理物件和紋理單元時,我們必須指定target,例如:我們可以把targe維1D紋理物件A和target維2D的紋理物件B同時繫結到同一個紋理單元。
取樣操作通常在片元shader中實施,具體操作是通過一個取樣函式,取樣函式需要知道取樣的紋理單元,因為shader中可能有多個紋理單元。具體是通過一組紋理uniform變數來區分不同的紋理單元,這些uniform變數和紋理單元是一一對應關係,當你對某個uniform變數進行取樣操作時,該變數對應的紋理物件被使用。
最後再來看一下采樣物件,注意不要把它和取樣uniform變數混淆。紋理物件包含影象資料,也包括配置取樣操作的引數等等,這些引數是取樣狀態的一部分,我們也可以建立一個取樣物件,配置它的引數,並把它繫結到紋理單元,這將會過載紋理物件中定義的取樣狀態,本文中,我們並沒有實施取樣物件。
下圖總結了我們前面學習的一些概念:
主要程式碼:
OpenGL能夠裝入記憶體中的紋理資料,但並沒有提供一個方法,把影象檔案,比如PNG,JPG等,裝入到記憶體中,我們使用一個開源的影象處理庫 ImageMagick, 該庫支援多種格式的影象處理。在程式程式碼中,直接包含了該庫的原始碼。
大部分的紋理操作被包裝在texture類中:
texture.h
class
Texture
{
public:
Texture(GLenum TextureTarget, const std::string& FileName);
bool Load();
void Bind(GLenum TextureUnit);
};
建立一個紋理物件時候,我們需要指定target(我們用GL_TEXTURE_2D),以及影象檔名字,之後,我們可以呼叫Load函式,來裝入紋理資料。如果需要把紋理物件繫結到特殊的紋理單元,我們可以用Bind函式。
texture.cpp
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;
}
用上面的程式碼,我們把影象檔案裝入記憶體(此時在system memory中),並準備裝入OpenGL。我們使用了Magic::Image例項,並提供影象檔名字,使用該函式後,將把紋理影象資料裝入m_pImage物件內部,OpenGL不能直接訪問,所以我們接著做一個write操作,把紋理資料寫到m_blob變量表示的記憶體中,我們使用的影象格式是RGBA。BLOB (Binary Large Object)是一個二進位制檔案塊,常用來儲存影象塊,以便其它程式使用。
glGenTextures(1,
&m_textureObj);
上面這個OpenGL函式和 glGenBuffers()很相似,第一個引數是個數字,指定要建立的紋理物件數量,第二個引數是紋理物件陣列。在本教程中,我們使用一個紋理物件。
glBindTexture(m_textureTarget,
m_textureObj);
通過glBindTexture()函式,我們繫結一個紋理物件,這樣下面所有對紋理的操作都是基於該物件,如果我們要操作別的紋理物件,需要重新使用glBindTexture()函式繫結別的紋理物件。glBindTexture()函式中第二個引數是紋理物件控制代碼,第一個引數是紋理target,它的值可能是 GL_TEXTURE_1D, GL_TEXTURE_2D等等。不同的紋理物件同時只能繫結一個target,在本教程中,target在紋理類建構函式中實施,我們使用的是GL_TEXTURE_2D 。
glTexImage2D(m_textureTarget,
0, GL_RGBA, m_pImage->columns(), m_pImage->rows(), 0, GL_RGBA, GL_UNSIGNED_BYTE, m_blob.data());
glTexImage2D函式用來裝入紋理物件的資料,也就是把system memory中的資料(m_blob)和紋理物件關聯起來,可能在該函式呼叫時候就拷貝到video memory,也可能是延時拷貝,這個是由driver控制的。 glTexImage* 函式有幾個版本,每個版本都對應一個紋理target。該函式的第一個引數是紋理target,第二個引數是LOD(層次細節),一個紋理物件可能包含多個解析度的相同影象,這些影象稱作mipmap層,每個mipmap層數都有一個LOD索引,範圍從0到最高解析度。本教程程式中,只有一個mipmap層,所以該值為0 。
第三個引數是紋理物件的格式,你可以指定為4通道的顏色RGBA,或者僅指定紅色通道GL_RED,本教程程式中,我們使用GL_RGBA ,接下來的2個引數是紋理的高度和寬度,通過 ImageMagick的內部函式rows和colomns,我們可以很方便的得到這兩個值。第5個引數是紋理的邊選項,本程式中我們設定為0。
最後的三個引數指定源紋理資料的格式,型別以及資料記憶體地址。格式指定顏色channel的格式,這必須和m_blob中的data相匹配,型別描述每個顏色channel的格式,本程式中為無符號8位數字GL_UNSIGNED_BYTE,最後一個引數是紋理資料記憶體地址。
glTexParameterf(m_textureTarget,
GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(m_textureTarget, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
(texture.cpp)
void
Texture::Bind(GLenum TextureUnit)
{
glActiveTexture(TextureUnit);
glBindTexture(m_textureTarget, m_textureObj);
}
在3D程式中,可能有多個draw,每個draw提交前,可能需要繫結不同的紋理,以便在shader中使用,上面的Bind函式就是使我們方便的切換不同的紋理,它的引數是一個紋理單元。
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;
};
這是更新後的頂點shader,這兒有一個輸入引數紋理座標,是一個2D向量,在頂點shader中我們並沒有對紋理座標進行任何變化,而是直接輸出,但在片元shader前的光柵化階段,會對紋理座標進行插值操作。
in vec2 TexCoord0;
out
vec4 FragColor;
uniform sampler2D gSampler;
void main()
{
FragColor = texture2D(gSampler, TexCoord0.st);
};
上面是更新後的片元shader,其中有一個輸入變數TexCoord0,它包含了插值後的紋理座標,還有一個uniform變數gSampler,它是sampler2D型別,應用程式必須設定紋理單元值以便和這個uniform變數連線起來,這樣shader才能訪問紋理,返回值就是取樣的紋理單元顏色。後面的光照的教程中,都是根據光照因子乘以這個取樣顏色,從而得到最終的畫素顏色。
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)) };
新的頂點結構包括頂點位置以及頂點紋理座標。
tutorial16.cpp
...
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);
在渲染迴圈中也有一些程式碼變動,因為增加了紋理座標屬性,所以我們啟動了屬性1,這和頂點shader中的layout是一致的,接著我們會呼叫glVertexAttribPointer指定頂點緩衝中紋理座標的位置,紋理座標是2個浮點數,所以函式第二個引數是2,注意第五個引數都是頂點結構的大小,這對於位置和紋理屬性是一樣的, 這個引數稱作 'vertex stride',就是2個頂點之間的位元組數目。在我們的頂點緩衝中,包含pos0, texture coords0, pos1, texture coords1, 等等,前面的教程中,只有一個位置屬性,所以該引數設定為0。最後一個引數頂點結構起始地址到紋理屬性的偏移位元組數。
在draw呼叫前,我們進行一次紋理繫結操作,注意下面的禁止頂點屬性函式呼叫,指定頂點屬性後,我們需要在一次禁止它。
glFrontFace(GL_CW);
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);
上面三個函式設定三角形面背面剔除功能,啟用該功能後,會在PA階段,剔除法向朝後的三角面(背面三角形本來就看不見),從而這些面不會做片元shader,從而提高程式效能。第一個函式指定三角形頂點為順時針順序,就是說從前面看向三角形時,它的頂點是順時針排列,第二個函式指定剔除背面(而不是前面),第三個引數開啟剔除功能。
glUniform1i(gSampler,
0);
設定紋理單元的索引,我們將會在片元shader中通過uniform變數使用紋理。在前面的程式碼中,gSampler會通過 glGetUniformLocation()函式得到。
pTexture
= new Texture(GL_TEXTURE_2D, "test.png");
if (!pTexture->Load()) {
return 1;
}
上面的程式碼建立紋理物件,並裝入它。
程式執行後介面如下: