Compressing a texture
Open the live demo · Read the source · View on GitHub
A raw RGBA8 texture is four bytes a pixel no matter what it shows. A block-compressed one trades a little quality for a fixed ratio: sixteen pixels become one block of a handful of bytes. This page compresses one small image four different ways.
Step 1: A source image #
A checkerboard, sixteen pixels on a side so every mip level down to 4x4 still lands on a whole block boundary.
// A 16x16 checkerboard, whole 4x4 blocks in both directions, which
// every encoder here requires.
final pixels = Uint8List(16 * 16 * 4);
for (var y = 0; y < 16; y++) {
for (var x = 0; x < 16; x++) {
final lit = ((x ~/ 4) + (y ~/ 4)).isEven;
final o = (y * 16 + x) * 4;
final value = lit ? 220 : 40;
pixels[o] = value;
pixels[o + 1] = value;
pixels[o + 2] = lit ? 60 : 200;
pixels[o + 3] = 255;
}
}
_source = Rgba8Image(width: 16, height: 16, pixels: pixels);
Step 2: Four encoders, one image #
encodeBc1 and encodeBc3 are the two DirectX-style block formats, the
second carrying an alpha block the first does not. encodeEtc2Rgb8 is
the format most Android GPUs prefer, and encodeAstc4x4 is the newest of
the four, one fixed block size among several the real format supports.
_bc1 = encodeBc1(_source);
_bc3 = encodeBc3(_source);
_etc2 = encodeEtc2Rgb8(_source);
_astc = encodeAstc4x4(_source);
Step 3: A mip chain, and a container to hold it #
buildMipChain halves an image with a box filter down to one pixel.
Everything past 4x4 is too small for a block encoder to take, so this
page stops there and writes what is left as a real KTX2 file with
writeKtx2.
// A block encoder needs whole 4x4 blocks, so the chain stops at the
// last level that still is one: 16, 8, 4, and no further.
final chain = buildMipChain(
_source,
).where((level) => level.width % 4 == 0 && level.height % 4 == 0);
_ktx2 = writeKtx2(
vkFormat: VkFormat.bc1RgbaUNormBlock,
pixelWidth: _source.width,
pixelHeight: _source.height,
levels: <Uint8List>[for (final level in chain) encodeBc1(level)],
);
Step 4: Compare the sizes #
Every encoder here should shrink the picture; the point of this page is to see by how much.
int get _rawBytes => _source.pixels.length;
String _report() =>
'raw RGBA8: $_rawBytes bytes\n'
'BC1: ${_bc1.length} bytes (${(_rawBytes / _bc1.length).toStringAsFixed(1)}x)\n'
'BC3: ${_bc3.length} bytes (${(_rawBytes / _bc3.length).toStringAsFixed(1)}x)\n'
'ETC2: ${_etc2.length} bytes (${(_rawBytes / _etc2.length).toStringAsFixed(1)}x)\n'
'ASTC 4x4: ${_astc.length} bytes '
'(${(_rawBytes / _astc.length).toStringAsFixed(1)}x)\n'
'KTX2 with a mip chain: ${_ktx2.length} bytes';
Step 5: Check the claim #
Every one of the four blocks has to be smaller than the pixels it came
from, and the container writeKtx2 wrote has to actually be a file.
// Every block format above packs a 4x4 patch of pixels into fewer
// bytes than the raw pixels take, or it is not compression.
if (_bc1.length >= _rawBytes ||
_bc3.length >= _rawBytes ||
_etc2.length >= _rawBytes ||
_astc.length >= _rawBytes) {
throw StateError('a compressed block is not smaller than the raw one');
}
if (_ktx2.length < 12) {
throw StateError('writeKtx2 did not produce a file');
}
if (frame.drawCalls < 1) {
throw StateError('the block was not drawn');
}