迁移到现代资源格式:glTF 和 KTX2
简介
在前面的章节中,我们一直使用 tinyobjloader 加载 Wavefront OBJ 格式的 3D 模型,并使用 stb_image 加载 PNG 和 JPEG 等常见图像格式的纹理。虽然这些库和格式简单且广泛支持,但现代图形应用程序通常受益于更高级的资源格式。
在本章中,我们将探讨如何从以下内容迁移:
-
Wavefront OBJ(使用 tinyobjloader 加载)迁移到 glTF(使用 tinygltf 加载)
-
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 使用的格式存储
-
可扩展:该格式可以通过新功能进行扩展
理解 KTX2
什么是 KTX2?
KTX2(Khronos Texture 2.0)是一种用于存储 GPU 使用优化的纹理数据的容器文件格式。它设计用于与 Vulkan、OpenGL 和 DirectX 等现代图形 API 高效协作。
KTX2 的关键特性包括:
-
GPU 就绪格式:支持所有 GPU 纹理格式,包括压缩格式
-
Mipmap 存储:高效存储完整的 mipmap 链
-
元数据:包括纹理属性的信息
-
超级压缩:支持 Basis Universal 等额外压缩
-
直接上传:数据通常可以直接上传到 GPU 而无需处理
从 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 版本相比,此实现的关键区别在于:
-
数据结构:glTF 使用更复杂的数据结构,包括访问器、缓冲视图和缓冲区
-
属性访问:我们需要通过这些结构导航以访问顶点数据
-
多个网格和图元:glTF 模型可以包含多个网格,每个网格有多个图元
-
组件类型:我们需要处理不同的索引组件类型(8 位、16 位、32 位)
从 stb_image 迁移到 KTX
加载 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 版本相比,此实现的关键区别在于:
-
加载 API:我们使用 KTX API 加载纹理
-
纹理元数据:KTX 提供有关纹理属性的元数据
-
资源清理:我们需要显式销毁 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(),
// ... 其他参数 ...
};
使用 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;
从其他格式转换为 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
优化 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
用于处理 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 库