跳转到内容

OpenGL 编程/现代 OpenGL 教程加载 OBJ

来自维基教科书,开放书籍,开放世界

虽然你可以通过手动输入顶点来创建任何形状,但从专用编辑器中编辑形状要实用得多。不幸的是,OpenGL 不包含任何网格读取函数。但是,.obj 格式解析起来相当容易,大多数 3D 程序(包括 Blender)都支持导出该格式。在本教程中,我们将重点介绍如何将 Blender 中的 Suzanne 猴子加载到我们的 OpenGL 程序中。

创建 Suzanne

[编辑 | 编辑源代码]
Blender 截图

Suzanne 是 Blender 的测试模型。它有 500 个多边形,这对我们来说是一个很好的测试。

要创建它,请运行 Blender(我们将使用 2.58 版本),然后

  • 删除场景中的所有元素(右键单击它们并按x
  • 在顶部菜单中,单击添加 > 网格 > 猴子
  • 键入n 以显示变换面板,然后
    • 将位置设置为 (0, 0, 0)
    • 将旋转设置为 (90, 0, 0)
  • 在顶部菜单中,单击文件 > 导出 > Wavefront (.obj)
    • 为了保留 Blender 的方向,请仔细设置以下选项(以切换到“Y 向上”OpenGL 坐标)
      • 向前:-Z 向前
      • 向上:Y 向上
    • 勾选“三角化”,以便我们得到三角形面而不是四边形面

Blender 将创建两个文件,suzanne.obj 和 suzanne.mtl

  • .obj 文件包含网格:顶点和面
  • .mtl 文件包含有关材质的信息(材质模板库)

现在我们只加载网格。

文件格式

[编辑 | 编辑源代码]

使用文本编辑器检查 .obj 文件。我们看到该格式非常简单

  • 按行结构化
  • #开头的行是注释
  • o 引入一个新的对象
  • v 引入一个顶点
  • vn 引入一个法线
  • f 引入一个面,使用顶点索引,从 1 开始

我们需要填充几个 C 数组

  • 顶点
  • 元素
  • 法线(用于光照计算)

该格式还具有其他功能,但现在我们将忽略它们。

这是一个简单的实现,它适用于我们的对象。

我们的解析器将是有限的(不支持多个对象、替代顶点格式、多边形等),但对于我们的直接需求来说已经足够了。

void load_obj(const char* filename, vector<glm::vec4> &vertices, vector<glm::vec3> &normals, vector<GLushort> &elements)
{
    ifstream in(filename, ios::in);
    if (!in)
    {
        cerr << "Cannot open " << filename << endl; exit(1);
    }

    string line;
    while (getline(in, line))
    {
        if (line.substr(0,2) == "v ")
        {
            istringstream s(line.substr(2));
            glm::vec4 v; s >> v.x; s >> v.y; s >> v.z; v.w = 1.0f;
            vertices.push_back(v);
        }
        else if (line.substr(0,2) == "f ")
        {
            istringstream s(line.substr(2));
            GLushort a,b,c;
            s >> a; s >> b; s >> c;
            a--; b--; c--;
           elements.push_back(a); elements.push_back(b); elements.push_back(c);
        }
        /* anything else is ignored */
    }

    normals.resize(vertices.size(), glm::vec3(0.0, 0.0, 0.0));
    for (int i = 0; i < elements.size(); i+=3)
    {
        GLushort ia = elements[i];
        GLushort ib = elements[i+1];
        GLushort ic = elements[i+2];
        glm::vec3 normal = glm::normalize(glm::cross(
        glm::vec3(vertices[ib]) - glm::vec3(vertices[ia]),
        glm::vec3(vertices[ic]) - glm::vec3(vertices[ia])));
        normals[ia] = normals[ib] = normals[ic] = normal;
    }
}

我们使用 C++ 向量来简化内存管理。我们通过引用传递参数,主要是因为访问指向向量的指针的语法变得很糟糕((*elements)[i]

我们可以这样加载 .obj 文件

  vector<glm::vec4> suzanne_vertices;
  vector<glm::vec3> suzanne_normals;
  vector<GLushort> suzanne_elements;
  [...]
  load_obj("suzanne.obj", suzanne_vertices, suzanne_normals, suzanne_elements);

并使用以下方法将其传递给 OpenGL

  glEnableVertexAttribArray(attribute_v_coord);
  // Describe our vertices array to OpenGL (it can't guess its format automatically)
  glBindBuffer(GL_ARRAY_BUFFER, vbo_mesh_vertices);
  glVertexAttribPointer(
    attribute_v_coord,  // attribute
    4,                  // number of elements per vertex, here (x,y,z,w)
    GL_FLOAT,           // the type of each element
    GL_FALSE,           // take our values as-is
    0,                  // no extra data between each position
    0                   // offset of first element
  );

  glEnableVertexAttribArray(attribute_v_normal);
  glBindBuffer(GL_ARRAY_BUFFER, vbo_mesh_normals);
  glVertexAttribPointer(
    attribute_v_normal, // attribute
    3,                  // number of elements per vertex, here (x,y,z)
    GL_FLOAT,           // the type of each element
    GL_FALSE,           // take our values as-is
    0,                  // no extra data between each position
    0                   // offset of first element
  );

  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo_mesh_elements);
  int size;  glGetBufferParameteriv(GL_ELEMENT_ARRAY_BUFFER, GL_BUFFER_SIZE, &size);  
  glDrawElements(GL_TRIANGLES, size/sizeof(GLushort), GL_UNSIGNED_SHORT, 0);
Suzanne,现在在我们的应用程序中

最后,我们使用 Y 向上坐标系和面向 Suzanne 的相机相应地调整我们的视图

  glm::mat4 view = glm::lookAt(
    glm::vec3(0.0,  2.0, 4.0),   // eye
    glm::vec3(0.0,  0.0, 0.0),   // direction
    glm::vec3(0.0,  1.0, 0.0));  // up
  glm::mat4 projection = glm::perspective(45.0f, 1.0f*screen_width/screen_height, 0.1f, 100.0f);

我作弊了一点,并实现了一个 Gouraud 光照模型。我们稍后会介绍这个模型。

平面着色 - 复制顶点和法线

[编辑 | 编辑源代码]
Suzanne 使用平面着色

正如我们在 纹理教程 中讨论的,有时同一个顶点将获得不同的值,具体取决于使用哪个面。如果我们不想共享法线(并且在计算顶点法线时选择一个任意面,就像我们在上面所做的那样),就会出现这种情况。在这种情况下,我们需要在每次使用不同法线时复制顶点,然后重新创建元素数组。这将占用更多加载时间,但从长远来看,OpenGL 处理的速度会更快。发送到 OpenGL 的顶点越少越好。或者,如前所述,在本示例中,我们只会在顶点出现时复制它们;接下来,我们可以不用元素数组进行操作。

  for (int i = 0; i < elements.size(); i++) {
    vertices.push_back(shared_vertices[elements[i]]);
    if ((i % 3) == 2) {
      GLushort ia = elements[i-2];
      GLushort ib = elements[i-1];
      GLushort ic = elements[i];
      glm::vec3 normal = glm::normalize(glm::cross(
        shared_vertices[ic] - shared_vertices[ia],
	shared_vertices[ib] - shared_vertices[ia]));
      for (int n = 0; n < 3; n++)
	normals.push_back(normal);
    }
  }
  glDrawArrays(GL_TRIANGLES, 0, suzanne_vertices.size());

使用此设置,我们可以实现平面着色:在片段着色器中,变化变量实际上不会在顶点之间变化,因为每个三角形的 3 个顶点的法线将相同。

平均法线

[编辑 | 编辑源代码]

我们的算法有效,但是如果两个面引用同一个向量,则最后一个面将覆盖该顶点的法线。这意味着对象的外观可能根据面的顺序而有很大不同。

为了解决这个问题,我们可以对两个面的法线求平均。要对两个向量求平均,您需要取第一个向量的一半加上第二个向量的一半。这里我们使用nb_seen 来存储向量系数,因此我们可以随时对新的向量求平均,而无需存储完整的向量列表

法线平均
  mesh->normals.resize(mesh->vertices.size(), glm::vec3(0.0, 0.0, 0.0));
  nb_seen.resize(mesh->vertices.size(), 0);
  for (int i = 0; i < mesh->elements.size(); i+=3) {
    GLushort ia = mesh->elements[i];
    GLushort ib = mesh->elements[i+1];
    GLushort ic = mesh->elements[i+2];
    glm::vec3 normal = glm::normalize(glm::cross(
      glm::vec3(mesh->vertices[ib]) - glm::vec3(mesh->vertices[ia]),
      glm::vec3(mesh->vertices[ic]) - glm::vec3(mesh->vertices[ia])));

    int v[3];  v[0] = ia;  v[1] = ib;  v[2] = ic;
    for (int j = 0; j < 3; j++) {
      GLushort cur_v = v[j];
      nb_seen[cur_v]++;
      if (nb_seen[cur_v] == 1) {
	mesh->normals[cur_v] = normal;
      } else {
	// average
	mesh->normals[cur_v].x = mesh->normals[cur_v].x * (1.0 - 1.0/nb_seen[cur_v]) + normal.x * 1.0/nb_seen[cur_v];
	mesh->normals[cur_v].y = mesh->normals[cur_v].y * (1.0 - 1.0/nb_seen[cur_v]) + normal.y * 1.0/nb_seen[cur_v];
	mesh->normals[cur_v].z = mesh->normals[cur_v].z * (1.0 - 1.0/nb_seen[cur_v]) + normal.z * 1.0/nb_seen[cur_v];
	mesh->normals[cur_v] = glm::normalize(mesh->normals[cur_v]);
      }
    }
  }

预先计算的法线

[编辑 | 编辑源代码]

TODO:改进解析器以支持 .obj 法线

Obj 格式支持预先计算的法线。有趣的是,它们是在面中指定的,因此,如果一个顶点出现在多个面中,它可能会获得不同的法线,这意味着我们必须使用上面讨论的顶点复制技术。

例如,Suzanne 的基本导出引用了顶点 #1,它具有两个不同的法线 #1 和 #7

v 0.437500 0.164063 0.765625
...
vn 0.664993 -0.200752 0.719363
...
f 47//1 1//1 3//1
...
f 1//7 11//7 9//7
f 1//7 9//7 3//7

相比之下,MD2/MD3 格式(在 Quake 等游戏中使用)也包含预先计算的法线,但它们附加到顶点,而不是面。

另请参阅

[编辑 | 编辑源代码]
  • TooL:OpenGL OBJ 加载器,在 GNU GPL 下发布(但 OpenGL 1.x)

< OpenGL 编程

浏览并下载 完整代码
华夏公益教科书