Computer Graphics · Texturing

Block Compression and the Endpoints It Stores

How a fixed-rate block codec reconstructs four colours from two endpoints, why the block grid forces independent decoding, and what that costs at mip level boundaries.

A texture is read far more than it is written, and it is read at a rate that scales with screen coverage and frame rate. That asymmetry is why texture compression exists at all and why it is a different problem from image compression. A PNG or a JPEG decoder produces an array in memory, which is then sampled; the memory traffic is uncompressed. A block-compressed texture stays compressed in memory, and the sampler reconstructs the texels it needs on the fly, so the bandwidth saving survives into the sampling path. The price is a fixed rate, a fixed block grid, and a reconstruction rule that is deliberately simple enough to implement in silicon.

A 4×4 block, two endpoints, two index bits

The most widely supported family encodes each 4×4 block of texels in 64 bits. Two 16-bit values hold endpoint colours in RGB565, and 32 bits hold sixteen 2-bit indices, one per texel, selecting among four colours. The four colours are derived from the endpoints by fixed interpolation:

$$c_0,\quad c_1,\quad \tfrac{2}{3}c_0 + \tfrac{1}{3}c_1,\quad \tfrac{1}{3}c_0 + \tfrac{2}{3}c_1$$

with the four reconstruction levels positioned along the line segment between the endpoints at parameters 0, 1, 1/3, and 2/3. The decoder’s whole job is to read a 64-bit word, expand the two endpoints from 5-6-5 to 8 bits per channel, compute the two intermediate colours, and select per texel. That is a handful of adds and shifts, which is why the format is universally supported.

Sixteen texels in 64 bits is 4 bits per texel, an 8:1 reduction against RGBA8 and 6:1 against RGB8. The rate is fixed regardless of content: a photographic texture and a flat colour occupy the same space, so the encoder cannot spend more bits where they would help. That rigidity is the defining constraint of the format family.

Compression is therefore a search. The encoder must choose two endpoints and sixteen indices that minimize error against the original block. Finding the optimal pair is expensive, so practical encoders cluster the block’s colours, fit a line through the cluster, project each texel onto the line, and quantize the endpoints. Different encoders differ mainly in how they choose the line and how many candidates they test, which is why the same texture compressed by two encoders can differ visibly in quality while remaining bit-identical in decode.

/* Decode one 64-bit BC1 block into 16 RGBA8 texels, four-colour mode.
   When c0 <= c1 the block instead decodes as c0, c1, (c0+c1)/2, and
   transparent black at index 3; that branch is omitted here. */
void decode_bc1(uint64_t block, unsigned char out[16][4]) {
    unsigned c0 = (unsigned)(block & 0xFFFF);
    unsigned c1 = (unsigned)((block >> 16) & 0xFFFF);
    unsigned idx = (unsigned)(block >> 32);

    int r0 = (c0 >> 11) & 0x1F, g0 = (c0 >> 5) & 0x3F, b0 = c0 & 0x1F;
    int r1 = (c1 >> 11) & 0x1F, g1 = (c1 >> 5) & 0x3F, b1 = c1 & 0x1F;

    for (int i = 0; i < 16; ++i) {
        unsigned k = (idx >> (2 * i)) & 3;   /* two bits per texel, packed low first */
        int r, g, b, a = 255;
        switch (k) {
        case 0: r = r0; g = g0; b = b0; break;
        case 1: r = r1; g = g1; b = b1; break;
        case 2: r = (2 * r0 + r1) / 3; g = (2 * g0 + g1) / 3; b = (2 * b0 + b1) / 3; break;
        /* Index 3 is the far third of the segment in this mode. */
        default: r = (r0 + 2 * r1) / 3; g = (g0 + 2 * g1) / 3; b = (b0 + 2 * b1) / 3; break;
        }
        /* 5- and 6-bit endpoints expand to 8 bits by replication, not scaling. */
        out[i][0] = (unsigned char)((r << 3) | (r >> 2));
        out[i][1] = (unsigned char)((g << 2) | (g >> 4));
        out[i][2] = (unsigned char)((b << 3) | (b >> 2));
        out[i][3] = (unsigned char)a;
    }
}

The endpoint expansion is not a multiplication by 255/31. Replicating the high bits into the low ones makes the maximum endpoint value map exactly to 255 and the minimum to 0, which preserves the white and black of the original. Scaling by 255/63 for the green channel would be arithmetically equivalent within one unit and costs a multiply; the shift-and-or is what hardware does.

The endpoint ordering is a mode bit

The format’s most easily missed detail is that the comparison between the two packed endpoints selects a decoding mode. When the first endpoint’s 16-bit value is numerically greater than the second, the block decodes as described: two intermediate colours and opaque alpha. When it is less than or equal, the block decodes with three intermediate colours plus an explicit transparent black at index 3. The same 64 bits therefore have two meanings, and the sign of one comparison chooses between them.

