# 第五課:紋理立方體
本課學習如下幾點:
- 什么是UV坐標
- 怎樣自行加載紋理
- 怎樣在OpenGL中使用紋理
- 什么是濾波?什么是mipmap?怎樣使用?
- 怎樣利用GLFW更加有效地加載紋理?
- 什么是alpha通道?
## 關于UV坐標
給一個模型貼紋理時,需要通過某種方式告訴OpenGL用哪一塊圖像來填充三角形。這是借助UV坐標來實現的。
每個頂點除了位置坐標外還有兩個浮點數坐標:U和V。這兩個坐標用于獲取紋理,如下圖所示:

注意紋理是怎樣在三角形上扭曲的。
## 自行加載.BMP圖片
了解BMP文件格式并不重要:很多庫可以幫你做這個。但BMP格式極為簡單,可以幫助你理解那些庫的工作原理。所以,我們從頭開始寫一個BMP文件加載器,以便你理解其工作原理,不過(在實際工程中)**千萬別再用這個實驗品**。
如下是加載函數的聲明:
~~~
GLuint loadBMP_custom(const char * imagepath);
~~~
使用方式如下:
~~~
GLuint image = loadBMP_custom("./my_texture.bmp");
~~~
接下來看看如何讀取BMP文件。
首先需要一些數據。讀取文件時將設置這些變量。
~~~
// Data read from the header of the BMP file
unsigned char header[54]; // Each BMP file begins by a 54-bytes header
unsigned int dataPos; // Position in the file where the actual data begins
unsigned int width, height;
unsigned int imageSize; // = width*height*3
// Actual RGB data
unsigned char * data;
~~~
現在正式開始打開文件。
~~~
// Open the file
FILE * file = fopen(imagepath,"rb");
if (!file) {printf("Image could not be openedn"); return 0;}
~~~
文件一開始是54字節長的文件頭,用于標識“這是不是一個BMP文件”、圖像大小、像素位等等。來讀取文件頭吧:
~~~
if ( fread(header, 1, 54, file)!=54 ){ // If not 54 bytes read : problem
printf("Not a correct BMP filen");
return false;
}
~~~
文件頭總是以“BM”開頭。實際上,如果用十六進制編輯器打開BMP文件,你會看到如下情形:

