OpenGL 编程/现代 OpenGL 教程加载 OBJ
虽然你可以通过手动输入顶点来创建任何形状,但从专用编辑器中编辑形状要实用得多。不幸的是,OpenGL 不包含任何网格读取函数。但是,.obj 格式解析起来相当容易,大多数 3D 程序(包括 Blender)都支持导出该格式。在本教程中,我们将重点介绍如何将 Blender 中的 Suzanne 猴子加载到我们的 OpenGL 程序中。
Suzanne 是 Blender 的测试模型。它有 500 个多边形,这对我们来说是一个很好的测试。
要创建它,请运行 Blender(我们将使用 2.58 版本),然后
- 删除场景中的所有元素(右键单击它们并按x)
- 在顶部菜单中,单击添加 > 网格 > 猴子
- 键入n 以显示变换面板,然后
- 将位置设置为 (0, 0, 0)
- 将旋转设置为 (90, 0, 0)
- 在顶部菜单中,单击文件 > 导出 > Wavefront (.obj)
- 为了保留 Blender 的方向,请仔细设置以下选项(以切换到“Y 向上”OpenGL 坐标)
- 向前:-Z 向前
- 向上:Y 向上
- 勾选“三角化”,以便我们得到三角形面而不是四边形面
- 为了保留 Blender 的方向,请仔细设置以下选项(以切换到“Y 向上”OpenGL 坐标)
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);
最后,我们使用 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 光照模型。我们稍后会介绍这个模型。
正如我们在 纹理教程 中讨论的,有时同一个顶点将获得不同的值,具体取决于使用哪个面。如果我们不想共享法线(并且在计算顶点法线时选择一个任意面,就像我们在上面所做的那样),就会出现这种情况。在这种情况下,我们需要在每次使用不同法线时复制顶点,然后重新创建元素数组。这将占用更多加载时间,但从长远来看,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)