KTX and KTX 2
KTX (Khronos Texture) is the texture container of OpenGL, Vulkan and glTF: a header that names the GPU format, then every mip
level of every array layer, cube face and depth slice. KTX 1.1 (.ktx) names OpenGL formats; KTX 2.0 (.ktx2) names Vulkan formats,
describes its colour in a data format descriptor and can compress each mip level again with Zstandard or zlib. Both read, and KTX 2 writes, through
the Egami.Formats.Extended package: register them with WithKtx() and WithKtx2(), or WithExtendedFormats()
(Extended formats). They are two formats, KtxFormat and Ktx2Format, with one set of options and details.
| Reads | KTX 1.1 in both byte orders and KTX 2.0, uncompressed or Zstandard and zlib supercompressed: the 8, 16 and 32-bit UNORM, SNORM, sRGB, UINT, SINT and float formats, BGRA, the 565, 5551 and 4444 packed formats, A2B10G10R10, B10G11R11 and E5B9G9R9 floats, BC1 to BC7 with BC6H unsigned and signed, ETC1, ETC2 (RGB8, RGB8A1, RGBA8, UNORM and sRGB), EAC R11 and RG11 (unsigned and signed), ASTC in all 14 2D footprints, UNORM, sRGB and HDR (SFLOAT), and Basis Universal: ETC1S (BasisLZ, with or without an alpha slice, video included), UASTC LDR 4 × 4 and UASTC HDR 4 × 4. 1D and 2D textures, arrays, cube maps and volume textures, with their mips |
| Writes | KTX 2.0 only: 8-bit R, RG, RGB, RGBA and BGRA (UNORM and sRGB), 16-bit UNORM R, RG, RGB and RGBA, half-float RGBA, float R, RGB and RGBA, UINT and SINT in 8, 16 and 32 bits, D16 and D32 depth, A2B10G10R10, B10G11R11, E5B9G9R9 and the 565, 1555 and 4444 packed formats (not SNORM), BC1 to BC7 with BC6H, ETC1, ETC2, EAC (unsigned and signed), ASTC LDR in all 14 2D footprints, and Basis Universal ETC1S and UASTC LDR 4 × 4; mips, arrays, cube maps, Zstandard and zlib. Texture also writes volumes |
| Natural pixel type | As DDS: Rgba8 for BC1, BC2, BC3, BC7, ETC2 RGB8A1 and RGBA8, ASTC LDR, Basis Universal with alpha and 8-bit RGBA; Gray8 for BC4 and R8; Rgb8 for BC5, ETC1, ETC2 RGB8, Basis Universal without alpha, RG8 and RGB8; Gray16 for EAC R11; Rgb16 for EAC RG11; Rgba16 for 16-bit UNORM; RgbaF16 for BC6H, ASTC HDR, UASTC HDR, half floats and the packed floats; RgbaF32 for 32-bit floats; Bgr565, Bgra5551 and Bgra4444 for the packed formats. A swizzle that moves channels widens the type to its RGBA form |
FormatInfo |
KtxFormat.Info: name KTX, extension .ktx, MIME type image/ktx. Ktx2Format.Info: name KTX2, extension .ktx2, MIME type image/ktx2 |
// KTX 1.1 (.ktx) and KTX 2.0 (.ktx2) are two formats; WithExtendedFormats() registers both.
EgamiContext context = EgamiContext.Default.WithKtx().WithKtx2();
// Load gives mip 0 of the first surface, upright, in the format's natural pixel type (Rgba8 for RGBA8 and BC7).
using Raster texture = Raster.Load("texture.ktx2", context);
texture.Save("texture.png");
// Probe reads the headers alone: the surfaces at mip 0 are its images.
RasterInfo info = Raster.Probe("sky.ktx2", context);
Console.WriteLine($"{info.Width}×{info.Height}, {info.ImageCount} surface(s)");
3D ASTC, PVRTC and the newer Basis Universal families (UASTC HDR 6 × 6, XUASTC LDR and XUBC7) are not read: a load or probe
throws UnsupportedFormatException that names the family, such as "KTX 2 textures in Basis Universal UASTC HDR 6x6 compression
are not supported yet (supercompression scheme 4)".
Surfaces and mips
Load reads mip 0 of the first surface. The surfaces are the array layers, each with its cube faces (in the order +X, −X, +Y,
−Y, +Z, −Z) or depth slices; RasterInfo.ImageCount counts them. LoadAll gives each at mip 0, and with
KtxReaderOptions.IncludeMipLevels every mip after the surface it belongs to. KtxReaderOptions.Surface and
KtxReaderOptions.MipLevel pick one; for a volume, depth slice Surface at mip m is slice Surface >> m of that mip.
ReaderOptions.MinimumSize (without a Region) picks the smallest mip that still covers the size.
// One surface and mip: cube face -Y (faces go +X, -X, +Y, -Y, +Z, -Z), second mip if it has one.
using Raster face = Raster.Load("sky.ktx2", context, new KtxReaderOptions { Surface = 3 });
// Every face at mip 0, or with every mip after its face.
using RasterList faces = Raster.LoadAll("sky.ktx2", context);
using RasterList all = Raster.LoadAll("texture.ktx2", context, new KtxReaderOptions { IncludeMipLevels = true });
// The pixels as the file stores them: no KTXorientation flip and no KTXswizzle.
using Raster stored = Raster.Load("texture.ktx2", context, new KtxReaderOptions { KeepStoredLayout = true });
A KTX 2 file stores its smallest mip first, and a supercompressed level is inflated whole into a pooled buffer before its rows are read; an uncompressed level streams a row of pixels or blocks at a time, as DDS does.
Orientation and swizzle
The KTXorientation key says which way the rows and columns run: rd (right, down) is the default, and OpenGL tools often
write ru, bottom row first. Load turns the texture to rd, so it comes out upright as other formats do. The KTXswizzle key
says where each channel comes from (rgba, rrr1 for a gray texture stored as red, rg01); load applies it. Set
KtxReaderOptions.KeepStoredLayout to read the pixels as stored. KtxDetails.Orientation and KtxDetails.Swizzle keep the
keys either way, so a renderer can apply them itself. A malformed key is ignored with an OrientationIgnored or
SwizzleIgnored warning.
Values
Block formats decode as DDS's do, with the same shared decoders: BC4, BC5, BC6H and BC7 exactly, BC1 to BC3 from the exact
interpolation of their endpoints. BC1_RGB formats load opaque. ETC1, ETC2 and EAC decode exactly as their specification
(the Khronos Data Format Specification) says; an ETC1 file decodes with the ETC2 decoder, which reads every valid ETC1 block the
same. EAC's 11-bit values widen to 16 bits by bit replication, and signed EAC maps −1 to 1 onto 0 to 65535. ASTC decodes as ARM's
astcenc does, bit for bit: LDR blocks to 8 bits per channel (sRGB files interpolate their 8-bit endpoints as the sRGB decode mode
says), HDR blocks to half floats, an illegal block to magenta (LDR) or NaN (HDR), and blocks of any of the 14 2D footprints. Two-channel formats load with blue 0;
KtxReaderOptions.ReconstructNormalZ rebuilds blue as a normal map's Z. SNORM, SINT and UINT formats map as DDS's do. A KTX 1
file written big-endian is swapped by its glTypeSize, and the 4-byte row padding of KTX 1 uncompressed data is skipped.
Basis Universal
Basis Universal textures decode straight to pixels, bit for bit as basisu's own unpacker does, with no detour through a GPU
format. An ETC1S file (Vulkan format 0, BasisLZ supercompression) keeps its endpoint and selector codebooks and Huffman tables
in the file's global data, and each image is a slice of codebook references; a second slice per image, when the descriptor has
one, is the alpha (its green channel). An ETC1S video (the KTXanimData key) codes later frames against the one before, so a
P-frame surface decodes from the last I-frame up. A UASTC LDR file (descriptor colour model 166) is 4 × 4 blocks of 16 bytes in
any of UASTC's 19 modes, often supercompressed with Zstandard; the reserved mode decodes to magenta, as basisu's does. A UASTC
HDR 4 × 4 file (colour model 167) holds ASTC HDR blocks and decodes as ASTC HDR does, to RgbaF16.
The descriptor says whether there is alpha: ETC1S with an alpha slice, and UASTC whose sample is RGBA or RRRG, load as
Rgba8; the rest load as Rgb8. KtxDetails.FormatName names the family, the channels and the transfer, such as
ETC1S_RGBA_SRGB or UASTC_4x4_RGB_UNORM, and KtxDetails.VkFormat is 0 for ETC1S and UASTC LDR. A lenient read of a BasisLZ level that does
not decode keeps the blocks decoded so far and leaves the rest black, with LevelDamaged; a strict read throws.
Colour, alpha and details
A KTX 2 file's data format descriptor names its colour primaries and transfer function. sRGB transfer with BT.709 primaries is
tagged ColorSpaceHint.Srgb, linear with BT.709 ColorSpaceHint.Linear, and other pairs (Display P3, BT.2020 with PQ or HLG
and the rest) set RasterMetadata.CodePoints to their H.273 values. In a KTX 1 file only an _SRGB format says anything, and
is tagged sRGB. Colours the descriptor marks premultiplied are un-premultiplied on load.
KtxDetails, in RasterMetadata.FormatDetails, keeps what the file says: the Vulkan format (VkFormat, FormatName) or KTX 1's
GlInternalFormat, the KtxSupercompression, the KtxDimension, cube flag, layers, faces and depth, the mip count, sRGB, the
KtxAlphaMode, the orientation and swizzle, the key/value pairs that hold text (KTXwriter, KTXglFormat and your own), and
which surface and mip the raster is.
// What the file says: the Vulkan format, supercompression, layout, mips, colour, orientation and key/value data.
using Raster texture = Raster.Load("texture.ktx2", context);
if (texture.Metadata.FormatDetails is KtxDetails ktx)
{
Console.WriteLine($"{ktx.FormatName} (VkFormat {ktx.VkFormat}), {ktx.Supercompression}, {ktx.MipLevels} mips");
Console.WriteLine($"{ktx.Dimension}, cube: {ktx.IsCube}, sRGB {ktx.IsSrgb}, alpha {ktx.AlphaMode}, orientation {ktx.Orientation}");
foreach ((string key, string value) in ktx.KeyValues)
{
Console.WriteLine($"{key} = {value}");
}
}
Writing
Ktx2WriterOptions mirrors DdsWriterOptions. With no options a raster is written in the uncompressed format nearest its
pixel type (R8G8B8A8 for Rgba8, R8 for Gray8, R16G16B16A16_SFLOAT for half floats and so on), with the full mip chain
and no supercompression. A raster loaded from KTX, KTX 2 or DDS keeps its file's format when the writer has it, ETC1S and UASTC
included; a block format is otherwise only chosen with Ktx2WriterOptions.Format, a KtxTextureFormat:
| Family | Formats | Encoder |
|---|---|---|
| BC | Bc1 to Bc5, Bc6h, Bc6hSigned, Bc7 |
The DDS writer's encoders |
| ETC | Etc1, Etc2Rgb, Etc2RgbA1, Etc2Rgba, EacR11, EacRg11 and their signed forms |
Mode search for ETC1, plus T, H and planar for ETC2 from effort 3 up; EAC is a table search. Effort 0 is a fast tier (about 30 megapixels a second on one thread); effort 4 matches etc2comp's quality at its effort 40 |
| ASTC | Astc4x4 to Astc12x12, LDR |
Direct, base+offset, base+scale and mixed endpoint modes, dual planes and 2 to 4 partitions, with least-squares weight fits. Efforts 1 to 5 stop after one quick encode when a block already scores well, so smooth and noisy pictures go fast; effort 4 is within about 0.2 dB of astcenc -medium. ASTC HDR is not encoded |
| Basis Universal | Etc1s, Uastc |
ETC1S codebooks (BasisQuality, 1 to 255) with BasisLZ; UASTC's 19 modes with BC1 and ETC1 transcoder hints and optional RDO (UastcRdo) |
Effort runs 0 (fastest) to 6 (best) and defaults to 4. Srgb picks a format's _SRGB form; left null, it follows the
source file, or else the raster's tag: an untagged raster is sRGB, as Egami reads it, and a linear one is not. Formats with no
sRGB form write UNORM. MipLevels is 0 for the full chain, made with MipChain (premultiplied, in linear light for sRGB), or
that many; integer and depth formats write mip 0 alone unless a count is given. Supercompression is Zstandard or Zlib with an optional CompressionLevel; ETC1S is always BasisLZ. Cube makes
SaveAll write cube maps (six faces a cube, a cube map array for more); without it SaveAll writes a texture array.
PremultipliedAlpha premultiplies the colour and flags it so.
// With no options: the nearest uncompressed format (R8G8B8A8_SRGB for 8-bit colour) and the full mip chain.
using (FileStream output = File.Create("duck.ktx2"))
{
image.Save(output, Ktx2Format.Info);
}
// A GPU block format: BC7 for desktop, ASTC or ETC2 for mobile; effort runs 0 (fastest) to 6 (best).
using (FileStream output = File.Create("duck-astc.ktx2"))
{
image.Save(output, Ktx2Format.Info, new Ktx2WriterOptions { Format = KtxTextureFormat.Astc6x6, Effort = 3 });
}
// Basis Universal, which a viewer transcodes to whatever its GPU reads: UASTC with RDO and Zstandard (glTF's usual
// pick), or ETC1S for the smallest files.
using (FileStream output = File.Create("duck-uastc.ktx2"))
{
image.Save(output, Ktx2Format.Info, new Ktx2WriterOptions
{
Format = KtxTextureFormat.Uastc,
UastcRdo = 2,
Supercompression = KtxSupercompression.Zstandard,
});
}
using (FileStream output = File.Create("duck-etc1s.ktx2"))
{
image.Save(output, Ktx2Format.Info, new Ktx2WriterOptions { Format = KtxTextureFormat.Etc1s, BasisQuality = 128 });
}
// Six faces into a cube map; SaveAll without Cube writes a texture array.
using RasterList faces = Raster.LoadAll("sky.ktx2", context);
using (FileStream output = File.Create("sky-bc1.ktx2"))
{
Raster.SaveAll(faces, output, Ktx2Format.Info, new Ktx2WriterOptions { Cube = true, Format = KtxTextureFormat.Bc1 });
}
The data format descriptor is the one KTX-Software writes for the format, with the primaries and transfer function of the
raster's tag (ColorCodePoints such as Display P3 or BT.2100 PQ go in as KHR_DF values). The key/value data holds
KTXorientation (rd, or rdi for a volume) and KTXwriter. Every file the writer makes in the test corpus passes
ktx validate, and libktx reads back the same level data; tools/make-ktx-references.py --written checks them again. The
output is the same for any number of threads: blocks encode on their own, ETC1S builds its codebooks deterministically and
UASTC's rate-distortion pass works in fixed bands.
Recipes:
BasisQuality is not basisu's -q scale: Egami's ETC1S at about 170 makes files the size of basisu's at 128, with slightly
higher quality, and lower qualities also trade some quality for size in the endpoints. A UASTC block's BC1 hint is set only when
the shortcut it allows stays close to a full BC1 encode, so a transcoder never takes a bad shortcut.
- glTF and the web:
UastcwithUastcRdo1 to 4 andZstandardfor colour that must look right;Etc1satBasisQuality64 to 128 for the smallest downloads. Viewers transcode both to the GPU's own format. - Desktop:
Bc7for colour,Bc5for normal maps,Bc4for masks,Bc6hfor HDR. - Mobile:
Astc6x6(orAstc4x4for more quality,Astc8x8for less space) on recent GPUs,Etc2RgbaorEtc2Rgbwhere ASTC is missing,EacRg11for normal maps.
Textures
Texture (namespace Egami.Formats.Extended.Textures) holds a whole texture: every layer, cube face and depth slice at every
mip, loaded with Texture.Load from DDS, KTX or KTX 2 whether or not the formats are registered. GetImage and SetImage reach
one image by mip, layer, face and slice; GenerateMips makes the mips again from mip 0 (2D textures and cube maps) and
TrimMips drops the smallest. Save writes DDS or KTX 2 with exactly the mips the texture holds, so a texture with hand-made
mips, or a volume, survives a round trip; the writer options choose the format as for a raster. A volume saves to either
format, an array of volumes only to KTX 2. Texture.FromSurfaces and Texture.FromSlices build one from rasters.
// Every layer, face, depth slice and mip of a DDS, KTX or KTX 2 file.
using Texture sky = Texture.Load("sky.ktx2");
Console.WriteLine($"{sky.Width}×{sky.Height}, {sky.Layers} layer(s) of {sky.Faces} face(s), {sky.MipLevels} mips");
// Edit one face, make the mips again from mip 0, and save to DDS or KTX 2 with exactly those mips.
Raster top = sky.GetImage(mip: 0, face: 2);
sky.SetImage(top.Derive(x => x.Grayscale(), CancellationToken.None), mip: 0, face: 2);
sky.GenerateMips(colorSpace: ColorSpace.Linear);
sky.Save("sky-edited.dds", new DdsWriterOptions { Format = DdsTextureFormat.Bc7 });
sky.Save("sky-edited.ktx2", new Ktx2WriterOptions { Format = KtxTextureFormat.Bc7 });
// Or build one from images: here a texture array of two layers with its mip chain.
using Texture array = Texture.FromSurfaces([Raster.Load("duck.png"), Raster.Load("duck.png")]);
array.GenerateMips();
array.Save("ducks.ktx2");
Streaming and damaged files
Warnings start Ktx. for a KTX 1 file and Ktx2. for a KTX 2 file. A lenient read of a file that ends early keeps the rows
it has and leaves the rest black with LevelShort or DataMissing; a supercompressed level that does not inflate reads as
black with LevelDamaged; a header that claims more mips than its size allows is read with the mips it can have and
MipCountRepaired; a KTX 1 imageSize word that disagrees with the format gets ImageSizeWrong, and the format's size is
used; damaged key/value data or descriptor is skipped with KeyValueDamaged or DescriptorDamaged. A strict read throws
CorruptDataException for each. Probe reads the headers, the level index, the descriptor and the key/value data, never the
pixels.