# 第七課:模型加載
目前為止,我們一直在硬編碼描述立方體。你一定覺得這樣做很笨拙、不方便。
本課將學習從文件中加載3D模型。和加載紋理類似,我們先寫一個小的、功能有限的加載器,接著再為大家介紹幾個比我們寫的更好的、實用的庫。
為了讓課程盡可能簡單,我們將采用簡單、常用的OBJ格式。同樣也是出于簡單原則,我們只處理每個頂點有一個UV坐標和一個法向量的OBJ文件(目前你不需要知道什么是法向量)。
## 加載OBJ模型
加載函數在common/objloader.hpp中聲明,在common/objloader.cpp中實現。函數原型如下:
~~~
bool loadOBJ(
const char * path,
std::vector & out_vertices,
std::vector & out_uvs,
std::vector & out_normals
)
~~~
我們讓loadOBJ讀取文件路徑,把數據寫入out_vertices/out_uvs/out_normals。如果出錯則返回false。std::vector是C++中的數組,可存放glm::vec3類型的數據,數組大小可任意修改,不過std::vector和數學中的向量(vector)是兩碼事。其實它只是個數組。最后提一點,符號&意思是這個函數將會直接修改這些數組。
### OBJ文件示例
OBJ文件看起來大概像這樣:
~~~
# Blender3D v249 OBJ File: untitled.blend
# www.blender3d.org
mtllib cube.mtl
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 -1.000000
v 0.999999 1.000000 1.000001
v -1.000000 1.000000 1.000000
v -1.000000 1.000000 -1.000000
vt 0.748573 0.750412
vt 0.749279 0.501284
vt 0.999110 0.501077
vt 0.999455 0.750380
vt 0.250471 0.500702
vt 0.249682 0.749677
vt 0.001085 0.750380
vt 0.001517 0.499994
vt 0.499422 0.500239
vt 0.500149 0.750166
vt 0.748355 0.998230
vt 0.500193 0.998728
vt 0.498993 0.250415
vt 0.748953 0.250920
vn 0.000000 0.000000 -1.000000
vn -1.000000 -0.000000 -0.000000
vn -0.000000 -0.000000 1.000000
vn -0.000001 0.000000 1.000000
vn 1.000000 -0.000000 0.000000
vn 1.000000 0.000000 0.000001
vn 0.000000 1.000000 -0.000000
vn -0.000000 -1.000000 0.000000
usemtl Material_ray.png
s off
f 5/1/1 1/2/1 4/3/1
f 5/1/1 4/3/1 8/4/1
f 3/5/2 7/6/2 8/7/2
f 3/5/2 8/7/2 4/8/2
f 2/9/3 6/10/3 3/5/3
f 6/10/4 7/6/4 3/5/4
f 1/2/5 5/1/5 2/9/5
f 5/1/6 6/10/6 2/9/6
f 5/1/7 8/11/7 6/10/7
f 8/11/7 7/12/7 6/10/7
f 1/2/8 2/9/8 3/13/8
f 1/2/8 3/13/8 4/14/8
~~~
因此:
-
# 是注釋標記,就像C++中的//
- usemtl和mtlib描述了模型的外觀。本課用不到。
- v代表頂點
- vt代表頂點的紋理坐標
- vn代表頂點的法向
- f代表面
v vt vn都很好理解。f比較麻煩。例如f 8/11/7 7/12/7 6/10/7:
- 8/11/7描述了三角形的第一個頂點
- 7/12/7描述了三角形的第二個頂點
- 6/10/7描述了三角形的第三個頂點
- 對于第一個頂點,8指向要用的頂點。此例中是-1.000000 1.000000 -1.000000(索引從1開始,和C++中從0開始不同)
- 11指向要用的紋理坐標。此例中是0.748355 0.998230。
- 7指向要用的法向。此例中是0.000000 1.000000 -0.000000。
我們稱這些數字為索引。若幾個頂點共用同一個坐標,索引就顯得很方便,文件中只需保存一個“V”,可以多次引用,節省了存儲空間。
不好的地方在于,我們不能讓OpenGL混用頂點、紋理和法向索引。因此本課采用的方法是創建一個標準的、未加索引的模型。等第九課時再討論索引,屆時將會介紹如何解決OpenGL的索引問題。
### 用Blender創建OBJ文件
我們寫的蹩腳加載器功能實在有限,因此在導出模型時得格外小心。下圖展示了在Blender中導出模型的情形:

### 讀取OBJ文件
OK,真正開始編碼了。需要一些臨時變量存儲.obj文件的內容。
~~~
std::vector vertexIndices, uvIndices, normalIndices;
std::vector temp_vertices;
std::vector temp_uvs;
std::vector temp_normals;
~~~
學第五課紋理立方體時,你已學會如何打開文件了:
~~~
FILE * file = fopen(path, "r");
if( file == NULL ){
printf("Impossible to open the file !n");
return false;
}
~~~
讀文件直到文件末尾:
~~~
while( 1 ){
char lineHeader[128];
// read the first word of the line
int res = fscanf(file, "%s", lineHeader);
if (res == EOF)
break; // EOF = End Of File. Quit the loop.
// else : parse lineHeader
~~~
(注意,我們假設第一行的文字長度不超過128,這樣做太愚蠢了。但既然這只是個實驗品,就湊合一下吧)
首先處理頂點:
~~~
if ( strcmp( lineHeader, "v" ) == 0 ){
glm::vec3 vertex;
fscanf(file, "%f %f %fn", &vertex.x, &vertex.y, &vertex.z );
temp_vertices.push_back(vertex);
~~~
也就是說,若第一個字是“v”,則后面一定是3個float值,于是以這3個值創建一個glm::vec3變量,將其添加到數組。
~~~
}else if ( strcmp( lineHeader, "vt" ) == 0 ){
glm::vec2 uv;
fscanf(file, "%f %fn", &uv.x, &uv.y );
temp_uvs.push_back(uv);
~~~
也就是說,如果不是“v”而是“vt”,那后面一定是2個float值,于是以這2個值創建一個glm::vec2變量,添加到數組。
以同樣的方式處理法向:
~~~
}else if ( strcmp( lineHeader, "vn" ) == 0 ){
glm::vec3 normal;
fscanf(file, "%f %f %fn", &normal.x, &normal.y, &normal.z );
temp_normals.push_back(normal);
~~~
接下來是“f”,略難一些:
~~~
}else if ( strcmp( lineHeader, "f" ) == 0 ){
std::string vertex1, vertex2, vertex3;
unsigned int vertexIndex[3], uvIndex[3], normalIndex[3];
int matches = fscanf(file, "%d/%d/%d %d/%d/%d %d/%d/%dn", &vertexIndex[0], &uvIndex[0], &normalIndex[0], &vertexIndex[1], &uvIndex[1], &normalIndex[1], &vertexIndex[2], &uvIndex[2], &normalIndex[2] );
if (matches != 9){
printf("File can't be read by our simple parser : ( Try exporting with other optionsn");
return false;
}
vertexIndices.push_back(vertexIndex[0]);
vertexIndices.push_back(vertexIndex[1]);
vertexIndices.push_back(vertexIndex[2]);
uvIndices .push_back(uvIndex[0]);
uvIndices .push_back(uvIndex[1]);
uvIndices .push_back(uvIndex[2]);
normalIndices.push_back(normalIndex[0]);
normalIndices.push_back(normalIndex[1]);
normalIndices.push_back(normalIndex[2]);
~~~
代碼與前面的類似,只不過讀取的數據多一些。
### 處理數據
我們只需改變一下數據的形式。讀取的是字符串,現在有了一組數組。這還不夠,我們得把數據組織成OpenGL要求的形式。也就是去掉索引,只保留頂點坐標數據。這步操作稱為索引。
遍歷每個三角形(每個“f”行)的每個頂點(每個 v/vt/vn):
~~~
// For each vertex of each triangle
for( unsigned int i=0; i
~~~
頂點坐標的索引存放到vertexIndices[i]:
~~~
unsigned int vertexIndex = vertexIndices[i];
~~~
因此坐標是temp_vertices[ vertexIndex-1 ](-1是因為C++的下標從0開始,而OBJ的索引從1開始,還記得嗎?):
~~~
glm::vec3 vertex = temp_vertices[ vertexIndex-1 ];
~~~
這樣就有了一個頂點坐標:
~~~
out_vertices.push_back(vertex);
~~~
UV和法向同理,任務完成!
## 使用加載的數據
到這一步,幾乎什么變化都沒發生。這次我們不再聲明一個static const GLfloat g_vertex_buffer_data[] = {…},而是創建一個頂點數組(UV和法向同理)。用正確的參數調用loadOBJ:
~~~
// Read our .obj file
std::vector vertices;
std::vector uvs;
std::vector normals; // Won't be used at the moment.
bool res = loadOBJ("cube.obj", vertices, uvs, normals);
~~~
把數組傳給OpenGL:
~~~
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), &vertices[0], GL_STATIC_DRAW);
~~~
結束了!
## 結果
不好意思,紋理不好看。我不太擅長美工。歡迎您來提供一些好的紋理。
## 其他模型格式及加載器
這個小巧的加載器應該比較適合初學,不過別在實際中使用它。參考一下[實用鏈接和工具](http://www.opengl-tutorial.org/miscellaneous/useful-tools-links/)頁面,看看有什么能用的。不過請注意,等到第九課才會真正用到這些工具。