1 - 基础模型导入

箱子见多了就有点烦了,接下来我们尝试引入3D艺术家精心创作的各种各样模型。本文主要讲了模型加载库Assimp的配置和使用。

Assimp

现今的模型文件种类很多,各不相同,这需要我们手写很多个专属导入器。幸运的是,有一个现成的库AssimpOpen Asset Import Library的缩写)已经帮我们把这些东西给写好了。

简介

Assimp可以导入/导出多种不同格式的模型文件,它会将所有模型数据加载到它的通用数据结构中以供我们使用。它的数据结构(简化版)如下图所示:

Assimp将整个模型加载进一个 场景(Scene) 对象,它会包含导入的模型/场景中的所有数据(根节点,真正的Mesh对象,材质)。然后节点中的mMeshes则存储的是Mesh对象数据的索引。一个Mesh(网格)对象包含了渲染所需要的所有相关数据,如顶点位置、法向量、纹理坐标、面和材质等。

构建Assimp

可以在这里下载最新的Assimp源码,并用CMake-GuiVS2022去构建它。构建好后,将dll文件直接扔到程序可执行文件的同一目录中;将libinclude放到之前的目录里。

构建好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());
}

如果该网格存在指向材质对象的索引,就从scenemMaterials数组中获取对应材质,并将其通过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)