1 - 基础模型导入
箱子见多了就有点烦了,接下来我们尝试引入3D艺术家精心创作的各种各样模型。本文主要讲了模型加载库Assimp
的配置和使用。
Assimp
现今的模型文件种类很多,各不相同,这需要我们手写很多个专属导入器。幸运的是,有一个现成的库Assimp
(Open Asset Import Library的缩写)已经帮我们把这些东西给写好了。
简介
Assimp
可以导入/导出多种不同格式的模型文件,它会将所有模型数据加载到它的通用数据结构中以供我们使用。它的数据结构(简化版)如下图所示:
Assimp
将整个模型加载进一个 场景(Scene) 对象,它会包含导入的模型/场景中的所有数据(根节点,真正的Mesh对象,材质)。然后节点中的mMeshes
则存储的是Mesh对象数据的索引。一个Mesh(网格)对象包含了渲染所需要的所有相关数据,如顶点位置、法向量、纹理坐标、面和材质等。
构建Assimp
可以在这里下载最新的Assimp
源码,并用CMake-Gui
和VS2022
去构建它。构建好后,将dll
文件直接扔到程序可执行文件的同一目录中;将lib
和include
放到之前的目录里。
构建好Assimp
后,我们就能用它的API去导入模型了。
网格类
不过在导入模型之前,我们得先定义自己的Mesh
类,将读取的模型数据转换为让OpenGL能理解的格式。
数据结构
想想Mesh类应包含哪些数据。
首先,一个网格至少得有一系列的顶点数据,每个顶点包含它各自的位置、法向量和纹理坐标。顶点的结构体如下:
struct Vertex
{
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
然后,一个网格还包含了它相关的纹理数据,该结构体存储了纹理的id和它的类型(漫反射贴图/镜面光贴图等)以及它的路径:
struct Texture
{
unsigned int id;
std::string type;
std::string path;
};
类定义
知道Mesh类的数据结构后,就能定义Mesh类了:
class Mesh
{
public:
/* 网格数据 */
std::vector<Vertex> vertices;
std::vector<unsigned int> indices;
std::vector<Texture> textures;
unsigned int VAO;
/* 函数 */
Mesh(const std::vector<Vertex>& vertices_, const std::vector<unsigned int>& indices_, const std::vector<Texture>& textures_) : vertices(vertices_), indices(indices_), textures(textures_)
{
setupMesh();
}
void draw(Shader& shader);
private:
/* 渲染数据 */
unsigned int VBO, EBO;
/* 函数 */
void setupMesh();
};
初始化
接下来看看负责初始化的setupMesh()
函数,在这里进行顶点数据的配置:
void Mesh::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);
}
其中,offsetof(s, m)
是一个预处理指令,它的第一个参数是一个结构体,第二个参数是这个结构体中变量的名字。这个宏会返回那个变量距结构体头部的字节偏移量(Byte Offset)。
渲染
然后是负责渲染的Draw()
函数,它负责绑定该Mesh的纹理,并把这个Mesh给画出来:
void Mesh::draw(Shader& shader)
{
// 对应纹理的序号
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for (unsigned int i = 0; i < textures.size(); i++)
{
// 激活正确的纹理单元
glActiveTexture(GL_TEXTURE0 + i);
// 找到正确的纹理序号
std::string number;
std::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.setInt((name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
// 画出该Mesh
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, static_cast<unsigned int>(indices.size()), GL_UNSIGNED_INT, 0);
// 解绑&重置相关属性
glBindVertexArray(0);
glActiveTexture(GL_TEXTURE0);
}
其中,有关纹理的命名标准如下:
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_specular1;
......
模型类
接下来终于能用Assimp
的API来编写实际的模型加载与数据转换代码了。该Model
类将会使用Assimp
来加载模型,并将他转换为一至多个Mesh
对象。
类定义
基本的类结构如下:
class Model
{
public:
/* 模型数据 */
std::vector<Mesh> meshes;
std::string directory;
/* 函数 */
Model(const std::string& path)
{
loadModel(path);
}
void draw(Shader& shader);
private:
/* 函数 */
void loadModel(const std::string& path);
void processNode(aiNode* node, const aiScene* scene);
Mesh processMesh(aiMesh* mesh, const aiScene* scene);
std::vector<Texture> loadMaterialTextures(aiMaterial* material, aiTextureType type, std::string& typeName);
};
渲染
模型类的渲染很简单,只需调用所有Mesh的Draw函数即可:
void Model::draw(Shader& shader)
{
for (unsigned int i = 0; i < meshes.size(); ++i)
{
meshes[i].draw(shader);
}
}
模型导入
loadModel()
在导入模型之前,得先引入Assimp
的头文件:
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
然后就能进行模型导入了:
// 读取模型 & 预处理
Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
牛逼之处在于,它不需要显式指定要导入什么格式的文件,直接一行代码搞定。对于importer.ReadFile()
,它第二个参数是一些 后期处理(Post-processing)选项:
aiProcess_Triangulate
:如果模型不是全部由三角形组成,就将所有图元形状变换为三角形。aiProcess_FlipUVs
:处理的时候翻转y轴的纹理坐标。aiProcess_GenNormals
:如果模型不包含法向量,就为每个顶点创建法线。aiProcess_splitLargeMeshes
:将较大的Mesh分割成更小的SubMesh,如果渲染有最大顶点限制,只能渲染较小的Mesh,他会很有用。aiProcess_OptimizeMeshes
:将多个小Mesh拼成一个大Mesh,减少绘制调用从而优化。
其他后期处理指令等知识可以在这里找到。
接下来开始处理场景及其根节点:
// 检查场景和根节点是不是null,且数据是否完整
if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{
std::cout << "ERROR::ASSIMP::" << importer.GetErrorString() << std::endl;
return;
}
// 获取文件路径的目录路径
directory = path.substr(0, path.find_last_of('/'));
// 从根节点开始,递归处理所有节点
processNode(scene->mRootNode, scene);
以上所有内容是loadModel()
函数里的。
processNode()
对于每个节点,我们需要处理它的所有网格,包括获取索引等工作:
// 处理节点所有的网格(如果有的话)
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
其中,先检查每个节点的网格索引,并索引场景的mMeshes
数组来获取对应的网格。返回的网格将会传递到processMesh()
中,它会返回一个我们自定义的Mesh对象。
处理完本节点所有的网格后,再递归处理它的子节点:
// 递归处理子节点
for (unsigned int i = 0; i < node->mNumChildren; ++i)
{
processNode(node->mChildren[i], scene);
}
以上所有内容是processNode()
函数里的。
processMesh()
接下来尝试将它的aiMesh
对象转换成我们自定义的Mesh
对象。我们只需访问它的相关属性,将有用的存储到我们自己的对象里就好.
该函数的大致结构如下:
Mesh Model::processMesh(aiMesh* mesh, const aiScene* scene)
{
std::vector<Vertex> vertices;
std::vector<unsigned int> indices;
std::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);
}
处理网格的过程主要有三部分:获取所有的顶点数据,获取它们的网格索引,获取相关的材质数据。
首先是顶点数据:
for (unsigned int i = 0; i < mesh->mNumVertices; ++i)
{
Vertex vertex;
// 处理顶点信息(位置,法线,纹理坐标)
// 位置
glm::vec3 vector;
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;
// 法线
if (mesh->HasNormals())
{
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
}
// todo: else计算法线?
// 纹理坐标
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);
}
vertices.push_back(vertex);
}
其中,处理纹理坐标要特殊些。Assimp
允许一个模型在一个顶点上有最多8个不同的纹理坐标,我们只关心第一组纹理坐标。我们同样也想检查网格是否真的包含了纹理坐标。
然后是索引:
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]);
}
}
我们遍历模型所有的面,并存储里边三角形的顶点索引。
最后处理材质:
if (mesh->mMaterialIndex >= 0)
{
aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
std::vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
std::vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}
如果该网格存在指向材质对象的索引,就从scene
的mMaterials
数组中获取对应材质,并将其通过loadMaterialTexture()
函数来加载和生成纹理。
loadMaterialTexture()
loadMaterialTexture()
函数如下:
std::vector<Texture> Model::loadMaterialTextures(aiMaterial* material, aiTextureType type, std::string& typeName)
{
std::vector<Texture> textures;
for (unsigned int i = 0; i < material->GetTextureCount(type); ++i)
{
aiString str;
material->GetTexture(type, i, &str);
// 如果纹理已经被加载,就跳过加载(记得加入textures_loaded成员)
bool skip = false;
for (unsigned int j = 0; j < texture_loaded.size(); ++j)
{
if (std::strcmp(texture_loaded[j].path.data(), str.C_Str()) == 0)
{
textures.push_back(texture_loaded[j]);
skip = true;
break;
}
}
// 加载未加载的纹理
if (!skip)
{
Texture texture;
texture.id = TextureFromFile(str.C_Str(), this->directory);
texture.type = typeName;
texture.path = str.C_Str();
textures.push_back(texture);
textures_loaded.push_back(texture);
}
}
return textures;
}
其中,先通过GetTextureCount()
检查存储在材质中纹理的数量,这个函数需要一个纹理类型。然后用GetTexture()
获取每个纹理的文件位置,存储在aiString
中。然后使用TextureFromFile()
工具函数加载纹理并获取id。
注意:这里模型文件中纹理文件的路径是相对路径,如果碰到绝对路径的情况,记得尝试修改相关文件,让纹理使用相对路径。
TextureFromFile()
该函数用stb_image.h
加载一个纹理,并返回该纹理的ID。
unsigned int Model::TextureFromFile(const char* path, const string& directory)
{
std::string fileName(path);
fileName = directory + '/' + fileName;
unsigned int textureID;
glGenTextures(1, &textureID);
int width, height, nrComponents;
unsigned char* data = stbi_load(fileName.c_str(), &width, &height, &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;
}
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}
else
{
std::cout << "Texture failed to load at path: " << path << std::endl;
}
stbi_image_free(data);
return textureID;
}
加载模型
终于可以加载模型了,主渲染代码如下:
// ......
// 自己的着色器类
Shader objShader("./shader/shader.vert", "./shader/shader.frag");
Shader lightShader("./shader/shader.vert", "./shader/light.frag");
// 自己的模型类
Model nanoSuit("./models/nanosuit_2/nanosuit.obj");
glCheckError();
// 渲染循环
while (!glfwWindowShouldClose(window))
{
// ......
// MVP
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(0.0f, 0.0f, 0.0f));
model = glm::scale(model, glm::vec3(0.3f, 0.3f, 0.3f));
glm::mat4 view = camera.GetViewMatrix();
glm::mat4 proj = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
// 法线矩阵
glm::mat3 normalMat = glm::transpose(glm::inverse(glm::mat3(model)));
// 渲染模型
objShader.use();
//objShader.setVec3("viewPos", camera.Front);
objShader.setMat4("model", model);
objShader.setMat4("view", view);
objShader.setMat4("projection", proj);
objShader.setMat3("normalMat", normalMat);
nanoSuit.draw(objShader);
// ......
}
最终效果如图:
参考资料
- 模型 - LearnOpenGL CN (learnopengl-cn.github.io)