第七课:模型加载

目前为止,我们一直在硬编码描述立方体。你一定觉得这样做很笨拙、不方便。

本课将学习从文件中加载3D模型。和加载纹理类似,我们先写一个小的、功能有限的加载器,接着再为大家介绍几个比我们写的更好的、实用的库。

为了让课程尽可能简单,我们将采用简单、常用的OBJ格式。同样也是出于简单原则,我们只处理每个顶点有一个UV坐标和一个法向量的OBJ文件(目前你不需要知道什么是法向量)。

加载OBJ模型

加载函数在common/objloader.hpp中声明,在common/objloader.cpp中实现。函数原型如下:

  1. bool loadOBJ(
  2. const char * path,
  3. std::vector & out_vertices,
  4. std::vector & out_uvs,
  5. std::vector & out_normals
  6. )

我们让loadOBJ读取文件路径,把数据写入out_vertices/out_uvs/out_normals。如果出错则返回false。std::vector是C++中的数组,可存放glm::vec3类型的数据,数组大小可任意修改,不过std::vector和数学中的向量(vector)是两码事。其实它只是个数组。最后提一点,符号&意思是这个函数将会直接修改这些数组。

OBJ文件示例

OBJ文件看起来大概像这样:

  1. # Blender3D v249 OBJ File: untitled.blend
  2. # www.blender3d.org
  3. mtllib cube.mtl
  4. v 1.000000 -1.000000 -1.000000
  5. v 1.000000 -1.000000 1.000000
  6. v -1.000000 -1.000000 1.000000
  7. v -1.000000 -1.000000 -1.000000
  8. v 1.000000 1.000000 -1.000000
  9. v 0.999999 1.000000 1.000001
  10. v -1.000000 1.000000 1.000000
  11. v -1.000000 1.000000 -1.000000
  12. vt 0.748573 0.750412
  13. vt 0.749279 0.501284
  14. vt 0.999110 0.501077
  15. vt 0.999455 0.750380
  16. vt 0.250471 0.500702
  17. vt 0.249682 0.749677
  18. vt 0.001085 0.750380
  19. vt 0.001517 0.499994
  20. vt 0.499422 0.500239
  21. vt 0.500149 0.750166
  22. vt 0.748355 0.998230
  23. vt 0.500193 0.998728
  24. vt 0.498993 0.250415
  25. vt 0.748953 0.250920
  26. vn 0.000000 0.000000 -1.000000
  27. vn -1.000000 -0.000000 -0.000000
  28. vn -0.000000 -0.000000 1.000000
  29. vn -0.000001 0.000000 1.000000
  30. vn 1.000000 -0.000000 0.000000
  31. vn 1.000000 0.000000 0.000001
  32. vn 0.000000 1.000000 -0.000000
  33. vn -0.000000 -1.000000 0.000000
  34. usemtl Material_ray.png
  35. s off
  36. f 5/1/1 1/2/1 4/3/1
  37. f 5/1/1 4/3/1 8/4/1
  38. f 3/5/2 7/6/2 8/7/2
  39. f 3/5/2 8/7/2 4/8/2
  40. f 2/9/3 6/10/3 3/5/3
  41. f 6/10/4 7/6/4 3/5/4
  42. f 1/2/5 5/1/5 2/9/5
  43. f 5/1/6 6/10/6 2/9/6
  44. f 5/1/7 8/11/7 6/10/7
  45. f 8/11/7 7/12/7 6/10/7
  46. f 1/2/8 2/9/8 3/13/8
  47. 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中导出模型的情形:
第七课:模型加载 - 图1

读取OBJ文件

OK,真正开始编码了。需要一些临时变量存储.obj文件的内容。

  1. std::vector vertexIndices, uvIndices, normalIndices;
  2. std::vector temp_vertices;
  3. std::vector temp_uvs;
  4. std::vector temp_normals;

学第五课纹理立方体时,你已学会如何打开文件了:

  1. FILE * file = fopen(path, "r");
  2. if( file == NULL ){
  3. printf("Impossible to open the file !n");
  4. return false;
  5. }

