原帖地址:http://ogldev.atspace.co.uk/www/tutorial22/tutorial22.html
前面的教程中,我们都是使用手工指定三维模型,渲染一些简单的物体,比如,正方体、四面体金字塔等等。如果要渲染复杂的物体,该物体包含很多的顶点,每个顶点除了位置,还有很多的属性,比如一张人脸,那么通过在程序中指定顶点缓冲来渲染的话,几乎是不可能的事情,因为模型太复杂了。通常在三维游戏或者一些商业三维应用中,都是艺术家通过一些专用的建模软件,比如Blender, Maya 或者 3ds Max来进行物体建模,模型完成后,然后导出一定的模型文件格式,最后游戏引擎或者别的应用程序,可以读取这些模型文件,产生顶点缓冲、索引缓冲以及一些其它的设置,从而完成复杂模型渲染。本篇教程中,我们将学习如何解析模型文件,并在我们的程序中使用。
几乎每种游戏引擎或者建模软件都有自己的模型格式,开发一个自己的解析器,来兼容大部分的模型格式,是件费力费时的工作。本篇教程中,我们使用一个第三方开源库Open Asset Import Library来导入模型文件,Assimp开源库能处理很多模型文件格式,比如D3D的x文件,静态的obj文件等等,而且Assimp库是用c++写的,很容易集成到我们的程序里。
本教程中,我们不会详细介绍Assimp库的原理,感兴趣的朋友可以去它的网站看看,里面有很多介绍,或者你也可以研究它内部的代码,看它是如何解析模型文件的,本文中,只是介绍了如何在我们的程序中通过Assimp库装入三维模型。
(注意:开始编写程序前,你要确保安装了Assimp库,可以从上面给出的链接处下载)
主要代码:
mesh.h
class Mesh
{
public:
Mesh();
~Mesh();
bool LoadMesh(const std::string& Filename);
void Render();
private:
bool InitFromScene(const aiScene* pScene, const std::string& Filename);
void InitMesh(unsigned int Index, const aiMesh* paiMesh);
bool InitMaterials(const aiScene* pScene, const std::string& Filename);
void Clear();
#define INVALID_MATERIAL 0xFFFFFFFF
struct MeshEntry {
MeshEntry();
~MeshEntry();
bool Init(const std::vector& Vertices,
const std::vector& Indices);
GLuint VB;
GLuint IB;
unsigned int NumIndices;
unsigned int MaterialIndex;
};
std::vector m_Entries;
std::vector m_Textures;
};
Mesh类是Assimp库和我们OpenGL程序的接口, 该类会通过LoadMesh函数从一个模型文件中装入数据,用来产生顶点缓冲,索引缓冲,纹理对象等等。为了渲染三维模型,我们也在该类中增加了Render函数。Mesh类的内部数据结构是和Assimp库装入模型的方式相匹配的, Assimp库用了一个aiScene对象来表示装入的模型,aiScene包含了各种各样模型数据的mesh结构。在aiScene对象中,至少会有一种mesh结构,复杂的模型中,可能包含多种mesh结构。m_Entries是一个MeshEntry类型的向量,每个MeshEntry都对应aiScene对象中的一个mesh结构,这些mesh结构包含顶点缓冲,索引缓冲,纹理索引等等。 现在我们的材质只是一个简单的纹理,因为MeshEntries之间可能会共享纹理,所以我们的Mesh类的包含一个单独的向量m_Texures, MeshEntry::MaterialIndex会指向该MeshEntry在m_Textures中对应的纹理。
mesh.cpp
bool Mesh::LoadMesh(const std::string& Filename)
{
// 释放掉以前装入的模型数据
Clear();
bool Ret = false;
Assimp::Importer Importer;
const aiScene* pScene = Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs);
if (pScene) {
Ret = InitFromScene(pScene, Filename);
}
else {
printf("Error parsing '%s': '%s'\n", Filename.c_str(), Importer.GetErrorString());
}
return Ret;
}
我们在LoadMesh函数中装入模型文件。首先,我们会创建一个Assimp::Importer类实例,并调用它的成员函数 ReadFile来装入模型文件,该函数的参数有2个,第一个是要装入模型文件的全路径名称,第二个是模型数据后处理选项 。Assimp在装入模型时候,可以进行很多有用的操作,比如,如果模型缺少法向数据,我们可以指定后处理选项让 Assimp 为Mesh自动计算法向,Assimp还可以执行一些优化操作以便改进性能,等等诸如此类的操作。我们通过下面的链接去产看所有的后处理选项, 点击这儿。
在本篇教程中,我们用了三个选项: aiProcess_Triangulate, 表示会把模型三角形话,如果模型是多面体数据,Assimp会替我们把这些多边形顶点转化为三角形mesh顶点数据,例如一个四边形可能会被转化为2个三角形。第二个选项 aiProcess_GenSmoothNormals表示,如果原始顶点没有法向数据,Assimp会为顶点产生法向数据。最后一个选项aiProcess_FlipUVsv表示,沿着y方向翻转纹理坐标,这在渲染quake模型时候是必须的[注意:我们这些后处理选项是可以通过或操作叠加的] 。模型装入成功后,我们会得到一个指向aiScene 对象的指针,该对象中会包含以aiMesh结构分类的所有模型数据。最后,我们会调用InitFromScene函数,初始化mesh对象。
mesh.cpp
bool Mesh::InitFromScene(const aiScene* pScene, const std::string& Filename)
{
m_Entries.resize(pScene->mNumMeshes);
m_Textures.resize(pScene->mNumMaterials);
//逐个初始化场景中的mesh对象
for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
const aiMesh* paiMesh = pScene->mMeshes[i];
InitMesh(i, paiMesh);
}
return InitMaterials(pScene, Filename);
}
在初始化三维渲染场景函数中,我们首先为mesh entries和texture vectors两个成员变量分配空间,它们的大小分别为aiScene对象中的mesh和材质数量。接着,我们会遍历aiScene对象中的mesh数组,来逐个初始化mesh entries成员变量。
void Mesh::InitMesh(unsigned int Index, const aiMesh* paiMesh)
{
m_Entries[Index].MaterialIndex = paiMesh->mMaterialIndex;
std::vector Vertices;
std::vector Indices;
...
在初始化mesh时候,我们首先会保存材质索引,在渲染过程中,该值用来绑定正确的纹理,接下来,我们会创建2个STL向量,用来存储顶点缓冲和索引缓冲。STL向量通常会被数据存在连续的缓冲中,而且使用方便,我们很容易把向量中的数据装入到opengl buffer中去[通过glBufferData函数]。
const aiVector3D Zero3D(0.0f, 0.0f, 0.0f);
for (unsigned int i = 0 ; i < paiMesh->mNumVertices ; i++) {
const aiVector3D* pPos = &(paiMesh->mVertices[i]);
const aiVector3D* pNormal = &(paiMesh->mNormals[i]) : &Zero3D;
const aiVector3D* pTexCoord = paiMesh->HasTextureCoords(0) ? &(paiMesh->mTextureCoords[0][i]) : &Zero3D;
Vertex v(Vector3f(pPos->x, pPos->y, pPos->z),
Vector2f(pTexCoord->x, pTexCoord->y),
Vector3f(pNormal->x, pNormal->y, pNormal->z));
Vertices.push_back(v);
}
...
在上面的代码中,我们生成顶点缓冲的数据(放在Vertices向量中)。
我们使用了aiMesh类的下列属性:
- mNumVertices - 顶点数量
- mVertices - 顶点位置向量mNumVertices
- mNormals - 顶点法向向量 mNormals
- mTextureCoords - 顶点纹理坐标向量 mTextureCoords ,注意一个顶点可能包含多个纹理坐标,所以该变量是一个二维数组。
我们把mesh的顶点,法向,纹理分别放在三个数组中,最终我们会用这三个数组构建顶点属性结构,并把顶点属性结构变量v保存到顶点缓冲变量Vertices中。注意:一些模型可能没有纹理,也不存在纹理坐标,所以我们从aiMesh对象中取纹理时候,要先调用HasTextureCoords(0)函数进行判断,另外一个顶点可能有多个纹理坐标,但在本教程中,我们只用了一个纹理坐标,所以使用paiMesh->mTextureCoords[0][i],0表示第一个纹理坐标,当不在纹理坐标时候,我们只是简单的把纹理坐标负值为0。
for (unsigned int i = 0 ; i < paiMesh->mNumFaces ; i++) {
const aiFace& Face = paiMesh->mFaces[i];
assert(Face.mNumIndices == 3);
Indices.push_back(Face.mIndices[0]);
Indices.push_back(Face.mIndices[1]);
Indices.push_back(Face.mIndices[2]);
}
...
上面的代码中,我们生成索引缓冲:aiMesh类的成员变量mNumFaces指定了每个mesh中包含多少个多边形(三角形),mFaces成员变量包含具体的索引数据。我们首先会判断每个多边形的顶点数是否为3,不为3的话会产生异常(前面装入模型时候,我们已经旋转了三角形化),接着我们会把三角形的索引数据保存到Indices向量中去。
m_Entries[Index].Init(Vertices, Indices);
}
最后,我们会用顶点和索引向量初始化MeshEntry变量。在Init函数中,会用glGenBuffer(), glBindBuffer() and glBufferData()几个函数产生顶点和索引缓冲。
bool Mesh::InitMaterials(const aiScene* pScene, const std::string& Filename)
{
for (unsigned int i = 0 ; i < pScene->mNumMaterials ; i++) {
const aiMaterial* pMaterial = pScene->mMaterials[i];
...
该函数会装入模型所用的所有纹理。aiScene对象的成员变量mNumMaterials中有材质的数量,mMaterials则是一个指向aiMaterials结构的数组。aiMaterial是一个很庞大,复杂的类,通常材质被组织成纹理栈的形式,在两个连续的纹理之间,我们需要配置blend和strength函数,blend函数用来决定2个纹理颜色如何相加操作,而strength函数决定两个纹理颜色如何相乘操作,这两个函数都是aiMaterial的一部分。在本教程中,为了和前面的光照shader一致,我们将忽略这两个函数。
m_Textures[i] = NULL;
if (pMaterial->GetTextureCount(aiTextureType_DIFFUSE) > 0) {
aiString Path;
if (pMaterial->GetTexture(aiTextureType_DIFFUSE, 0, &Path, NULL, NULL, NULL, NULL, NULL) == AI_SUCCESS) {
std::string FullPath = Dir + "/" + Path.data;
m_Textures[i] = new Texture(GL_TEXTURE_2D, FullPath.c_str());
if (!m_Textures[i]->Load()) {
printf("Error loading texture '%s'\n", FullPath.c_str());
delete m_Textures[i];
m_Textures[i] = NULL;
Ret = false;
}
}
}
...
一个材质可能包含多个纹理,并不是其中的每个纹理都有颜色,比如有的纹理表示高度图,有的纹理表示法向图,偏移图等等。我们光照模型现在只用了一个单纹理来对应所有的光照类型,所以我们只关注漫反射光材质,因此,我们会aiMaterial::GetTextureCount() 函数检测有多少个材质存在,这个函数用纹理类型作为参数,返回值该指定类型纹理的数量。该函数第一个参数即为纹理类型,第二个参数是索引,我们总是指定为0,第三个参数指定纹理文件名字,后面的5个参数是各种各样的纹理配置,比如blend因子,map模式,纹理操作等等,这些参数是可选的,在我们程序中,总是被指定为NULL。我们会把纹理文件名字和目录名字连接起来,我们会假设模型文件和纹理文件在同一个目录。
if (!m_Textures[i]) {
m_Textures[i] = new Texture(GL_TEXTURE_2D, "./white.png");
Ret = m_Textures[i]->Load();
}
}
return Ret;
}
有时候,在模型目录,纹理文件并不存在,此时渲染的结果可能是一片漆黑,所以我们会增加上面的一段代码,当在模型目录找不到纹理时候,我们会装入一个默认的纹理文件,该文件是一副白色的png图片。
void Mesh::Render()
{
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
glBindBuffer(GL_ARRAY_BUFFER, m_Entries[i].VB);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Entries[i].IB);
const unsigned int MaterialIndex = m_Entries[i].MaterialIndex;
if (MaterialIndex < m_Textures.size() && m_Textures[MaterialIndex]) {
m_Textures[MaterialIndex]->Bind(GL_TEXTURE0);
}
glDrawElements(GL_TRIANGLES, m_Entries[i].NumIndices, GL_UNSIGNED_INT, 0);
}
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
}
在前面教程中,我们都把渲染函数放在主cpp中,本篇教程代码中,我们会把Render函数分离出来。我们会遍历m_Entries,指定顶点缓冲,索引缓冲,以及材质,最后调用draw函数进行gpu渲染操作,这样我们就可以在场景中渲染多个物体了。
glut_backend.cpp
glEnable(GL_DEPTH_TEST);
最后我们在程序初始化开启深度测试,以保证前后遮挡的物体渲染正确。开启深度测试的代码在GLUTBackendRun函数中。
glutInitDisplayMode(GLUT_DOUBLE|GLUT_RGBA|GLUT_DEPTH);
我们还要初始化深度缓冲,通常深度缓冲初始化时,每个像素深度值都是1.0,和颜色缓冲相似,所有像素在深度缓冲中都有一个对应的单元。
tutorial22.cpp
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
在每帧渲染前,我们都要清除深度缓冲和颜色缓冲,如果不做这个操作,可能深度缓冲和颜色缓冲中的值还是上一帧的结果,这可能会使得渲染结果不正确。
程序执行后界面如下: