1. 程式人生 > >OpenGL學習腳印: 二維紋理對映(2D textures)

OpenGL學習腳印: 二維紋理對映(2D textures)

寫在前面
前面兩節介紹了向量和矩陣,以及座標和轉換相關的數學,再繼續討論模型變換等其他包含數學內容的部分之前,本節介紹二維紋理對映,為後面學習做一個準備。紋理對映本身也是比較大的主題,本節只限於討論二維紋理的基本使用,對於紋理對映的其他方法,後面會繼續學習。可以從我的github下載本節程式碼。

通過本節可以瞭解到

  • 紋理對映的概念和原理
  • 二維紋理對映的處理方法

使用紋理增加物體表面細節

要使渲染的物體更加逼真,一方面我們可以使用更多的三角形來建模,通過複雜的模型來逼近物體,但是這種方法會增加繪製流水線的負荷,而且很多情況下不是很方便的。使用紋理,將物體表面的細節對映到建模好的物體表面,這樣不僅能使渲染的模型表面細節更豐富,而且比較方便高效。紋理對映就是這樣一種方法,在程式中通過為物體指定紋理座標,通過紋理座標獲取紋理物件中的紋理,最終顯示在螢幕區域上,已達到更加逼真的效果。

紋素(texel)和紋理座標

使用紋素這個術語,而不是畫素來表示紋理物件中的顯示元素,主要是為了強調紋理物件的應用方式。紋理物件通常是通過紋理圖片讀取到的,這個資料儲存到一個二維陣列中,這個陣列中的元素稱為紋素(texel),紋素包含顏色值和alpha值。紋理物件的大小的寬度和高度應該為2的整數冪,例如16, 32, 64, 128, 256。要想獲取紋理物件中的紋素,需要使用紋理座標(texture coordinate)指定。

紋理座標應該與紋理物件大小無關,這樣指定的紋理座標當紋理物件大小變更時,依然能夠工作,比如從256x256大小的紋理,換到512x256時,紋理座標依然能夠工作。因此紋理座標使用規範化的值,大小範圍為[0,1],紋理座標使用uv表示,如下圖所示(來自:

Basic Texture Mapping):
紋理座標

u軸從左至右,v軸從底向上指向。右上角為(1,1),左下角為(0,0)。
通過指定紋理座標,可以對映到紋素。例如一個256x256大小的二維紋理,座標(0.5,1.0)對應的紋素即是(128,256)。(256x0.5 = 128, 256x1.0 = 256)。

紋理對映時只需要為物體的頂點指定紋理座標即可,其餘部分由片元著色器插值完成,如下圖所示(來自A textured cube):
紋理座標使用

模型變換和紋理座標

所謂模型變換,就是對物體進行縮放、旋轉、平移等操作,後面會著重介紹。當對物體進行這些操作時,頂點對應的紋理座標不會進行改變,通過插值後,物體的紋理也像緊跟著物體發生了變化一樣。如下圖所示為變換前物體的紋理座標(來自:

Basic Texture Mapping):
模型變換前

經過旋轉等變換後,物體和對應的紋理座標如下圖所示,可以看出上面圖中紋理部分的房子也跟著發生了旋轉。(來自:Basic Texture Mapping):
模型變換後

注意 有一些技術可以使紋理座標有控制地發生改變,本節不深入討論,這裡我們的紋理座標在模型變換下保持不變。

建立紋理物件

建立紋理物件的過程同前面講述的建立VBO,VAO類似:

   GLuint textureId;
   glGenTextures(1, &textureId);
   glBindTexture(GL_TEXTURE_2D, textureId);

這裡我們繫結到GL_TEXTURE_2D目標,表示二維紋理。

WRAP引數

上面提到紋理座標(0.5, 1.0)到紋素的對映,恰好為(128,256)。如果紋理座標超出[0,0]到[1,1]的範圍該怎麼處理呢? 這個就是wrap引數由來,它使用以下方式來處理:

  • GL_REPEAT:座標的整數部分被忽略,重複紋理,這是OpenGL紋理預設的處理方式.
  • GL_MIRRORED_REPEAT: 紋理也會被重複,但是當紋理座標的整數部分是奇數時會使用映象重複。
  • GL_CLAMP_TO_EDGE: 座標會被截斷到[0,1]之間。結果是座標值大的被截斷到紋理的邊緣部分,形成了一個拉伸的邊緣(stretched edge pattern)。
  • GL_CLAMP_TO_BORDER: 不在[0,1]範圍內的紋理座標會使用使用者指定的邊緣顏色。

