加载模型

      +

      介绍

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

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

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

      示例网格

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

      对于本教程,我决定使用 nigelgohViking room 模型(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;
      constexpr uint64_t FenceTimeout = 100000000;
      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 数组。 用非常量容器作为类成员替换它们:

      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 多得多的顶点。 记得也要更改 vkCmdBindIndexBuffer 参数:

      commandBuffers[currentFrame]->bindIndexBuffer( **indexBuffer, 0, vk::IndexType::eUint32 );

      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 分量。

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

      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{};
      
              ...
      
              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<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 的大小,那么您会看到它已经从 1,500,000 缩小到 265,645! 这意味着每个顶点平均在约 6 个三角形中被重用。 这确实为我们节省了大量的 GPU 内存。

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