This is a genuine piece of engineering economy: alpha punching was added to the format without spending a bit on a mode flag, by reusing an ordering that the encoder controls anyway. It also means an encoder cannot choose endpoints freely; if it wants the three-colour-plus-transparent mode it must order the endpoints accordingly, and if it sorts them the other way to simplify the search it silently gives up alpha support. Decoders must implement the comparison, and an encoder that emits a block where $c_0 = c_1$ lands in the transparent mode, where index 3 yields black with zero alpha rather than an interpolated colour.

The four-bits-per-texel rate of the 64-bit block leaves little room, so the rest of the family spends its bits differently. Formats that spend 128 bits per block and keep more bits per endpoint component give finer gradients, at twice the rate. Formats that add two endpoint sets, each covering 8 of the 16 texels, handle blocks that straddle a sharp edge, which the single-line model cannot: one line through a block containing both dark and light regions produces a muddy interpolation across the boundary. Formats that split normal maps into two channels reconstruct the third, taking advantage of a normal’s third component being determined by the other two up to sign.

The block grid is not negotiable

A 4×4 block is the addressing unit, and every texel belongs to exactly one. Blocks cannot be split, reordered, or partially fetched with the same simplicity: the decoder needs all 64 bits before it can produce any texel. That has three consequences.

The first is that random access is coarser. Filtering a single texel requires decoding a whole block, so the four taps of a bilinear fetch touch one to four blocks depending on where the sample point falls. When the two-by-two tap group straddles a block boundary in both directions, four blocks are decoded, 64 texels, to produce four. The average case is much better, since a group straddling one boundary needs two blocks, but the cost is content-independent and proportional to how often the sample lattice crosses block boundaries.

The second is that mip generation must decode first. Averaging block-compressed texels in their compressed form is meaningless because the packed endpoints and indices are not linearly related to colour. The chain is therefore built at full resolution and each level compressed independently, which means the encoder’s block choices at each level are independent and a level’s blocks do not align with the blocks of the level above. At the transition between levels, trilinear blending mixes two independently quantized values, and the quantization errors do not cancel.

The third consequence is that non-power-of-two and odd dimensions need padding. A 33×33 texture has a partial block row and column; the encoder fills the missing texels by replication or with a chosen constant, and the sampler must never read the padding. Because the block grid is fixed in the compressed representation, the logical dimensions cannot be recovered from the data alone and must be carried in metadata. A decoder given only a byte count can compute the padded dimensions but not the intended ones. The same constraint makes block boundaries a factor in level construction, since a chain built at full resolution and compressed level by level, as described in mip chain construction, aligns its blocks independently at each level.

Filtering compressed data and what it costs

Hardware filters in two possible places: decode to a small cache and filter the decoded values, or filter in the compressed domain. Filtering decoded values is exact but needs a texel cache, which is why texture units have one. The cache holds recently decoded blocks, and its size determines how often a tap group misses; a 2×2 bilinear group centred in a block hits with high probability, and groups near boundaries miss more.

Filtering in the compressed domain can be approximated by interpolating the endpoints of adjacent blocks and reusing the indices, which is cheaper but not equivalent, because the palette of each block is derived from its own endpoints. Hardware implementations of high-quality anisotropic filtering on compressed textures generally decode and filter, accepting the cache traffic.

The rate also scales the mip chain. At 4 bits per texel the base level is half a byte per texel and the geometric series brings the complete chain to two thirds of a byte per texel, the same 4/3 factor the uncompressed case has. A 1024×1024 BC1 chain is therefore about 0.67 MiB against 5.33 MiB for the same texture in RGBA8, which is the number that decides whether a set of textures fits in a budget.

What compression cannot do is preserve precision. Five and six bits per channel means the endpoint values themselves are quantized before any interpolation happens, so a smooth gradient across a block has at most four distinct values along one line and the block boundaries are visible as steps on a large, slowly varying surface. Encoders mitigate this by choosing endpoints that straddle the block’s colour range rather than fitting its extremes, which trades a small mean error for hiding the boundary step. The bandwidth saving is real and the quality cost is a fixed, predictable loss of chroma and gradient resolution, which is why the format family is used for albedo and detail maps and not for data that must be reconstructed precisely, as in filtering normal maps without breaking the normals.

References

  1. Real-Time Rendering, Fourth Edition, Chapter 6: Texturing — Akenine-Moller, Haines, Hoffman, Pesce, Zwicker; CRC Press, 2018
  2. Texture Block Compression in Direct3D 11 — Microsoft Direct3D documentation
  3. OpenGL Wiki: S3 Texture Compression — Khronos Group