完整代码见: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成员函数判断是否为0
aiCamera** 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 4
struct Vertex {
// position
glm::vec3 Position;
// normal
glm::vec3 Normal;
// texCoords
glm::vec2 TexCoords;
// tangent
glm::vec3 Tangent;
// bitangent
glm::vec3 Bitangent;
//bone indexes which will influence this vertex
int m_BoneIDs[MAX_BONE_INFLUENCE];
//weights from each bone
float m_Weights[MAX_BONE_INFLUENCE];
};
struct Texture {
unsigned int id;
string type;
string path;
};
class Mesh {
public:
// mesh Data
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
unsigned int VAO;
// constructor
Mesh(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 mesh
void Draw(Shader &shader)
{
// bind appropriate textures
unsigned 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 string
else if(name == "texture_normal")
number = std::to_string(normalNr++); // transfer unsigned int to string
else if(name == "texture_height")
number = std::to_string(heightNr++); // transfer unsigned int to string
// now set the sampler to the correct texture unit
glUniform1i(glGetUniformLocation(shader.ID, (name + number).c_str()), i);
// and finally bind the texture
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
// draw mesh
glBindVertexArray(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/arrays
void setupMesh()
{
// create buffers/arrays
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
// load data into vertex buffers
glBindBuffer(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 Positions
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// vertex normals
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// vertex texture coords
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
// vertex tangent
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Tangent));
// vertex bitangent
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Bitangent));
// ids
glEnableVertexAttribArray(5);
glVertexAttribIPointer(5, 4, GL_INT, sizeof(Vertex), (void*)offsetof(Vertex, m_BoneIDs));
// weights
glEnableVertexAttribArray(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 - 合并小网格,减少Drawcall
const 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为负数
//和节点-网格的关系一样,网格本身只存储材质索引,场景对象才存储真正的aiMaterial
if(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