本文档是对Vulkan Tutorial教程的翻译,非官方翻译,仅供vulkan爱好者参考学习。如果有翻译错误,请留言指出,或者联系占航(hangliebe@163.com)进行修正,感谢。建议英文好的朋友直接读原文档。

关注我的微博主页

2022-02-20 v1.0

加载模型

简介

你的程序现在已经准备好渲染有纹理的3D网格了,但是当前verticesindices数组中的几何图形还不是很有趣。在这一章中,我们将对程序进行扩展,从一个实际的模型文件中加载顶点和索引,使显卡真正做一些工作。

许多图形API教程都让读者在这样的章节中编写自己的OBJ加载器。这样做的问题是,任何远程有趣的3D应用很快就会需要这种文件格式不支持的功能,比如骨骼动画。在这一章中,我们将*从OBJ模型中加载网格数据,但我们将更多地关注于将网格数据与程序本身的整合,而不是从文件中加载的细节。

我们将使用tinyobjloader库来从OBJ文件中加载顶点和面。它的速度很快,而且很容易集成,因为它是一个像stb_image一样的单文件库。去上面链接的资源库,下载tiny_obj_loader.h文件到你的库目录下的一个文件夹。确保使用master分支的文件版本,因为最新的官方版本已经过时了。

Visual Studio

将包含tiny_obj_loader.h的目录添加到Additional Include Directories路径中。

img

Makefile

将带有tiny_obj_loader.h的目录加入到GCC的包含目录中:

VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
STB_INCLUDE_PATH = /home/user/libraries/stb
TINYOBJ_INCLUDE_PATH = /home/user/libraries/tinyobjloader

...

CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include -I$(STB_INCLUDE_PATH) -I$(TINYOBJ_INCLUDE_PATH)

Sample mesh

在这一章中,我们还不会启用灯光,所以使用一个将灯光烘烤到纹理中的样本模型会有帮助。找到这种模型的一个简单方法是在Sketchfab上寻找3D扫描。该网站上的许多模型都是以OBJ格式提供的,并有许可权。

在这个教程中,我决定使用nigelgoh (CC BY 4.0)的Viking room模型。我调整了该模型的大小和方向,将其作为当前几何形状的替代品。

你可以自由地使用你自己的模型,但要确保它只由一种材料组成,而且尺寸大约为1.5 x 1.5 x 1.5单位。如果它大于这个尺寸,那么你将不得不改变视图矩阵。把模型文件放在一个新的models目录下,放在shaderstextures旁边,并把纹理图像放在textures目录下。

在你的程序中放两个新的配置变量来定义模型和纹理的路径。

const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;

const std::string MODEL_PATH = "models/viking_room.obj";
const std::string TEXTURE_PATH = "textures/viking_room.png";

And update createTextureImage to use this path variable:

stbi_uc* pixels = stbi_load(TEXTURE_PATH.c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);

加载顶点和索引

我们现在要从模型文件中加载顶点和索引,所以你现在应该删除全局的verticesindices数组。用非const容器代替它们作为类成员:

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库相同。包括tiny_obj_loader.h文件,并确保在一个源文件中定义TINYOBJLOADER_IMPLEMENTATION以包括函数体并避免链接器错误:

#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>

我们现在要写一个loadModel函数,使用这个库将网格中的顶点数据填充到verticesindices容器中。它应该在创建顶点和索引缓冲区之前被调用:

void initVulkan() {
    ...
    loadModel();
    createVertexBuffer();
    createIndexBuffer();
    ...
}

...

void loadModel() {

}

一个模型通过调用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.verticesattrib.normalsattrib.texcoords向量中保存所有的位置、法线和纹理坐标。形状 “容器包含所有独立的对象和它们的面。每个面由一个顶点数组组成,每个顶点包含位置、法线和纹理坐标属性的索引。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_indexnormal_indextexcoord_index成员。我们需要使用这些索引来查找attrib数组中的实际顶点属性:

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。同样地,每个条目有两个纹理坐标组件。012的偏移量用来访问X、Y和Z分量,或者在纹理坐标的情况下访问U和V分量。

现在运行你的程序并启用优化功能(例如Visual Studio的Release模式和GCC的O3编译器标志)。这是必要的,因为否则加载模型会非常慢。你应该看到类似以下的情况:

img

很好,几何图形看起来很正确,但是纹理是怎么回事?OBJ格式假定了一个坐标系统,其中垂直坐标为0意味着图像的底部,然而我们已经将我们的图像以从上到下的方向上传到了Vulkan,其中0意味着图像的顶部。通过翻转纹理坐标的垂直部分来解决这个问题:

vertex.texCoord = {
    attrib.texcoords[2 * index.texcoord_index + 0],
    1.0f - attrib.texcoords[2 * index.texcoord_index + 1]
};

当你再次运行你的程序时,你现在应该看到正确的结果:img

所有的辛勤工作终于开始得到回报,有了这样的演示!

当模型旋转时,你可能会注意到后面(墙的背面)看起来有点奇怪。这是正常的,只是因为该模型的设计并不是真的要从那一面看。

顶点去重

不幸的是,我们还没有真正利用好索引缓冲区的优势。vertices向量包含很多重复的顶点数据,因为很多顶点被包含在多个三角形中。我们应该只保留唯一的顶点,并在它们出现的时候使用索引缓冲区来重用它们。实现这一目标的直接方法是使用mapunordered_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中。

现在程序将无法编译,因为使用用户定义的类型,如我们的Vertex结构作为哈希表的键,需要我们实现两个函数:平等测试和哈希计算。前者可以通过覆盖Vertex结构中的==操作符来轻松实现:

bool operator==(const Vertex& other) const {
    return pos == other.pos && color == other.color && texCoord == other.texCoord;
}

Vertex的哈希函数是通过指定std::hash的模板专业化实现的。哈希函数是一个复杂的话题,但cppreference.com推荐以下方法结合结构的字段来创建一个质量不错的哈希函数:

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结构之外。GLM类型的哈希函数需要使用以下标题来包含:

#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/hash.hpp>

哈希函数是在gtx文件夹中定义的,这意味着它在技术上仍然是GLM的一个实验性扩展。因此你需要定义GLM_ENABLE_EXPERIMENTAL来使用它。这意味着API在未来可能会随着GLM的新版本而改变,但实际上API是非常稳定的。

现在你应该能够成功地编译和运行你的程序。如果你检查一下`顶点’的大小,那么你会发现它已经从1,500,000缩减到265,645了!这意味着每个顶点都是重构的。这意味着每个顶点平均被重用在~6个三角形中。这无疑为我们节省了大量的GPU内存。

C++ code / Vertex shader / Fragment shader

留言板