迁移到现代资源格式:glTF 和 KTX2

      +

      简介

      在前面的章节中,我们一直使用 tinyobjloader 加载 Wavefront OBJ 格式的 3D 模型,并使用 stb_image 加载 PNG 和 JPEG 等常见图像格式的纹理。虽然这些库和格式简单且广泛支持,但现代图形应用程序通常受益于更高级的资源格式。

      在本章中,我们将探讨如何从以下内容迁移:

      1. Wavefront OBJ(使用 tinyobjloader 加载)迁移到 glTF(使用 tinygltf 加载)

      2. PNG 等常见图像格式(使用 stb_image 加载)迁移到 KTX2(使用 KTX 库加载)

      这种迁移提供了几个优势:

      • 更全面的模型数据:glTF 支持动画、骨骼绑定、PBR 材质等

      • GPU 优化的纹理:KTX2 支持压缩纹理格式、mipmaps 和其他 GPU 友好功能

      • 行业标准:glTF 和 KTX2 都是 Khronos 标准,专为现代图形 API 设计

      让我们深入了解迁移过程,看看如何调整 Vulkan 应用程序以使用这些现代格式。

      理解 glTF

      什么是 glTF?

      glTF(GL 传输格式)是一种免版税的规范,用于高效传输和加载 3D 场景和模型。由 Khronos Group 开发,glTF 旨在成为 "3D 的 JPEG" - 3D 内容的通用发布格式。

      glTF 的关键特性包括:

      • 紧凑的文件大小:二进制数据高效存储

      • 快速加载:最小化加载时所需的处理

      • 完整的 3D 场景表示:包括网格、材质、纹理、动画等

      • 运行时就绪:数据以可直接由 GPU 使用的格式存储

      • 可扩展:该格式可以通过新功能进行扩展

      比较 OBJ 和 glTF

      让我们比较 OBJ 格式和 glTF:

      特性 OBJ glTF

      文件格式

      基于文本

      JSON + 二进制数据(GLB 选项为单一文件)

      支持的数据

      几何、基本材质、纹理坐标

      几何、PBR 材质、动画、骨骼、场景、相机等

      材质系统

      基本(MTL 文件)

      基于物理的渲染(PBR)

      动画支持

      关键帧和骨骼动画

      坐标系

      右手系

      右手系,Y 轴向上

      行业采用

      传统标准

      实时 3D 的现代标准

      理解 KTX2

      什么是 KTX2?

      KTX2(Khronos Texture 2.0)是一种用于存储 GPU 使用优化的纹理数据的容器文件格式。它设计用于与 Vulkan、OpenGL 和 DirectX 等现代图形 API 高效协作。

      KTX2 的关键特性包括:

      • GPU 就绪格式:支持所有 GPU 纹理格式,包括压缩格式

      • Mipmap 存储:高效存储完整的 mipmap 链

      • 元数据:包括纹理属性的信息

      • 超级压缩:支持 Basis Universal 等额外压缩

      • 直接上传:数据通常可以直接上传到 GPU 而无需处理

      比较 PNG/JPEG 和 KTX2

      让我们比较传统图像格式和 KTX2:

      特性 PNG/JPEG KTX2

      文件格式

      通用图像格式

      GPU 优化的纹理容器

      压缩

      通用(PNG)或有损(JPEG)

      GPU 纹理压缩(BC、ETC、ASTC)+ 超级压缩

      Mipmaps

      不支持

      内置 mipmap 链支持

      GPU 上传

      需要转换

      可以直接上传到 GPU

      元数据

      有限

      全面的纹理元数据

      支持的功能

      基本 2D 图像

      所有 GPU 纹理类型(2D、3D、立方体贴图、数组)

      从 tinyobjloader 迁移到 tinygltf

      设置 tinygltf

      首先,我们需要包含 tinygltf 库而不是 tinyobjloader:

      // 替换为:
      #define TINYGLTF_IMPLEMENTATION
      #define STB_IMAGE_WRITE_IMPLEMENTATION
      #include <tiny_gltf.h>

      注意,tinygltf 内部使用 stb_image 进行图像加载,但我们稍后将用 KTX2 替换纹理加载代码。

      加载 glTF 模型

      现在,让我们修改 loadModel() 函数以使用 tinygltf 而不是 tinyobjloader:

      void loadModel() {
          // 使用 tinygltf 加载模型而不是 tinyobjloader
          tinygltf::Model model;
          tinygltf::TinyGLTF loader;
          std::string err;
          std::string warn;
      
          bool ret = loader.LoadASCIIFromFile(&model, &err, &warn, MODEL_PATH);
      
          if (!warn.empty()) {
              std::cout << "glTF 警告:" << warn << std::endl;
          }
      
          if (!err.empty()) {
              std::cout << "glTF 错误:" << err << std::endl;
          }
      
          if (!ret) {
              throw std::runtime_error("加载 glTF 模型失败");
          }
      
          // 处理模型中的所有网格
          std::unordered_map<Vertex, uint32_t> uniqueVertices{};
      
          for (const auto& mesh : model.meshes) {
              for (const auto& primitive : mesh.primitives) {
                  // 获取索引
                  const tinygltf::Accessor& indexAccessor = model.accessors[primitive.indices];
                  const tinygltf::BufferView& indexBufferView = model.bufferViews[indexAccessor.bufferView];
                  const tinygltf::Buffer& indexBuffer = model.buffers[indexBufferView.buffer];
      
                  // 获取顶点位置
                  const tinygltf::Accessor& posAccessor = model.accessors[primitive.attributes.at("POSITION")];
                  const tinygltf::BufferView& posBufferView = model.bufferViews[posAccessor.bufferView];
                  const tinygltf::Buffer& posBuffer = model.buffers[posBufferView.buffer];
      
                  // 如果可用,获取纹理坐标
                  bool hasTexCoords = primitive.attributes.find("TEXCOORD_0") != primitive.attributes.end();
                  const tinygltf::Accessor* texCoordAccessor = nullptr;
                  const tinygltf::BufferView* texCoordBufferView = nullptr;
                  const tinygltf::Buffer* texCoordBuffer = nullptr;
      
                  if (hasTexCoords) {
                      texCoordAccessor = &model.accessors[primitive.attributes.at("TEXCOORD_0")];
                      texCoordBufferView = &model.bufferViews[texCoordAccessor->bufferView];
                      texCoordBuffer = &model.buffers[texCoordBufferView->buffer];
                  }
      
                  // 处理顶点
                  for (size_t i = 0; i < posAccessor.count; i++) {
                      Vertex vertex{};
      
                      // 获取位置
                      const float* pos = reinterpret_cast<const float*>(&posBuffer.data[posBufferView.byteOffset + posAccessor.byteOffset + i * 12]);
                      vertex.pos = {pos[0], pos[1], pos[2]};
      
                      // 如果可用,获取纹理坐标
                      if (hasTexCoords) {
                          const float* texCoord = reinterpret_cast<const float*>(&texCoordBuffer->data[texCoordBufferView->byteOffset + texCoordAccessor->byteOffset + i * 8]);
                          vertex.texCoord = {texCoord[0], 1.0f - texCoord[1]};
                      } else {
                          vertex.texCoord = {0.0f, 0.0f};
                      }
      
                      // 设置默认颜色
                      vertex.color = {1.0f, 1.0f, 1.0f};
      
                      // 如果顶点唯一,则添加
                      if (!uniqueVertices.contains(vertex)) {
                          uniqueVertices[vertex] = static_cast<uint32_t>(vertices.size());
                          vertices.push_back(vertex);
                      }
                  }
      
                  // 处理索引
                  const unsigned char* indexData = &indexBuffer.data[indexBufferView.byteOffset + indexAccessor.byteOffset];
      
                  // 处理不同的索引组件类型
                  if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT) {
                      const uint16_t* indices16 = reinterpret_cast<const uint16_t*>(indexData);
                      for (size_t i = 0; i < indexAccessor.count; i++) {
                          Vertex vertex = vertices[indices16[i]];
                          indices.push_back(uniqueVertices[vertex]);
                      }
                  } else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT) {
                      const uint32_t* indices32 = reinterpret_cast<const uint32_t*>(indexData);
                      for (size_t i = 0; i < indexAccessor.count; i++) {
                          Vertex vertex = vertices[indices32[i]];
                          indices.push_back(uniqueVertices[vertex]);
                      }
                  } else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE) {
                      const uint8_t* indices8 = reinterpret_cast<const uint8_t*>(indexData);
                      for (size_t i = 0; i < indexAccessor.count; i++) {
                          Vertex vertex = vertices[indices8[i]];
                          indices.push_back(uniqueVertices[vertex]);
                      }
                  }
              }
          }
      }

      与 tinyobjloader 版本相比,此实现的关键区别在于:

      1. 数据结构:glTF 使用更复杂的数据结构,包括访问器、缓冲视图和缓冲区

      2. 属性访问:我们需要通过这些结构导航以访问顶点数据

      3. 多个网格和图元:glTF 模型可以包含多个网格,每个网格有多个图元

      4. 组件类型:我们需要处理不同的索引组件类型(8 位、16 位、32 位)

      高级 glTF 功能

      虽然我们的基本实现仅提取几何和纹理坐标,但 glTF 支持更多功能,您可能希望使用:

      • 材质:通过 primitive.material 访问 PBR 材质属性

      • 动画:处理 model.animations 中的动画数据

      • 骨骼:处理 model.skins 中的骨骼数据

      • 场景和节点:通过 model.scenesmodel.nodes 处理场景层次结构

      对于完整的应用程序,您通常会处理这些附加功能以充分利用 glTF。

      从 stb_image 迁移到 KTX

      设置 KTX

      首先,我们需要包含 KTX 库:

      // 替换为:
      #include <ktx.h>

      加载 KTX2 纹理

      现在,让我们修改 createTextureImage() 函数以使用 KTX 而不是 stb_image:

      void createTextureImage() {
          // 加载 KTX2 纹理而不是使用 stb_image
          ktxTexture* kTexture;
          KTX_error_code result = ktxTexture_CreateFromNamedFile(
              TEXTURE_PATH.c_str(),
              KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT,
              &kTexture);
      
          if (result != KTX_SUCCESS) {
              throw std::runtime_error("加载 ktx 纹理图像失败!");
          }
      
          // 获取纹理尺寸和数据
          uint32_t texWidth = kTexture->baseWidth;
          uint32_t texHeight = kTexture->baseHeight;
          ktx_size_t imageSize = ktxTexture_GetImageSize(kTexture, 0);
          ktx_uint8_t* ktxTextureData = ktxTexture_GetData(kTexture);
      
          // 创建暂存缓冲区
          vk::raii::Buffer stagingBuffer({});
          vk::raii::DeviceMemory stagingBufferMemory({});
          createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);
      
          // 将纹理数据复制到暂存缓冲区
          void* data = stagingBufferMemory.mapMemory(0, imageSize);
          memcpy(data, ktxTextureData, imageSize);
          stagingBufferMemory.unmapMemory();
      
          // 从 KTX 格式确定 Vulkan 格式
          vk::Format textureFormat = vk::Format::eR8G8B8A8Srgb; // 默认格式,应从 KTX 元数据确定
      
          // 创建纹理图像
          createImage(texWidth, texHeight, textureFormat, vk::ImageTiling::eOptimal,
                     vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
                     vk::MemoryPropertyFlagBits::eDeviceLocal, textureImage, textureImageMemory);
      
          // 将数据从暂存缓冲区复制到纹理图像
          transitionImageLayout(textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal);
          copyBufferToImage(stagingBuffer, textureImage, texWidth, texHeight);
          transitionImageLayout(textureImage, vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eShaderReadOnlyOptimal);
      
          // 清理 KTX 资源
          ktxTexture_Destroy(kTexture);
      }

      与 stb_image 版本相比,此实现的关键区别在于:

      1. 加载 API:我们使用 KTX API 加载纹理

      2. 纹理元数据:KTX 提供有关纹理属性的元数据

      3. 资源清理:我们需要显式销毁 KTX 纹理对象

      高级 KTX 功能

      此基本实现仅处理简单的 2D 纹理,但 KTX2 支持更多功能:

      处理 Mipmaps

      KTX2 文件可以包含预生成的 mipmaps。以下是使用方法:

      // 获取 mipmap 级别
      uint32_t mipLevels = kTexture->numLevels;
      
      // 创建支持 mipmap 的图像
      vk::ImageCreateInfo imageInfo{
          // ... 其他参数 ...
          .mipLevels = mipLevels,
          // ... 其他参数 ...
      };
      
      // 复制每个 mip 级别
      for (uint32_t i = 0; i < mipLevels; i++) {
          ktx_size_t offset;
          KTX_error_code result = ktxTexture_GetImageOffset(kTexture, i, 0, 0, &offset);
      
          // ... 将此 mip 级别复制到图像 ...
      }

      使用压缩纹理格式

      KTX2 支持 GPU 纹理压缩格式。以下是处理方法:

      // 从 KTX 格式确定 Vulkan 格式
      vk::Format textureFormat;
      switch (kTexture->vkFormat) {
          case VK_FORMAT_BC7_SRGB_BLOCK:
              textureFormat = vk::Format::eBc7SrgbBlock;
              break;
          case VK_FORMAT_BC5_UNORM_BLOCK:
              textureFormat = vk::Format::eBc5UnormBlock;
              break;
          // ... 其他格式映射 ...
          default:
              textureFormat = vk::Format::eR8G8B8A8Srgb;
              break;
      }

      处理立方体贴图和纹理数组

      KTX2 可以存储立方体贴图和纹理数组:

      // 检查纹理是否为立方体贴图
      bool isCubemap = kTexture->isCubemap;
      
      // 获取层数
      uint32_t layerCount = kTexture->numLayers;
      
      // 创建适当的图像
      vk::ImageCreateInfo imageInfo{
          // ... 其他参数 ...
          .imageType = vk::ImageType::e2D,
          .arrayLayers = layerCount,
          .flags = isCubemap ? vk::ImageCreateFlagBits::eCubeCompatible : vk::ImageCreateFlags(),
          // ... 其他参数 ...
      };

      将资源转换为 glTF 和 KTX2

      将 OBJ 转换为 glTF

      要将现有的 OBJ 文件转换为 glTF,可以使用各种工具:

      • Blender:打开 OBJ 文件并导出为 glTF

      • obj2gltf:用于将 OBJ 转换为 glTF 的命令行工具

      • assimp:可以在各种 3D 格式之间转换的库

      使用 obj2gltf 的示例:

      obj2gltf -i model.obj -o model.gltf

      使用 KTX2 文件

      创建 KTX2 文件

      有几种方法可以创建 KTX2 文件:

      使用 KTX-Software 工具

      KTX-Software 包提供了用于创建 KTX2 文件的命令行工具:

      • toktx:用于从现有图像创建 KTX2 文件的主要工具

      基本用法:

      # 创建基本的 KTX2 文件
      toktx texture.ktx2 texture.png
      
      # 创建带有 mipmaps 的 KTX2 文件
      toktx --mipmap texture.ktx2 texture.png
      
      # 创建带有 Basis Universal 压缩的 KTX2 文件
      toktx --bcmp texture.ktx2 texture.png
      
      # 创建带有特定 GPU 压缩格式(BC7)的 KTX2 文件
      toktx --bcmp --format BC7_RGBA texture.ktx2 texture.png
      
      # 创建立方体贴图 KTX2 文件
      toktx --cubemap cubemap.ktx2 posx.png negx.png posy.png negy.png posz.png negz.png

      使用 KTX 库 API

      您还可以使用 KTX 库 API 以编程方式创建 KTX2 文件:

      #include <ktx.h>
      
      // 创建新的 KTX2 纹理
      ktxTexture2* texture;
      ktxTextureCreateInfo createInfo = {
          .vkFormat = VK_FORMAT_R8G8B8A8_SRGB,
          .baseWidth = 512,
          .baseHeight = 512,
          .baseDepth = 1,
          .numDimensions = 2,
          .numLevels = 1,
          .numLayers = 1,
          .numFaces = 1,
          .isArray = KTX_FALSE,
          .generateMipmaps = KTX_FALSE
      };
      
      KTX_error_code result = ktxTexture2_Create(&createInfo, KTX_TEXTURE_CREATE_ALLOC_STORAGE, &texture);
      
      // 设置图像数据
      uint32_t* imageData = new uint32_t[512 * 512];
      // ... 填充图像数据 ...
      ktxTexture_SetImageFromMemory(ktxTexture(texture), 0, 0, 0, imageData, 512 * 512 * 4);
      
      // 写入文件
      ktxTexture_WriteToNamedFile(ktxTexture(texture), "output.ktx2");
      
      // 清理
      ktxTexture_Destroy(ktxTexture(texture));
      delete[] imageData;

      使用图像编辑软件

      一些图像编辑和 3D 建模软件可以直接导出到 KTX2:

      • Substance Designer:可以直接将纹理导出为 KTX2 格式

      • Blender:通过插件可以将纹理导出为 KTX2

      • GIMP:通过 KTX 插件可以将图像保存为 KTX2

      从其他格式转换为 KTX2

      可以从各种流行的图像格式创建 KTX2 文件:

      从 PNG/JPEG/TIFF

      最简单的转换是使用 toktx 从标准图像格式转换:

      # 将 PNG 转换为 KTX2
      toktx texture.ktx2 texture.png
      
      # 将 JPEG 转换为 KTX2
      toktx texture.ktx2 texture.jpg
      
      # 将 TIFF 转换为 KTX2
      toktx texture.ktx2 texture.tiff

      从 DDS(DirectX 纹理格式)

      DDS 是另一种 GPU 优化的纹理格式,通常与 DirectX 一起使用:

      # 使用 texconv 先将 DDS 转换为 PNG
      texconv -ft png texture.dds
      
      # 然后将 PNG 转换为 KTX2
      toktx texture.ktx2 texture.png

      或者,您可以使用 Khronos Texture Tools:

      ktx2ktx2 --convert texture.dds texture.ktx2

      从 HDR/EXR(高动态范围格式)

      对于 HDR 纹理:

      # 将 HDR 转换为 KTX2
      toktx --hdr texture.ktx2 texture.hdr
      
      # 将 EXR 转换为 KTX2(可能需要中间转换)
      toktx --hdr texture.ktx2 texture.exr

      从 PSD(Photoshop)

      对于 Photoshop 文件:

      # 先将 PSD 导出为 PNG
      # 然后转换为 KTX2
      toktx texture.ktx2 texture.png

      优化 KTX2 文件

      为了充分利用 KTX2 文件,请考虑以下优化技术:

      压缩选项

      KTX2 支持各种压缩方法:

      # Basis Universal 压缩(高度可移植)
      toktx --bcmp texture.ktx2 texture.png
      
      # ASTC 压缩(适用于移动设备)
      toktx --format ASTC_4x4_RGBA texture.ktx2 texture.png
      
      # BC7 压缩(适用于桌面)
      toktx --format BC7_RGBA texture.ktx2 texture.png
      
      # ETC2 压缩(适用于 Android)
      toktx --format ETC2_RGBA texture.ktx2 texture.png

      Mipmap 生成

      Mipmaps 提高渲染性能和质量:

      # 生成 mipmaps
      toktx --mipmap texture.ktx2 texture.png
      
      # 使用特定过滤器生成 mipmaps
      toktx --mipmap --filter lanczos texture.ktx2 texture.png

      元数据

      KTX2 文件可以包含元数据:

      # 添加键值元数据
      toktx --mipmap --key "author" --value "Your Name" texture.ktx2 texture.png

      用于处理 KTX2 文件的工具

      有几种工具可用于处理 KTX2 文件:

      命令行工具

      • KTX-Software Suite

      • toktx:创建 KTX2 文件

      • ktx2ktx2:在 KTX 版本之间转换

      • ktxinfo:显示有关 KTX 文件的信息

      • ktxsc:对 KTX2 文件应用超级压缩

      • ktxunpack:将 KTX 文件解压缩为单独的图像

      库和 SDK

      • KTX-Software Library:用于读取、写入和处理 KTX 文件的 C/C++ 库

      • libktx:KTX-Software 使用的核心库

      • Basis Universal:KTX2 中使用的压缩技术

      • Vulkan SDK:包括 KTX 工具和库

      • glTF-Transform:可以处理 glTF 文件中的 KTX2 纹理的 JavaScript 库

      查看器和调试器

      • KTX Load Test:KTX-Software 的一部分,用于查看 KTX 文件

      • RenderDoc:可以检查 KTX2 纹理的图形调试器

      • Khronos Texture Tools:包括 KTX 文件的查看器

      • glTF Viewer:许多 glTF 查看器支持 KTX2 纹理

      与游戏引擎集成

      • Unity:通过插件支持 KTX2

      • Unreal Engine:通过插件支持 KTX2

      • Godot:正在开发 KTX2 支持

      • Three.js:支持 KTX2 纹理

      • Babylon.js:支持 KTX2 纹理

      将图像转换为 KTX2

      要将现有图像文件转换为 KTX2,可以使用:

      • toktx:包含在 KTX-Software 包中的命令行工具

      • KTX-Software:用于创建和操作 KTX 文件的库

      使用 toktx 创建带有 Basis Universal 压缩的 KTX2 文件的示例:

      toktx --bcmp texture.ktx2 texture.png

      结论

      从 OBJ/PNG 迁移到 glTF/KTX2 为现代图形应用程序带来了显著的好处:

      • 更好的性能:为 GPU 使用优化的格式

      • 更多功能:支持高级 3D 功能和纹理格式

      • 行业标准:专为现代图形 API 设计的格式

      虽然迁移需要一些代码更改,但在性能、功能和未来兼容性方面的好处使其对于严肃的图形应用程序来说是值得的。