狸村 Mystic Web · 狸村
目录

引言

你的程序现在已准备好渲染带纹理的 3D 网格,但当前 verticesindices 数组中的几何体还不够有趣。 在本章中,我们将扩展程序,从实际模型文件中加载顶点和索引,让图形卡真正做一些工作。

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

我们将使用 tinyobjloader 库从 OBJ 文件加载顶点和面。 它很快,而且易于集成,因为它是一个像 stb_image 一样的单文件库。 这在开发环境章节中已经提到过,应该作为本部分教程的依赖项之一。

示例网格

在本章中,我们不会启用光照,因此使用一个将光照烘焙到纹理中的示例模型会很有帮助。 找到此类模型的一个简单方法是在 Sketchfab 上寻找 3D 扫描模型。 该网站上的许多模型都以 OBJ 格式提供,并带有宽松的许可证。

对于本教程,我决定使用 nigelgoh维京房间 模型(https://web.archive.org/web/20200428202538/https://sketchfab.com/3d-models/viking-room-a49f1b8e4f5c4ecf9e1fe7d81915ad38[CC BY 4.0])。 我调整了模型的大小和方向,使其可以直接替换当前几何体:

你可以随意使用自己的模型,但要确保它只包含一种材质,并且尺寸大约为 1.5 x 1.5 x 1.5 单位。 如果它更大,那么你将需要更改视图矩阵。 将模型文件放在 shaderstextures 旁边的新 models 目录中,并将纹理图像放在 textures 目录中。

在程序中添加两个新的配置变量来定义模型和纹理路径:

constexpr uint32_t WIDTH                = 800;
constexpr uint32_t HEIGHT               = 600;
const std::string  MODEL_PATH           = "models/viking_room.obj";
const std::string  TEXTURE_PATH         = "textures/viking_room.png";

并更新 createTextureImage 以使用此路径变量:

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;
vk::raii::Buffer       vertexBuffer       = nullptr;
vk::raii::DeviceMemory vertexBufferMemory = nullptr;
vk::raii::Buffer       indexBuffer        = nullptr;
vk::raii::DeviceMemory indexBufferMemory  = nullptr;

你应该将索引的类型从 uint16_t 改为 uint32_t,因为顶点数将远多于 65535。 稍微复杂的构造 vk::IndexTypeValue<decltype(indices)::value_type>::value 会自动获取索引的类型。

commandBuffer.bindIndexBuffer(*indexBuffer, 0, vk::IndexTypeValue<decltype(indices)::value_type>::value);

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 向量中保存所有位置、法线和纹理坐标。 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_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 分量。

现在运行你的程序,你应该会看到类似以下的内容:

inverted texture coordinates

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

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

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

drawing model

所有艰苦的工作终于开始得到回报,就像这样一个演示!

当模型旋转时,你可能会注意到背面(墙壁的背面)看起来有点奇怪。 这是正常的,仅仅是因为该模型并非真正设计为从那个方向观看。

顶点去重

不幸的是,我们还没有真正利用索引缓冲的优势。 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{};

        ...

				auto [it, inserted] = uniqueVertices.insert({vertex, static_cast<uint32_t>(vertices.size())});
				if (inserted)
				{
            vertices.push_back(vertex);
				}

				indices.push_back(it->second);
    }
}

每次我们从 OBJ 文件读取一个顶点时,我们插入 { vertex, index } 对,其中索引是 vertices 数组的当前大小。 仅当顶点确实被 inserted 时,索引值才会被设置。否则,映射中该 vertex 的索引不会改变。 并且如果顶点被 inserted,我们将其存储在 vertices 中。 无论哪种情况,该顶点在 vertices 向量中的索引都会被添加到 indices 向量中。

程序现在将编译失败,因为使用像我们的 Vertex 结构体这样的用户定义类型作为哈希表的键,需要我们实现两个函数:相等性测试和哈希计算。 前者可以通过在 Vertex 结构体中重写 == 运算符来轻松实现:

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

Vertex 的哈希函数通过为 std::hash<T> 指定模板特化来实现。 哈希函数是一个复杂的主题,但 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 非常稳定。

你现在应该能够成功编译并运行你的程序。 如果你检查 vertices 的大小,你会发现它已经从 11,484 缩小到 3,566! 这意味着每个顶点平均被 ~3 个三角形重用。 这无疑为我们节省了大量 GPU 内存。

在下一章中,我们将学习一种改善纹理渲染的技术。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。