Computer Graphics · Graphics Tooling
KTX2 Containers and What Basis Transcoding Actually Saves
How a KTX2 file stores mip levels, faces, and layers in one level index, and why a supercompressed texture can be transcoded to several GPU formats without re-encoding.
A texture destined for several platforms has a delivery problem. Desktop GPUs want BC formats; mobile GPUs want ASTC or ETC2; a software fallback wants something uncompressed. Encoding the source three times produces three files, three download sizes, and three chances for the encoders to disagree about colour. KTX2 with Basis supercompression is the format designed around that: one file holds an intermediate representation, and the client transcodes it to whichever GPU format it needs, locally, at load time.
The container before the codec
KTX2’s structure is a header, a level index, a data format descriptor, and a key-value block, followed by the pixel data. The design constraint that shapes all of it is that a texture is not one image but a set: $m$ mip levels × $f$ array elements or faces × $d$ depth slices. Every one of those is a separate payload, and each may have a different byte length.
The header begins with a 12-byte identifier, then fields for the format version, the total file size, the texture type (2D, 3D, array, cubemap), the pixel format, the dimensions, the layer and face counts, the level count, and a supercompression scheme code. The important structural choice is the level index: an array of levelCount entries, each holding three 64-bit values — the byte offset of that level’s data, its byte length, and its uncompressed byte length.
Storing both lengths is what makes supercompression tractable. The compressed length says how many bytes to read; the uncompressed length says how large a buffer to allocate before inflating. Without the second value, a loader would have to inflate into a growing buffer or parse the compressed stream’s header, and for a GPU upload path the allocation must be known up front.
For a cubemap the six faces of a level are stored contiguously and in a fixed face order, so the offset of face $k$ within a level is $k \times (\text{level length} / \text{face count})$ — but only when all faces are the same size, which is true for a cubemap and not true for a texture array of differing dimensions. The level index handles both because it addresses the level, not the face.
The data format descriptor is a separate byte block describing the semantic channel layout: which channels exist, their bit depths, and their meaning in an enumeration that distinguishes colour data from normals from generic scalars. This is the piece that lets a loader know a two-channel texture is a normal map rather than a red-green colour, without a filename convention. It is also the piece most often omitted by tools that write KTX2 as a thin wrapper over a GPU format, which is why files in the wild vary in how much a consumer can infer.
What supercompression adds
The header’s supercompression scheme code names a compressor applied to the whole payload. Two schemes matter in practice: a general-purpose lossless compressor, and Basis Universal’s UASTC or ETC1S modes.
The general-purpose mode is straightforward: the pixel data is compressed as a byte stream, the level index gives both lengths, and the loader decompresses before uploading. This reduces download size but does not reduce GPU memory, because the upload is still the full uncompressed block format. Its value is in transit only.
The Basis modes are different in kind. The payload is not a GPU format at all; it is an intermediate representation that a transcoder converts into a GPU format at load time. That means:
- The file does not need to know which GPU format the target supports.
- GPU memory holds the transcoded blocks, so the residency cost equals the target format’s rate rather than the intermediate’s.
- The transcode is a block-local operation, so it can be done in parallel and at a granularity that does not require the whole image in memory at once.
The intermediate is also smaller than most target formats. An ETC1S payload carries a small palette and per-texel selectors, and the same payload transcodes to ETC1, ETC2, BC1, BC3, BC7, ASTC, and an uncompressed RGBA fallback. A BC1 output is 4 bits per texel; an ETC1S payload for the same image is typically well under that, because the palette is shared across a block and the selectors are entropy-coded.
Two intermediate modes and the trade between them
UASTC and ETC1S are not quality levels of one codec; they are different data structures.
ETC1S is the smaller and older mode. Each block stores a base colour from a small codebook plus intensity modifiers, so the block’s texels are constrained to a line in RGB space — the same model as BC1, but with the endpoints drawn from a shared palette rather than stored per block. The consequence is the same as BC1’s: blocks containing two unrelated colours are represented badly. It is also lossy in a way that cannot be undone by transcoding, and it does not carry alpha natively in the base mode; alpha requires a second payload or a separate texture.
UASTC is a 128-bit-per-block format with a richer model: more endpoint precision, more modes, and explicit support for a second channel pair. Its transcoded output is close to what a direct BC7 encode would produce, and its own payload is several times larger than an ETC1S payload for the same image. Because it is itself a block format at 8 bits per texel, its payload can additionally be losslessly compressed for transit, which is the common configuration.
The choice is therefore a three-way trade: ETC1S for the smallest download and lowest memory, UASTC for quality close to a direct encode, and a direct per-platform encode for maximum quality with no transcoding step at all. The last option is what a pipeline with a single target should do; the transcoding path earns its complexity only when the target set is genuinely plural.
| Mode | Payload rate | Transcoded quality | Alpha | Typical role |
|---|---|---|---|---|
| ETC1S | well under 4 bpp | Below BC1 in places | Separate payload | Download-constrained delivery |
| UASTC | 8 bpp before compression | Near direct BC7 | Native | Quality delivery across formats |
| Direct BC7 | 8 bpp | Encoder-dependent optimum | Native | Single-platform build |
Transcoding is a format conversion, not a decode
The critical property is that transcoding does not go through RGBA. A naive implementation would decode each block to 16 RGBA texels and then re-encode them into the target block, which would accumulate the intermediate’s error twice and cost far more time. Real transcoders map the intermediate block’s parameters directly onto the target block’s parameters.
For BC1 the mapping is nearly direct: the intermediate’s palette index and selector become the BC1 endpoints and per-texel indices. For BC7 the mapping is more involved because BC7 has several modes, and the transcoder picks the mode whose partition structure matches the intermediate block’s. For an uncompressed fallback the transcoder does decode to RGBA, which is why that path is slower and larger.
The arithmetic is per-block and independent, which gives two useful properties. First, the transcode parallelizes across blocks with no synchronization. Second, a transcoder can produce a partial result for a partially-uploaded texture, which is what allows a texture to be streamed: the first mip level can be transcoded and sampled while the rest arrives. The level index’s per-level offsets are what make that streamable, since the client can request level $n$ without reading levels below it.
The cost is that the transcode happens on the client, once per texture per load. A texture loaded at startup pays it once; a texture streamed during play pays it at a moment when the frame budget is already tight. The mitigation is to transcode into a persistent GPU allocation and never re-transcode, which is the normal case, and to move the transcode off the critical path for textures whose first use is predictable.
Sizing, and the arithmetic that decides the layout
Total file size for a KTX2 texture with uncompressed or block-compressed levels is the sum over levels of the per-level payload, and each level’s payload is
$$\text{level bytes} = \left\lceil \frac{W}{4} \right\rceil \cdot \left\lceil \frac{H}{4} \right\rceil \cdot B \cdot f$$
for a 4×4-block format with $B$ bytes per block and $f$ faces or layers. The rounding-up per level is why a full chain from a non-multiple-of-four dimension wastes a little at every level, and why the level index’s per-level lengths are not derivable from the dimensions alone for such textures — they must be stored.
For a supercompressed payload the relationship is the same, with the level length being the compressed size and the uncompressed length being the block-format size. A loader allocating GPU memory uses the uncompressed length; a loader deciding how much to download uses the compressed one. Both are in the file, and a file that omits the uncompressed length forces the client to compute it, which is possible for a block format but not for an arbitrary intermediate.
Alignment appears here too, in a form that matters for direct GPU upload. The level index offsets are 64-bit and must be aligned to the block size of the format, so that a loader can map a level directly into a GPU resource without a copy. A file whose level data begins at an unaligned offset can still be read but not uploaded by direct mapping, and the resulting copy is proportional to the texture’s size.
Where the format choice leaks back into the pipeline
The transcoding path imposes constraints that propagate upstream, and they are the practical reason a project either commits to it or does not.
Channel semantics must be declared, not implied. A transcoder needs to know whether the payload is colour or data, because the two are transcoded differently — colour data goes through a path that accounts for perceptual error, data through one that does not. That information lives in the data format descriptor, so the conditioning step that produces the file must set it. A pipeline that writes the descriptor from a template rather than from the texture’s role produces files that transcode correctly and look wrong.
Alpha is not free in ETC1S. A texture with meaningful alpha needs either a second payload or a different mode, and a pipeline that treats alpha as an afterthought discovers this at the point where the first transparent asset ships. The channel layout decision interacts here with the packing conventions described in channel packing conventions: a mask stored in the alpha channel of a colour texture forces the alpha-capable path for every texture that shares the layout.
The block rate bounds the atlas. A block-compressed texture needs dimensions that are multiples of the block size, or the edge blocks are padded and the gutter calculation changes. Atlas dimensions are chosen in the packing step, treated in UV chart packing and gutter space, and a packer that is unaware of the block size produces atlases whose effective texel density is slightly lower than its own report.
Determinism is not guaranteed. Transcoding an ETC1S payload to two different target formats produces two outputs with different error, so a project that tests on one platform and ships on another is testing a different image. The difference is usually small and occasionally visible on high-contrast edges, which is the sort of discrepancy that costs a day to diagnose when it appears.
The alternative to all of this — encoding per target, storing several variants in one archive, selecting at runtime — trades file size and build time for exact control over each platform’s output. That is the choice the block-format survey in block-compressed texture bit rates sets up, and which one is correct depends on whether the target set is stable enough to encode for explicitly.
References
- KTX File Format Specification, Version 2 — Khronos Group
- Basis Universal GPU Texture Codec — Binomial
- Basis Universal Texture Interim Compression Scheme Specification — Khronos Group