现在你的程序已经为有贴图的3D网格渲染做好准备了,但是现在的顶点和索引数组都是比较无聊的。本章我们扩展该程序来从真实的模型文件加载顶点和索引数据,以让显卡做点真正的工作。
许多图形API教程让读者自己写OBJ加载器,这样做的问题是,稍微有点意思的3D程序很快会要求本格式不支持的特性,比如骨骼动画。我们会从OBJ模型加载数据,但是我们主要关注集成网格数据,而不是如何从文件加载。
我们会使用tinyobjloader库来从OBJ文件加载顶点和面。它比较快,易于集成,因为它就是一个单个的库文件,就和stb_image一样。到:
https://github.com/syoyo/tinyobjloader
下载最新的tiny_obj_loader.h文件,放在自己的库目录中。确保下载的是master分支的,因为最新的官方发行版已经过时了。
将其添加到VS的附加包含目录中。
本章我们不会启用光照,所以用已经有光照烘焙贴图的模型是不错的选择。可以在:
找到这样的模型,且许多都是OBJ格式,许可也比较自由。这里选择Escadrone的Chalet Hippolyte Chassande Baroz模型:
https://sketchfab.com/3d-models/chalet-hippolyte-chassande-baroz-e925320e1d5744d9ae661aeff61e7aef
我调整了其大小和方向来作为当前几何体的替换:
https://vulkan-tutorial.com/resources/chalet.obj.zip
https://vulkan-tutorial.com/resources/chalet.jpg
它有五十万三角形,对我们程序来说十个不错的基准测试。可以用自己的模型,但是要保证它仅有一个材质,且它的大小大约为1.5*1.5*1.5单位。如果比这个值大,那你要改变视图矩阵了。在shaders同级目录建立models目录,把obj文件放进去。然后将贴图文件放在textures目录。
写两个新的配置变量定义模型和贴图路径:
const int WIDTH = 800;
const int HEIGHT = 600;
const std::string MODEL_PATH = "models/chalet.obj";
const std::string TEXTURE_PATH = "textures/chalet.jpg";
并修改createTextureImage方法来使用该路径:
stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(),
&texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
我们打算从模型加载顶点和索引了,所以你应该去除全局vertices和indices数组了。使用不是常量的容器替换它们作为类成员:
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
你应该将索引类型从uint16_t改为uint32_t,因为顶点数量会远超65535。记住还要修改vkCmdBindIndexBuffer参数:
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT32);
包含tinyobjloader库和STB一样,要确保定义TINYOBJLOADER_IMPLEMENTATION来包含方法体以免链接报错:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
现在我们要写一个loadModel方法,使用该库从网格中抽出数据放到vertices和indices容器中。它应该在初始化Vulkan方法中的顶点和索引缓冲创建之前调用:
loadModel();
createVertexBuffer();
createIndexBuffer();
通过调用tinyobj::LoadObj将模型导入到库的数据结构:
void loadModel() {
tinyobj::attrib_t attrib;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string warn, err;
if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, MODEL_PATH.c_str())) {
throw std::runtime_error(warn + err);
}
}
OBJ文件由点、法线、贴图坐标和面组成。面由任意数量顶点组成,每个顶点通过索引引用了一个位置,法线及贴图坐标。这使得重用整个顶点成为可能,且也能重用各个属性。
attrib容器保存所有点、法线和贴图坐标在attrib.vertices,attrib.normals和attrib.texcoords向量中。shapes容器包含所有独立对象和它们的面。每个面由一组顶点组成,每个顶点包含了点的索引,法线和贴图坐标属性。OBJ模型也能定义每个面的材质和贴图,但是这里就忽略了。
err字符串包含错误信息,warn字符串包含了加载文件时的警告信息,比如丢失材质定义等。只有LoadObj返回false的时候才是真的失败。OBJ文件的面实际上可以包含任意数量的顶点,然而我们的程序只能渲染三角形。幸运的是,LoadObj能将这些面转换为三角形,这也是默认启用的功能。
我们会将所有面结合起来,作为一个模型,所以就遍历所有形状即可:
for (const auto& shape : shapes) {
}
三角化特性已经保证每个面有三个点,所以我们现在可以直接迭代整个顶点然后直接导出到我们的vertices向量中:
for (const auto& shape : shapes) {
for (const auto& index : shape.mesh.indices) {
Vertex vertex = {};
vertices.push_back(vertex);
indices.push_back(indices.size());
}
}
为了简单起见,我们认为每个顶点是独一无二的,也就可以用自增索引了。index遍历是tinyobj::index_t 类型的,包含了vertex_index,normal_index和texcoord_index成员。我们要使用这些索引来查找真正的顶点属性:
vertex.pos = {
attrib.vertices[3 * index.vertex_index + 0],
attrib.vertices[3 * index.vertex_index + 1],
attrib.vertices[3 * index.vertex_index + 2]
};
vertex.texCoord = {
attrib.texcoords[2 * index.texcoord_index + 0],
attrib.texcoords[2 * index.texcoord_index + 1]
};
vertex.color = { 1.0f, 1.0f, 1.0f };
不幸的是,attrib.vertices数组是float类型的而不是glm::vec3这样的类型,所以你要把索引乘以3。类似地,每个入口都有两个贴图坐标组件。偏置0,1和2用于访问X,Y和Z组件,或者贴图坐标中的U和V组件。
选择Release模式运行程序,否则加载模型会很慢。你会看到这样的效果:
很好,几何看着没问题,但是贴图是怎么回事?OBJ格式认为的坐标系统垂直坐标为0的时候表示图形底部,但是我们将图像上传到Vulkan是从上到下的,所以0表示的是图像顶部。解决该问题的方法就是翻转垂直坐标:
vertex.texCoord = {
attrib.texcoords[2 * index.texcoord_index + 0],
1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
};
现在运行程序如下:
我们目前还未用索引缓冲呢。vertices向量包含许多重复的顶点数据,因为许多顶点是被包含在很多三角形中的。我们应该保存独一无二的顶点并使用索引缓冲来重用它们。有个比较直白的方法就是使用map或者unordered_map来保存这些独立顶点和各自的索引:
#include <unordered_map>
...
std::unordered_map<Vertex, uint32_t> uniqueVertices = {};
for (const auto& shape : shapes) {
for (const auto& index : shape.mesh.indices) {
Vertex vertex = {};
...
if (uniqueVertices.count(vertex) == 0) {
uniqueVertices[vertex] = static_cast<uint32_t>(vertices.size());
vertices.push_back(vertex);
}
indices.push_back(uniqueVertices[vertex]);
}
}
每次我们从OBJ文件读取一个顶点,我们就检查是否已经看到过一样位置一样贴图坐标的顶点。如果没有就添加到vertices并存储其索引到uniqueVertices容器。之后我们添加新顶点的索引到indices。如果我们看到过一样的顶点,我们就找到它在uniqueVertices中的索引并存储到indices。
现在程序还无法运行,因为我们使用用户自定义类型的结构体作为哈希表的key需要实现两个方法:相等性测试和哈希计算。前者容易实现,就是在Vertex结构体中重写==运算符:
bool operator==(const Vertex& other) const {
return pos == other.pos && color == other.color && texCoord == other.texCoord;
}
Vertex的哈希方法需要指定std::hash<T>模板明细来实现。哈希方法是个复杂的话题,推荐用下面的方法创建较好质量的哈希方法:
namespace std {
template<> struct hash<Vertex> {
size_t operator()(Vertex const& vertex) const {
return ((hash<glm::vec3>()(vertex.pos) ^
(hash<glm::vec3>()(vertex.color) << 1)) >> 1) ^
(hash<glm::vec2>()(vertex.texCoord) << 1);
}
};
}
这段代码放在Vertex之外,哈希方法使用要包含头文件:
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/hash.hpp>
哈希方法是在gtx目录定义的,意味着它是实验性的,因此要定义GLM_ENABLE_EXPERIMENTAL来使用。
现在可以成功运行程序了,如果你检查了vertices大小,会发现它从一百五十万降低到了26万。这意味着每个顶点是被大约6个三角形重用的,这极大减少了GPU内存消耗。
暂无评论内容