這裡寫圖片描述

在OpenGL中設定wrap引數方式如下:

   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

上面的幾個選項對應的都是整數,因此使用glTexParameteri來設定。

Filter引數

當使用紋理座標對映到紋素陣列時,正好得到對應紋素的中心位置的情況,很少出現。例如上面的(0.5,1.0)對應紋素(128,256)的情況是比較少的。如果紋理座標對映到紋素位置(152.34,745.14)該怎麼辦呢 ?

一種方式是對這個座標進行取整,使用最佳逼近點來獲取紋素,這種方式即點取樣(point sampling),也就是最近鄰濾波( nearest neighbor filtering)。這種方式容易導致走樣誤差,明顯有畫素塊的感覺。最近鄰濾波方法的示意圖如下所示(來自A Textured Cube):
最近鄰濾波
圖中目標紋素位置,離紅色這個紋素最近,因此選擇紅色作為最終輸出紋素。

另外還存在其他濾波方法,例如線性濾波方法(linear filtering),它使用紋素位置(152.34,745.14)附近的一組紋素的加權平均值來確定最終的紋素值。例如使用 ( (152,745), (153,745), (152,744) and (153,744) )這四個紋素值的加權平均值。權係數通過與目標點(152.34,745.14)的距離遠近反映,距離(152.34,745.14)越近,權係數越大,即對最終的紋素值影響越大。線性濾波的示意圖如下圖所示(來自A Textured Cube):
線性濾波
圖中目標紋素位置周圍的4個紋素通過加權平均計算出最終輸出紋素。

還存在其他的濾波方式,如三線性濾波(Trilinear filtering)等,感興趣的可以參考texture filtering wiki。最近鄰濾波和線性濾波的對比效果如下圖所示(來自Textures objects and parameters):

最近鄰和線性濾波對比

可以看出最近鄰方法獲取的紋素看起來有明顯的畫素塊,而線性濾波方法獲取的紋素看起來比較平滑。兩種方法各自有不同的應用場合,不能說線性濾波一定比最近鄰濾波方法好,例如要製造8點陣圖形效果(8 bit graphics,每個畫素使用8位位元組表示)需要使用最近鄰濾波。作為一個興趣瞭解,8點陣圖形效果看起來也是很酷的(可以檢視Welcome 8-bit, Pixel-Art Images Gallery!)獲得更多8點陣圖形),例如下面這張使用Excel製作的8點陣圖(來自Excel is a great for making 8 bit graphics!):
這裡寫圖片描述

另外一個問題是,紋理應用到物體上,最終要繪製在顯示裝置上,這裡存在一個紋素到畫素的轉換問題。有三種情形(參考自An Introduction to Texture Filtering):

  • 一個紋素最終對應螢幕上的多個畫素 這稱之為放大(magnification)
  • 一個紋素對應螢幕上的一個畫素 這種情況不需要濾波方法
  • 一個紋素對應少於一個畫素,或者說多個紋素對應螢幕上的一個畫素 這個稱之為縮小(minification)
    放大和縮小的示意圖如下:
    magnification and minification

在OpenGL中通過使用下面的函式,為紋理的放大和縮小濾波設定相關的控制選項:

glTexParameteri(GL_TEXTURE_2D, 
    GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, 
    GL_TEXTURE_MIN_FILTER, GL_NEAREST); 

其中GL_LINEAR對應線性濾波,GL_NEAREST對應最近鄰濾波方式。

使用Mipmaps

