第三章 模型加载
3.1 Assimp
1、Assimp能够导入多种模型文件格式,将所有的模型数据加载至Assimp的通用数据结构中,我们就能够从Assimp的数据结构中提取我们所需的所有数据了。
2、Assimp会将整个模型加载进一个场景对象,场景对象中包含一系列的节点,每个节点包含了场景对象中所储存数据的索引,每个节点都可以有任意数量的子节点。(图中左半边)
-
场景的根节点含子节点,子节点中有一系列指向场景对象中
mMeshes
数组中储存的网格数据的索引。 -
注意
:
Scene
下的
mMeshes
数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引
3.2 网格
3.2.1 网格类
1、通过Assimp加载模型数据到程序中后,需要将数据转换为OpenGL能够理解的格式。因此定义一个网格类,将模型数据读取到网格对象中,网格(Mesh)代表的是单个的可绘制实体。
2、一个网格应该需要一系列的顶点,每个顶点包含位置向量、法向量和纹理坐标向量;一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图)。
struct Vertex
{
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
struct Texture
{
unsigned int id;
std::string type;//diffuse or specular
std::string path;
};
3、在构造函数中,将所有数据赋予了网格,在
setupMesh
函数中初始化缓冲,最终使用
Draw
函数来绘制网格。注意将着色器传入了Draw函数中,可以让在绘制之前设置一些
uniform
(像是链接采样器到纹理单元)。
class Mesh
{
public:
/* 网格数据 */
std::vector<Vertex> vertices;
std::vector<unsigned int>indices;//索引绘制的索引,EBO绘制时需要
std::vector<Texture> textures;
Mesh(std::vector<Vertex> vertices,std::vector<unsigned int>indices,std::vector<Texture> textures);//构造函数
void Draw(Shader* shader);//设置unform变量(如链接采样器到纹理单元),绘制网格
private:
unsigned int VAO, VBO, EBO;//缓冲对象
void SetupMesh();//初始化缓冲
};
Mesh::Mesh(std::vector<Vertex> vertices, std::vector<unsigned int> indices, std::vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
SetupMesh();
}
3.2.2 初始化缓冲
1、现在有一大列的网格数据用于渲染,因此必须配置正确的缓冲,并通过顶点属性指针定义顶点着色器的布局(
layout
)。
void Mesh::SetupMesh()
{
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
glGenBuffers(1, &EBO);
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);//解绑VAO,回归到0
}
2、结构体的内存布局是连续的,如果将结构体作为数据数组使用,它会以顺序排列结构体的变量,直接转换为在数组缓冲中所需要的
float
数组。
-
sizeof(Vertex)
运算可以用来计算它的字节大小,是32字节的(8个float * 每个4字节)。 -
offsetof(s, m)
预处理指令,s是结构体,m是结构体中变量。这个宏会返回那个变量距结构体头部的字节偏移量,正好用在
glVertexAttribPointer
函数中。
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];
3.2.3 渲染
先绑定相应的纹理,根据纹理贴图的类型,设置不同的
uniform
变量。然后通过
glDrawElements
函数渲染这个网格。
void Mesh::Draw(Shader * shader)
{
for (unsigned int i = 0; i < textures.size(); i++)
{
if (textures[i].type == "texture_diffuse")
{
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
shader->setInt("material.diffuse", 0);
}
else if (textures[i].type == "texture_specular")
{
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
shader->setInt("material.specular", 1);
}
}
shader->setFloat("material.shiness", 32.0f);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);//解绑VAO
glActiveTexture(GL_TEXTURE0);//解绑纹理单元
}
3.3 模型
3.3.1 模型类
使用模型类对象来加载模型,并将它转换到上一节中创建的
Mesh
对象中。
class Model
{
public:
std::vector<Mesh> meshes;
std::string directory;//模型目录
Model(std::string const &path);
void Draw(Shader* shader);
private:
std::vector<Texture> textures_loaded;//已经加载过的纹理
void loadModel(std::string const &path);//通过Assimp库将场景导入
void processNode(aiNode* node, const aiScene* scene);//处理场景里的节点
Mesh processMesh(aiMesh* mesh, const aiScene* scene); //通过节点里的索引,取出网格信息
std::vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, std::string typeName);//取出纹理信息
unsigned int TextureFromFile(const char *path);//从文件中读取图片,作为纹理
};
在构造函数中,通过模型路径将文件导入,模型文件置于Debug文件夹下。
Model::Model(std::string const & path)
{
loadModel(path);
}
//路径生成,定义模型对象
std::string exePath = argv[0];
Model model(exePath.substr(0, exePath.find_last_of("\\")) + "\\model\\nanosuit.obj");
Draw
函数遍历了所有网格,并调用它们各自的Draw函数。
void Model::Draw(Shader * shader)
{
for (unsigned int i = 0; i < meshes.size(); i++)
{
meshes[i].Draw(shader);
}
}
3.3.2 导入模型
1、包含头文件
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
2、先声明了
Assimp
命名空间内的
Importer
,之后调用了它的
ReadFile
函数。加载模型至
scene
的数据结构中。
scene
是
Assimp
数据接口的根对象,可以访问模型中所有的数据。
void Model::loadModel(std::string const & path)
{
Assimp::Importer importer;//声明输入对象
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);//将场景读入,将图元变成三角形,翻转uv
//检查场景或根节点是否为空,场景是否不完全
if (!scene || !scene->mRootNode || scene->mFlags&AI_SCENE_FLAGS_INCOMPLETE)
{
std::cout << "error::assimp::" << importer.GetErrorString() << std::endl;
}
//获取资源文件目录
directory = path.substr(0, path.find_last_of("\\"));
processNode(scene->mRootNode, scene);
}
-
ReadFile
函数的第二个参数是后期处理的选项,对导入的数据做一些额外的操作。
aiProcess_Triangulate
将模型所有的图元形状变换为三角形;
aiProcess_FlipUVs
将翻转y轴的纹理坐标(在OpenGL中大部分的图像的y轴都是反的)。 - 加载了模型之后,检查场景和其根节点是否为null,并且检查是否产生了数据不完整的标记(flag)。
3.3.3 处理节点
1、加载完模型后,先处理场景中的节点,即场景结构图中的左半边。将根节点传入了递归的
processNode
函数,先处理当前节点,再递归处理该节点所有的子节点。
2、每个节点包含了一系列的网格索引,每个索引指向场景对象中的那个特定网格。因此,处理节点的目的就是获取这些网格索引,获取每个网格,处理每个网格。
void Model::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));
}
//完成当前节点搜索后,继续搜索子节点
for (unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}
3、首先检查每个节点的网格索引,并索引场景的
mMeshes
数组来获取对应的网格。返回的网格
aiMesh* mesh
将会传递到
processMesh
函数中,它会返回一个
Mesh
对象,我们可以将它存储在
Vector<Mesh>meshes
。
4、使用节点的目的是将网格之间定义一个父子关系。这种父子网格系统的一个使用案例是,当你想位移一个汽车的网格时,你可以保证它的所有子网格(比如引擎网格、方向盘网格、轮胎网格)都会随着一起位移。
3.3.4 处理网格
1、访问网格
aiMesh* mesh
的相关属性,分别获取顶点数据,网格索引和材质数据。这些数据将会储存在三个
vector
当中,利用它们构建一个Mesh对象,并将它们储存到
Vector<Mesh>meshes
。
Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> 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);
}
2、获取顶点数据
//每个节点索引了一个网格,每个网格代表着不同的组件,如手臂,身体等
//每个网格的顶点索引,指向了顶点数据
for (unsigned int i = 0; i < mesh->mNumVertices; i++)
{
Vertex tempVertex;
//顶点
tempVertex.Position.x = mesh->mVertices[i].x;
tempVertex.Position.y = mesh->mVertices[i].y;
tempVertex.Position.z = mesh->mVertices[i].z;
//法线
if (mesh->HasNormals())
{
tempVertex.Normal.x = mesh->mNormals[i].x;
tempVertex.Normal.y = mesh->mNormals[i].y;
tempVertex.Normal.z = mesh->mNormals[i].z;
}
//纹理坐标,一个顶点可以有8组纹理坐标,但一般只使用第一组
if (mesh->mTextureCoords[0])
{
tempVertex.TexCoords.x = mesh->mTextureCoords[0][i].x;
tempVertex.TexCoords.y = mesh->mTextureCoords[0][i].y;
}
else
{
tempVertex.TexCoords = glm::vec2(0, 0);
}
vertices.push_back(tempVertex);
}
3、获取索引绘制的索引信息。
Assimp
的接口定义了每个网格都有一个面(
Face
)数组,面数组定义了在每个图元中,应该绘制哪个顶点,并以什么顺序绘制。所以遍历所有的面,并储存了面的索引到
indices
这个
vector
中就可以了。
//每个网格的面索引,指向了面数组,其中包含了绘制索引
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]);
}
}
4、获取材质数据。一个网格包含了一个指向材质对象的索引,位于
mMaterialIndex
属性中。通过索引场景的
mMaterials
数组获取真正的材质,首先从
mMaterials
数组中获取
aiMaterial
对象,然后加载网格的漫反射和/或镜面光贴图。
if(mesh->mMaterialIndex >= 0)
{
aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector<Texture> specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}
3.3.5 加载纹理贴图
1、材质对象
material
中的每种纹理类型都对应了一个纹理位置数组,使用
loadMaterialTextures
函数遍历了给定纹理类型的纹理位置,加载图像并生成了纹理,将信息储存在了一个
Texture
结构体中。不同的纹理类型都以
aiTextureType_
为前缀。
2、通过
GetTextureCount
函数检查储存在材质中纹理的数量,使用
GetTexture
获取每个纹理的文件位置,储存在一个
aiString
中。使用
TextureFromFile
函数,用
stb_image.h
加载一个纹理并返回该纹理的
ID
。
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> 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;
}
2、由于大多数场景都会在多个网格中重用部分纹理,因此将所有加载过的纹理全局储存,每当想加载一个纹理的时候,首先去检查它有没有被加载过。如果有的话,会直接使用那个纹理。
std::vector<Texture> Model::loadMaterialTextures(aiMaterial * mat, aiTextureType type, std::string typeName)
{
std::vector<Texture> texture;
//计算在材质对象中,某个纹理类型的数量
for (unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString path;
mat->GetTexture(type, i, &path);//获取纹理的文件位置
//检查纹理是否被加载过,如果加载过就直接读取
bool skip = false;
for (unsigned int i = 0; i < textures_loaded.size(); i++)
{
if (strcmp(textures_loaded[i].path.data(), path.C_Str()) == 0)
{
texture.push_back(textures_loaded[i]);
skip = true;
break;
}
}
//如果纹理没被加载过,就从文件位置中加载纹理
if (!skip)
{
Texture tempTex;
tempTex.id = TextureFromFile(path.C_Str());//利用库读取图片,利用图片生成纹理,返回纹理id
tempTex.type = typeName;
tempTex.path = path.C_Str();
textures_loaded.push_back(tempTex);
texture.push_back(tempTex);
}
}
return texture;
}
3、利用文件位置读取图像,生成纹理的函数之前已经实现过
unsigned int Model::TextureFromFile(const char * path)
{
unsigned int texture_id;
glGenTextures(1, &texture_id);
glBindTexture(GL_TEXTURE_2D, texture_id);
stbi_set_flip_vertically_on_load(true);
std::string filename = this->directory + "\\" + path;
int width, height, nrComponents;
unsigned char* data = stbi_load(filename.c_str(), &height, &width, &nrComponents, 0);
if (data)
{
GLenum format;
if (nrComponents == 1) format = GL_RED;
else if (nrComponents == 3) format = GL_RGB;
else if (nrComponents == 4) format = GL_RGBA;
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
}
else
{
std::cout << "Texture failed to load at path: " << filename << std::endl;
}
return texture_id;
}