读文件直到文件末尾:

  1. while( 1 ){
  2. char lineHeader[128];
  3. // read the first word of the line
  4. int res = fscanf(file, "%s", lineHeader);
  5. if (res == EOF)
  6. break; // EOF = End Of File. Quit the loop.
  7. // else : parse lineHeader

(注意,我们假设第一行的文字长度不超过128,这样做太愚蠢了。但既然这只是个实验品,就凑合一下吧)

首先处理顶点:

  1. if ( strcmp( lineHeader, "v" ) == 0 ){
  2. glm::vec3 vertex;
  3. fscanf(file, "%f %f %fn", &vertex.x, &vertex.y, &vertex.z );
  4. temp_vertices.push_back(vertex);

也就是说,若第一个字是“v”,则后面一定是3个float值,于是以这3个值创建一个glm::vec3变量,将其添加到数组。

  1. }else if ( strcmp( lineHeader, "vt" ) == 0 ){
  2. glm::vec2 uv;
  3. fscanf(file, "%f %fn", &uv.x, &uv.y );
  4. temp_uvs.push_back(uv);

也就是说,如果不是“v”而是“vt”,那后面一定是2个float值,于是以这2个值创建一个glm::vec2变量,添加到数组。

以同样的方式处理法向:

  1. }else if ( strcmp( lineHeader, "vn" ) == 0 ){
  2. glm::vec3 normal;
  3. fscanf(file, "%f %f %fn", &normal.x, &normal.y, &normal.z );
  4. temp_normals.push_back(normal);

接下来是“f”,略难一些:

  1. }else if ( strcmp( lineHeader, "f" ) == 0 ){
  2. std::string vertex1, vertex2, vertex3;
  3. unsigned int vertexIndex[3], uvIndex[3], normalIndex[3];
  4. 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] );
  5. if (matches != 9){
  6. printf("File can't be read by our simple parser : ( Try exporting with other optionsn");
  7. return false;
  8. }
  9. vertexIndices.push_back(vertexIndex[0]);
  10. vertexIndices.push_back(vertexIndex[1]);
  11. vertexIndices.push_back(vertexIndex[2]);
  12. uvIndices .push_back(uvIndex[0]);
  13. uvIndices .push_back(uvIndex[1]);
  14. uvIndices .push_back(uvIndex[2]);
  15. normalIndices.push_back(normalIndex[0]);
  16. normalIndices.push_back(normalIndex[1]);
  17. normalIndices.push_back(normalIndex[2]);

代码与前面的类似,只不过读取的数据多一些。

处理数据

我们只需改变一下数据的形式。读取的是字符串,现在有了一组数组。这还不够,我们得把数据组织成OpenGL要求的形式。也就是去掉索引,只保留顶点坐标数据。这步操作称为索引。

遍历每个三角形(每个“f”行)的每个顶点(每个 v/vt/vn):

  1. // For each vertex of each triangle
  2. for( unsigned int i=0; i

顶点坐标的索引存放到vertexIndices[i]:

  1. unsigned int vertexIndex = vertexIndices[i];

因此坐标是temp_vertices[ vertexIndex-1 ](-1是因为C++的下标从0开始,而OBJ的索引从1开始,还记得吗?):

  1. glm::vec3 vertex = temp_vertices[ vertexIndex-1 ];

这样就有了一个顶点坐标:

  1. out_vertices.push_back(vertex);

UV和法向同理,任务完成!

使用加载的数据

到这一步,几乎什么变化都没发生。这次我们不再声明一个static const GLfloat g_vertex_buffer_data[] = {…},而是创建一个顶点数组(UV和法向同理)。用正确的参数调用loadOBJ:

  1. // Read our .obj file
  2. std::vector vertices;
  3. std::vector uvs;
  4. std::vector normals; // Won't be used at the moment.
  5. bool res = loadOBJ("cube.obj", vertices, uvs, normals);

把数组传给OpenGL:

  1. glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), &vertices[0], GL_STATIC_DRAW);

结束了!

结果

不好意思,纹理不好看。我不太擅长美工。欢迎您来提供一些好的纹理。

其他模型格式及加载器

这个小巧的加载器应该比较适合初学,不过别在实际中使用它。参考一下实用链接和工具页面,看看有什么能用的。不过请注意,等到第九课才会真正用到这些工具。