| /* | |
| * Copyright 2026 Rive | |
| */ | |
| // Parse-only KTX2 reader for Rive runtime. | |
| // | |
| // Extracts vkFormat, logical pixel dims, and mip-level block payloads from a | |
| // KTX2 container. Does NOT decompress — block data is returned verbatim so | |
| // the renderer can hand it straight to the GPU's compressed texture upload | |
| // path. Today only BC7 (UNORM / SRGB) is recognized; other vkFormat values | |
| // are rejected. | |
| // | |
| // Spec: https://registry.khronos.org/KTX/specs/2.0/ktxspec.v2.html | |
| #include "rive/decoders/decode_ktx2.hpp" | |
| #include "rive/decoders/astc_footprints.hpp" | |
| #include "rive/decoders/texture_decoder.hpp" | |
| #include <algorithm> | |
| #include <cstdio> | |
| #include <cstring> | |
| namespace rive | |
| { | |
| namespace | |
| { | |
| constexpr uint8_t Ktx2Identifier[12] = { | |
| 0xAB, | |
| 0x4B, | |
| 0x54, | |
| 0x58, | |
| 0x20, | |
| 0x32, | |
| 0x30, | |
| 0xBB, | |
| 0x0D, | |
| 0x0A, | |
| 0x1A, | |
| 0x0A, | |
| }; | |
| constexpr uint32_t VK_FORMAT_BC7_UNORM_BLOCK = 145; | |
| constexpr uint32_t VK_FORMAT_BC7_SRGB_BLOCK = 146; | |
| constexpr uint32_t VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK = 151; | |
| constexpr uint32_t VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK = 152; | |
| // ASTC block range endpoints; the body maps the family by index between them. | |
| constexpr uint32_t VK_FORMAT_ASTC_4x4_UNORM_BLOCK = 157; | |
| constexpr uint32_t VK_FORMAT_ASTC_12x12_SRGB_BLOCK = 184; | |
| constexpr uint32_t SupercompressionNone = 0; | |
| #pragma pack(push, 1) | |
| struct Ktx2Header | |
| { | |
| uint32_t vkFormat; | |
| uint32_t typeSize; | |
| uint32_t pixelWidth; | |
| uint32_t pixelHeight; | |
| uint32_t pixelDepth; | |
| uint32_t layerCount; | |
| uint32_t faceCount; | |
| uint32_t levelCount; | |
| uint32_t supercompressionScheme; | |
| uint32_t dfdByteOffset; | |
| uint32_t dfdByteLength; | |
| uint32_t kvdByteOffset; | |
| uint32_t kvdByteLength; | |
| uint64_t sgdByteOffset; | |
| uint64_t sgdByteLength; | |
| }; | |
| struct Ktx2LevelIndex | |
| { | |
| uint64_t byteOffset; | |
| uint64_t byteLength; | |
| uint64_t uncompressedByteLength; | |
| }; | |
| #pragma pack(pop) | |
| static_assert(sizeof(Ktx2Header) == 68, "KTX2 header must be 68 bytes"); | |
| static_assert(sizeof(Ktx2LevelIndex) == 24, | |
| "KTX2 level index entry must be 24 bytes"); | |
| constexpr uint32_t MaxDimension = 16384; | |
| constexpr uint32_t MaxLevels = 16; | |
| inline uint64_t expectedBlockBytes(uint32_t pixelWidth, | |
| uint32_t pixelHeight, | |
| uint32_t blockWidth, | |
| uint32_t blockHeight, | |
| uint32_t bytesPerBlock) | |
| { | |
| const uint64_t blocksX = (pixelWidth + blockWidth - 1u) / blockWidth; | |
| const uint64_t blocksY = (pixelHeight + blockHeight - 1u) / blockHeight; | |
| return blocksX * blocksY * bytesPerBlock; | |
| } | |
| } // namespace | |
| bool DecodeKtx2(const uint8_t* bytes, | |
| size_t byteCount, | |
| Ktx2DecodeResult& out, | |
| const Ktx2HwSupport& hwSupport) | |
| { | |
| if (byteCount < sizeof(Ktx2Identifier) + sizeof(Ktx2Header)) | |
| { | |
| std::fprintf(stderr, "DecodeKtx2: file too small\n"); | |
| return false; | |
| } | |
| if (std::memcmp(bytes, Ktx2Identifier, sizeof(Ktx2Identifier)) != 0) | |
| { | |
| std::fprintf(stderr, "DecodeKtx2: bad magic\n"); | |
| return false; | |
| } | |
| Ktx2Header header; | |
| std::memcpy(&header, bytes + sizeof(Ktx2Identifier), sizeof(header)); | |
| // Map vkFormat → (GPUTextureFormat, blockWidth, blockHeight, | |
| // bytesPerBlock, srgb). | |
| GPUTextureFormat outFormat; | |
| uint8_t blockWidth = 4; | |
| uint8_t blockHeight = 4; | |
| uint32_t bytesPerBlock = 16; | |
| bool srgb = false; | |
| if (header.vkFormat == VK_FORMAT_BC7_UNORM_BLOCK || | |
| header.vkFormat == VK_FORMAT_BC7_SRGB_BLOCK) | |
| { | |
| outFormat = GPUTextureFormat::bc7; | |
| srgb = (header.vkFormat == VK_FORMAT_BC7_SRGB_BLOCK); | |
| } | |
| else if (header.vkFormat == VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK || | |
| header.vkFormat == VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK) | |
| { | |
| outFormat = GPUTextureFormat::etc2; | |
| // ETC2 RGBA8 = 8 bytes EAC alpha + 8 bytes ETC2 RGB = 16 per block. | |
| bytesPerBlock = 16; | |
| srgb = (header.vkFormat == VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK); | |
| } | |
| else if (header.vkFormat >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && | |
| header.vkFormat <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) | |
| { | |
| const uint32_t idx = | |
| (header.vkFormat - VK_FORMAT_ASTC_4x4_UNORM_BLOCK) / 2u; | |
| outFormat = GPUTextureFormat::astc; | |
| blockWidth = AstcFootprints[idx].width; | |
| blockHeight = AstcFootprints[idx].height; | |
| srgb = (header.vkFormat % 2u) == 0u; // SRGB at UNORM+1. | |
| } | |
| else | |
| { | |
| std::fprintf(stderr, | |
| "DecodeKtx2: unsupported vkFormat %u\n", | |
| header.vkFormat); | |
| return false; | |
| } | |
| if (header.supercompressionScheme != SupercompressionNone) | |
| { | |
| std::fprintf(stderr, | |
| "DecodeKtx2: supercompressionScheme %u not supported\n", | |
| header.supercompressionScheme); | |
| return false; | |
| } | |
| if (header.faceCount != 1 || header.layerCount != 0) | |
| { | |
| std::fprintf( | |
| stderr, | |
| "DecodeKtx2: cubemaps/arrays not supported (faces=%u layers=%u)\n", | |
| header.faceCount, | |
| header.layerCount); | |
| return false; | |
| } | |
| if (header.pixelWidth == 0 || header.pixelWidth > MaxDimension || | |
| header.pixelHeight == 0 || header.pixelHeight > MaxDimension) | |
| { | |
| std::fprintf(stderr, | |
| "DecodeKtx2: dimensions out of range (%ux%u)\n", | |
| header.pixelWidth, | |
| header.pixelHeight); | |
| return false; | |
| } | |
| const uint32_t levelCount = header.levelCount == 0 ? 1u : header.levelCount; | |
| if (levelCount > MaxLevels) | |
| { | |
| std::fprintf(stderr, | |
| "DecodeKtx2: levelCount %u exceeds cap %u\n", | |
| levelCount, | |
| MaxLevels); | |
| return false; | |
| } | |
| const size_t levelIndexOffset = sizeof(Ktx2Identifier) + sizeof(Ktx2Header); | |
| const size_t levelIndexBytes = | |
| static_cast<size_t>(levelCount) * sizeof(Ktx2LevelIndex); | |
| if (byteCount < levelIndexOffset + levelIndexBytes) | |
| { | |
| std::fprintf(stderr, "DecodeKtx2: truncated level index\n"); | |
| return false; | |
| } | |
| // Read level entries (level 0 first in the index = largest mip). | |
| std::vector<Ktx2LevelIndex> entries(levelCount); | |
| std::memcpy(entries.data(), bytes + levelIndexOffset, levelIndexBytes); | |
| // Validate each level + sum total payload size for the output buffer. | |
| uint64_t totalBytes = 0; | |
| for (uint32_t i = 0; i < levelCount; ++i) | |
| { | |
| const Ktx2LevelIndex& e = entries[i]; | |
| const uint32_t logW = std::max<uint32_t>(1u, header.pixelWidth >> i); | |
| const uint32_t logH = std::max<uint32_t>(1u, header.pixelHeight >> i); | |
| const uint64_t expected = expectedBlockBytes(logW, | |
| logH, | |
| blockWidth, | |
| blockHeight, | |
| bytesPerBlock); | |
| if (e.byteLength != expected) | |
| { | |
| std::fprintf( | |
| stderr, | |
| "DecodeKtx2: level %u byteLength %llu != block grid %llu " | |
| "for %ux%u\n", | |
| i, | |
| static_cast<unsigned long long>(e.byteLength), | |
| static_cast<unsigned long long>(expected), | |
| logW, | |
| logH); | |
| return false; | |
| } | |
| if (e.byteOffset > byteCount || e.byteLength > byteCount - e.byteOffset) | |
| { | |
| std::fprintf(stderr, "DecodeKtx2: level %u out of buffer\n", i); | |
| return false; | |
| } | |
| totalBytes += e.byteLength; | |
| } | |
| // Concatenate level 0 .. N-1 into one contiguous buffer (largest first). | |
| out.format = outFormat; | |
| out.pixelWidth = header.pixelWidth; | |
| out.pixelHeight = header.pixelHeight; | |
| out.levelCount = levelCount; | |
| out.blockWidth = blockWidth; | |
| out.blockHeight = blockHeight; | |
| out.srgb = srgb; | |
| out.softwareDecoded = false; | |
| out.blocks.resize(static_cast<size_t>(totalBytes)); | |
| size_t writeOffset = 0; | |
| for (uint32_t i = 0; i < levelCount; ++i) | |
| { | |
| const Ktx2LevelIndex& e = entries[i]; | |
| std::memcpy(out.blocks.data() + writeOffset, | |
| bytes + e.byteOffset, | |
| static_cast<size_t>(e.byteLength)); | |
| writeOffset += static_cast<size_t>(e.byteLength); | |
| } | |
| // HW-cap fallback: if the backend can't sample this format directly, | |
| // software-decode mip 0 to RGBA8 in place. Caller uploads as rgba32. | |
| bool needFallback = false; | |
| switch (out.format) | |
| { | |
| case GPUTextureFormat::bc1: | |
| case GPUTextureFormat::bc2: | |
| case GPUTextureFormat::bc3: | |
| case GPUTextureFormat::bc7: | |
| needFallback = !hwSupport.bc; | |
| break; | |
| case GPUTextureFormat::astc: | |
| needFallback = !hwSupport.astc; | |
| break; | |
| case GPUTextureFormat::etc2: | |
| needFallback = !hwSupport.etc2; | |
| break; | |
| default: | |
| break; | |
| } | |
| if (needFallback) | |
| { | |
| // Decode every level. Source layout: level 0 first, levels tight | |
| // (matches how we just wrote `out.blocks`). Output layout: each | |
| // level's logical width * height * 4 bytes, also tight. | |
| // | |
| // We allocate the decoded chain into a temp buffer first so the | |
| // original block bytes remain valid for the per-level decode calls. | |
| std::vector<uint8_t> decoded; | |
| size_t totalRgba = 0; | |
| for (uint32_t i = 0; i < levelCount; ++i) | |
| { | |
| const uint32_t logW = std::max<uint32_t>(1u, out.pixelWidth >> i); | |
| const uint32_t logH = std::max<uint32_t>(1u, out.pixelHeight >> i); | |
| totalRgba += static_cast<size_t>(logW) * logH * 4; | |
| } | |
| decoded.reserve(totalRgba); | |
| size_t srcOffset = 0; | |
| for (uint32_t i = 0; i < levelCount; ++i) | |
| { | |
| const uint32_t logW = std::max<uint32_t>(1u, out.pixelWidth >> i); | |
| const uint32_t logH = std::max<uint32_t>(1u, out.pixelHeight >> i); | |
| const size_t levelBytes = | |
| static_cast<size_t>(entries[i].byteLength); | |
| auto bmp = decode_texture(out.blocks.data() + srcOffset, | |
| levelBytes, | |
| logW, | |
| logH, | |
| out.format, | |
| out.blockWidth, | |
| out.blockHeight); | |
| if (!bmp) | |
| { | |
| std::fprintf(stderr, | |
| "DecodeKtx2: HW lacks support for format %u " | |
| "and software decoder unavailable (level %u)\n", | |
| static_cast<unsigned>(out.format), | |
| i); | |
| return false; | |
| } | |
| // Match the PNG runtime path: premultiplied texels. | |
| bmp->pixelFormat(Bitmap::PixelFormat::RGBAPremul); | |
| decoded.insert(decoded.end(), | |
| bmp->bytes(), | |
| bmp->bytes() + bmp->numBytes()); | |
| srcOffset += levelBytes; | |
| } | |
| out.blocks = std::move(decoded); | |
| out.format = GPUTextureFormat::rgba32; | |
| out.blockWidth = 1; | |
| out.blockHeight = 1; | |
| out.srgb = false; | |
| out.softwareDecoded = true; | |
| // `out.levelCount` already matches the KTX2 level count. | |
| } | |
| return true; | |
| } | |
| } // namespace rive |