考慮一個情景:當物體在場景中離觀察者很遠,最終只用一個螢幕畫素來顯示時,這個畫素該如何通過紋素確定呢?如果使用最近鄰濾波來獲取這個紋素,那麼顯示效果並不理想。需要使用紋素的均值來反映物體在場景中離我們很遠這個效果,對於一個 256×256的紋理,計算平均值是一個耗時工作,不能實時計算,因此可以通過提前計算一組這樣的紋理用來滿足這種需求。這組提前計算的按比例縮小的紋理就是Mipmaps。Mipmaps紋理大小每級是前一等級的一半,按大小遞減順序排列為:

  • 原始紋理 256×256
  • Mip 1 = 128×128
  • Mip 2 = 64×64
  • Mip 3 = 32×32
  • Mip 4 = 16×16
  • Mip 5 = 8×8
  • Mip 6 = 4×4
  • Mip 7 = 2×2
  • Mip 8 = 1×1

OpenGL會根據物體離觀察者的距離選擇使用合適大小的Mipmap紋理。Mipmap紋理示意圖如下所示(來自wiki Mipmap):
Mipmap
OpenGL中通過函式glGenerateMipmap(GL_TEXTURE_2D);來生成Mipmap,前提是已經指定了原始紋理。原始紋理必須自己通過讀取紋理圖片來載入,這個後面會介紹。
如果直接在不同等級的MipMap之間切換,會形成明顯的邊緣,因此對於Mipmap也可以同紋素一樣使用濾波方法在不同等級的Mipmap之間濾波。要在不同等級的MipMap之間濾波,需要將之前設定的GL_TEXTURE_MIN_FILTER選項更改為以下選項之一:

  • GL_NEAREST_MIPMAP_NEAREST: 使用最接近畫素大小的Mipmap,紋理內部使用最近鄰濾波。
  • GL_LINEAR_MIPMAP_NEAREST: 使用最接近畫素大小的Mipmap,紋理內部使用線性濾波。
  • GL_NEAREST_MIPMAP_LINEAR: 在兩個最接近畫素大小的Mipmap中做線性插值,紋理內部使用最近鄰濾波。
  • GL_LINEAR_MIPMAP_LINEAR: 在兩個最接近畫素大小的Mipmap中做線性插值,紋理內部使用線性濾波。

Mipmap使用注意 使用使用glGenerateMipmap(GL_TEXTURE_2D)產生Mipmap的前提是你已經載入了原始的紋理物件。使用MipMap時設定GL_TEXTURE_MIN_FILTER選項才能起作用,設定GL_TEXTURE_MAG_FILTER的Mipmap選項將會導致無效操作,OpenGL錯誤碼為GL_INVALID_ENUM。

設定Mipmap選項如下程式碼所示:

   glTexParameteri(GL_TEXTURE_2D, 
       GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

載入原始紋理

從圖片載入紋理這部分工作不是OpenGL函式完成的,可以通過外部庫實現。這裡我們使用SOIL(Simple OpenGL Image Library)庫完成。下載完這個庫後,你需要編譯到本地平臺對應版本。你可以從我的github處下載已經編譯好的32位庫。
使用SOIL載入紋理的程式碼如下:

GLubyte *imageData = NULL;
int picWidth, picHeight;
imageData = SOIL_load_image("wood.png", 
    &picWidth, &picHeight, 0, SOIL_LOAD_RGB); // 讀取圖片資料
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 
    picWidth,picHeight, 0, GL_RGB, 
    GL_UNSIGNED_BYTE, imageData); // 定義紋理影象

其中glTexImage2D函式定義紋理影象的格式,寬度和高度等資訊,具體引數如下:

API void glTexImage2D( GLenum target,
GLint level,
GLint internalFormat,
GLsizei width,
GLsizei height,
GLint border,
GLenum format,
GLenum type,
const GLvoid * data);

1.target引數指定設定的紋理目標,必須是GL_TEXTURE_2D, GL_PROXY_TEXTURE_2D等引數。
2.level指定紋理等級,0代表原始紋理,其餘等級對應Mipmap紋理等級。
3.internalFormat指定OpenGL儲存紋理的格式,我們讀取的圖片格式包含RGB顏色,因此這裡也是用RGB顏色。
4.width和height引數指定儲存的紋理大小,我們之前利用SOIL讀取圖片時已經獲取了圖片大小,這裡直接使用即可。
5. border 引數為歷史遺留引數,只能設定為0.
6. 最後三個引數指定原始圖片資料的格式(format)和資料型別(type,為GL_UNSIGNED_BYTE, GL_BYTE等值),以及資料的記憶體地址(data指標)。

