// 替换掉这段:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
// 改用这段:
#define TINYGLTF_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <tiny_gltf.h>
简介
在前面的章节中,我们用 tinyobjloader 加载 Wavefront OBJ 格式的 3D 模型,用 stb_image 加载 PNG、JPEG 等常见格式的纹理。这些库和格式固然简单且被广泛支持,但现代图形应用往往能从更高级的资源格式中获益。
本章将探讨如何从以下组合迁移:
-
Wavefront OBJ(用 tinyobjloader 加载)迁移到 glTF(用 tinygltf 加载)
-
PNG 等常见图像格式(用 stb_image 加载)迁移到 KTX2(用 KTX 库加载)
这次迁移带来若干好处:
-
更完整的模型数据:glTF 支持动画、骨骼绑定、PBR 材质等
-
GPU 优化的纹理:KTX2 支持压缩纹理格式、mipmap 以及其他对 GPU 友好的特性
-
业界标准:glTF 和 KTX2 都是 Khronos 专为现代图形 API 设计的标准
下面深入迁移过程,看看如何让 Vulkan 应用改用这些现代格式。
理解 glTF
什么是 glTF?
glTF(GL Transmission Format)是一份免版税的规范,用于高效传输和加载 3D 场景与模型。glTF 由 Khronos 组织制定,设计目标是成为“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)+ 超级压缩 |
mipmap |
不支持 |
内置 mipmap 链支持 |
GPU 上传 |
需要转换 |
可直接上传到 GPU |
元数据 |
有限 |
完整的纹理元数据 |
支持的特性 |
基础 2D 图像 |
所有 GPU 纹理类型(2D、3D、立方体贴图、数组) |
从 tinyobjloader 迁移到 tinygltf
为了加载 glTF 模型(而不是 obj),我们将使用开源的 仅头文件的 C++ tiny glTF 库。
配置 tinygltf
首先,需要引入 tinygltf 库来替代 tinyobjloader:
注意 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.LoadBinaryFromFile(&model, &err, &warn, MODEL_PATH);
if (!warn.empty())
{
std::cout << "glTF warning: " << warn << std::endl;
}
if (!err.empty())
{
std::cout << "glTF error: " << err << std::endl;
}
if (!ret)
{
throw std::runtime_error("Failed to load glTF model");
}
vertices.clear();
indices.clear();
// 处理模型中的所有网格
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];
}
uint32_t baseVertex = static_cast<uint32_t>(vertices.size());
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]);
// glTF 使用右手、Y 轴向上的坐标系,而本场景采用 Z 轴向上。
// 在这里对 Y 坐标取负会让模型被镜像,而不是被重新定向——
// Vulkan 的裁剪空间 Y 轴约定已经在 updateUniformBuffer 中通过
// proj[1][1] *= -1 单独处理过了,所以我们保持位置不变,
// 由那里的旋转来完成 Y-up 到 Z-up 的转换。
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], texCoord[1]};
}
else
{
vertex.texCoord = {0.0f, 0.0f};
}
vertex.color = {1.0f, 1.0f, 1.0f};
vertices.push_back(vertex);
}
const unsigned char *indexData = &indexBuffer.data[indexBufferView.byteOffset + indexAccessor.byteOffset];
size_t indexCount = indexAccessor.count;
size_t indexStride = 0;
// 根据分量类型确定索引步长
if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT)
{
indexStride = sizeof(uint16_t);
}
else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT)
{
indexStride = sizeof(uint32_t);
}
else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE)
{
indexStride = sizeof(uint8_t);
}
else
{
throw std::runtime_error("Unsupported index component type");
}
indices.reserve(indices.size() + indexCount);
for (size_t i = 0; i < indexCount; i++)
{
uint32_t index = 0;
if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT)
{
index = *reinterpret_cast<const uint16_t *>(indexData + i * indexStride);
}
else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT)
{
index = *reinterpret_cast<const uint32_t *>(indexData + i * indexStride);
}
else if (indexAccessor.componentType == TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE)
{
index = *reinterpret_cast<const uint8_t *>(indexData + i * indexStride);
}
indices.push_back(baseVertex + index);
}
}
}
}
与 tinyobjloader 版本相比,这份实现的主要差异在于:
-
数据结构:glTF 使用更复杂的数据结构,包含访问器(accessor)、缓冲区视图(buffer view)和缓冲区(buffer)
-
属性访问:需要通过这些结构逐层定位,才能访问顶点数据
-
多网格与多图元:glTF 模型可以包含多个网格,每个网格又可以有多个图元
-
分量类型:需要处理不同的索引分量类型(8 位、16 位、32 位)
进阶 glTF 特性
我们的基础实现只提取了几何体和纹理坐标,而 glTF 还支持许多你可能想用的特性:
-
材质:通过
primitive.material访问 PBR 材质属性 -
动画:处理
model.animations中的动画数据 -
骨骼:处理
model.skins中的骨骼数据 -
场景与节点:通过
model.scenes和model.nodes处理场景层级
在完整的应用中,通常还要处理这些额外特性,才能充分利用 glTF。
从 stb_image 迁移到 KTX
为了加载 KTX 文件(而不是 png、jpeg 这类格式),我们将使用开源的 KTX(Khronos Texture)库与工具。
配置 KTX
首先,需要引入 KTX 库:
// 替换掉这段:
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
// 改用这段:
#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("failed to load ktx texture image!");
}
// 获取纹理尺寸与数据
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 支持的特性多得多:
处理 mipmap
KTX2 文件可以包含预先生成好的 mipmap。用法如下:
// 获取 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 文件的命令行工具:
-
ktx create:从现有图像创建 KTX2 文件的主要工具
|
Note
|
独立的 toktx 可执行文件已被统一的 ktx CLI 的 create 子命令取代。除了命令名不同,还有两点变化:输入文件现在位于输出文件 之前(而不是之后),并且 --format 成为必需参数,它接受 VkFormat 名称(去掉 VK_FORMAT_ 前缀),而不是简短的压缩编解码器名称。
|
一般用法:
# 创建一个基本的 KTX2 文件
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
# 创建带 mipmap 的 KTX2 文件
ktx create --format R8G8B8A8_SRGB --generate-mipmap texture.png texture.ktx2
# 创建使用 Basis Universal(ETC1S)压缩的 KTX2 文件
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
# 创建使用特定 GPU 块压缩格式(BC7)的 KTX2 文件
ktx create --format BC7_SRGB_BLOCK texture.png texture.ktx2
# 创建立方体贴图 KTX2 文件
ktx create --format R8G8B8A8_SRGB --cubemap posx.png negx.png posy.png negy.png posz.png negz.png cubemap.ktx2
使用 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 转换
最简单的转换是用 ktx create 处理标准图像格式:
# 把 PNG 转换为 KTX2
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
# 把 JPEG 转换为 KTX2
ktx create --format R8G8B8A8_SRGB texture.jpg texture.ktx2
# 把 TIFF 转换为 KTX2
ktx create --format R8G8B8A8_SRGB texture.tiff texture.ktx2
从 DDS(DirectX 纹理格式)转换
DDS 是另一种针对 GPU 优化的纹理格式,常与 DirectX 配合使用:
# 先使用 texconv 把 DDS 转换为 PNG
texconv -ft png texture.dds
# 再把 PNG 转换为 KTX2
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
也可以直接转换现有的 KTX1 文件:
ktx convert texture.ktx texture.ktx2
从 HDR/EXR(高动态范围格式)转换
对于 HDR 纹理,应使用浮点格式,而不是 8 位 UNORM/SRGB 格式:
# 把 HDR 转换为 KTX2
ktx create --format R16G16B16A16_SFLOAT texture.hdr texture.ktx2
# 把 EXR 转换为 KTX2(可能需要中间转换)
ktx create --format R16G16B16A16_SFLOAT texture.exr texture.ktx2
从 PSD(Photoshop)转换
对于 Photoshop 文件:
# 先把 PSD 导出为 PNG
# 再转换为 KTX2
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
优化 KTX2 文件
要充分发挥 KTX2 文件的优势,可以考虑以下优化手段:
压缩选项
KTX2 支持多种压缩方式:
# Basis Universal 压缩(高度可移植)
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
# ASTC 压缩(适合移动端)
ktx create --format ASTC_4x4_SRGB_BLOCK texture.png texture.ktx2
# BC7 压缩(适合桌面端)
ktx create --format BC7_SRGB_BLOCK texture.png texture.ktx2
# ETC2 压缩(适合 Android)
ktx create --format ETC2_R8G8B8A8_SRGB_BLOCK texture.png texture.ktx2
生成 mipmap
mipmap 能提升渲染性能与画质:
# 生成 mipmap
ktx create --format R8G8B8A8_SRGB --generate-mipmap texture.png texture.ktx2
# 使用特定滤镜生成 mipmap
ktx create --format R8G8B8A8_SRGB --generate-mipmap --mipmap-filter lanczos4 texture.png texture.ktx2
元数据
可以用 ktx info 查看 KTX2 文件中已嵌入的键值元数据;而在创建时添加自定义键值对,则由 ktx 工具集中的另一个步骤完成,并不对应 create 的某个标志——具体请查看你所安装的 KTX-Software 版本中的 ktx help create,因为这一点在不同版本之间有过变化。
处理 KTX2 文件的工具
有多种工具可用于处理 KTX2 文件:
命令行工具
-
KTX-Software 套件(
ktxCLI,它取代了独立的toktx/ktx2ktx2/ktxinfo/ktxsc/ktxunpack可执行文件): -
ktx create:创建 KTX2 文件 -
ktx convert:在 KTX 各版本之间转换 -
ktx info:显示 KTX 文件的信息 -
ktx deflate:对 KTX2 文件应用超级压缩 -
ktx extract:把 KTX 文件解包为单张图像
库与 SDK
-
KTX-Software 库:用于读写和处理 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,可以使用:
-
ktx create:KTX-Software 软件包附带的命令行工具
-
KTX-Software:一套用于创建和操作 KTX 文件的库与工具
用 ktx create 创建一个带 Basis Universal 压缩的 KTX2 文件的示例:
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
结语
从 OBJ/PNG 迁移到 glTF/KTX2,能为现代图形应用带来明显收益:
-
更好的性能:针对 GPU 使用优化的格式
-
更多特性:支持进阶 3D 特性与纹理格式
-
业界标准:专为现代图形 API 设计的格式
虽然迁移需要改一些代码,但它在性能、功能和长期适用性上的收益,对严肃的图形应用来说完全值得。