Migrating to Modern Asset Formats: glTF and KTX2
Introduction
In previous chapters, we’ve been using tinyobjloader to load 3D models in the Wavefront OBJ format and stb_image to load textures in common image formats like PNG and JPEG. While these libraries and formats are simple and widely supported, modern graphics applications often benefit from more advanced asset formats.
In this chapter, we’ll explore how to migrate from:
-
Wavefront OBJ (loaded with tinyobjloader) to glTF (loaded with tinygltf)
-
Common image formats like PNG (loaded with stb_image) to KTX2 (loaded with the KTX library)
This migration offers several advantages:
-
More comprehensive model data: glTF supports animations, skeletal rigs, PBR materials, and more
-
GPU-optimized textures: KTX2 supports compressed texture formats, mipmaps, and other GPU-friendly features
-
Industry standard: Both glTF and KTX2 are Khronos standards designed specifically for modern graphics APIs
Let’s dive into the migration process and see how to adapt our Vulkan application to use these modern formats.
Understanding glTF
What is glTF?
glTF (GL Transmission Format) is a royalty-free specification for the efficient transmission and loading of 3D scenes and models. Developed by the Khronos Group, glTF is designed to be a "JPEG for 3D" - a common publishing format for 3D content.
Key features of glTF include:
-
Compact file size: Binary data is stored efficiently
-
Fast loading: Minimizes processing needed at load time
-
Complete 3D scene representation: Includes meshes, materials, textures, animations, and more
-
Runtime-ready: Data is stored in formats that can be directly used by the GPU
-
Extensible: The format can be extended with new capabilities
Comparing OBJ and glTF
Let’s compare the OBJ format with glTF:
| Feature | OBJ | glTF |
|---|---|---|
File format |
Text-based |
JSON + binary data (GLB option for single file) |
Supported data |
Geometry, basic materials, texture coordinates |
Geometry, PBR materials, animations, skeletons, scenes, cameras, etc. |
Material system |
Basic (MTL files) |
Physically-Based Rendering (PBR) |
Animation support |
None |
Keyframe and skeletal animations |
Coordinate system |
Right-handed |
Right-handed, Y-up |
Industry adoption |
Legacy standard |
Modern standard for real-time 3D |
Understanding KTX2
What is KTX2?
KTX2 (Khronos Texture 2.0) is a container file format for storing texture data optimized for GPU usage. It’s designed to work efficiently with modern graphics APIs like Vulkan, OpenGL, and DirectX.
Key features of KTX2 include:
-
GPU-ready formats: Supports all GPU texture formats including compressed formats
-
Mipmap storage: Efficiently stores complete mipmap chains
-
Metadata: Includes information about the texture’s properties
-
Supercompression: Supports additional compression like Basis Universal
-
Direct uploads: Data can often be uploaded directly to the GPU without processing
Comparing PNG/JPEG and KTX2
Let’s compare traditional image formats with KTX2:
| Feature | PNG/JPEG | KTX2 |
|---|---|---|
File format |
General-purpose image format |
GPU-optimized texture container |
Compression |
General-purpose (PNG) or lossy (JPEG) |
GPU texture compression (BC, ETC, ASTC) + supercompression |
Mipmaps |
Not supported |
Built-in mipmap chain support |
GPU upload |
Requires conversion |
Can be directly uploaded to GPU |
Metadata |
Limited |
Comprehensive texture metadata |
Supported features |
Basic 2D images |
All GPU texture types (2D, 3D, cubemaps, arrays) |
Migrating from tinyobjloader to tinygltf
For loading glTF models (instead of obj) we’ll be using the open source headery only C++ tiny glTF library.
Setting Up tinygltf
First, we need to include the tinygltf library instead of tinyobjloader:
// Replace this:
#define TINYOBJLOADER_IMPLEMENTATION
#include <tiny_obj_loader.h>
// With this:
#define TINYGLTF_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <tiny_gltf.h>
Note that tinygltf uses stb_image internally for image loading, but we’ll be replacing the texture loading code with KTX2 later.
Loading a glTF Model
Now, let’s modify our loadModel() function to use tinygltf instead of tinyobjloader:
void loadModel()
{
// Use tinygltf to load the model instead of 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();
// Process all meshes in the model
for (const auto &mesh : model.meshes)
{
for (const auto &primitive : mesh.primitives)
{
// Get indices
const tinygltf::Accessor &indexAccessor = model.accessors[primitive.indices];
const tinygltf::BufferView &indexBufferView = model.bufferViews[indexAccessor.bufferView];
const tinygltf::Buffer &indexBuffer = model.buffers[indexBufferView.buffer];
// Get vertex positions
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];
// Get texture coordinates if available
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 uses a right-handed, Y-up coordinate system, while this scene is set up
// Z-up. Negating the Y coordinate here would mirror the model instead of
// reorienting it - Vulkan's clip-space Y convention is already handled
// separately via proj[1][1] *= -1 in updateUniformBuffer, so we keep the
// position as-is and let a rotation there do the Y-up to Z-up conversion.
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;
// Determine index stride based on component type
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);
}
}
}
}
The key differences in this implementation compared to the tinyobjloader version are:
-
Data structure: glTF uses a more complex data structure with accessors, buffer views, and buffers
-
Attribute access: We need to navigate through these structures to access vertex data
-
Multiple meshes and primitives: glTF models can contain multiple meshes, each with multiple primitives
-
Component types: We need to handle different index component types (8-bit, 16-bit, 32-bit)
Advanced glTF Features
While our basic implementation only extracts geometry and texture coordinates, glTF supports many more features that you might want to use:
-
Materials: Access PBR material properties through
primitive.material -
Animations: Process animation data in
model.animations -
Skeletons: Handle skeletal data in
model.skins -
Scenes and nodes: Process scene hierarchy through
model.scenesandmodel.nodes
For a complete application, you would typically process these additional features to take full advantage of glTF.
Migrating from stb_image to KTX
For loading KTX files (instead of formats like png or jpeg) we’ll be using the open source KTX (Khronos Texture) Library and Tools.
Setting Up KTX
First, we need to include the KTX library:
// Replace this:
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
// With this:
#include <ktx.h>
Loading a KTX2 Texture
Now, let’s modify our createTextureImage() function to use KTX instead of stb_image:
void createTextureImage() {
// Load KTX2 texture instead of using 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!");
}
// Get texture dimensions and data
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);
// Create staging buffer
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);
// Copy texture data to staging buffer
void* data = stagingBufferMemory.mapMemory(0, imageSize);
memcpy(data, ktxTextureData, imageSize);
stagingBufferMemory.unmapMemory();
// Determine the Vulkan format from KTX format
vk::Format textureFormat = vk::Format::eR8G8B8A8Srgb; // Default format, should be determined from KTX metadata
// Create the texture image
createImage(texWidth, texHeight, textureFormat, vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
vk::MemoryPropertyFlagBits::eDeviceLocal, textureImage, textureImageMemory);
// Copy data from staging buffer to texture image
transitionImageLayout(textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal);
copyBufferToImage(stagingBuffer, textureImage, texWidth, texHeight);
transitionImageLayout(textureImage, vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eShaderReadOnlyOptimal);
// Cleanup KTX resources
ktxTexture_Destroy(kTexture);
}
The key differences in this implementation compared to the stb_image version are:
-
Loading API: We use the KTX API to load the texture
-
Texture metadata: KTX provides metadata about the texture’s properties
-
Resource cleanup: We need to explicitly destroy the KTX texture object
Advanced KTX Features
This basic implementation only handles simple 2D textures, but KTX2 supports many more features:
Handling Mipmaps
KTX2 files can contain pre-generated mipmaps. Here’s how to use them:
// Get mipmap levels
uint32_t mipLevels = kTexture->numLevels;
// Create image with mipmap support
vk::ImageCreateInfo imageInfo{
// ... other parameters ...
.mipLevels = mipLevels,
// ... other parameters ...
};
// Copy each mip level
for (uint32_t i = 0; i < mipLevels; i++) {
ktx_size_t offset;
KTX_error_code result = ktxTexture_GetImageOffset(kTexture, i, 0, 0, &offset);
// ... copy this mip level to the image ...
}
Using Compressed Texture Formats
KTX2 supports GPU texture compression formats. Here’s how to handle them:
// Determine the Vulkan format from KTX format
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;
// ... other format mappings ...
default:
textureFormat = vk::Format::eR8G8B8A8Srgb;
break;
}
Handling Cubemaps and Texture Arrays
KTX2 can store cubemaps and texture arrays:
// Check if the texture is a cubemap
bool isCubemap = kTexture->isCubemap;
// Get the number of layers
uint32_t layerCount = kTexture->numLayers;
// Create appropriate image
vk::ImageCreateInfo imageInfo{
// ... other parameters ...
.imageType = vk::ImageType::e2D,
.arrayLayers = layerCount,
.flags = isCubemap ? vk::ImageCreateFlagBits::eCubeCompatible : vk::ImageCreateFlags(),
// ... other parameters ...
};
Converting Assets to glTF and KTX2
Converting OBJ to glTF
To convert existing OBJ files to glTF, you can use various tools:
-
Blender: Open the OBJ file and export as glTF
-
obj2gltf: A command-line tool for converting OBJ to glTF
-
assimp: A library that can convert between various 3D formats
Example using obj2gltf:
obj2gltf -i model.obj -o model.gltf
Working with KTX2 Files
Creating KTX2 Files
There are several ways to create KTX2 files:
Using the KTX-Software Tools
The KTX-Software package provides command-line tools for creating KTX2 files:
-
ktx create: The primary tool for creating KTX2 files from existing images
The standalone toktx executable has been superseded by the unified ktx CLI’s create subcommand. Besides the command name, two things changed: the input file(s) now come before the output file (not after), and --format is required and takes a VkFormat name (minus the VK_FORMAT_ prefix) rather than a short compression codec name.
|
Basic usage:
# Create a basic KTX2 file
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
# Create a KTX2 file with mipmaps
ktx create --format R8G8B8A8_SRGB --generate-mipmap texture.png texture.ktx2
# Create a KTX2 file with Basis Universal (ETC1S) compression
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
# Create a KTX2 file with a specific GPU block-compression format (BC7)
ktx create --format BC7_SRGB_BLOCK texture.png texture.ktx2
# Create a cubemap KTX2 file
ktx create --format R8G8B8A8_SRGB --cubemap posx.png negx.png posy.png negy.png posz.png negz.png cubemap.ktx2
Using the KTX Library API
You can also create KTX2 files programmatically using the KTX library API:
#include <ktx.h>
// Create a new KTX2 texture
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);
// Set image data
uint32_t* imageData = new uint32_t[512 * 512];
// ... fill image data ...
ktxTexture_SetImageFromMemory(ktxTexture(texture), 0, 0, 0, imageData, 512 * 512 * 4);
// Write to file
ktxTexture_WriteToNamedFile(ktxTexture(texture), "output.ktx2");
// Clean up
ktxTexture_Destroy(ktxTexture(texture));
delete[] imageData;
Converting from Other Formats to KTX2
KTX2 files can be created from various popular image formats:
From PNG/JPEG/TIFF
The simplest conversion is from standard image formats using ktx create:
# Convert PNG to KTX2
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
# Convert JPEG to KTX2
ktx create --format R8G8B8A8_SRGB texture.jpg texture.ktx2
# Convert TIFF to KTX2
ktx create --format R8G8B8A8_SRGB texture.tiff texture.ktx2
From DDS (DirectX Texture Format)
DDS is another GPU-optimized texture format commonly used with DirectX:
# Using texconv to convert DDS to PNG first
texconv -ft png texture.dds
# Then convert PNG to KTX2
ktx create --format R8G8B8A8_SRGB texture.png texture.ktx2
Alternatively, you can convert an existing KTX1 file directly:
ktx convert texture.ktx texture.ktx2
From HDR/EXR (High Dynamic Range Formats)
For HDR textures, use a floating-point format instead of an 8-bit UNORM/SRGB one:
# Convert HDR to KTX2
ktx create --format R16G16B16A16_SFLOAT texture.hdr texture.ktx2
# Convert EXR to KTX2 (may require intermediate conversion)
ktx create --format R16G16B16A16_SFLOAT texture.exr texture.ktx2
Optimizing KTX2 Files
To get the most out of KTX2 files, consider these optimization techniques:
Compression Options
KTX2 supports various compression methods:
# Basis Universal compression (highly portable)
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
# ASTC compression (good for mobile)
ktx create --format ASTC_4x4_SRGB_BLOCK texture.png texture.ktx2
# BC7 compression (good for desktop)
ktx create --format BC7_SRGB_BLOCK texture.png texture.ktx2
# ETC2 compression (good for Android)
ktx create --format ETC2_R8G8B8A8_SRGB_BLOCK texture.png texture.ktx2
Mipmap Generation
Mipmaps improve rendering performance and quality:
# Generate mipmaps
ktx create --format R8G8B8A8_SRGB --generate-mipmap texture.png texture.ktx2
# Generate mipmaps with a specific filter
ktx create --format R8G8B8A8_SRGB --generate-mipmap --mipmap-filter lanczos4 texture.png texture.ktx2
Metadata
You can inspect the key-value metadata already embedded in a KTX2 file with ktx info; adding custom key-value pairs at creation time is handled by a separate step in the ktx toolset rather than a flag on create — see ktx help create for the version of KTX-Software you have installed, since this has changed between releases.
Tools for Working with KTX2 Files
Several tools are available for working with KTX2 files:
Command-line Tools
-
KTX-Software Suite (the
ktxCLI, which superseded the standalonetoktx/ktx2ktx2/ktxinfo/ktxsc/ktxunpackexecutables): -
ktx create: Create KTX2 files -
ktx convert: Convert between KTX versions -
ktx info: Display information about KTX files -
ktx deflate: Apply supercompression to KTX2 files -
ktx extract: Unpack a KTX file to individual images
Libraries and SDKs
-
KTX-Software Library: C/C++ library for reading, writing, and processing KTX files
-
libktx: The core library used by KTX-Software
-
Basis Universal: Compression technology used in KTX2
-
Vulkan SDK: Includes KTX tools and libraries
-
glTF-Transform: JavaScript library that can process KTX2 textures in glTF files
Converting Images to KTX2
To convert existing image files to KTX2, you can use:
-
ktx create: A command-line tool included with the KTX-Software package
-
KTX-Software: A library with tools for creating and manipulating KTX files
Example using ktx create to create a KTX2 file with Basis Universal compression:
ktx create --format R8G8B8A8_SRGB --encode basis-lz texture.png texture.ktx2
Conclusion
Migrating from OBJ/PNG to glTF/KTX2 brings significant benefits for modern graphics applications:
-
Better performance: Optimized formats for GPU usage
-
More features: Support for advanced 3D features and texture formats
-
Industry standards: Formats designed specifically for modern graphics APIs
While the migration requires some code changes, the benefits in terms of performance, features, and future-proofing make it worthwhile for serious graphics applications.