使用紋理的完整過程

Step1 首先要指定紋理座標,這個座標和頂點位置、頂點顏色一樣處理,使用索引繪製,程式碼如下所示:

   // 指定頂點屬性資料 頂點位置 顏色 紋理
    GLfloat vertices[] = {
        -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,0.0f, 0.0f,  // 0
        0.5f,  -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,1.0f, 0.0f,  // 1
        0.5f,  0.5f,  0.0f, 0.0f, 0.0f, 1.0f,1.0f, 1.0f,  // 2
        -0.5f, 0.5f,  0.0f, 1.0f, 1.0f, 0.0f,0.0f, 1.0f   // 3
    };
    GLushort indices[] = {
        0, 1, 2,  // 第一個三角形
        0, 2, 3   // 第二個三角形
    };

同頂點位置和顏色一樣,需要指定紋理座標的解析方式。上面的資料格式如下圖所示(來自www.learnopengl.com):
data format

這個格式的說明在OpenGL學習腳印: 繪製一個三角形 已經講過,如果不清楚,可以回過頭去檢視。通過檢視上圖,我們按照如下方式設定glVertexAttribPointer,讓OpenGL知道如何解析上述資料:

    // 頂點位置屬性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 
        8 * sizeof(GL_FLOAT), (GLvoid*)0);
    glEnableVertexAttribArray(0);
    // 頂點顏色屬性
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
        8 * sizeof(GL_FLOAT), (GLvoid*)(3 * sizeof(GL_FLOAT)));
    glEnableVertexAttribArray(1);
    // 頂點紋理座標
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
        8 * sizeof(GL_FLOAT), (GLvoid*)(6 * sizeof(GL_FLOAT)));
    glEnableVertexAttribArray(2);

對應的頂點著色器如下:

#version 330

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 color;
layout(location = 2) in vec2 textCoord; // 紋理座標

out vec3 VertColor;
out vec2 TextCoord;

void main()
{
    gl_Position = vec4(position, 1.0);
    VertColor = color;
    TextCoord = textCoord;
}

Step2 :然後需要設定OpenGL紋理引數;最後通過讀取紋理圖片,定義紋理影象格式等資訊。紋理資料最終傳遞到了顯示卡中儲存。

   // Section3 準備紋理物件
    // Step1 建立並繫結紋理物件
    GLuint textureId;
    glGenTextures(1, &textureId);
    glBindTexture(GL_TEXTURE_2D, textureId);
    // Step2 設定wrap引數
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    // Step3 設定filter引數
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
        GL_LINEAR_MIPMAP_LINEAR); // 為MipMap設定filter方法
    // Step4 載入紋理
    GLubyte *imageData = NULL;
    int picWidth, picHeight;
    imageData = SOIL_load_image("wood.png", 
        &picWidth, &picHeight, 0, SOIL_LOAD_RGB);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, picWidth, picHeight, 
        0, GL_RGB, GL_UNSIGNED_BYTE, imageData);
    glGenerateMipmap(GL_TEXTURE_2D);
    // Step5 釋放紋理圖片資源
    SOIL_free_image_data(imageData);
    glBindTexture(GL_TEXTURE_2D, 0);

注意 圖片資源在建立完紋理後就可以釋放了,使用SOIL_free_image_data完成。

Step3 著色器中使用紋理物件
在頂點著色器中我們傳遞了紋理座標,有了紋理座標,獲取最終的紋素使用過在片元著色器中完成的。由於紋理物件通過使用uniform變數來像片元著色器傳遞,實際上這裡傳遞的是對應紋理單元(texture unit)的索引號。紋理單元、紋理物件對應關係如下圖所示:
紋理單元

著色器通過紋理單元的索引號索引紋理單元,每個紋理單元可以繫結多個紋理到不同的目標(1D,2D)。OpenGL可以支援的紋理單元數目,一般至少有16個,依次為GL_TEXTURE0 到GL_TEXTURE15,紋理單元最大支援數目可以通過查詢GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS常量獲取。這些常量值是按照順序定義的,因此可以採用 GL_TEXTURE0 + i 的形式書寫常量,其中整數i在[0, GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS)範圍內。