因此,得檢查一下頭兩個字節是否確為‘B’和‘M’:
~~~
if ( header[0]!='B' || header[1]!='M' ){
printf("Not a correct BMP filen");
return 0;
}
~~~
現在可以讀取文件中圖像大小、數據位置等信息了:
~~~
// Read ints from the byte array
dataPos = *(int*)&(header[0x0A]);
imageSize = *(int*)&(header[0x22]);
width = *(int*)&(header[0x12]);
height = *(int*)&(header[0x16]);
~~~
如果這些信息缺失得手動補齊:
~~~
// Some BMP files are misformatted, guess missing information
if (imageSize==0) imageSize=width*height*3; // 3 : one byte for each Red, Green and Blue component
if (dataPos==0) dataPos=54; // The BMP header is done that way
~~~
現在我們知道了圖像的大小,可以為之分配一些內存,把圖像讀進去:
~~~
// Create a buffer
data = new unsigned char [imageSize];
// Read the actual data from the file into the buffer
fread(data,1,imageSize,file);
//Everything is in memory now, the file can be closed
fclose(file);
~~~
到了真正的OpenGL部分了。創建紋理和創建頂點緩沖器差不多:創建一個紋理、綁定、填充、配置。
在glTexImage2D函數中,GL_RGB表示顏色由三個分量構成,GL_BGR則說明在內存中顏色值是如何存儲的。實際上,BMP存儲的并不是RGB,而是BGR,因此得把這個告訴OpenGL。
~~~
// Create one OpenGL texture
GLuint textureID;
glGenTextures(1, &textureID);
// "Bind" the newly created texture : all future texture functions will modify this texture
glBindTexture(GL_TEXTURE_2D, textureID);
// Give the image to OpenGL
glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, width, height, 0, GL_BGR, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
~~~
稍后再解釋最后兩行代碼。同時,得在C++代碼中使用剛寫好的函數加載一個紋理:
~~~
GLuint Texture = loadBMP_custom("uvtemplate.bmp");
~~~
另外十分重要的一點: 使用2次冪(power-of-two)的紋理!
- 優質紋理: 128*128*, 256*256, 1024*1024, 2*2…
- 劣質紋理: 127*128, 3*5, …
- 勉強可以但很怪異的紋理: 128*256
## 在OpenGL中使用紋理
先來看看片斷著色器。大部分代碼一目了然:
~~~
#version 330 core
// Interpolated values from the vertex shaders
in vec2 UV;
// Ouput data
out vec3 color;
// Values that stay constant for the whole mesh.
uniform sampler2D myTextureSampler;
void main(){
// Output color = color of the texture at the specified UV
color = texture( myTextureSampler, UV ).rgb;
}
~~~
注意三個點:
- 片斷著色器需要UV坐標。看似合情合理。
- 同時也需要一個“Sampler2D”來獲知要加載哪一個紋理(同一個著色器中可以訪問多個紋理)
- 最后一點,用texture()訪問紋理,該方法返回一個(R,G,B,A)的vec4變量。馬上就會了解到分量A。
頂點著色器也很簡單,只需把UV坐標傳給片斷著色器:
~~~
#version 330 core
// Input vertex data, different for all executions of this shader.
layout(location = 0) in vec3 vertexPosition_modelspace;
layout(location = 1) in vec2 vertexUV;
// Output data ; will be interpolated for each fragment.
out vec2 UV;
// Values that stay constant for the whole mesh.
uniform mat4 MVP;
void main(){
// Output position of the vertex, in clip space : MVP * position
gl_Position = MVP * vec4(vertexPosition_modelspace,1);
// UV of the vertex. No special space for this one.
UV = vertexUV;
}
~~~
還記得第四課中的“layout(location = 1) in vec2 vertexUV” 嗎?我們得在這兒把相同的事情再做一遍,但這次的緩沖器中放的不是(R,G,B)三元組,而是(U,V)數對。
~~~
// Two UV coordinatesfor each vertex. They were created with Blender. You'll learn shortly how to do this yourself.
static const GLfloat g_uv_buffer_data[] = {
0.000059f, 1.0f-0.000004f,
0.000103f, 1.0f-0.336048f,
0.335973f, 1.0f-0.335903f,
1.000023f, 1.0f-0.000013f,
0.667979f, 1.0f-0.335851f,
0.999958f, 1.0f-0.336064f,
0.667979f, 1.0f-0.335851f,
0.336024f, 1.0f-0.671877f,
0.667969f, 1.0f-0.671889f,
1.000023f, 1.0f-0.000013f,
0.668104f, 1.0f-0.000013f,
0.667979f, 1.0f-0.335851f,
0.000059f, 1.0f-0.000004f,
0.335973f, 1.0f-0.335903f,
0.336098f, 1.0f-0.000071f,
0.667979f, 1.0f-0.335851f,
0.335973f, 1.0f-0.335903f,
0.336024f, 1.0f-0.671877f,
1.000004f, 1.0f-0.671847f,
0.999958f, 1.0f-0.336064f,
0.667979f, 1.0f-0.335851f,
0.668104f, 1.0f-0.000013f,
0.335973f, 1.0f-0.335903f,
0.667979f, 1.0f-0.335851f,
0.335973f, 1.0f-0.335903f,
0.668104f, 1.0f-0.000013f,
0.336098f, 1.0f-0.000071f,
0.000103f, 1.0f-0.336048f,
0.000004f, 1.0f-0.671870f,
0.336024f, 1.0f-0.671877f,
0.000103f, 1.0f-0.336048f,
0.336024f, 1.0f-0.671877f,
0.335973f, 1.0f-0.335903f,
0.667969f, 1.0f-0.671889f,
1.000004f, 1.0f-0.671847f,
0.667979f, 1.0f-0.335851f
};
~~~
上述UV坐標對應于下面的模型:

其余的就很清楚了。創建一個緩沖器、綁定、填充、配置,與往常一樣繪制頂點緩沖器對象。要注意把glVertexAttribPointer的第二個參數(大小)3改成2。
結果如下:

