完整代码见:zaizai77/Cherno-OpenGL: OpenGL 小白学习之路
Assimp
3D建模工具如Blender、3DS Max在导出模型文件时,会自动生成所有的顶点坐标、顶点法线和纹理坐标。
.obj 格式只包含了模型数据和材质信息(颜色、贴图等)
Assimp是一个开源的模型导入库,支持数十种不同的3D模型格式。
使用Assimp导入模型时,通常会把模型加载入一个场景(Scene)对象,它包含了导入的模型/场景内的所有数据。Assimp会把场景载入为一系列的节点,每个节点包含了场景对象中存储数据的索引。
- 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中。Scene对象也包含了场景根节点的引用。
- 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引。Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引。(真正的Mesh数据存在Scene节点中,Scene节点本事在层级面板中不可见,根节点和子节点就像是层级面板中的父对象和子对象,他们不存储数据,只存储索引)
- 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
- 一个网格包含了多个面。Face代表的是物体的渲染图元(Primitive)(三角形、方形、点)。一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的
- 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)。
借助 Assimp 加载模型的步骤:
- 加载物体到 Scene 对象中
- 遍历所有节点,获取对应的 Mesh 对象
- 处理每个 Mesh 对象以获取渲染所需的数据
之后我们得到一系列的网格数据,我嫩会将他们包含在 Model 独享中
一个 Model 由若干个 Mesh 组成,一个 Mesh 是一个单独的形状,是 OpenGL 中绘制物体的最小单位
如果我们想要绘制一个模型,我们不需要将整个模型渲染为一个整体,只需要渲染组成模型的每个独立的网格就可以了。
Assimp 数据结构
struct aiNode{aiNode **mChildren; //子节点数组unsigned int *mMeshes; //网格数据的索引数组aiMetadata* mMetaData; //元数据数组aiString mName; //节点名unsigned int mNumChildren; //子节点数量unsigned int mNumMeshes; //网格数量aiNode *mParent; //父节点aiMatrix4x4 mTransformation; //变换矩阵
}
struct aiScene{aiAnimation** Animations; //可通过HasAnimations成员函数判断是否为0aiCamera** mCameras; //同上unsigned int mFlags;aiLight** mLights;aiMaterial** mMaterials;aiMesh** mMeshes;aiMetadata* mMetaData;aiString mName;unsigned int mNumAnimations;unsigned int mNumCameras;unsigned int mNumLights;unsigned int mNumMaterials;unsigned int mNumMeshes;unsigned int mNumTextures;aiNode* mRootNode;aiTexture **mTextures;
}
struct aiMesh{aiAnimMesh** mAnimMeshes;aiVector3D* mBitangents;aiBone** mBones;aiColor4D* mColors[AI_MAX_NUMBER_OF_COLOR_SETS];aiFaces* mFaces;unsigned int mMaterialIndex;unsigned int mMethod;aiString mName;aiVector3D* mNormals;unsigned int mNumAnimMeshes;unsigned int mNumBones;unsigned int mNumFaces;unsigned int mNumUVComponents[AI_MAX_NUMBER_OF_TEXTURECOORDS];unsigned int mNumVertices;unsigned int mPrimitiveTypes;aiVector3D* mTangents;aiVector3D* mTextureCoords[AI_MAX_NUMBER_OF_TEXTURECOORDS];aiString mTextureCoordsNames[AI_MAX_NUMBER_OF_TEXTURECOORDS];aiVector3D* mVertices;
}
网格
通过使用Assimp,我们可以加载不同的模型到程序中,但是载入后它们都被储存为Assimp的数据结构。我们需要将这些数据转换成 OpenGL 可以理解的格式
网格(Mesh)代表的是单个可绘制实体,它包含了顶点数据,索引和纹理
需要的属性:
- 顶点需要位置向量,法向量,纹理坐标
- 纹理对象需要 unsigned int 句柄,纹理类型(漫反射,高光贴图等),纹理路径
struct Vertex {glm::vec3 Position;glm::vec3 Normal;glm::vec2 TexCoords;
};struct Texture {unsigned int id;string type;string path;
};
网格类的结构:
class Mesh {public:/* 网格数据 */vector<Vertex> vertices;vector<unsigned int> indices;vector<Texture> textures;/* 函数 */Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);void Draw(Shader shader);private:/* 渲染数据 */unsigned int VAO, VBO, EBO;/* 函数 */void setupMesh();
};
在构造函数中,我们将所有必须的数据赋予了网格,我们在setupMesh函数中初始化缓冲,并最终使用Draw函数来绘制网格。注意我们将一个着色器传入了Draw函数中,将着色器传入网格类中可以让我们在绘制之前设置一些uniform
构造函数的内容非常易于理解。我们只需要使用构造函数的参数设置类的公有变量就可以了。我们在构造函数中还调用了setupMesh函数:
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{this->vertices = vertices;this->indices = indices;this->textures = textures;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);
}
C++结构体有一个很棒的特性,它们的内存布局是连续的(Sequential)。
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];
结构体的另外一个很好的用途是它的预处理指令 offsetof(Vertex,Normal),它的第一个参数是一个结构体,第二个参数是这个结构体中变量的名字。这个宏会返回那个变量距结构体头部的字节偏移量(Byte Offset)。
渲染
绘制之前需要先绑定相应的纹理,但是一开始并不知道网格有多少纹理,为了解决这个问题,我们使用命名规则,
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
...uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
...
根据这个标准,我们可以在着色器中定义任意需要数量的纹理采样器,如果一个网格真的包含了(这么多)纹理,我们也能知道它们的名字是什么。根据这个标准,我们也能在一个网格中处理任意数量的纹理,开发者也可以自由选择需要使用的数量,他只需要定义正确的采样器就可以了(虽然定义少的话会有点浪费绑定和uniform调用)。
最终的渲染代码:
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); // 在绑定之前激活相应的纹理单元// 获取纹理序号(diffuse_textureN 中的 N)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.setInt(("material." + name + number).c_str(), i);glBindTexture(GL_TEXTURE_2D, textures[i].id);}glActiveTexture(GL_TEXTURE0);// 绘制网格glBindVertexArray(VAO);glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);glBindVertexArray(0);
}
Mesh 类的完整代码:
#ifndef MESH_H
#define MESH_H#include <glad/glad.h> // holds all OpenGL type declarations#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>#include <learnopengl/shader.h>#include <string>
#include <vector>
using namespace std;#define MAX_BONE_INFLUENCE 4struct Vertex {// positionglm::vec3 Position;// normalglm::vec3 Normal;// texCoordsglm::vec2 TexCoords;// tangentglm::vec3 Tangent;// bitangentglm::vec3 Bitangent;//bone indexes which will influence this vertexint m_BoneIDs[MAX_BONE_INFLUENCE];//weights from each bonefloat m_Weights[MAX_BONE_INFLUENCE];
};struct Texture {unsigned int id;string type;string path;
};class Mesh {
public:// mesh Datavector<Vertex> vertices;vector<unsigned int> indices;vector<Texture> textures;unsigned int VAO;// constructorMesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures){this->vertices = vertices;this->indices = indices;this->textures = textures;// now that we have all the required data, set the vertex buffers and its attribute pointers.setupMesh();}// render the meshvoid Draw(Shader &shader) {// bind appropriate texturesunsigned int diffuseNr = 1;unsigned int specularNr = 1;unsigned int normalNr = 1;unsigned int heightNr = 1;for(unsigned int i = 0; i < textures.size(); i++){glActiveTexture(GL_TEXTURE0 + i); // active proper texture unit before binding// retrieve texture number (the N in diffuse_textureN)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++); // transfer unsigned int to stringelse if(name == "texture_normal")number = std::to_string(normalNr++); // transfer unsigned int to stringelse if(name == "texture_height")number = std::to_string(heightNr++); // transfer unsigned int to string// now set the sampler to the correct texture unitglUniform1i(glGetUniformLocation(shader.ID, (name + number).c_str()), i);// and finally bind the textureglBindTexture(GL_TEXTURE_2D, textures[i].id);}// draw meshglBindVertexArray(VAO);glDrawElements(GL_TRIANGLES, static_cast<unsigned int>(indices.size()), GL_UNSIGNED_INT, 0);glBindVertexArray(0);// always good practice to set everything back to defaults once configured.glActiveTexture(GL_TEXTURE0);}private:// render data unsigned int VBO, EBO;// initializes all the buffer objects/arraysvoid setupMesh(){// create buffers/arraysglGenVertexArrays(1, &VAO);glGenBuffers(1, &VBO);glGenBuffers(1, &EBO);glBindVertexArray(VAO);// load data into vertex buffersglBindBuffer(GL_ARRAY_BUFFER, VBO);// A great thing about structs is that their memory layout is sequential for all its items.// The effect is that we can simply pass a pointer to the struct and it translates perfectly to a glm::vec3/2 array which// again translates to 3/2 floats which translates to a byte array.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);// set the vertex attribute pointers// vertex PositionsglEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);// vertex normalsglEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));// vertex texture coordsglEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));// vertex tangentglEnableVertexAttribArray(3);glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Tangent));// vertex bitangentglEnableVertexAttribArray(4);glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Bitangent));// idsglEnableVertexAttribArray(5);glVertexAttribIPointer(5, 4, GL_INT, sizeof(Vertex), (void*)offsetof(Vertex, m_BoneIDs));// weightsglEnableVertexAttribArray(6);glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, m_Weights));glBindVertexArray(0);}
};
#endif
模型
创建另一个类来完整的表示一个模型。我们会使用 Assimp 来加载模型,并将它转换至多个 Mesh
对象
Model 类的结构
class Model
{public:/* 函数 */Model(char *path){loadModel(path);}void Draw(Shader shader); private:/* 模型数据 */vector<Mesh> meshes;string directory;/* 函数 */void loadModel(string path);void processNode(aiNode *node, const aiScene *scene);Mesh processMesh(aiMesh *mesh, const aiScene *scene);vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName);
};
遍历了所有网格,并调用它们各自的Draw函数。
void Draw(Shader &shader)
{for(unsigned int i = 0; i < meshes.size(); i++)meshes[i].Draw(shader);
}
导入3D模型到OpenGL
要想导入一个模型,并将它转换到我们自己的数据结构中的话,首先我们需要包含Assimp对应的头文件:
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
Importer 类用于加载模型文件:
void loadModel(string path){Assimp::Importer importer;//参数一为文件路径,参数二为后处理选项。此处意味:将所有图元转换为三角形|翻转纹理坐标以适应OpenGL设置//除此以外,还有://aiProcess_GenNormals - 生成法向量//aiProcess_SplitLargeMeshes - 分割大网格,防止超过顶点渲染限制//aiProcess_OptimizeMeshes - 合并小网格,减少Drawcallconst aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);//检查场景和根节点是否为null.//mFlags与特定宏求与,得到场景是否完全加载。这么做的目的是:位操作性能好if(!scene||scene->mFlags&AI_SCENE_FLAGS_INCOMPLETE||!scene->mRootNode){//导入期的GetErrorString()函数可得到错误信息cout<<"ERROR::ASSIMP::"<<import.GetErrorString()<<endl;return;}//剔除文件本身的名称,得到目录路径directory = path.substr(0,path.find_last_of('/')); //find_last_of:查找string最后出现的某字符的索引//由根节点开始,可以遍历到所有节点。所以首先处理根节点//processNode函数为递归函数processNode(scene->mRootNode,scene);
}
void processNode(aiNode *node, const aiScene* scene){//mNumMeshes指当前节点存储的网格数据数量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);}
}
之所以费这么多心思遍历子节点获取网格,而不是直接遍历aiScene的Mesh数组,是因为:
无论是在游戏引擎里还是在3D建模软件中,都存在类似层级面板的东西。在这里,网格之间有严格的父子关系,而节点之间的关系就体现了这一点。
如果单纯遍历Mesh数组,那网格之间的父子关系就被丢弃了。
ProcessMesh 函数用于把aiMesh对象转换为我们自己的Mesh类。实现这一步很简单,只需要访问aiMesh的所有属性,并把它们赋值给Mesh类的属性即可。
Mesh processMesh(aiMesh* mesh, const aiScene* scene){vector<Vertex> vertices;vector<Texture> textures;vector<unsigned int> indices;//处理顶点for(unsigned int i=0;i<mesh->mNumVertices;i++){Vertex vertex;glm::vec3 tmpVec;tmpVec.x = mesh->mVertices[i].x;tmpVec.y = mesh->mVertices[i].y;tmpVec.z = mesh->mVertices[i].z;vertex.Position = tmpVec;tmpVec.x = mesh->mNormals[i].x;tmpVec.y = mesh->mNormals[i].y;tmpVec.z = mesh->mNormals[i].z;vertex.Normal = tmpVec;glm::vec2 uv;//aiMesh结构体的mTexCoords可以被看作是二维数组。它的第一维是纹理的序号(Assimp允许同一个顶点上包含八个纹理的uv),第二维才是表示uv的二维向量。if(mesh->mTexCoords[0]){uv.x = mesh->mTexCoords[0][i].x;uv.y = mesh->mTexCoords[0][i].y;vertex.TexCoords = uv;}else{vertex.TexCoords = glm::vec2(0.0f,0.0f);}vertices.push_back(vertex);}//处理索引//每个网格包含了若干面,每个面包含了绘制这个面的顶点索引。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]);}}//处理材质//一个网格只能使用一个材质,如果网格没有材质,mMaterialIndex为负数//和节点-网格的关系一样,网格本身只存储材质索引,场景对象才存储真正的aiMaterialif(mesh->mMaterialIndex>=0){aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];vector<Texture> diffuseMaps = loadMaterialTextures(material,aiTextureType_DIFFUSE,"texture_diffuse");//其实这里用for循环也行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());}
}
到这里,我们Mesh类的属性就都填充完毕了。接下来,我们要结合stbi_image库来加载材质中的纹理。
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName){vector<Texture> textures;for(unsigned int i=0;i<mat->GetTextureCount(type);i++){aiString str;//这里获取到的str是纹理的文件名,而非路径mat->GetTexture(type,i,&str);bool skip = false;for(unsigned int j = 0; j < this->textures_loaded.size(); j++){//aiString.data()也可以用于获取const char*//这里匹配了当前纹理与textures_loaded数组中的内容。若发现匹配的,则直接跳过加载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;//aiString可以用C_Str()函数转化为const char*//这里的directory是模型所在的目录texture.id = TextureFromFile(str.C_Str(),this->directory); texture.type = typeName;texture.path = str;textures.push_back(texture);}}return textures;
}unsigned int TextureFromFile(const char* path, const string &directory){string filename = string(path);filename = directory + '/' + filename;unsigned int id;glGenTextures(1,&id);int width, height, channels;unsigned char* data = stbi_load(filename.c_str(), &width,&height,&channels,0);if(data){GLenum format;if(channels==1){format = GL_RED;}else if(channels==3){format = GL_RGB;}else if(channels==4){format = GL_RGBA;}glBindTexture(GL_TEXTURE_2D,id);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);stbi_image_free(data);}else{std::cout << "Texture failed to load at path: " << path << std::endl;stbi_image_free(data);}return id;
}
参考:Assimp - LearnOpenGL CN
LearnOpenGL学习笔记(七) - 模型导入 - Yoi's Home