Compressed texture formats in Vulkan: BC, ASTC, ETC2 and KTX2 loading

A 2048×2048 RGBA8 texture is 16 MB — sampled, cached, streamed. The same texture as BC7 is 4 MB and as ASTC 4×4 even less. GPU-compressed formats decode in the texture unit: they stay compressed in memory, in caches, on the bus — only the sampler expands them.

The landscape

formats.txt
BC1/BC3 (DXT1/5):   4bpp / 8bpp — old, fast, noticeable on gradients
BC4/BC5:            single/dual channel — normals, heightmaps
BC6H:               HDR, floating point
BC7:                high quality RGBA — the desktop standard
ASTC 4x4 … 12x12:   scalable 8→0.89bpp — mobile standard, desktop too
ETC2/EAC:           GLES3 baseline — mobile only, mostly obsolete on PC

Loading a compressed texture

The API is identical — only the format and upload rules differ:

upload.cpp
vk::ImageCreateInfo ici{};
ici.format   = vk::Format::eBc7UnormBlock;   // or eAstc4x4UnormBlock
ici.extent   = {w, h, 1};                    // pixel extent, not blocks
ici.usage    = vk::ImageUsageFlagBits::eSampled
             | vk::ImageUsageFlagBits::eTransferDst;
ici.mipLevels = mipCount;

// Upload rules differ from uncompressed:
//   vkCmdCopyBufferToImage bufferRowLength/bufferImageHeight are in
//   TEXELS but must be multiples of the compressed block extent
//   (4x4 for BC, NxM for ASTC); image extent is rounded up to a
//   multiple of block size for partial edge blocks.
vk::BufferImageCopy r{};
r.imageExtent = {w, h, 1};   // API takes pixel extent; it aligns itself
r.imageSubresource = {vk::ImageAspectFlagBits::eColor, level, 0, 1};
cmd.copyBufferToImage(staging, img, eTransferDstOptimal, r);

Compressed images can’t be color/depth attachments, can’t be eHostImageCopy-written, and can’t be vkCmdCopyImage’d at sub-block granularity — only sampled.

KTX2 — the container that does transcoding

KTX2 (Khronos Texture 2) wraps compressed mip-chains and supports UASTC/ETC1S supercompression — one master file transcodes at load time to whatever the target supports:

ktx2.cpp
ktxTexture2* t;
ktxTexture2_CreateFromNamedFile("albedo.ktx2",
    KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &t);

// pick the target format this device supports:
ktx_transcode_fmt_e tf = pickFormat(dev);   // BC7 / ASTC / ETC2 / RGBA32
if (ktxTexture2_NeedsTranscoding(t))
    ktxTexture2_TranscodeBasis(t, tf, 0);

// t->pData now holds e.g. BC7 blocks → vkCmdCopyBufferToImage
// image format = vk::Format from ktxTexture2_GetVkFormat? map tf → VkFormat

libktx handles the parse; the blocks upload like any compressed format.

Choosing a format

pick.txt
desktop PC:        BC7 (or BC3 for legacy) — universal since Fermi/HD7k
mobile/Android:    ASTC 4x4..8x8 (Arm/Adreno/Mali all support)
best quality:      ASTC 4x4 ≈ BC7 quality, more block-size options
HDR textures:      BC6H
normals:           BC5 / ASTC 2-channel (store xy, reconstruct z)
one file all-GPUs: KTX2 UASTC → transcode per-platform at load

Check support before creating: vkGetPhysicalDeviceFormatProperties2 → optimalTilingFeatures & eSampledImage on the compressed format — an unsupported BC7 must fall back to RGBA8 upload.

Practical rules

  • Upload extents are in pixels, but bufferRowLength/imageExtent align to the compressed block — a 13×17 BC7 image uploads as 16×20.
  • Compressed VkImage = VK_IMAGE_USAGE_SAMPLED_BIT only — no color/depth attachment, no storage.
  • Mips count the same way, but each mip rounds to block size separately — a 4×4 mip at level 6 is still one 16-byte block.
  • KTX2’s UASTC→ASTC transcode needs libktx + basisu; the resulting block data is what you upload — the container is load-time only.
  • formatFeatureFlags on a compressed format still shows SAMPLED — the “compression” is in the format name, not a usage flag.

Check out similar posts by category: Vulkan C++