狸村 Mystic Web · 狸村
目录

引言

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

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

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

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

这种迁移具有几个优势:

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

  • GPU 优化的纹理:KTX2 支持压缩纹理格式、mipmap 和其他 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 纹理 2.0)是一种容器文件格式,用于存储针对 GPU 使用优化的纹理数据。它被设计为与现代图形 API(如 Vulkan、OpenGL 和 DirectX)高效协作。

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:

// 替换此内容:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>

// 替换为:
#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.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 轴向上
                // Vulkan 使用右手坐标系,Y 轴向下
                // 我们需要翻转 Y 坐标
                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 版本相比,此实现的主要区别是:

  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 文件(替代 png 或 jpeg 等格式),我们将使用开源 KTX(Khronos 纹理)库和工具

设置 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 版本相比,此实现的主要区别是:

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

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

  3. 资源清理:我们需要显式销毁 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 文件的命令行工具:

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

基本用法:

# 创建基本的 KTX2 文件
toktx texture.ktx2 texture.png

# 创建带 mipmap 的 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 是另一种常用于 DirectX 的 GPU 优化纹理格式:

# 使用 texconv 先将 DDS 转换为 PNG
texconv -ft png texture.dds

# 然后将 PNG 转换为 KTX2
toktx texture.ktx2 texture.png

或者,你可以使用 Khronos 纹理工具:

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 生成

Mipmap 可提高渲染性能和质量:

# 生成 mipmap
toktx --mipmap texture.ktx2 texture.png

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

元数据

KTX2 文件可以包含元数据:

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

使用 KTX2 文件的工具

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

命令行工具

  • KTX-Software 套件

  • toktx:创建 KTX2 文件

  • ktx2ktx2:在 KTX 版本之间转换

  • ktxinfo:显示 KTX 文件信息

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

  • ktxunpack:将 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,你可以使用:

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

  • KTX-Software:包含创建和操作 KTX 文件的工具库

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

toktx --bcmp texture.ktx2 texture.png

结论

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

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

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

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

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

Vulkan 是 Khronos Group Inc. 的注册商标

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