Vulkan教程 – 23 加载模型

现在你的程序已经为有贴图的3D网格渲染做好准备了,但是现在的顶点和索引数组都是比较无聊的。本章我们扩展该程序来从真实的模型文件加载顶点和索引数据,以让显卡做点真正的工作。

许多图形API教程让读者自己写OBJ加载器,这样做的问题是,稍微有点意思的3D程序很快会要求本格式不支持的特性,比如骨骼动画。我们会从OBJ模型加载数据,但是我们主要关注集成网格数据,而不是如何从文件加载。

我们会使用tinyobjloader库来从OBJ文件加载顶点和面。它比较快,易于集成,因为它就是一个单个的库文件,就和stb_image一样。到:

https://github.com/syoyo/tinyobjloader

下载最新的tiny_obj_loader.h文件,放在自己的库目录中。确保下载的是master分支的,因为最新的官方发行版已经过时了。

将其添加到VS的附加包含目录中。

本章我们不会启用光照,所以用已经有光照烘焙贴图的模型是不错的选择。可以在:

https://sketchfab.com/

找到这样的模型,且许多都是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内存消耗。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片