本文同时发布在我的个人博客上:https://dragon_boy.gitee.io
模型
在实际工作中,例如做游戏和作动画,我们并不会在程序中手动定义顶点、法线和纹理坐标,我们往往通过专门用来三维造型的软件来构建需要模型,例如maya,blender,3dmax。这些三维造型软件允许我们建立复杂的模型,并通过UV贴图赋予纹理,软件会自动生成顶点坐标、法线和纹理坐标,之后我们可以将所有的信息到处到一个文件中。市面上有许多主流的3d模型文件格式,比如obj,只存储模型的信息,不保留颜色信息,当然还有fbx,abc,collada等,每种格式的存储模型的信息和方式都是不同的。接下来我们会学习解析这种模型文件来导入模型。
Assimp
一个非常有名的模型导入库为Assimp(open asset import library)。Assimp可以导入许多格式的模型文件并将数据存储在Assimp生成的数据结构中,然后我们就可以从该数据结构中获取我们需要的信息。当通过Assimp导入模型后,整个模型被导入名为scene的对象中。Assimp有一系列节点,用于存储scene对象中的不同数据的索引,每个节点又有任意数量的子节点。Assimp最简单的模型结构如下:
- 所有的数据被存储在Scene对象中,例如材质和网格,同时也包含对所有根节点的引用。
- 场景的根节点包含许多子节点,并包含由点构成网格的顺序索引,索引存储在mMeshes数组中。Scene对象的mMeshes数组包含真正的Mesh对象,而节点中的mMeshes数组只包含索引。
- 一个Mesh对象包含所有渲染需要用到的数据,如顶点位置,法线向量,纹理坐标,面,材质。
- 一个网格包含有几个面,而一个面代表一个可渲染的基本几何体(三角形,四边形,点)。一个面包含构成基本几何体的点的索引。由于点和它的索引是分离的,我们可以使用EBO来绘制基本几何体。
- 最后一个网格也与一个Material对象链接,包含针对Scene对象的材质的索引。Material对象提供一些方法来重建一个对象的材质属性。
整理一下思路,我们需要做的是:首先,将模型文件的数据导入到Scene对象中,再通过递归的方式检索每个节点相关的Mesh对象,通过处理Mesh对象我们获取它的顶点数据,索引和材质属性。最后我们得到一个模型对象,其中包含我们需要的所有网格数据。
注意:在建模时,我们往往不会直接建立一个整体模型,而是分组件建立,例如一个人的模型,我们会将头和身子分开,然后创建头发,服装,小道具,最后组合起来。而这其中的每一个组件即一个网格,一个模型会包含多个网格。
编译Assimp
和我们使用的其它的第三方库一样,我们会编译官方提供的源码来保证能够符合本机的环境。官网:http://assimp.org/index.php/downloads。如有编译错误请参考官网:https://learnopengl.com/Model-Loading/Assimp。
网格类
通过Assimp,我们可以导入模型文件并将数据保存在Assimp特有的数据结构中,但我们需要将其转化为OpenGL可以使用的格式。上面我们说过,一个网格代表一个可绘制的实体,接下来我们创建自己的Mesh类。
一个网格至少包含一系列的顶点,每个顶点包含一个坐标向量,一个法线向量,一个纹理坐标向量,一个网格同时也包含绘制的索引,包含纹理信息的材质数据。
首先定义一个顶点的结构体,包含位置,法线,纹理坐标的属性:
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
同样,,我们想管理纹理数据,我们定义一个纹理的结构体:
struct Texture {
unsigned int id;
string type;
};
接下来构建我们的网格类:
class Mesh{
public:
// 网格数据
vector vertices;
vector indices;
vector textures;
//构造方法
Mesh(vector vertices, vector indices, vector textures);
// 绘制网格
void Draw(Shader shader);
private:
// 缓冲对象
unsigned int VAO, VBO, EBO;
//初始化缓冲对象
void setupMesh();
};
实现构造方法:
Mesh(vector vertices, vector indices, vector textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
setupMesh();
}
初始化
还是那么一套对于VAO,VBO,EBO的配置,我们来实现setupMesh方法:
void setupMesh()
{
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);
// 顶点位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// 顶点法线
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// 顶点纹理
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray(0);
}
注意上面的代码,由于结构体的特性,属性在结构体中的排列是有顺序的,同时可以很方便的转化为数组来方便传入数组缓冲。比如我们定义一个顶点,并赋予属性,那么顺序如下:
Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];
因为这种特性,我们可以直接传入结构体指针作为缓冲数据:
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
结构体的另一个特性是我们可以使用offset函数来获取偏移量。offset(s,m)的将结构体作为第一个参数, 第二个参数传入属性名。这个方法返回从结构体的初始位置到属性的位置计算的字节偏移量,我们在glVertexAttribPointer使用:
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
渲染
接下来我们来实现Draw方法。由于我们不清除一个网格拥有多少种贴图,以及每种贴图有多少张,所以我们可以为纹理编号。如每个漫反射纹理被定义为:texture_diffuseN,高光纹理为:texture_specularN(N的最大值由OpenGL限制)。这样我们可以很方便的在着色器中这样定义纹理:
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
接着就可以在Draw方法中为这些纹理赋予编号并激活和绑定纹理,然后绘制三角形:
void Draw(Shader shader)
{
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for(unsigned int i = 0; i < textures.size(); i++)
{
glActiveTexture(GL_TEXTURE0 + i); // 在绑定前激活纹理
// 检索纹理编号
string number;
string name = textures[i].type;
if(name == "texture_diffuse")
number = std::to_string(diffuseNr++);
else if(name == "texture_specular")
number = std::to_string(specularNr++);
shader.setFloat(("material." + name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
// 绘制网格
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
在上述的方法中我们加入了material结构体来管理纹理。
Mesh类的代码:Mesh.h。
模型类
接下来我们构建模型导入类。就像之前说的,一个模型包含若干网格,在模型类中我们会使用多个网格对象。
下面是Model类的基础定义:
class Model
{
public:
// 构造方法
Model(char *path)
{
loadModel(path);
}
// 绘制模型
void Draw(Shader shader);
private:
// 模型数据(多个网格)
vector meshes;
string directory;
// 导入模型
void loadModel(string path);
// 处理节点
void processNode(aiNode *node, const aiScene *scene);
// 处理网格对象
Mesh processMesh(aiMesh *mesh, const aiScene *scene);
// 导入材质纹理
vector loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName);
};
一个模型类包含一系列网格对象,构造方法要求文件的路径,接着通过构造方法中的loadModel方法导入模型。所有的private方法用来处理模型数据。
Draw方法很简单,绘制所有的网格:
void Draw(Shader shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
导入3D模型到OpenGL
接下来我们要使用Assimp库:
#include
#include
#include
我们来实现loadModel方法。就像之前说的,我们先将模型文件中的数据存储在Scene对象中:
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
我们创建一个Importer对象来使用读取文件的ReadFile方法。该方法的一个参数要求一个文件路径,第二个参数要求一些后置处理选项。aiProcess_Trianglulate代表我们强制将所有的基本几何体转化为三角形。aiProcess_FlipUVs代表我们要反转y轴的纹理坐标,这是由于图片的原点在左上角,而OpenGL存储纹理的坐标原点在左下角。当然,还有以下几种后置处理命令:
- aiProcess_GenNoemals:如果模型不包含法线信息将为每个顶点创建法线。
- aiProcess_SplitLargeMeshes:将大的网格分割为若干个小的网格。如果网格的顶点数超出了渲染要求的最大顶点数,可以使用这个命令。
- aiProcess_OptimizeMeshes:这个命令尝试将一些网格合并为大的网格,来减少绘制次数。
这里还有许多其它的assimp提供的后置处理命令:post-processing。
完整的loadModel函数如下:
void loadModel(string path)
{
Assimp::Importer import;
const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{
cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
return;
}
directory = path.substr(0, path.find_last_of('/'));
processNode(scene->mRootNode, scene);
}
在导入模型后我们检查场景和根节点是否为空,同时检查返回的数据是否不完整。我们接着检索文件所在的路径。如果没有问题的话我们就开始处理场景的节点,我们将根节点作为processNode的第一个参数,接着递归整个场景。
注意,每个节点包含一系列网格索引,每个索引指向场景中的一个网格对象。因此我们检索这些网格索引,检索每个网格,处理每个网格,接着处理每个节点的子节点。在处理完所有节点后结束递归。下面是processNode的实现:
void processNode(aiNode *node, const aiScene *scene)
{
// 处理所有节点的网格
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
// then do the same for each of its children
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}
我们首先检索每个节点的网格索引,然后检索相关的网格对象,并传入porcessMesh进行处理,并将结果存储到我们定义的成员变量meshes中。接着对所有的子节点做同样的操作,所有的节点被处理后结束递归。
将aiMesh对象转化为我们定义的Mesh对象
下面我们实现processMesh方法:
Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
vector vertices;
vector indices;
vector textures;
for(unsigned int i = 0; i < mesh->mNumVertices; i++)
{
Vertex vertex;
// 处理顶点位置,法线,纹理坐标
...
vertices.push_back(vertex);
}
// 处理索引
...
// 处理材质
if(mesh->mMaterialIndex >= 0)
{
...
}
return Mesh(vertices, indices, textures);
}
处理过程包含3部分,检索所有的顶点数据,检索索引,检索材质。检索数据存储在我们定义的三个变量中,我们组装为Mesh对象并作为返回值。
处理顶点数据很简单,位置通过mesh->nVertices检索,法线通过mesh->mNormals检索,纹理通过mesh->mTextureCoords检索:
顶点位置:
glm::vec3 vector;
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;
顶点法线:
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
纹理坐标,注意,Assimp允许模型至多由8种不同的纹理坐标,但我们只关心纹理集合的首元素是否含有纹理坐标:
if(mesh->mTextureCoords[0]) // 判断纹理集的首元素是否由纹理坐标
{
glm::vec2 vec;
vec.x = mesh->mTextureCoords[0][i].x;
vec.y = mesh->mTextureCoords[0][i].y;
vertex.TexCoords = vec;
}
else
vertex.TexCoords = glm::vec2(0.0f, 0.0f);
索引
接着我们处理索引。Assimp种,每个网包含一系列面,每个面代表一个基本几何体,在这里我们设置的是三角形。一个面包含绘制面的点的索引。所以我们检索所有的面然后存储每个面的索引:
for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
aiFace face = mesh->mFaces[i];
for(unsigned int j = 0; j < face.mNumIndices; j++)
indices.push_back(face.mIndices[j]);
}
材质
和节点一样,一个网格只包含对材质对象的索引。为了检索网格的材质,我们需要场景mMaterials数组的索引,这被存储在mMaterialIndex属性中。我们首先确定网格是否包含材质信息:
if(mesh->mMaterialIndex >= 0)
{
aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}
我们通过索引从场景的mMaterials数组中获取aiMaterial对象,其中包含网格的材质信息,即每种纹理的位置。接着我们从aiMaterial对象中加载纹理。我们通过loadMaterialTextures方法来实现。
loadMaterialTextures基于纹理类型检索所有的纹理位置,并检索所有的纹理文件位置,然后加载和生成纹理并保存在Vertex结构体中:
vector loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str;
textures.push_back(texture);
}
return textures;
}
我们通过GetTextureCount来获取某一种纹理的数量。接着通过GetTexture方法检索每个纹理文件的位置并存储在一个aiString类型的变量中。接着我们使用TextureFromFile(stb_image.h的方法)来加载纹理文件并返回纹理ID。
注意,这里我们假设模型的纹理文件保存在和模型的相同目录中,我们可以改变directory来获取任意路径下的纹理文件。
优化
现在仍存在一个问题,即一张纹理可能会有多个网格对象使用。所以我们这样优化一下代码:我们全局存储所有导入的纹理,接着每次导入纹理的时候,我们先检测纹理是否已经导入;如果是,我们跳过所有的导入步骤并使用对应的纹理。
我们先为Texture结构体添加文件路径属性:
struct Texture {
unsigned int id;
string type;
string path;
};
接着,定义一个变量存储所有导入了的纹理:
vector textures_loaded;
在loadMaterialTextures方法中,我们比较纹理文件的路径来判断纹理是否复用。如果是,则跳过纹理导入和生成步骤,并使用对应已生成的纹理。修改过的方法如下:
vector loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
bool skip = false;
for(unsigned int j = 0; j < textures_loaded.size(); j++)
{
if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
{
textures.push_back(textures_loaded[j]);
skip = true;
break;
}
}
if(!skip)
{
// 如果纹理文件没有导入,则导入并生成纹理
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str.C_Str();
textures.push_back(texture);
textures_loaded.push_back(texture); // 添加到已导入纹理队列
}
}
return textures;
}
最终模型类的代码参考在这里:Model.h。
最后,我们就可以使用模型类导入复杂的模型来丰富我们的场景了。请多多关注原文:https://learnopengl.com/Model-Loading/Model。