放大后:

## 什么是濾波和mipmap?怎樣使用?
正如在上面截圖中看到的,紋理質量不是很好。這是因為在loadBMP_custom函數中,有兩行這樣寫道:
~~~
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
~~~
這意味著在片斷著色器中,texture()將直接提取位于(U,V)坐標的紋素(texel)。

有幾種方法可以改善這一狀況。
### 線性濾波(Linear filtering)
若采用線性濾波。texture()會查看周圍的紋素,然后根據UV坐標距離各紋素中心的距離來混合顏色。這就避免了前面看到的鋸齒狀邊緣。

線性濾波可以顯著改善紋理質量,應用的也很多。但若想獲得更高質量的紋理,可以采用各向異性濾波,不過速度上有些慢。
### 各向異性濾波(Anisotropic filtering)
這種方法逼近了真正片斷中的紋素區塊。例如下圖中稍稍旋轉了的紋理,各向異性濾波將沿藍色矩形框的主方向,作一定數量的采樣(即所謂的“各向異性層級”),計算出其內的顏色。

### Mipmaps
線性濾波和各向異性濾波都存在一個共同的問題。那就是如果從遠處觀察紋理,只對4個紋素作混合顯得不夠。實際上,如果3D模型位于很遠的地方,屏幕上只看得見一個片斷(像素),那計算平均值得出最終顏色值時,圖像所有的紋素都應該考慮在內。很顯然,這樣做沒有考慮性能問題。相反,人們引入了mipmap這一概念:

- 一開始,把圖像縮小到原來的1/2,接著一次做下去,直到圖像只有1×1大小(應該是圖像所有紋素的平均值)
- 繪制模型時,根據紋素大小選擇合適的mipmap。
- 可以選用nearest、linear、anisotropic等任意一種濾波方式來對mipmap采樣。
- 要想效果更好,可以對兩個mipmap采樣然后混合,得出結果。
好在這個比較簡單,OpenGL都幫我們做好了,只需一個簡單的調用:
~~~
// When MAGnifying the image (no bigger mipmap available), use LINEAR filtering
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// When MINifying the image, use a LINEAR blend of two mipmaps, each filtered LINEARLY too
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
// Generate mipmaps, by the way.
glGenerateMipmap(GL_TEXTURE_2D);
~~~
## 怎樣利用GLFW加載紋理?
我們的loadBMP_custom函數很棒,因為這是我們自己寫的!不過用專門的庫更好。GLFW就可以加載紋理(僅限TGA文件):
~~~
GLuint loadTGA_glfw(const char * imagepath){
// Create one OpenGL texture
GLuint textureID;
glGenTextures(1, &textureID);
// "Bind" the newly created texture : all future texture functions will modify this texture
glBindTexture(GL_TEXTURE_2D, textureID);
// Read the file, call glTexImage2D with the right parameters
glfwLoadTexture2D(imagepath, 0);
// Nice trilinear filtering.
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_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D);
// Return the ID of the texture we just created
return textureID;
}
~~~
## 壓縮紋理
學到這兒,你可能會想怎樣加載JPEG文件而不是TGA文件呢?
簡單的說:別這么干。還有更好的選擇。
### 創建壓縮紋理
- 下載[The Compressonator](http://developer.amd.com/tools-and-sdks/archive/legacy-cpu-gpu-tools/the-compressonator/),一款ATI工具
- 用它加載一個二次冪紋理
- 將其壓縮成DXT1、DXT3或DXT5格式(這些格式之間的差別請參考[Wikipedia](http://en.wikipedia.org/wiki/S3_Texture_Compression)):

- 生成mipmap,這樣就不用在運行時生成mipmap了。
- 導出為.DDS文件。
至此,圖像已壓縮為可被GPU直接使用的格式。在著色中隨時調用texture()均可以實時解壓。這一過程看似很慢,但由于它節省了很多內存空間,傳輸的數據量就少了。傳輸內存數據開銷很大;紋理解壓縮卻幾乎不耗時(有專門的硬件負責此事)。一般情況下,才用壓縮紋理可使性能提升20%。
### 使用壓縮紋理
來看看怎樣加載壓縮紋理。這和加載BMP的代碼很相似,只不過文件頭的結構不一樣:
~~~
GLuint loadDDS(const char * imagepath){
unsigned char header[124];
FILE *fp;
/* try to open the file */
fp = fopen(imagepath, "rb");
if (fp == NULL)
return 0;
/* verify the type of file */
char filecode[4];
fread(filecode, 1, 4, fp);
if (strncmp(filecode, "DDS ", 4) != 0) {
fclose(fp);
return 0;
}
/* get the surface desc */
fread(&header, 124, 1, fp);
unsigned int height = *(unsigned int*)&(header[8 ]);
unsigned int width = *(unsigned int*)&(header[12]);
unsigned int linearSize = *(unsigned int*)&(header[16]);
unsigned int mipMapCount = *(unsigned int*)&(header[24]);
unsigned int fourCC = *(unsigned int*)&(header[80]);
~~~
文件頭之后是真正的數據:緊接著是mipmap層級。可以一次性批量地讀取:
~~~
unsigned char * buffer;
unsigned int bufsize;
/* how big is it going to be including all mipmaps? */
bufsize = mipMapCount > 1 ? linearSize * 2 : linearSize;
buffer = (unsigned char*)malloc(bufsize * sizeof(unsigned char));
fread(buffer, 1, bufsize, fp);
/* close the file pointer */
fclose(fp);
~~~
這里要處理三種格式:DXT1、DXT3和DXT5。我們得把“fourCC”標識轉換成OpenGL能識別的值。
~~~
unsigned int components = (fourCC == FOURCC_DXT1) ? 3 : 4;
unsigned int format;
switch(fourCC)
{
case FOURCC_DXT1:
format = GL_COMPRESSED_RGBA_S3TC_DXT1_EXT;
break;
case FOURCC_DXT3:
format = GL_COMPRESSED_RGBA_S3TC_DXT3_EXT;
break;
case FOURCC_DXT5:
format = GL_COMPRESSED_RGBA_S3TC_DXT5_EXT;
break;
default:
free(buffer);
return 0;
}
~~~
像往常一樣創建紋理:
~~~
// Create one OpenGL texture
GLuint textureID;
glGenTextures(1, &textureID);
// "Bind" the newly created texture : all future texture functions will modify this texture
glBindTexture(GL_TEXTURE_2D, textureID);
~~~
現在只需逐個填充mipmap:
~~~
unsigned int blockSize = (format == GL_COMPRESSED_RGBA_S3TC_DXT1_EXT) ? 8 : 16;
unsigned int offset = 0;
/* load the mipmaps */
for (unsigned int level = 0; level < mipMapCount && (width || height); ++level)
{
unsigned int size = ((width+3)/4)*((height+3)/4)*blockSize;
glCompressedTexImage2D(GL_TEXTURE_2D, level, format, width, height,
0, size, buffer + offset);
offset += size;
width /= 2;
height /= 2;
}
free(buffer);
return textureID;
~~~
### 反轉UV坐標
DXT壓縮源自DirectX。和OpenGL相比,DirectX中的V紋理坐標是反過來的。所以使用壓縮紋理時,得用(coord.v, 1.0-coord.v)來獲取正確的紋素。這步操作何時做都可以:可以在導出腳本中做,可以在加載器中做,也可以在著色器中做……
## 總結
剛剛學習的是創建、加載以及在OpenGL中使用紋理。
總的來說,壓縮紋理體積小、加載迅速、使用便捷,應該只用壓縮紋理;主要的缺點是得用The Compressonator來轉換圖像格式。
## 練習
- 源代碼中實現了DDS加載器,但沒有做紋理坐標的改動(譯者注:指文中講述的反轉 UV坐標)。在適當的位置添加該功能,以使正方體正確顯示。
- 試試各種DDS格式。所得結果有何不同?壓縮率呢?
- 試試在The Compressonator不生成mipmap。結果如何?請給出3種方案解決這一問題。
## 參考文獻
- [Using texture compression in OpenGL](http://www.oldunreal.com/editing/s3tc/ARB_texture_compression.pdf) , Sébastien Domine, NVIDIA