From e24145dcfdbe84edbfa07e422501d3c1eba54f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 08:45:43 +0900 Subject: [PATCH 1/9] chore: add ktx-software submodule --- .gitmodules | 5 ++++- dependencies/KTX-Software | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) create mode 160000 dependencies/KTX-Software diff --git a/.gitmodules b/.gitmodules index 061aabc5..94660a1d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -134,4 +134,7 @@ path = dependencies/DirectX-Headers url = https://github.com/Tom94/DirectX-Headers shallow = true - +[submodule "dependencies/KTX-Software"] + path = dependencies/KTX-Software + url = https://github.com/Tom94/KTX-Software + shallow = true diff --git a/dependencies/KTX-Software b/dependencies/KTX-Software new file mode 160000 index 00000000..dd3b8b0a --- /dev/null +++ b/dependencies/KTX-Software @@ -0,0 +1 @@ +Subproject commit dd3b8b0a788c9e61ca835bec3c11961d5ffe2d6f From 84e3fc444e851b3e3ad2c33c03a37644d61f5c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 10:29:07 +0900 Subject: [PATCH 2/9] feat(ktx): add non-BC compressed KTX support --- CMakeLists.txt | 3 +- dependencies/CMakeLists.txt | 13 + include/tev/imageio/KtxImageLoader.h | 35 +++ src/HelpWindow.cpp | 2 + src/ImageViewer.cpp | 1 + src/imageio/ImageLoader.cpp | 4 + src/imageio/KtxImageLoader.cpp | 450 +++++++++++++++++++++++++++ 7 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 include/tev/imageio/KtxImageLoader.h create mode 100644 src/imageio/KtxImageLoader.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d9d582fb..78d1718c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(TEV_SOURCES include/tev/imageio/JpegTurboImageLoader.h src/imageio/JpegTurboImageLoader.cpp include/tev/imageio/JpegTurboImageSaver.h src/imageio/JpegTurboImageSaver.cpp include/tev/imageio/JxrImageLoader.h src/imageio/JxrImageLoader.cpp + include/tev/imageio/KtxImageLoader.h src/imageio/KtxImageLoader.cpp include/tev/imageio/PfmImageLoader.h src/imageio/PfmImageLoader.cpp include/tev/imageio/PngImageLoader.h src/imageio/PngImageLoader.cpp include/tev/imageio/PngImageSaver.h src/imageio/PngImageSaver.cpp @@ -276,7 +277,7 @@ endif() target_compile_definitions(tev PRIVATE ${TEV_DEFINITIONS}) -set(TEV_LIBS cfitsio clip exif_static gdcm-tev JPEG::JPEG jxrglue libraw::libraw_r nanogui OpenEXR::OpenEXR openjpeg-tev png_static tiff webpdemux XMPCore xsimd) +set(TEV_LIBS cfitsio clip exif_static gdcm-tev JPEG::JPEG jxrglue ktx_read libraw::libraw_r nanogui OpenEXR::OpenEXR openjpeg-tev png_static tiff webpdemux XMPCore xsimd) if (MSVC) list(APPEND TEV_LIBS wsock32 ws2_32) endif() diff --git a/dependencies/CMakeLists.txt b/dependencies/CMakeLists.txt index b8e5d84c..5d84dddb 100644 --- a/dependencies/CMakeLists.txt +++ b/dependencies/CMakeLists.txt @@ -383,6 +383,19 @@ target_include_directories(gdcm-tev INTERFACE ${GDCM_INCLUDE_PATH}) # Compile jxrlib for JPEG XR support add_subdirectory(jxrlib EXCLUDE_FROM_ALL) +# Compile KTX-Software for KTX2 support +set(BUILD_SHARED_LIBS OFF CACHE BOOL " " FORCE) +set(KTX_FEATURE_JS OFF CACHE BOOL " " FORCE) +set(KTX_FEATURE_TESTS OFF CACHE BOOL " " FORCE) +set(LIBKTX_VERSION_READ_ONLY ON CACHE BOOL " " FORCE) +set(LIBKTX_VERSION_FULL OFF CACHE BOOL " " FORCE) +set(LIBKTX_FEATURE_KTX1 ON CACHE BOOL " " FORCE) +set(LIBKTX_FEATURE_KTX2 ON CACHE BOOL " " FORCE) +set(LIBKTX_FEATURE_VK_UPLOAD OFF CACHE BOOL " " FORCE) +set(LIBKTX_FEATURE_GL_UPLOAD OFF CACHE BOOL " " FORCE) +set(LIBKTX_FEATURE_ETC_UNPACK ON CACHE BOOL " " FORCE) +add_subdirectory(KTX-Software EXCLUDE_FROM_ALL) + # Cross-platform SIMD set(XSIMD_SKIP_INSTALL ON CACHE BOOL " " FORCE) add_subdirectory(xsimd EXCLUDE_FROM_ALL) diff --git a/include/tev/imageio/KtxImageLoader.h b/include/tev/imageio/KtxImageLoader.h new file mode 100644 index 00000000..30d2aaa0 --- /dev/null +++ b/include/tev/imageio/KtxImageLoader.h @@ -0,0 +1,35 @@ +/* + * tev -- the EDR viewer + * + * Copyright (C) 2026 Thomas Müller + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace tev { + +class KtxImageLoader final : public ImageLoader { +public: + Task> load( + std::istringstream& iStream, const fs::path& path, std::string_view channelSelector, const ImageLoaderSettings& settings, int priority + ) const override; + + std::string name() const override { return "KTX"; } +}; + +} // namespace tev diff --git a/src/HelpWindow.cpp b/src/HelpWindow.cpp index 3650b18f..4499f03b 100644 --- a/src/HelpWindow.cpp +++ b/src/HelpWindow.cpp @@ -231,6 +231,7 @@ HelpWindow::HelpWindow(Widget* parent, weak_ptr weakIpc, function c addSpacer(about, 20); addLibrary(about, "args", "Single-header argument parsing library"); + addLibrary(about, "bcdec", "Single-header BC1-BC7 decoder library"); addLibrary(about, "cfitsio", "FITS data file library"); addLibrary(about, "clip", "Cross-platform clipboard library"); addLibrary(about, "concurrentqueue", "Cross-platform lightweight semaphore"); @@ -246,6 +247,7 @@ HelpWindow::HelpWindow(Widget* parent, weak_ptr weakIpc, function c addLibrary(about, "Glad", "Multi-language GL loader-generator"); addLibrary(about, "GLFW", "OpenGL desktop development library"); addLibrary(about, "jxrlib", "JPEG XR image format library"); + addLibrary(about, "KTX-Software", "KTX (Khronos texture) library"); #ifdef TEV_SUPPORT_HEIC addLibrary(about, "libde265", "Open h.265 video codec library"); #endif diff --git a/src/ImageViewer.cpp b/src/ImageViewer.cpp index fef83cdb..f7b4d53e 100644 --- a/src/ImageViewer.cpp +++ b/src/ImageViewer.cpp @@ -2437,6 +2437,7 @@ void ImageViewer::openImageDialog() { {"jpeg,jpg", "JPEG image" }, {"jxl", "JPEG XL image" }, {"jxr,hdp,wdp", "JPEG XR image" }, + {"ktx,ktx2", "Khronos texture" }, {"pam,pbm,pfm,pgm,pnm,ppm", "Portable *Map image" }, {"pic", "PIC image" }, {"png", "Portable Network Graphics image" }, diff --git a/src/imageio/ImageLoader.cpp b/src/imageio/ImageLoader.cpp index cf7044df..041d787b 100644 --- a/src/imageio/ImageLoader.cpp +++ b/src/imageio/ImageLoader.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -68,6 +69,7 @@ const vector>& ImageLoader::getLoaders() { #ifdef TEV_SUPPORT_JXL imageLoaders.emplace_back(new JxlImageLoader()); #endif + imageLoaders.emplace_back(new KtxImageLoader()); imageLoaders.emplace_back(new FitsImageLoader()); imageLoaders.emplace_back(new QoiImageLoader()); imageLoaders.emplace_back(new WebpImageLoader()); @@ -108,6 +110,8 @@ const vector& ImageLoader::supportedMimeTypes() { "image/jpeg", "image/jxl", "image/jxr", + "image/ktx", + "image/ktx2", "image/png", "image/qoi", "image/tga", diff --git a/src/imageio/KtxImageLoader.cpp b/src/imageio/KtxImageLoader.cpp new file mode 100644 index 00000000..6eeac66d --- /dev/null +++ b/src/imageio/KtxImageLoader.cpp @@ -0,0 +1,450 @@ +/* + * tev -- the EDR viewer + * + * Copyright (C) 2026 Thomas Müller + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include + +#include + +#include + +using namespace nanogui; +using namespace std; + +namespace tev { + +namespace { + +// VkFormat values we can read directly. Spelled out numerically so that libktx remains our only dependency -- ktx.h exposes vkFormat as a +// plain uint32 and does not pull in the Vulkan headers. +enum : uint32_t { + VK_FORMAT_UNDEFINED = 0, + VK_FORMAT_R8_UNORM = 9, + VK_FORMAT_R8_SRGB = 15, + VK_FORMAT_R8G8_UNORM = 16, + VK_FORMAT_R8G8_SRGB = 22, + VK_FORMAT_R8G8B8_UNORM = 23, + VK_FORMAT_R8G8B8_SRGB = 29, + VK_FORMAT_B8G8R8_UNORM = 30, + VK_FORMAT_B8G8R8_SRGB = 36, + VK_FORMAT_R8G8B8A8_UNORM = 37, + VK_FORMAT_R8G8B8A8_SRGB = 43, + VK_FORMAT_B8G8R8A8_UNORM = 44, + VK_FORMAT_B8G8R8A8_SRGB = 50, + VK_FORMAT_R16_UNORM = 70, + VK_FORMAT_R16_SFLOAT = 76, + VK_FORMAT_R16G16_UNORM = 77, + VK_FORMAT_R16G16_SFLOAT = 83, + VK_FORMAT_R16G16B16_UNORM = 84, + VK_FORMAT_R16G16B16_SFLOAT = 90, + VK_FORMAT_R16G16B16A16_UNORM = 91, + VK_FORMAT_R16G16B16A16_SFLOAT = 97, + VK_FORMAT_R32_SFLOAT = 100, + VK_FORMAT_R32G32_SFLOAT = 103, + VK_FORMAT_R32G32B32_SFLOAT = 106, + VK_FORMAT_R32G32B32A32_SFLOAT = 109, + // ASTC LDR occupies a contiguous core range; ASTC HDR lives in the VK_EXT_texture_compression_astc_hdr extension range. + VK_FORMAT_ASTC_4x4_UNORM_BLOCK = 157, + VK_FORMAT_ASTC_12x12_SRGB_BLOCK = 184, + VK_FORMAT_ASTC_4x4_SFLOAT_BLOCK = 1000066000, + VK_FORMAT_ASTC_12x12_SFLOAT_BLOCK = 1000066013, +}; + +bool isAstcVkFormat(const uint32_t format) { + return (format >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && format <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) || + (format >= VK_FORMAT_ASTC_4x4_SFLOAT_BLOCK && format <= VK_FORMAT_ASTC_12x12_SFLOAT_BLOCK); +} + +// Subset of GL internal formats needed for KTX1. KTX1 predates VkFormat and describes its payload with the GL enum triple instead. +enum : uint32_t { + GL_LUMINANCE = 0x1909, + GL_LUMINANCE_ALPHA = 0x190A, + GL_RGB = 0x1907, + GL_RGBA = 0x1908, + GL_R8 = 0x8229, + GL_R16 = 0x822A, + GL_RG8 = 0x822B, + GL_RG16 = 0x822C, + GL_R16F = 0x822D, + GL_R32F = 0x822E, + GL_RG16F = 0x822F, + GL_RG32F = 0x8230, + GL_RGB8 = 0x8051, + GL_RGB16 = 0x8054, + GL_RGBA8 = 0x8058, + GL_RGBA16 = 0x805B, + GL_RGBA32F = 0x8814, + GL_RGB32F = 0x8815, + GL_RGBA16F = 0x881A, + GL_RGB16F = 0x881B, + GL_SRGB8 = 0x8C41, + GL_SRGB8_ALPHA8 = 0x8C43, +}; + +enum class ESampleType { + UNorm8, + UNorm16, + Float16, + Float32, +}; + +struct SampleLayout { + size_t numChannels = 0; + ESampleType type = ESampleType::UNorm8; + bool srgb = false; + bool bgr = false; + + size_t bytesPerSample() const { + switch (type) { + case ESampleType::UNorm8: return 1; + case ESampleType::UNorm16: + case ESampleType::Float16: return 2; + case ESampleType::Float32: return 4; + } + + return 0; + } +}; + +optional layoutFromVkFormat(const uint32_t format) { + switch (format) { + case VK_FORMAT_R8_UNORM: return SampleLayout{1, ESampleType::UNorm8, false, false}; + case VK_FORMAT_R8_SRGB: return SampleLayout{1, ESampleType::UNorm8, true, false}; + case VK_FORMAT_R8G8_UNORM: return SampleLayout{2, ESampleType::UNorm8, false, false}; + case VK_FORMAT_R8G8_SRGB: return SampleLayout{2, ESampleType::UNorm8, true, false}; + case VK_FORMAT_R8G8B8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, false}; + case VK_FORMAT_R8G8B8_SRGB: return SampleLayout{3, ESampleType::UNorm8, true, false}; + case VK_FORMAT_B8G8R8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, true}; + case VK_FORMAT_B8G8R8_SRGB: return SampleLayout{3, ESampleType::UNorm8, true, true}; + case VK_FORMAT_R8G8B8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, false, false}; + case VK_FORMAT_R8G8B8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, false}; + case VK_FORMAT_B8G8R8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, false, true}; + case VK_FORMAT_B8G8R8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, true}; + + case VK_FORMAT_R16_UNORM: return SampleLayout{1, ESampleType::UNorm16, false, false}; + case VK_FORMAT_R16G16_UNORM: return SampleLayout{2, ESampleType::UNorm16, false, false}; + case VK_FORMAT_R16G16B16_UNORM: return SampleLayout{3, ESampleType::UNorm16, false, false}; + case VK_FORMAT_R16G16B16A16_UNORM: return SampleLayout{4, ESampleType::UNorm16, false, false}; + + case VK_FORMAT_R16_SFLOAT: return SampleLayout{1, ESampleType::Float16, false, false}; + case VK_FORMAT_R16G16_SFLOAT: return SampleLayout{2, ESampleType::Float16, false, false}; + case VK_FORMAT_R16G16B16_SFLOAT: return SampleLayout{3, ESampleType::Float16, false, false}; + case VK_FORMAT_R16G16B16A16_SFLOAT: return SampleLayout{4, ESampleType::Float16, false, false}; + + case VK_FORMAT_R32_SFLOAT: return SampleLayout{1, ESampleType::Float32, false, false}; + case VK_FORMAT_R32G32_SFLOAT: return SampleLayout{2, ESampleType::Float32, false, false}; + case VK_FORMAT_R32G32B32_SFLOAT: return SampleLayout{3, ESampleType::Float32, false, false}; + case VK_FORMAT_R32G32B32A32_SFLOAT: return SampleLayout{4, ESampleType::Float32, false, false}; + + default: return nullopt; + } +} + +optional layoutFromGlInternalFormat(const uint32_t format) { + switch (format) { + case GL_LUMINANCE: + case GL_R8: return SampleLayout{1, ESampleType::UNorm8, false, false}; + case GL_LUMINANCE_ALPHA: + case GL_RG8: return SampleLayout{2, ESampleType::UNorm8, false, false}; + case GL_RGB: + case GL_RGB8: return SampleLayout{3, ESampleType::UNorm8, false, false}; + case GL_RGBA: + case GL_RGBA8: return SampleLayout{4, ESampleType::UNorm8, false, false}; + + case GL_SRGB8: return SampleLayout{3, ESampleType::UNorm8, true, false}; + case GL_SRGB8_ALPHA8: return SampleLayout{4, ESampleType::UNorm8, true, false}; + + case GL_R16: return SampleLayout{1, ESampleType::UNorm16, false, false}; + case GL_RG16: return SampleLayout{2, ESampleType::UNorm16, false, false}; + case GL_RGB16: return SampleLayout{3, ESampleType::UNorm16, false, false}; + case GL_RGBA16: return SampleLayout{4, ESampleType::UNorm16, false, false}; + + case GL_R16F: return SampleLayout{1, ESampleType::Float16, false, false}; + case GL_RG16F: return SampleLayout{2, ESampleType::Float16, false, false}; + case GL_RGB16F: return SampleLayout{3, ESampleType::Float16, false, false}; + case GL_RGBA16F: return SampleLayout{4, ESampleType::Float16, false, false}; + + case GL_R32F: return SampleLayout{1, ESampleType::Float32, false, false}; + case GL_RG32F: return SampleLayout{2, ESampleType::Float32, false, false}; + case GL_RGB32F: return SampleLayout{3, ESampleType::Float32, false, false}; + case GL_RGBA32F: return SampleLayout{4, ESampleType::Float32, false, false}; + + default: return nullopt; + } +} + +struct KtxTextureDeleter { + void operator()(ktxTexture* texture) const { + if (texture) { + ktxTexture_Destroy(texture); + } + } +}; + +using KtxTexturePtr = unique_ptr; + +// KTX stores orientation in the KTXorientation metadata key. KTX1 spells it "S=r,T=d", KTX2 "rd". The GL-flavored default is bottom-up, so +// a missing or "T=u"/"ru" value means we have to flip. +bool needsVerticalFlip(ktxTexture* texture) { + ktx_uint32_t valueLen = 0; + char* value = nullptr; + + if (ktxHashList_FindValue(&texture->kvDataHead, KTX_ORIENTATION_KEY, &valueLen, (void**)&value) != KTX_SUCCESS || !value) { + // No orientation metadata. KTX1 defaults to bottom-up, KTX2 to top-down. + return texture->classId == ktxTexture1_c; + } + + const string_view orientation{value, valueLen > 0 ? valueLen - 1 : 0}; + return orientation.find('u') != string_view::npos; +} + +string makePartName(ktxTexture* texture, const uint32_t level, const uint32_t layer, const uint32_t face, const uint32_t slice) { + static const array CUBE_FACE_NAMES = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"}; + + vector components; + + if (texture->isArray && texture->numLayers > 1) { + components.emplace_back(fmt::format("layer{}", layer)); + } + + if (texture->isCubemap && texture->numFaces > 1) { + components.emplace_back(CUBE_FACE_NAMES[std::min(face, CUBE_FACE_NAMES.size() - 1)]); + } + + if (texture->baseDepth > 1) { + components.emplace_back(fmt::format("z{}", slice)); + } + + if (texture->numLevels > 1) { + components.emplace_back(fmt::format("mip{}", level)); + } + + return join(components, "."); +} + +template float normalize(const T value); + +template <> float normalize(const uint8_t value) { return value / 255.0f; } +template <> float normalize(const uint16_t value) { return value / 65535.0f; } +template <> float normalize(const half value) { return static_cast(value); } +template <> float normalize(const float value) { return value; } + +template +Task convertToFloat( + const span src, + const size_t srcRowPitch, + const MultiChannelView& dst, + const SampleLayout& layout, + const bool flipY, + const int priority +) { + const size_t numChannels = std::min(layout.numChannels, dst.nChannels()); + const bool srgb = layout.srgb; + // Only the color channels of a BGR(A) image are swizzled; alpha stays put. + const auto numSwizzled = layout.bgr ? std::min(numChannels, 3uz) : 0uz; + const auto size = dst.size(); + + co_await ThreadPool::global().parallelFor( + 0, + size.y(), + posProd(size) * numChannels * approxCost(srgb ? ituth273::ETransfer::SRGB : ituth273::ETransfer::Linear), + [&](int y) { + const int srcY = flipY ? size.y() - 1 - y : y; + const T* __restrict srcRow = reinterpret_cast(&src[static_cast(srcY) * srcRowPitch]); + + for (int x = 0; x < size.x(); ++x) { + for (size_t c = 0; c < numChannels; ++c) { + const int srcC = c < numSwizzled ? numSwizzled - 1 - c : c; + float value = normalize(srcRow[x * numChannels + srcC]); + + // Alpha is always stored linearly, even in sRGB formats. + if (srgb && c < 3) { + value = ituth273::srgbToLinear(value); + } + + dst[c, x, y] = value; + } + } + }, + priority + ); +} + +} // namespace + +bool isKtxImage(istream& iStream) { + static const uint8_t KTX1_IDENTIFIER[12] = {0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A}; + static const uint8_t KTX2_IDENTIFIER[12] = {0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A}; + + array identifier{}; + iStream.read(reinterpret_cast(identifier.data()), identifier.size()); + + const bool result = !!iStream && iStream.gcount() == identifier.size() && + (ranges::equal(identifier, KTX1_IDENTIFIER) || ranges::equal(identifier, KTX2_IDENTIFIER)); + + iStream.clear(); + iStream.seekg(0); + + return result; +} + +Task> + KtxImageLoader::load(istringstream& iStream, const fs::path&, string_view, const ImageLoaderSettings&, int priority) const { + if (!isKtxImage(iStream)) { + throw FormatNotSupported{"File is not a KTX image."}; + } + + const string buffer = iStream.str(); + + ktxTexture* rawTexture = nullptr; + if (const KTX_error_code err = ktxTexture_CreateFromMemory( + reinterpret_cast(buffer.data()), buffer.size(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &rawTexture + ); + err != KTX_SUCCESS) { + throw ImageLoadError{fmt::format("Failed to parse KTX image: {}", ktxErrorString(err))}; + } + + const KtxTexturePtr texture{rawTexture}; + + bool premultipliedAlpha = false; + + // KTX2 payloads may need decoding before we can look at them as plain samples. Supercompression + // (Zstd/ZLIB) was already undone by the LOAD_IMAGE_DATA flag above; what remains is Basis and ASTC. + if (texture->classId == ktxTexture2_c) { + ktxTexture2* texture2 = reinterpret_cast(texture.get()); + + premultipliedAlpha = ktxTexture2_GetPremultipliedAlpha(texture2); + + if (ktxTexture2_NeedsTranscoding(texture2)) { + // BasisLZ/ETC1S and UASTC. RGBA32 here means 8 bits per component, so HDR UASTC payloads + // would clamp. Transcode those to BC6H instead and hand them to the block decoder below + // once that path exists. + if (const KTX_error_code err = ktxTexture2_TranscodeBasis(texture2, KTX_TTF_RGBA32, 0); err != KTX_SUCCESS) { + throw ImageLoadError{fmt::format("Failed to transcode KTX image: {}", ktxErrorString(err))}; + } + } else if (isAstcVkFormat(texture2->vkFormat)) { + // Decodes to RGBA8, SRGBA8 or RGBA32 (float) depending on whether the payload is LDR or HDR. + if (const KTX_error_code err = ktxTexture2_DecodeAstc(texture2); err != KTX_SUCCESS) { + throw ImageLoadError{fmt::format("Failed to decode ASTC payload: {}", ktxErrorString(err))}; + } + } + } + + // Resolve the sample layout from whichever format description this KTX version carries. + optional layout; + string formatName; + + if (texture->classId == ktxTexture2_c) { + const uint32_t vkFormat = reinterpret_cast(texture.get())->vkFormat; + layout = layoutFromVkFormat(vkFormat); + formatName = fmt::format("VkFormat {}", vkFormat); + } else { + const uint32_t glInternalFormat = reinterpret_cast(texture.get())->glInternalformat; + layout = layoutFromGlInternalFormat(glInternalFormat); + formatName = fmt::format("GL internal format {:#x}", glInternalFormat); + } + + if (!layout) { + // Everything that survives to here is block compressed: BCn and ETC/EAC in KTX1, plus BCn in + // KTX2. libktx does not expose a decoder for those, so they need bcdec/etcdec on top. + throw ImageLoadError{fmt::format("Unsupported KTX pixel format ({}).", formatName)}; + } + + if (texture->isCompressed) { + throw ImageLoadError{fmt::format("Unsupported block-compressed KTX pixel format ({}).", formatName)}; + } + + tlog::debug( + "ktx image: size={} format={} channels={} premultipliedAlpha={}", + Vector3i{(int)texture->baseWidth, (int)texture->baseHeight, (int)texture->baseDepth}, + formatName, + layout->numChannels, + premultipliedAlpha + ); + + const bool flipY = needsVerticalFlip(texture.get()); + const bool hasAlpha = layout->numChannels == 2 || layout->numChannels == 4; + + const span data = {ktxTexture_GetData(texture.get()), ktxTexture_GetDataSize(texture.get())}; + + vector result; + + for (uint32_t level = 0; level < texture->numLevels; ++level) { + const Vector2i levelSize{ + (int)std::max(1u, texture->baseWidth >> level), + (int)std::max(1u, texture->baseHeight >> level), + }; + + const uint32_t numSlices = std::max(1u, texture->baseDepth >> level); + const size_t rowPitch = ktxTexture_GetRowPitch(texture.get(), level); + + for (uint32_t layer = 0; layer < texture->numLayers; ++layer) { + for (uint32_t face = 0; face < texture->numFaces; ++face) { + for (uint32_t slice = 0; slice < numSlices; ++slice) { + // For 3D textures libktx indexes depth slices through the faceSlice parameter. + const uint32_t faceSlice = texture->numFaces > 1 ? face : slice; + + size_t offset = 0; + if (const KTX_error_code err = ktxTexture_GetImageOffset(texture.get(), level, layer, faceSlice, &offset); + err != KTX_SUCCESS) { + throw ImageLoadError{fmt::format("Failed to locate KTX subimage: {}", ktxErrorString(err))}; + } + + const size_t imageSize = rowPitch * levelSize.y(); + if (offset + imageSize > data.size()) { + throw ImageLoadError{"KTX subimage extends past the end of the payload."}; + } + + ImageData& resultData = result.emplace_back(); + resultData.channels = co_await makeInterleavedChannels( + layout->numChannels, + hasAlpha, + levelSize, + EPixelFormat::F32, + layout->type == ESampleType::Float32 ? EPixelFormat::F32 : EPixelFormat::F16, + "", + priority + ); + + resultData.hasPremultipliedAlpha = hasAlpha && premultipliedAlpha; + resultData.partName = makePartName(texture.get(), level, layer, face, slice); + + const auto dst = MultiChannelView{resultData.channels}; + const span src = data.subspan(offset, imageSize); + + switch (layout->type) { + case ESampleType::UNorm8: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; + case ESampleType::UNorm16: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; + case ESampleType::Float16: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; + case ESampleType::Float32: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; + } + } + } + } + } + + if (result.empty()) { + throw ImageLoadError{"KTX image contains no subimages."}; + } + + co_return result; +} + +} // namespace tev From 97d7e66762c573ea0d05c30911c0230ad093a985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 11:10:44 +0900 Subject: [PATCH 3/9] feat(ktx): add support for BCn and ETC comp; fix alpha and esoteric formats --- .gitmodules | 8 + dependencies/bcdec | 1 + dependencies/etcdec | 1 + src/HelpWindow.cpp | 1 + src/imageio/KtxImageLoader.cpp | 522 +++++++++++++++++++++++++++------ 5 files changed, 450 insertions(+), 83 deletions(-) create mode 160000 dependencies/bcdec create mode 160000 dependencies/etcdec diff --git a/.gitmodules b/.gitmodules index 94660a1d..0df97ccb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -138,3 +138,11 @@ path = dependencies/KTX-Software url = https://github.com/Tom94/KTX-Software shallow = true +[submodule "dependencies/bcdec"] + path = dependencies/bcdec + url = https://github.com/Tom94/bcdec + shallow = true +[submodule "dependencies/etcdec"] + path = dependencies/etcdec + url = https://github.com/Tom94/etcdec + shallow = true diff --git a/dependencies/bcdec b/dependencies/bcdec new file mode 160000 index 00000000..80859ed3 --- /dev/null +++ b/dependencies/bcdec @@ -0,0 +1 @@ +Subproject commit 80859ed3b7afb1c527a2a99d70c61457bea72d0c diff --git a/dependencies/etcdec b/dependencies/etcdec new file mode 160000 index 00000000..972875d4 --- /dev/null +++ b/dependencies/etcdec @@ -0,0 +1 @@ +Subproject commit 972875d403ed8ac27e0f35c2f29d819e710a688a diff --git a/src/HelpWindow.cpp b/src/HelpWindow.cpp index 4499f03b..7a72e425 100644 --- a/src/HelpWindow.cpp +++ b/src/HelpWindow.cpp @@ -243,6 +243,7 @@ HelpWindow::HelpWindow(Widget* parent, weak_ptr weakIpc, function c addLibrary(about, "DirectXMath", "DirectX math library"); addLibrary(about, "DirectX-Headers", "DirectX headers"); #endif + addLibrary(about, "etcdec", "Single-header ETC/EAC decoder library"); addLibrary(about, "GDCM", "Grassroots library for DICOM medical files"); addLibrary(about, "Glad", "Multi-language GL loader-generator"); addLibrary(about, "GLFW", "OpenGL desktop development library"); diff --git a/src/imageio/KtxImageLoader.cpp b/src/imageio/KtxImageLoader.cpp index 6eeac66d..d70862a6 100644 --- a/src/imageio/KtxImageLoader.cpp +++ b/src/imageio/KtxImageLoader.cpp @@ -21,10 +21,22 @@ #include #include +// BCDEC_BC4BC5_PRECISE enables signed BC4/BC5 and the float decoders, but also changes the bcdec_bc4/bc5 signatures. If another TU ever +// includes bcdec, move this define to the build system so all TUs agree, and keep BCDEC_IMPLEMENTATION in exactly one of them. +#define BCDEC_IMPLEMENTATION +#define BCDEC_BC4BC5_PRECISE +#include + +#define ETCDEC_IMPLEMENTATION +#include + #include #include +#include +#include + using namespace nanogui; using namespace std; @@ -32,8 +44,8 @@ namespace tev { namespace { -// VkFormat values we can read directly. Spelled out numerically so that libktx remains our only dependency -- ktx.h exposes vkFormat as a -// plain uint32 and does not pull in the Vulkan headers. +// VkFormat values we can read. Spelled out numerically so that libktx remains our only dependency -- ktx.h exposes vkFormat as a plain +// uint32 and does not pull in the Vulkan headers. enum : uint32_t { VK_FORMAT_UNDEFINED = 0, VK_FORMAT_R8_UNORM = 9, @@ -60,6 +72,34 @@ enum : uint32_t { VK_FORMAT_R32G32_SFLOAT = 103, VK_FORMAT_R32G32B32_SFLOAT = 106, VK_FORMAT_R32G32B32A32_SFLOAT = 109, + VK_FORMAT_B10G11R11_UFLOAT_PACK32 = 122, + VK_FORMAT_E5B9G9R9_UFLOAT_PACK32 = 123, + VK_FORMAT_BC1_RGB_UNORM_BLOCK = 131, + VK_FORMAT_BC1_RGB_SRGB_BLOCK = 132, + VK_FORMAT_BC1_RGBA_UNORM_BLOCK = 133, + VK_FORMAT_BC1_RGBA_SRGB_BLOCK = 134, + VK_FORMAT_BC2_UNORM_BLOCK = 135, + VK_FORMAT_BC2_SRGB_BLOCK = 136, + VK_FORMAT_BC3_UNORM_BLOCK = 137, + VK_FORMAT_BC3_SRGB_BLOCK = 138, + VK_FORMAT_BC4_UNORM_BLOCK = 139, + VK_FORMAT_BC4_SNORM_BLOCK = 140, + VK_FORMAT_BC5_UNORM_BLOCK = 141, + VK_FORMAT_BC5_SNORM_BLOCK = 142, + VK_FORMAT_BC6H_UFLOAT_BLOCK = 143, + VK_FORMAT_BC6H_SFLOAT_BLOCK = 144, + VK_FORMAT_BC7_UNORM_BLOCK = 145, + VK_FORMAT_BC7_SRGB_BLOCK = 146, + VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK = 147, + VK_FORMAT_ETC2_R8G8B8_SRGB_BLOCK = 148, + VK_FORMAT_ETC2_R8G8B8A1_UNORM_BLOCK = 149, + VK_FORMAT_ETC2_R8G8B8A1_SRGB_BLOCK = 150, + VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK = 151, + VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK = 152, + VK_FORMAT_EAC_R11_UNORM_BLOCK = 153, + VK_FORMAT_EAC_R11_SNORM_BLOCK = 154, + VK_FORMAT_EAC_R11G11_UNORM_BLOCK = 155, + VK_FORMAT_EAC_R11G11_SNORM_BLOCK = 156, // ASTC LDR occupies a contiguous core range; ASTC HDR lives in the VK_EXT_texture_compression_astc_hdr extension range. VK_FORMAT_ASTC_4x4_UNORM_BLOCK = 157, VK_FORMAT_ASTC_12x12_SRGB_BLOCK = 184, @@ -94,8 +134,37 @@ enum : uint32_t { GL_RGB32F = 0x8815, GL_RGBA16F = 0x881A, GL_RGB16F = 0x881B, + GL_R11F_G11F_B10F = 0x8C3A, + GL_RGB9_E5 = 0x8C3D, GL_SRGB8 = 0x8C41, GL_SRGB8_ALPHA8 = 0x8C43, + GL_COMPRESSED_RGB_S3TC_DXT1_EXT = 0x83F0, + GL_COMPRESSED_RGBA_S3TC_DXT1_EXT = 0x83F1, + GL_COMPRESSED_RGBA_S3TC_DXT3_EXT = 0x83F2, + GL_COMPRESSED_RGBA_S3TC_DXT5_EXT = 0x83F3, + GL_COMPRESSED_SRGB_S3TC_DXT1_EXT = 0x8C4C, + GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT = 0x8C4D, + GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT = 0x8C4E, + GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT = 0x8C4F, + GL_COMPRESSED_RED_RGTC1 = 0x8DBB, + GL_COMPRESSED_SIGNED_RED_RGTC1 = 0x8DBC, + GL_COMPRESSED_RG_RGTC2 = 0x8DBD, + GL_COMPRESSED_SIGNED_RG_RGTC2 = 0x8DBE, + GL_COMPRESSED_RGBA_BPTC_UNORM = 0x8E8C, + GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM = 0x8E8D, + GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT = 0x8E8E, + GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT = 0x8E8F, + GL_ETC1_RGB8_OES = 0x8D64, + GL_COMPRESSED_R11_EAC = 0x9270, + GL_COMPRESSED_SIGNED_R11_EAC = 0x9271, + GL_COMPRESSED_RG11_EAC = 0x9272, + GL_COMPRESSED_SIGNED_RG11_EAC = 0x9273, + GL_COMPRESSED_RGB8_ETC2 = 0x9274, + GL_COMPRESSED_SRGB8_ETC2 = 0x9275, + GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9276, + GL_COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9277, + GL_COMPRESSED_RGBA8_ETC2_EAC = 0x9278, + GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC = 0x9279, }; enum class ESampleType { @@ -106,11 +175,17 @@ enum class ESampleType { }; struct SampleLayout { + // Interleaved channels per pixel in the source. May exceed the number of output channels: BC1 RGB and ETC2 RGB decode to RGBA8 whose + // alpha must be dropped. size_t numChannels = 0; ESampleType type = ESampleType::UNorm8; + // Whether the last channel is alpha. Must be explicit because 2-channel formats can be either luminance-alpha or RG. + bool alpha = false; bool srgb = false; bool bgr = false; + size_t numOutputChannels() const { return alpha ? numChannels : std::min(numChannels, 3uz); } + size_t bytesPerSample() const { switch (type) { case ESampleType::UNorm8: return 1; @@ -123,73 +198,326 @@ struct SampleLayout { } }; +// Field order: {numChannels, type, alpha, srgb, bgr} optional layoutFromVkFormat(const uint32_t format) { switch (format) { - case VK_FORMAT_R8_UNORM: return SampleLayout{1, ESampleType::UNorm8, false, false}; - case VK_FORMAT_R8_SRGB: return SampleLayout{1, ESampleType::UNorm8, true, false}; - case VK_FORMAT_R8G8_UNORM: return SampleLayout{2, ESampleType::UNorm8, false, false}; - case VK_FORMAT_R8G8_SRGB: return SampleLayout{2, ESampleType::UNorm8, true, false}; - case VK_FORMAT_R8G8B8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, false}; - case VK_FORMAT_R8G8B8_SRGB: return SampleLayout{3, ESampleType::UNorm8, true, false}; - case VK_FORMAT_B8G8R8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, true}; - case VK_FORMAT_B8G8R8_SRGB: return SampleLayout{3, ESampleType::UNorm8, true, true}; - case VK_FORMAT_R8G8B8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, false, false}; - case VK_FORMAT_R8G8B8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, false}; - case VK_FORMAT_B8G8R8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, false, true}; - case VK_FORMAT_B8G8R8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, true}; - - case VK_FORMAT_R16_UNORM: return SampleLayout{1, ESampleType::UNorm16, false, false}; - case VK_FORMAT_R16G16_UNORM: return SampleLayout{2, ESampleType::UNorm16, false, false}; - case VK_FORMAT_R16G16B16_UNORM: return SampleLayout{3, ESampleType::UNorm16, false, false}; - case VK_FORMAT_R16G16B16A16_UNORM: return SampleLayout{4, ESampleType::UNorm16, false, false}; - - case VK_FORMAT_R16_SFLOAT: return SampleLayout{1, ESampleType::Float16, false, false}; - case VK_FORMAT_R16G16_SFLOAT: return SampleLayout{2, ESampleType::Float16, false, false}; - case VK_FORMAT_R16G16B16_SFLOAT: return SampleLayout{3, ESampleType::Float16, false, false}; - case VK_FORMAT_R16G16B16A16_SFLOAT: return SampleLayout{4, ESampleType::Float16, false, false}; - - case VK_FORMAT_R32_SFLOAT: return SampleLayout{1, ESampleType::Float32, false, false}; - case VK_FORMAT_R32G32_SFLOAT: return SampleLayout{2, ESampleType::Float32, false, false}; - case VK_FORMAT_R32G32B32_SFLOAT: return SampleLayout{3, ESampleType::Float32, false, false}; - case VK_FORMAT_R32G32B32A32_SFLOAT: return SampleLayout{4, ESampleType::Float32, false, false}; + case VK_FORMAT_R8_UNORM: return SampleLayout{1, ESampleType::UNorm8, false, false, false}; + case VK_FORMAT_R8_SRGB: return SampleLayout{1, ESampleType::UNorm8, false, true, false}; + case VK_FORMAT_R8G8_UNORM: return SampleLayout{2, ESampleType::UNorm8, false, false, false}; + case VK_FORMAT_R8G8_SRGB: return SampleLayout{2, ESampleType::UNorm8, false, true, false}; + case VK_FORMAT_R8G8B8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, false, false}; + case VK_FORMAT_R8G8B8_SRGB: return SampleLayout{3, ESampleType::UNorm8, false, true, false}; + case VK_FORMAT_B8G8R8_UNORM: return SampleLayout{3, ESampleType::UNorm8, false, false, true}; + case VK_FORMAT_B8G8R8_SRGB: return SampleLayout{3, ESampleType::UNorm8, false, true, true}; + case VK_FORMAT_R8G8B8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, true, false, false}; + case VK_FORMAT_R8G8B8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, true, false}; + case VK_FORMAT_B8G8R8A8_UNORM: return SampleLayout{4, ESampleType::UNorm8, true, false, true}; + case VK_FORMAT_B8G8R8A8_SRGB: return SampleLayout{4, ESampleType::UNorm8, true, true, true}; + + case VK_FORMAT_R16_UNORM: return SampleLayout{1, ESampleType::UNorm16, false, false, false}; + case VK_FORMAT_R16G16_UNORM: return SampleLayout{2, ESampleType::UNorm16, false, false, false}; + case VK_FORMAT_R16G16B16_UNORM: return SampleLayout{3, ESampleType::UNorm16, false, false, false}; + case VK_FORMAT_R16G16B16A16_UNORM: return SampleLayout{4, ESampleType::UNorm16, true, false, false}; + + case VK_FORMAT_R16_SFLOAT: return SampleLayout{1, ESampleType::Float16, false, false, false}; + case VK_FORMAT_R16G16_SFLOAT: return SampleLayout{2, ESampleType::Float16, false, false, false}; + case VK_FORMAT_R16G16B16_SFLOAT: return SampleLayout{3, ESampleType::Float16, false, false, false}; + case VK_FORMAT_R16G16B16A16_SFLOAT: return SampleLayout{4, ESampleType::Float16, true, false, false}; + + case VK_FORMAT_R32_SFLOAT: return SampleLayout{1, ESampleType::Float32, false, false, false}; + case VK_FORMAT_R32G32_SFLOAT: return SampleLayout{2, ESampleType::Float32, false, false, false}; + case VK_FORMAT_R32G32B32_SFLOAT: return SampleLayout{3, ESampleType::Float32, false, false, false}; + case VK_FORMAT_R32G32B32A32_SFLOAT: return SampleLayout{4, ESampleType::Float32, true, false, false}; default: return nullopt; } } +// Field order: {numChannels, type, alpha, srgb, bgr} optional layoutFromGlInternalFormat(const uint32_t format) { switch (format) { case GL_LUMINANCE: - case GL_R8: return SampleLayout{1, ESampleType::UNorm8, false, false}; - case GL_LUMINANCE_ALPHA: - case GL_RG8: return SampleLayout{2, ESampleType::UNorm8, false, false}; + case GL_R8: return SampleLayout{1, ESampleType::UNorm8, false, false, false}; + case GL_LUMINANCE_ALPHA: return SampleLayout{2, ESampleType::UNorm8, true, false, false}; + case GL_RG8: return SampleLayout{2, ESampleType::UNorm8, false, false, false}; case GL_RGB: - case GL_RGB8: return SampleLayout{3, ESampleType::UNorm8, false, false}; + case GL_RGB8: return SampleLayout{3, ESampleType::UNorm8, false, false, false}; case GL_RGBA: - case GL_RGBA8: return SampleLayout{4, ESampleType::UNorm8, false, false}; + case GL_RGBA8: return SampleLayout{4, ESampleType::UNorm8, true, false, false}; - case GL_SRGB8: return SampleLayout{3, ESampleType::UNorm8, true, false}; - case GL_SRGB8_ALPHA8: return SampleLayout{4, ESampleType::UNorm8, true, false}; + case GL_SRGB8: return SampleLayout{3, ESampleType::UNorm8, false, true, false}; + case GL_SRGB8_ALPHA8: return SampleLayout{4, ESampleType::UNorm8, true, true, false}; - case GL_R16: return SampleLayout{1, ESampleType::UNorm16, false, false}; - case GL_RG16: return SampleLayout{2, ESampleType::UNorm16, false, false}; - case GL_RGB16: return SampleLayout{3, ESampleType::UNorm16, false, false}; - case GL_RGBA16: return SampleLayout{4, ESampleType::UNorm16, false, false}; + case GL_R16: return SampleLayout{1, ESampleType::UNorm16, false, false, false}; + case GL_RG16: return SampleLayout{2, ESampleType::UNorm16, false, false, false}; + case GL_RGB16: return SampleLayout{3, ESampleType::UNorm16, false, false, false}; + case GL_RGBA16: return SampleLayout{4, ESampleType::UNorm16, true, false, false}; - case GL_R16F: return SampleLayout{1, ESampleType::Float16, false, false}; - case GL_RG16F: return SampleLayout{2, ESampleType::Float16, false, false}; - case GL_RGB16F: return SampleLayout{3, ESampleType::Float16, false, false}; - case GL_RGBA16F: return SampleLayout{4, ESampleType::Float16, false, false}; + case GL_R16F: return SampleLayout{1, ESampleType::Float16, false, false, false}; + case GL_RG16F: return SampleLayout{2, ESampleType::Float16, false, false, false}; + case GL_RGB16F: return SampleLayout{3, ESampleType::Float16, false, false, false}; + case GL_RGBA16F: return SampleLayout{4, ESampleType::Float16, true, false, false}; - case GL_R32F: return SampleLayout{1, ESampleType::Float32, false, false}; - case GL_RG32F: return SampleLayout{2, ESampleType::Float32, false, false}; - case GL_RGB32F: return SampleLayout{3, ESampleType::Float32, false, false}; - case GL_RGBA32F: return SampleLayout{4, ESampleType::Float32, false, false}; + case GL_R32F: return SampleLayout{1, ESampleType::Float32, false, false, false}; + case GL_RG32F: return SampleLayout{2, ESampleType::Float32, false, false, false}; + case GL_RGB32F: return SampleLayout{3, ESampleType::Float32, false, false, false}; + case GL_RGBA32F: return SampleLayout{4, ESampleType::Float32, true, false, false}; default: return nullopt; } } +// Formats that can't be read sample-by-sample and are first decoded into an intermediate buffer: block-compressed BCn/ETC/EAC, and the +// packed float formats, which are treated as 1x1 "blocks" so they share the same path. +enum class EEncoding { + BC1, + BC1A, + BC2, + BC3, + BC4, + BC5, + BC6H, + BC7, + ETC2_RGB, + ETC2_RGB_A1, + ETC2_RGBA, + EAC_R11, + EAC_RG11, + B10G11R11, + E5B9G9R9, +}; + +struct EncodedLayout { + EEncoding encoding; + bool srgb = false; + bool isSigned = false; + + int blockDim() const { return encoding == EEncoding::B10G11R11 || encoding == EEncoding::E5B9G9R9 ? 1 : 4; } + + size_t blockBytes() const { + switch (encoding) { + case EEncoding::B10G11R11: + case EEncoding::E5B9G9R9: return 4; + case EEncoding::BC1: + case EEncoding::BC1A: + case EEncoding::BC4: + case EEncoding::ETC2_RGB: + case EEncoding::ETC2_RGB_A1: + case EEncoding::EAC_R11: return 8; + default: return 16; + } + } + + size_t encodedSize(const Vector2i& size) const { + const int dim = blockDim(); + return (size_t)((size.x() + dim - 1) / dim) * (size_t)((size.y() + dim - 1) / dim) * blockBytes(); + } + + // Unit, in bytes, in which the decoder expects its destination pitch. bcdec's float and half decoders step a typed + // pointer, everything else (including etcdec's float decoders) steps bytes. + size_t pitchUnitBytes() const { + switch (encoding) { + case EEncoding::BC4: + case EEncoding::BC5: return sizeof(float); + case EEncoding::BC6H: return sizeof(uint16_t); + default: return 1; + } + } + + // Field order: {numChannels, type, alpha, srgb, bgr} + SampleLayout decodedLayout() const { + switch (encoding) { + // Opaque BC1 and ETC2 RGB still decode to RGBA8, and BC1's punch-through blocks write alpha 0. Drop the alpha channel. + case EEncoding::BC1: + case EEncoding::ETC2_RGB: return {4, ESampleType::UNorm8, false, srgb, false}; + case EEncoding::BC1A: + case EEncoding::BC2: + case EEncoding::BC3: + case EEncoding::BC7: + case EEncoding::ETC2_RGB_A1: + case EEncoding::ETC2_RGBA: return {4, ESampleType::UNorm8, true, srgb, false}; + // BC4/BC5 go through bcdec's float decoders so that SNORM variants land in [-1,1] without a signed sample type. + case EEncoding::BC4: return {1, ESampleType::Float32, false, false, false}; + case EEncoding::BC5: return {2, ESampleType::Float32, false, false, false}; + // BC6H's native precision is half. + case EEncoding::BC6H: return {3, ESampleType::Float16, false, false, false}; + // etcdec expands the 11-bit EAC values to 16 bits. + case EEncoding::EAC_R11: return {1, ESampleType::UNorm16, false, false, false}; + case EEncoding::EAC_RG11: return {2, ESampleType::UNorm16, false, false, false}; + case EEncoding::B10G11R11: + case EEncoding::E5B9G9R9: return {3, ESampleType::Float32, false, false, false}; + } + + return {}; + } +}; + +// Signed EAC is deliberately absent: etcdec decodes it with the unsigned formula and reinterprets the result, which does +// not match the spec's signed base codeword and clamping. Better to report it as unsupported than to show wrong values. +optional encodedLayoutFromVkFormat(const uint32_t format) { + switch (format) { + case VK_FORMAT_BC1_RGB_UNORM_BLOCK: return EncodedLayout{EEncoding::BC1, false, false}; + case VK_FORMAT_BC1_RGB_SRGB_BLOCK: return EncodedLayout{EEncoding::BC1, true, false}; + case VK_FORMAT_BC1_RGBA_UNORM_BLOCK: return EncodedLayout{EEncoding::BC1A, false, false}; + case VK_FORMAT_BC1_RGBA_SRGB_BLOCK: return EncodedLayout{EEncoding::BC1A, true, false}; + case VK_FORMAT_BC2_UNORM_BLOCK: return EncodedLayout{EEncoding::BC2, false, false}; + case VK_FORMAT_BC2_SRGB_BLOCK: return EncodedLayout{EEncoding::BC2, true, false}; + case VK_FORMAT_BC3_UNORM_BLOCK: return EncodedLayout{EEncoding::BC3, false, false}; + case VK_FORMAT_BC3_SRGB_BLOCK: return EncodedLayout{EEncoding::BC3, true, false}; + case VK_FORMAT_BC4_UNORM_BLOCK: return EncodedLayout{EEncoding::BC4, false, false}; + case VK_FORMAT_BC4_SNORM_BLOCK: return EncodedLayout{EEncoding::BC4, false, true}; + case VK_FORMAT_BC5_UNORM_BLOCK: return EncodedLayout{EEncoding::BC5, false, false}; + case VK_FORMAT_BC5_SNORM_BLOCK: return EncodedLayout{EEncoding::BC5, false, true}; + case VK_FORMAT_BC6H_UFLOAT_BLOCK: return EncodedLayout{EEncoding::BC6H, false, false}; + case VK_FORMAT_BC6H_SFLOAT_BLOCK: return EncodedLayout{EEncoding::BC6H, false, true}; + case VK_FORMAT_BC7_UNORM_BLOCK: return EncodedLayout{EEncoding::BC7, false, false}; + case VK_FORMAT_BC7_SRGB_BLOCK: return EncodedLayout{EEncoding::BC7, true, false}; + + // KTX2 has no ETC1 format; ETC1 data is stored as ETC2 RGB, which is a superset. + case VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK: return EncodedLayout{EEncoding::ETC2_RGB, false, false}; + case VK_FORMAT_ETC2_R8G8B8_SRGB_BLOCK: return EncodedLayout{EEncoding::ETC2_RGB, true, false}; + case VK_FORMAT_ETC2_R8G8B8A1_UNORM_BLOCK: return EncodedLayout{EEncoding::ETC2_RGB_A1, false, false}; + case VK_FORMAT_ETC2_R8G8B8A1_SRGB_BLOCK: return EncodedLayout{EEncoding::ETC2_RGB_A1, true, false}; + case VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK: return EncodedLayout{EEncoding::ETC2_RGBA, false, false}; + case VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK: return EncodedLayout{EEncoding::ETC2_RGBA, true, false}; + case VK_FORMAT_EAC_R11_UNORM_BLOCK: return EncodedLayout{EEncoding::EAC_R11, false, false}; + case VK_FORMAT_EAC_R11_SNORM_BLOCK: return EncodedLayout{EEncoding::EAC_R11, false, true}; + case VK_FORMAT_EAC_R11G11_UNORM_BLOCK: return EncodedLayout{EEncoding::EAC_RG11, false, false}; + case VK_FORMAT_EAC_R11G11_SNORM_BLOCK: return EncodedLayout{EEncoding::EAC_RG11, false, true}; + + case VK_FORMAT_B10G11R11_UFLOAT_PACK32: return EncodedLayout{EEncoding::B10G11R11, false, false}; + case VK_FORMAT_E5B9G9R9_UFLOAT_PACK32: return EncodedLayout{EEncoding::E5B9G9R9, false, false}; + + default: return nullopt; + } +} + +optional encodedLayoutFromGlInternalFormat(const uint32_t format) { + switch (format) { + case GL_COMPRESSED_RGB_S3TC_DXT1_EXT: return EncodedLayout{EEncoding::BC1, false, false}; + case GL_COMPRESSED_SRGB_S3TC_DXT1_EXT: return EncodedLayout{EEncoding::BC1, true, false}; + case GL_COMPRESSED_RGBA_S3TC_DXT1_EXT: return EncodedLayout{EEncoding::BC1A, false, false}; + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT: return EncodedLayout{EEncoding::BC1A, true, false}; + case GL_COMPRESSED_RGBA_S3TC_DXT3_EXT: return EncodedLayout{EEncoding::BC2, false, false}; + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT: return EncodedLayout{EEncoding::BC2, true, false}; + case GL_COMPRESSED_RGBA_S3TC_DXT5_EXT: return EncodedLayout{EEncoding::BC3, false, false}; + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT: return EncodedLayout{EEncoding::BC3, true, false}; + case GL_COMPRESSED_RED_RGTC1: return EncodedLayout{EEncoding::BC4, false, false}; + case GL_COMPRESSED_SIGNED_RED_RGTC1: return EncodedLayout{EEncoding::BC4, false, true}; + case GL_COMPRESSED_RG_RGTC2: return EncodedLayout{EEncoding::BC5, false, false}; + case GL_COMPRESSED_SIGNED_RG_RGTC2: return EncodedLayout{EEncoding::BC5, false, true}; + case GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT: return EncodedLayout{EEncoding::BC6H, false, false}; + case GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT: return EncodedLayout{EEncoding::BC6H, false, true}; + case GL_COMPRESSED_RGBA_BPTC_UNORM: return EncodedLayout{EEncoding::BC7, false, false}; + case GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM: return EncodedLayout{EEncoding::BC7, true, false}; + + case GL_ETC1_RGB8_OES: + case GL_COMPRESSED_RGB8_ETC2: return EncodedLayout{EEncoding::ETC2_RGB, false, false}; + case GL_COMPRESSED_SRGB8_ETC2: return EncodedLayout{EEncoding::ETC2_RGB, true, false}; + case GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2: return EncodedLayout{EEncoding::ETC2_RGB_A1, false, false}; + case GL_COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2: return EncodedLayout{EEncoding::ETC2_RGB_A1, true, false}; + case GL_COMPRESSED_RGBA8_ETC2_EAC: return EncodedLayout{EEncoding::ETC2_RGBA, false, false}; + case GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC: return EncodedLayout{EEncoding::ETC2_RGBA, true, false}; + case GL_COMPRESSED_R11_EAC: return EncodedLayout{EEncoding::EAC_R11, false, false}; + case GL_COMPRESSED_SIGNED_R11_EAC: return EncodedLayout{EEncoding::EAC_R11, false, true}; + case GL_COMPRESSED_RG11_EAC: return EncodedLayout{EEncoding::EAC_RG11, false, false}; + case GL_COMPRESSED_SIGNED_RG11_EAC: return EncodedLayout{EEncoding::EAC_RG11, false, true}; + + case GL_R11F_G11F_B10F: return EncodedLayout{EEncoding::B10G11R11, false, false}; + case GL_RGB9_E5: return EncodedLayout{EEncoding::E5B9G9R9, false, false}; + + default: return nullopt; + } +} + +// The 11- and 10-bit unsigned floats share half's 5-bit exponent and lack a sign bit, so shifting the mantissa up to half's +// 10 bits yields a valid half bit pattern -- including denormals, infinity and NaN. +float unpackUFloat(const uint32_t bits, const int numMantissaBits) { + half h; + h.setBits((uint16_t)(bits << (10 - numMantissaBits))); + return h; +} + +void decodeBlock(const EncodedLayout& encoded, const uint8_t* block, uint8_t* dst, const int dstPitch) { + switch (encoded.encoding) { + case EEncoding::BC1: + case EEncoding::BC1A: bcdec_bc1(block, dst, dstPitch); break; + case EEncoding::BC2: bcdec_bc2(block, dst, dstPitch); break; + case EEncoding::BC3: bcdec_bc3(block, dst, dstPitch); break; + case EEncoding::BC4: bcdec_bc4_float(block, dst, dstPitch, encoded.isSigned); break; + case EEncoding::BC5: bcdec_bc5_float(block, dst, dstPitch, encoded.isSigned); break; + case EEncoding::BC6H: bcdec_bc6h_half(block, dst, dstPitch, encoded.isSigned); break; + case EEncoding::BC7: bcdec_bc7(block, dst, dstPitch); break; + + case EEncoding::ETC2_RGB: etcdec_etc_rgb(block, dst, dstPitch); break; + case EEncoding::ETC2_RGB_A1: etcdec_etc_rgb_a1(block, dst, dstPitch); break; + case EEncoding::ETC2_RGBA: etcdec_eac_rgba(block, dst, dstPitch); break; + case EEncoding::EAC_R11: etcdec_eac_r11_u16(block, dst, dstPitch); break; + case EEncoding::EAC_RG11: etcdec_eac_rg11_u16(block, dst, dstPitch); break; + + case EEncoding::B10G11R11: { + uint32_t v; + memcpy(&v, block, sizeof(v)); + float* out = reinterpret_cast(dst); + out[0] = unpackUFloat(v & 0x7FF, 6); + out[1] = unpackUFloat((v >> 11) & 0x7FF, 6); + out[2] = unpackUFloat(v >> 22, 5); + } break; + + case EEncoding::E5B9G9R9: { + uint32_t v; + memcpy(&v, block, sizeof(v)); + // Shared exponent with bias 15, and 9-bit mantissas without an implicit leading one. + const float scale = ldexp(1.0f, (int)(v >> 27) - 15 - 9); + float* out = reinterpret_cast(dst); + out[0] = (v & 0x1FF) * scale; + out[1] = ((v >> 9) & 0x1FF) * scale; + out[2] = ((v >> 18) & 0x1FF) * scale; + } break; + } +} + +struct DecodedImage { + vector data; + size_t rowPitch = 0; +}; + +// Decodes into a buffer padded to whole blocks. convertToFloat only reads the unpadded region, so edge blocks need no special care. +Task decode(const span src, const Vector2i& size, const EncodedLayout& encoded, const int priority) { + const SampleLayout layout = encoded.decodedLayout(); + + const int blockDim = encoded.blockDim(); + const int numBlocksX = (size.x() + blockDim - 1) / blockDim; + const int numBlocksY = (size.y() + blockDim - 1) / blockDim; + const size_t pixelBytes = layout.numChannels * layout.bytesPerSample(); + const size_t blockBytes = encoded.blockBytes(); + + DecodedImage result; + result.rowPitch = (size_t)numBlocksX * blockDim * pixelBytes; + result.data.resize(result.rowPitch * numBlocksY * blockDim); + + const int dstPitch = (int)(result.rowPitch / encoded.pitchUnitBytes()); + + co_await ThreadPool::global().parallelFor( + 0, + numBlocksY, + (size_t)numBlocksX * numBlocksY * blockDim * blockDim * layout.numChannels, + [&](int by) { + for (int bx = 0; bx < numBlocksX; ++bx) { + // etcdec reads blocks through unsigned long long*, but KTX1 image offsets are only guaranteed to be 4-byte aligned. + alignas(16) uint8_t block[16]; + memcpy(block, &src[((size_t)by * numBlocksX + bx) * blockBytes], blockBytes); + + uint8_t* dst = &result.data[(size_t)by * blockDim * result.rowPitch + (size_t)bx * blockDim * pixelBytes]; + decodeBlock(encoded, block, dst, dstPitch); + } + }, + priority + ); + + co_return result; +} + struct KtxTextureDeleter { void operator()(ktxTexture* texture) const { if (texture) { @@ -200,19 +528,28 @@ struct KtxTextureDeleter { using KtxTexturePtr = unique_ptr; -// KTX stores orientation in the KTXorientation metadata key. KTX1 spells it "S=r,T=d", KTX2 "rd". The GL-flavored default is bottom-up, so -// a missing or "T=u"/"ru" value means we have to flip. -bool needsVerticalFlip(ktxTexture* texture) { +// KTX asks for NUL-terminated metadata strings, but not every writer emits the terminator. +string_view readStringValue(ktxTexture* texture, const char* key) { ktx_uint32_t valueLen = 0; char* value = nullptr; - if (ktxHashList_FindValue(&texture->kvDataHead, KTX_ORIENTATION_KEY, &valueLen, (void**)&value) != KTX_SUCCESS || !value) { - // No orientation metadata. KTX1 defaults to bottom-up, KTX2 to top-down. - return texture->classId == ktxTexture1_c; + if (ktxHashList_FindValue(&texture->kvDataHead, key, &valueLen, (void**)&value) != KTX_SUCCESS || !value) { + return {}; } - const string_view orientation{value, valueLen > 0 ? valueLen - 1 : 0}; - return orientation.find('u') != string_view::npos; + string_view result{value, valueLen}; + while (!result.empty() && result.back() == '\0') { + result.remove_suffix(1); + } + + return result; +} + +// KTX stores orientation in the KTXorientation metadata key. KTX1 spells it "S=r,T=d", KTX2 "rd". The GL-flavored default +// is bottom-up, so a missing or "T=u"/"ru" value means we have to flip. +bool needsVerticalFlip(ktxTexture* texture) { + const string_view orientation = readStringValue(texture, KTX_ORIENTATION_KEY); + return orientation.empty() ? texture->classId == ktxTexture1_c : orientation.find('u') != string_view::npos; } string makePartName(ktxTexture* texture, const uint32_t level, const uint32_t layer, const uint32_t face, const uint32_t slice) { @@ -255,10 +592,11 @@ Task convertToFloat( const bool flipY, const int priority ) { - const size_t numChannels = std::min(layout.numChannels, dst.nChannels()); + const size_t srcStride = layout.numChannels; + const size_t numChannels = std::min(layout.numOutputChannels(), dst.nChannels()); const bool srgb = layout.srgb; // Only the color channels of a BGR(A) image are swizzled; alpha stays put. - const auto numSwizzled = layout.bgr ? std::min(numChannels, 3uz) : 0uz; + const size_t numSwizzled = layout.bgr ? std::min(numChannels, 3uz) : 0uz; const auto size = dst.size(); co_await ThreadPool::global().parallelFor( @@ -271,8 +609,8 @@ Task convertToFloat( for (int x = 0; x < size.x(); ++x) { for (size_t c = 0; c < numChannels; ++c) { - const int srcC = c < numSwizzled ? numSwizzled - 1 - c : c; - float value = normalize(srcRow[x * numChannels + srcC]); + const size_t srcC = c < numSwizzled ? numSwizzled - 1 - c : c; + float value = normalize(srcRow[x * srcStride + srcC]); // Alpha is always stored linearly, even in sRGB formats. if (srgb && c < 3) { @@ -325,18 +663,18 @@ Task> bool premultipliedAlpha = false; - // KTX2 payloads may need decoding before we can look at them as plain samples. Supercompression - // (Zstd/ZLIB) was already undone by the LOAD_IMAGE_DATA flag above; what remains is Basis and ASTC. + // KTX2 payloads may need decoding before we can look at them as plain samples. Supercompression (Zstd/ZLIB) was already undone + // by the LOAD_IMAGE_DATA flag above; what remains is Basis and ASTC. if (texture->classId == ktxTexture2_c) { ktxTexture2* texture2 = reinterpret_cast(texture.get()); premultipliedAlpha = ktxTexture2_GetPremultipliedAlpha(texture2); if (ktxTexture2_NeedsTranscoding(texture2)) { - // BasisLZ/ETC1S and UASTC. RGBA32 here means 8 bits per component, so HDR UASTC payloads - // would clamp. Transcode those to BC6H instead and hand them to the block decoder below - // once that path exists. - if (const KTX_error_code err = ktxTexture2_TranscodeBasis(texture2, KTX_TTF_RGBA32, 0); err != KTX_SUCCESS) { + // BasisLZ/ETC1S and UASTC. UASTC HDR would clamp in 8-bit RGBA32, so it gets a half-float target instead. Both land as + // plain VkFormats handled below. + const ktx_transcode_fmt_e target = ktxTexture2_IsHDR(texture2) ? KTX_TTF_RGB_HALF : KTX_TTF_RGBA32; + if (const KTX_error_code err = ktxTexture2_TranscodeBasis(texture2, target, 0); err != KTX_SUCCESS) { throw ImageLoadError{fmt::format("Failed to transcode KTX image: {}", ktxErrorString(err))}; } } else if (isAstcVkFormat(texture2->vkFormat)) { @@ -349,38 +687,45 @@ Task> // Resolve the sample layout from whichever format description this KTX version carries. optional layout; + optional encoded; string formatName; if (texture->classId == ktxTexture2_c) { const uint32_t vkFormat = reinterpret_cast(texture.get())->vkFormat; layout = layoutFromVkFormat(vkFormat); + encoded = encodedLayoutFromVkFormat(vkFormat); formatName = fmt::format("VkFormat {}", vkFormat); } else { const uint32_t glInternalFormat = reinterpret_cast(texture.get())->glInternalformat; layout = layoutFromGlInternalFormat(glInternalFormat); + encoded = encodedLayoutFromGlInternalFormat(glInternalFormat); formatName = fmt::format("GL internal format {:#x}", glInternalFormat); } - if (!layout) { - // Everything that survives to here is block compressed: BCn and ETC/EAC in KTX1, plus BCn in - // KTX2. libktx does not expose a decoder for those, so they need bcdec/etcdec on top. + if (encoded) { + // Decoded into an intermediate buffer first, after which it's converted like any other image. + layout = encoded->decodedLayout(); + } else if (!layout || texture->isCompressed) { throw ImageLoadError{fmt::format("Unsupported KTX pixel format ({}).", formatName)}; } - if (texture->isCompressed) { - throw ImageLoadError{fmt::format("Unsupported block-compressed KTX pixel format ({}).", formatName)}; + // KTXswizzle maps output RGBA to source components. KTX2 has no luminance formats, so a two-channel format whose alpha is + // sourced from G is luminance-alpha. This applies to BC5 and EAC RG11 as much as to uncompressed RG. + if (const string_view swizzle = readStringValue(texture.get(), KTX_SWIZZLE_KEY); + layout->numChannels == 2 && swizzle.size() == 4 && swizzle[3] == 'g') { + layout->alpha = true; } tlog::debug( - "ktx image: size={} format={} channels={} premultipliedAlpha={}", + "ktx image: size={} format={} channels={} alpha={} premultipliedAlpha={}", Vector3i{(int)texture->baseWidth, (int)texture->baseHeight, (int)texture->baseDepth}, formatName, - layout->numChannels, + layout->numOutputChannels(), + layout->alpha, premultipliedAlpha ); const bool flipY = needsVerticalFlip(texture.get()); - const bool hasAlpha = layout->numChannels == 2 || layout->numChannels == 4; const span data = {ktxTexture_GetData(texture.get()), ktxTexture_GetDataSize(texture.get())}; @@ -407,15 +752,16 @@ Task> throw ImageLoadError{fmt::format("Failed to locate KTX subimage: {}", ktxErrorString(err))}; } - const size_t imageSize = rowPitch * levelSize.y(); + // For compressed formats ktxTexture_GetRowPitch is the pitch of a row of *blocks*, so rowPitch * height would overshoot. + const size_t imageSize = encoded ? encoded->encodedSize(levelSize) : rowPitch * levelSize.y(); if (offset + imageSize > data.size()) { throw ImageLoadError{"KTX subimage extends past the end of the payload."}; } ImageData& resultData = result.emplace_back(); resultData.channels = co_await makeInterleavedChannels( - layout->numChannels, - hasAlpha, + layout->numOutputChannels(), + layout->alpha, levelSize, EPixelFormat::F32, layout->type == ESampleType::Float32 ? EPixelFormat::F32 : EPixelFormat::F16, @@ -423,17 +769,27 @@ Task> priority ); - resultData.hasPremultipliedAlpha = hasAlpha && premultipliedAlpha; + resultData.hasPremultipliedAlpha = layout->alpha && premultipliedAlpha; resultData.partName = makePartName(texture.get(), level, layer, face, slice); const auto dst = MultiChannelView{resultData.channels}; - const span src = data.subspan(offset, imageSize); + span src = data.subspan(offset, imageSize); + size_t srcRowPitch = rowPitch; + + DecodedImage decoded; + if (encoded) { + decoded = co_await decode(src, levelSize, *encoded, priority); + src = decoded.data; + srcRowPitch = decoded.rowPitch; + } switch (layout->type) { - case ESampleType::UNorm8: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; - case ESampleType::UNorm16: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; - case ESampleType::Float16: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; - case ESampleType::Float32: co_await convertToFloat(src, rowPitch, dst, *layout, flipY, priority); break; + case ESampleType::UNorm8: co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); break; + case ESampleType::UNorm16: + co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); + break; + case ESampleType::Float16: co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); break; + case ESampleType::Float32: co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); break; } } } From ea70754a56d6a825714d1c72aa8781ec0d6caff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 13:04:38 +0900 Subject: [PATCH 4/9] feat(readme): mention KTX/KTX2 support --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 506602f2..8f731a90 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ $ cpack --config build/CPackConfig.cmake - __PFM__, PAM, PNM, PBM, PGM, PPM (generally: see [NetPBM](https://netpbm.sourceforge.net/) and [PFM](https://www.pauldebevec.com/Research/HDR/PFM/)) - __QOI__ (via [qoi](https://github.com/phoboslab/qoi)) - __DDS__ (via [DirectXTex](https://github.com/microsoft/DirectXTex)) +- __KTX2__, __KTX__ (via [KTX-Software](https://github.com/KhronosGroup/KTX-Software), [bcdec](https://github.com/iOrange/bcdec), and [encdec](https://github.com/iOrange/etcdec)) - __WEBP__ (via [libwebp](https://chromium.googlesource.com/webm/libwebp)) - __TIFF__, __DNG__ (via [libtiff](https://gitlab.com/libtiff/libtiff)) - __AVIF__ (including gain maps, e.g. HDR pictures from Android; via [dav1d](https://github.com/videolan/dav1d)+[libheif](https://github.com/strukturag/libheif)) From 2e500cc118e5b12cabcc770e1e52df745a85453c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 13:52:31 +0900 Subject: [PATCH 5/9] fix(ktx,cmake): pollution of installed files --- dependencies/CMakeLists.txt | 1 + dependencies/KTX-Software | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dependencies/CMakeLists.txt b/dependencies/CMakeLists.txt index 5d84dddb..269352ca 100644 --- a/dependencies/CMakeLists.txt +++ b/dependencies/CMakeLists.txt @@ -387,6 +387,7 @@ add_subdirectory(jxrlib EXCLUDE_FROM_ALL) set(BUILD_SHARED_LIBS OFF CACHE BOOL " " FORCE) set(KTX_FEATURE_JS OFF CACHE BOOL " " FORCE) set(KTX_FEATURE_TESTS OFF CACHE BOOL " " FORCE) +set(KTX_FEATURE_TOOLS OFF CACHE BOOL " " FORCE) set(LIBKTX_VERSION_READ_ONLY ON CACHE BOOL " " FORCE) set(LIBKTX_VERSION_FULL OFF CACHE BOOL " " FORCE) set(LIBKTX_FEATURE_KTX1 ON CACHE BOOL " " FORCE) diff --git a/dependencies/KTX-Software b/dependencies/KTX-Software index dd3b8b0a..ab47acfd 160000 --- a/dependencies/KTX-Software +++ b/dependencies/KTX-Software @@ -1 +1 @@ -Subproject commit dd3b8b0a788c9e61ca835bec3c11961d5ffe2d6f +Subproject commit ab47acfd79d34b87361a9ac6375565820856a819 From 680123af0c67132c55ad62fb2d8b7f68b7bd07bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 15:38:45 +0900 Subject: [PATCH 6/9] fix(ktx): don't hardcode VK_ and GL_ formats --- src/imageio/KtxImageLoader.cpp | 121 +-------------------------------- 1 file changed, 3 insertions(+), 118 deletions(-) diff --git a/src/imageio/KtxImageLoader.cpp b/src/imageio/KtxImageLoader.cpp index d70862a6..1c63bdd0 100644 --- a/src/imageio/KtxImageLoader.cpp +++ b/src/imageio/KtxImageLoader.cpp @@ -30,6 +30,9 @@ #define ETCDEC_IMPLEMENTATION #include +#include +#include + #include #include @@ -44,129 +47,11 @@ namespace tev { namespace { -// VkFormat values we can read. Spelled out numerically so that libktx remains our only dependency -- ktx.h exposes vkFormat as a plain -// uint32 and does not pull in the Vulkan headers. -enum : uint32_t { - VK_FORMAT_UNDEFINED = 0, - VK_FORMAT_R8_UNORM = 9, - VK_FORMAT_R8_SRGB = 15, - VK_FORMAT_R8G8_UNORM = 16, - VK_FORMAT_R8G8_SRGB = 22, - VK_FORMAT_R8G8B8_UNORM = 23, - VK_FORMAT_R8G8B8_SRGB = 29, - VK_FORMAT_B8G8R8_UNORM = 30, - VK_FORMAT_B8G8R8_SRGB = 36, - VK_FORMAT_R8G8B8A8_UNORM = 37, - VK_FORMAT_R8G8B8A8_SRGB = 43, - VK_FORMAT_B8G8R8A8_UNORM = 44, - VK_FORMAT_B8G8R8A8_SRGB = 50, - VK_FORMAT_R16_UNORM = 70, - VK_FORMAT_R16_SFLOAT = 76, - VK_FORMAT_R16G16_UNORM = 77, - VK_FORMAT_R16G16_SFLOAT = 83, - VK_FORMAT_R16G16B16_UNORM = 84, - VK_FORMAT_R16G16B16_SFLOAT = 90, - VK_FORMAT_R16G16B16A16_UNORM = 91, - VK_FORMAT_R16G16B16A16_SFLOAT = 97, - VK_FORMAT_R32_SFLOAT = 100, - VK_FORMAT_R32G32_SFLOAT = 103, - VK_FORMAT_R32G32B32_SFLOAT = 106, - VK_FORMAT_R32G32B32A32_SFLOAT = 109, - VK_FORMAT_B10G11R11_UFLOAT_PACK32 = 122, - VK_FORMAT_E5B9G9R9_UFLOAT_PACK32 = 123, - VK_FORMAT_BC1_RGB_UNORM_BLOCK = 131, - VK_FORMAT_BC1_RGB_SRGB_BLOCK = 132, - VK_FORMAT_BC1_RGBA_UNORM_BLOCK = 133, - VK_FORMAT_BC1_RGBA_SRGB_BLOCK = 134, - VK_FORMAT_BC2_UNORM_BLOCK = 135, - VK_FORMAT_BC2_SRGB_BLOCK = 136, - VK_FORMAT_BC3_UNORM_BLOCK = 137, - VK_FORMAT_BC3_SRGB_BLOCK = 138, - VK_FORMAT_BC4_UNORM_BLOCK = 139, - VK_FORMAT_BC4_SNORM_BLOCK = 140, - VK_FORMAT_BC5_UNORM_BLOCK = 141, - VK_FORMAT_BC5_SNORM_BLOCK = 142, - VK_FORMAT_BC6H_UFLOAT_BLOCK = 143, - VK_FORMAT_BC6H_SFLOAT_BLOCK = 144, - VK_FORMAT_BC7_UNORM_BLOCK = 145, - VK_FORMAT_BC7_SRGB_BLOCK = 146, - VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK = 147, - VK_FORMAT_ETC2_R8G8B8_SRGB_BLOCK = 148, - VK_FORMAT_ETC2_R8G8B8A1_UNORM_BLOCK = 149, - VK_FORMAT_ETC2_R8G8B8A1_SRGB_BLOCK = 150, - VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK = 151, - VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK = 152, - VK_FORMAT_EAC_R11_UNORM_BLOCK = 153, - VK_FORMAT_EAC_R11_SNORM_BLOCK = 154, - VK_FORMAT_EAC_R11G11_UNORM_BLOCK = 155, - VK_FORMAT_EAC_R11G11_SNORM_BLOCK = 156, - // ASTC LDR occupies a contiguous core range; ASTC HDR lives in the VK_EXT_texture_compression_astc_hdr extension range. - VK_FORMAT_ASTC_4x4_UNORM_BLOCK = 157, - VK_FORMAT_ASTC_12x12_SRGB_BLOCK = 184, - VK_FORMAT_ASTC_4x4_SFLOAT_BLOCK = 1000066000, - VK_FORMAT_ASTC_12x12_SFLOAT_BLOCK = 1000066013, -}; - bool isAstcVkFormat(const uint32_t format) { return (format >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && format <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) || (format >= VK_FORMAT_ASTC_4x4_SFLOAT_BLOCK && format <= VK_FORMAT_ASTC_12x12_SFLOAT_BLOCK); } -// Subset of GL internal formats needed for KTX1. KTX1 predates VkFormat and describes its payload with the GL enum triple instead. -enum : uint32_t { - GL_LUMINANCE = 0x1909, - GL_LUMINANCE_ALPHA = 0x190A, - GL_RGB = 0x1907, - GL_RGBA = 0x1908, - GL_R8 = 0x8229, - GL_R16 = 0x822A, - GL_RG8 = 0x822B, - GL_RG16 = 0x822C, - GL_R16F = 0x822D, - GL_R32F = 0x822E, - GL_RG16F = 0x822F, - GL_RG32F = 0x8230, - GL_RGB8 = 0x8051, - GL_RGB16 = 0x8054, - GL_RGBA8 = 0x8058, - GL_RGBA16 = 0x805B, - GL_RGBA32F = 0x8814, - GL_RGB32F = 0x8815, - GL_RGBA16F = 0x881A, - GL_RGB16F = 0x881B, - GL_R11F_G11F_B10F = 0x8C3A, - GL_RGB9_E5 = 0x8C3D, - GL_SRGB8 = 0x8C41, - GL_SRGB8_ALPHA8 = 0x8C43, - GL_COMPRESSED_RGB_S3TC_DXT1_EXT = 0x83F0, - GL_COMPRESSED_RGBA_S3TC_DXT1_EXT = 0x83F1, - GL_COMPRESSED_RGBA_S3TC_DXT3_EXT = 0x83F2, - GL_COMPRESSED_RGBA_S3TC_DXT5_EXT = 0x83F3, - GL_COMPRESSED_SRGB_S3TC_DXT1_EXT = 0x8C4C, - GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT = 0x8C4D, - GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT = 0x8C4E, - GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT = 0x8C4F, - GL_COMPRESSED_RED_RGTC1 = 0x8DBB, - GL_COMPRESSED_SIGNED_RED_RGTC1 = 0x8DBC, - GL_COMPRESSED_RG_RGTC2 = 0x8DBD, - GL_COMPRESSED_SIGNED_RG_RGTC2 = 0x8DBE, - GL_COMPRESSED_RGBA_BPTC_UNORM = 0x8E8C, - GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM = 0x8E8D, - GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT = 0x8E8E, - GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT = 0x8E8F, - GL_ETC1_RGB8_OES = 0x8D64, - GL_COMPRESSED_R11_EAC = 0x9270, - GL_COMPRESSED_SIGNED_R11_EAC = 0x9271, - GL_COMPRESSED_RG11_EAC = 0x9272, - GL_COMPRESSED_SIGNED_RG11_EAC = 0x9273, - GL_COMPRESSED_RGB8_ETC2 = 0x9274, - GL_COMPRESSED_SRGB8_ETC2 = 0x9275, - GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9276, - GL_COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9277, - GL_COMPRESSED_RGBA8_ETC2_EAC = 0x9278, - GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC = 0x9279, -}; - enum class ESampleType { UNorm8, UNorm16, From 2c8683ff1893a90961488bfae6324c0a6f8f8ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 15:41:37 +0900 Subject: [PATCH 7/9] fix(ktx): avoid unnecessary copy --- src/imageio/KtxImageLoader.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/imageio/KtxImageLoader.cpp b/src/imageio/KtxImageLoader.cpp index 1c63bdd0..31d2857a 100644 --- a/src/imageio/KtxImageLoader.cpp +++ b/src/imageio/KtxImageLoader.cpp @@ -534,7 +534,7 @@ Task> throw FormatNotSupported{"File is not a KTX image."}; } - const string buffer = iStream.str(); + const string_view buffer = iStream.view(); ktxTexture* rawTexture = nullptr; if (const KTX_error_code err = ktxTexture_CreateFromMemory( From dcc92afcbc99acfa3fdcf255ab517326f5d8661a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 2 Oct 2026 20:27:39 +0900 Subject: [PATCH 8/9] ci: fix Intel macOS build --- dependencies/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dependencies/CMakeLists.txt b/dependencies/CMakeLists.txt index 269352ca..0c3bc1c8 100644 --- a/dependencies/CMakeLists.txt +++ b/dependencies/CMakeLists.txt @@ -395,6 +395,17 @@ set(LIBKTX_FEATURE_KTX2 ON CACHE BOOL " " FORCE) set(LIBKTX_FEATURE_VK_UPLOAD OFF CACHE BOOL " " FORCE) set(LIBKTX_FEATURE_GL_UPLOAD OFF CACHE BOOL " " FORCE) set(LIBKTX_FEATURE_ETC_UNPACK ON CACHE BOOL " " FORCE) + +if (HAS_SSE2) + if (APPLE) + # Since we're targeting macOS 10.15 and later, Intel macOS always has SSE4.1 + set(ASTCENC_ISA_SSE41 ON) + else() + # Otherwise, assume only SSE2, *not* ASTENC's default of AVX2 on Intel platforms + set(ASTCENC_ISA_SSE2 ON) + endif() +endif() + add_subdirectory(KTX-Software EXCLUDE_FROM_ALL) # Cross-platform SIMD From eb525dc0ae033d1e7470a257bb0737af61b6224a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Sat, 3 Oct 2026 10:07:08 +0900 Subject: [PATCH 9/9] feat(ktx): put cubemap faces/slices/layers into channels, not parts --- src/imageio/KtxImageLoader.cpp | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/imageio/KtxImageLoader.cpp b/src/imageio/KtxImageLoader.cpp index 31d2857a..695b1ec7 100644 --- a/src/imageio/KtxImageLoader.cpp +++ b/src/imageio/KtxImageLoader.cpp @@ -437,7 +437,7 @@ bool needsVerticalFlip(ktxTexture* texture) { return orientation.empty() ? texture->classId == ktxTexture1_c : orientation.find('u') != string_view::npos; } -string makePartName(ktxTexture* texture, const uint32_t level, const uint32_t layer, const uint32_t face, const uint32_t slice) { +string makeLayerName(ktxTexture* texture, const uint32_t layer, const uint32_t face, const uint32_t slice) { static const array CUBE_FACE_NAMES = {"+X", "-X", "+Y", "-Y", "+Z", "-Z"}; vector components; @@ -454,6 +454,12 @@ string makePartName(ktxTexture* texture, const uint32_t level, const uint32_t la components.emplace_back(fmt::format("z{}", slice)); } + return join(components, "."); +} + +string makePartName(ktxTexture* texture, const uint32_t level) { + vector components; + if (texture->numLevels > 1) { components.emplace_back(fmt::format("mip{}", level)); } @@ -625,6 +631,10 @@ Task> const uint32_t numSlices = std::max(1u, texture->baseDepth >> level); const size_t rowPitch = ktxTexture_GetRowPitch(texture.get(), level); + ImageData& resultData = result.emplace_back(); + resultData.hasPremultipliedAlpha = layout->alpha && premultipliedAlpha; + resultData.partName = makePartName(texture.get(), level); + for (uint32_t layer = 0; layer < texture->numLayers; ++layer) { for (uint32_t face = 0; face < texture->numFaces; ++face) { for (uint32_t slice = 0; slice < numSlices; ++slice) { @@ -643,21 +653,17 @@ Task> throw ImageLoadError{"KTX subimage extends past the end of the payload."}; } - ImageData& resultData = result.emplace_back(); - resultData.channels = co_await makeInterleavedChannels( + auto channels = co_await makeInterleavedChannels( layout->numOutputChannels(), layout->alpha, levelSize, EPixelFormat::F32, layout->type == ESampleType::Float32 ? EPixelFormat::F32 : EPixelFormat::F16, - "", + makeLayerName(texture.get(), layer, face, slice), priority ); - resultData.hasPremultipliedAlpha = layout->alpha && premultipliedAlpha; - resultData.partName = makePartName(texture.get(), level, layer, face, slice); - - const auto dst = MultiChannelView{resultData.channels}; + const auto dst = MultiChannelView{channels}; span src = data.subspan(offset, imageSize); size_t srcRowPitch = rowPitch; @@ -676,6 +682,8 @@ Task> case ESampleType::Float16: co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); break; case ESampleType::Float32: co_await convertToFloat(src, srcRowPitch, dst, *layout, flipY, priority); break; } + + ranges::move(channels, back_inserter(resultData.channels)); } } }