作為一個瞭解,紋理物件不僅包含紋理資料,還包含取樣引數,這些取樣引數稱之為取樣狀態(sampling state)。而取樣物件(sampler object)就是隻包含取樣引數的物件,將它繫結到紋理單元時,它會覆蓋紋理物件中的取樣狀態,從而重新配置取樣方式。這裡不再繼續討論取樣物件的使用了。

要使用紋理必須在使用之前啟用對應的紋理單元,預設狀態下0號紋理單元是啟用的,因此即使沒有顯式地啟用也能工作。啟用並使用紋理的程式碼如下:

  // 使用0號紋理單元
  glActiveTexture(GL_TEXTURE0);
  glBindTexture(GL_TEXTURE_2D, textureId);
  glUniform1i(glGetUniformLocation(shader.programId, "tex"), 0); 

上述glUniform1i將0號紋理單元作為整數傳遞給片元著色器,片元著色器中使用uniform變數對應這個紋理取樣器,使用變數型別為:

uniform sampler2D tex;

uniform變數與attribute變數 uniform變數與頂點著色器中使用的屬性變數(attribute variables)不同,
屬性變數首先進入頂點著色器,如果要傳遞給片元著色器,需要在頂點著色器中定義輸出變數輸出到片元著色器。而uniform變數則類似於全域性變數,在整個著色器程式中都可見。

完整的片元著色器程式碼為:

#version 330

in vec3 VertColor;
in vec2 TextCoord;

uniform sampler2D tex;

out vec4 color;


void main()
{
    color = texture(tex, TextCoord);
}

其中texture函式根據紋理座標,獲取紋理物件中的紋素。
執行程式,效果如下圖所示:

二維紋理

這裡為繪製的矩形添加了紋理,可以從我的github下載程式完整程式碼。

重構程式碼

將上面處理紋理部分的程式碼整理成一個函式,放在textureHelper類裡,可以從我的github檢視這個類的程式碼。使用textureHelper類載入紋理的程式碼為:

GLint textureId = TextureHelper::load2DTexture("wood.png");

在上面的頂點著色器中,我們也傳遞了頂點顏色屬性,將頂點顏色和紋理混合,修改片元著色器中程式碼為:

color = texture(tex, TextCoord) * vec4(VertColor, 1.0f);

二維紋理與顏色混合

使用多個紋理單元

上面介紹了一個紋理單元支援多個紋理繫結到不同的目標,一個程式中也可以使用多個紋理單元載入多個2D紋理。使用多個紋理單元的程式碼如下:

shader.use();
// 使用0號紋理單元
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureId1);
glUniform1i(glGetUniformLocation(shader.programId, "tex1"), 0); 
// 使用1號紋理單元
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textureId2);
glUniform1i(glGetUniformLocation(shader.programId, "tex2"), 1); 

在著色器中,對兩個紋理的顏色進行混合:

   #version 330

in vec3 VertColor;
in vec2 TextCoord;

uniform sampler2D tex1;
uniform sampler2D tex2;
uniform float mixValue;

out vec4 color;


void main()
{
    vec4 color1 = texture(tex1, TextCoord);
    vec4 color2 = texture(tex2, TextCoord);
    color = mix(color1, color2, mixValue);
}

其中mix函式完成顏色插值,函式原型為:

API genType mix( genType x,
genType y,
genType a);

最終值得計算方法為:x×(1a)+y×a
mixValue通過程式傳遞,可以通過鍵盤上的A和S鍵,調整紋理混合值,改變混合效果。

執行效果如下:

two texture unit

畫面中這隻貓是倒立的,主要原因是載入圖片時,圖片的(0,0)位置一般在左上角,而OpenGL紋理座標的(0,0)在左下角,這樣y軸順序相反。有的圖片載入庫提供了相應的選項用來翻轉y軸,SOIL沒有這個選項。我們可以修改頂點資料中的紋理座標來達到目的,或者對於我們這裡的簡單情況使用如下程式碼實現y軸的翻轉:

vec4 color2 = texture(tex2, 
    vec2(TextCoord.s, 1.0 - TextCoord.t));

修改後的執行效果如下所示:

two texture unit mix

說明 限於時間關係,文中的示例圖片部分來源於網路,均註明了出處,向原作者表示感謝。

參考資料

推薦閱讀