Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ The compressor is CPU-side and can use significantly more computation than the d
| M2 Max | CPU 12 cores | 1300 MiB/s |
| M2 Max | GPU | 11850 MiB/s |

Average BC1 compression ratio for 74 images in test is **1.46:1**

| Block type | Average compression from the image suite |
|:-----: | :------------:
| BC1 | 1.46:1
| BC4 | 1.13:1
| BC5 | 1.1:1

All compression ratio are better than zlib.

Note: AI disclaimer, although everything is based on my ideas and my previous libraries, almost all the code was written by a LLM. It wouldn't have been possible with my fulltime job otherwise.

Expand Down Expand Up @@ -269,25 +276,23 @@ index 0: 3 raw bits (no model)
index i: symbol = prediction XOR index[i], encoded with a static 8-symbol Rice-Golomb model
prediction = index[i-1] (left)
index[i-4] (directly above, for the rows at offset 4, 8, 12)
context = family * 8 + prediction
context = left * 8 + top, the 8 x 8 matrix of the decoded neighbor indices:
left = index[i-1] (a row start has no left neighbor),
top = index[i-4] (the first row has no top neighbor),
the missing neighbor defaulting to 0
```

That is 3 endpoint-range families x 8 prediction values = 24 index models per channel. The family is derived from the block's own, already decoded endpoints, so the model selection costs no stream bits:

```text
family = |endpoint0 - endpoint1| (unsigned range in [0; 255])
< 5 → 0, < 40 → 1, else → 2
```
That is an 8 x 8 matrix = 64 index models per channel. The context is derived from the already decoded indices themselves, so the model selection costs no stream bits.

The 8-symbol models use a compact rank table (8 entries instead of 256): `3 + num_symbols ≤ 11` bytes each in the stream. The family thresholds (5 / 40) are fitted values: the index-XOR distributions drift smoothly with the endpoint contrast, and a sweep of the 3-family splits over the image suite puts the minimum here — every one of the 10 suite images improves and the stream shrinks 0.24% versus the original 8 / 16. Changing the family count instead (2 or 4 families) measured worse.
The 8-symbol models use a compact rank table (8 entries instead of 256): `3 + num_symbols ≤ 11` bytes each in the stream.

### Compressed stream

As BC1, the stream does not include width, height or format: the BC4 / BC5 decompressors are separate entry points and know their own header.

```text
* 2 endpoint models per channel — 256-symbol static models, 3 + num_symbols bytes each, no padding (max 259).
* 24 index models per channel — 8-symbol static models, 3 + num_symbols bytes each, no padding (max 11).
* 64 index models per channel — 8-symbol static models, 3 + num_symbols bytes each, no padding (max 11).
* Strip seed: 2 bytes per channel (endpoint 0, endpoint 1).
* Padded to a 2-byte boundary.
* 63 strip offsets (uint16_t each), relative deltas in dword units. Strip 0 always starts the strip data; its offset is zero and not stored.
Expand All @@ -309,8 +314,8 @@ Everything is validated byte-exact over all 64 strips, on CPU and GPU:

* GPU roundtrips: the `bcp_decompress_bc4` / `bcp_decompress_bc5` Metal kernels decode the brick AO / normal maps (1024×1024, `images/bc4` / `images/bc5`) and the result is verified byte-identical to the stb-built textures, the same way the BC1 kernel is tested.

* CPU: 8 synthetic 256×256 roundtrips (flat, checkerboard, gradient, SplitMix32 random, each as BC4 and BC5): the random pattern forces the raw-mode fallback and the full 24-context usage.
* CPU: 4 AO maps (`images/bc4`) and 4 normal maps (`images/bc5`), 1024×1024, converted per block with stb_dxt: the per-image ratio is printed to stdout (no CSV) and the suite average must stay above 1:1. Measured: **1.1244:1** average for BC4 (1.0868 – 1.1680 per image) and **1.0756:1** for BC5 (1.0466 – 1.0990 per image). BC5 compression ratio looks modest but zlib only achieves 1.010563:1 for the same images.
* CPU: 8 synthetic 256×256 roundtrips (flat, checkerboard, gradient, SplitMix32 random, each as BC4 and BC5): the random pattern forces the raw-mode fallback and the full 64-context usage.
* CPU: 4 AO maps (`images/bc4`) and 4 normal maps (`images/bc5`), 1024×1024, converted per block with stb_dxt: the per-image ratio is printed to stdout (no CSV) and the suite average must stay above 1:1. Measured: **1.1342:1** average for BC4 (1.0932 – 1.1577 per image) and **1.0995:1** for BC5 (1.0642 – 1.1334 per image). BC5 compression ratio looks modest but zlib only achieves 1.010563:1 for the same images.

The 8-bit formats leave less headroom than BC1: the indices of these maps sit close to 3 near-uniform bits each, so most of the gain comes from the endpoint deltas.

Expand Down Expand Up @@ -347,7 +352,7 @@ Each substream is self-contained (its own models, strip seed, 63 strip offsets,
Everything is validated byte-exact over all 64 strips on CPU:

* 4 synthetic 256×256 roundtrips (flat, checkerboard, gradient, SplitMix32 random): the color half and the alpha half each use the corresponding BC1 / BC4 pattern, so the random pattern forces the raw-mode fallback and the full model usage in both halves at once.
* 2 albedo + alpha maps (`images/bc3`), 1024×1024, converted per block with stb_dxt: the per-image ratio is printed to stdout (no CSV) and the suite average must stay above 1:1. Measured: **2.7149:1** average (2.9445:1 for leak-stain_albedo, 2.4853:1 for leaves). The ratio is dominated by the BC1 color half, which keeps the BC1 headroom; the 8-bit alpha half compresses like a standalone BC4.
* 2 albedo + alpha maps (`images/bc3`), 1024×1024, converted per block with stb_dxt: the per-image ratio is printed to stdout (no CSV) and the suite average must stay above 1:1. Measured: **2.8030:1** average (3.1231:1 for leak-stain_albedo, 2.4828:1 for leaves). The ratio is dominated by the BC1 color half, which keeps the BC1 headroom; the 8-bit alpha half compresses like a standalone BC4.

## Images

Expand Down
32 changes: 14 additions & 18 deletions src/bc_decompress.metal
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// The stream layout parsed here mirrors the CPU compressors / decompressors:
// BC1 [17 static models][top table size u8][4-align][strip seed u32][strip predictor u64][strip dict enable u64]
// [top table u32s][2-align][63 strip offsets u16, deltas in dword units][4-align][strip bitstreams]
// BC4 / BC5 [2 endpoint models per channel][24 index models per channel][strip seed, 2 bytes per channel]
// BC4 / BC5 [2 endpoint models per channel][64 index models per channel][strip seed, 2 bytes per channel]
// [2-align][63 strip offsets u16, deltas in dword units][4-align][strip bitstreams]
//
// On a corrupted stream the decode runs to completion and the output is garbage, mirroring the
Expand All @@ -42,7 +42,8 @@ constant uint NUM_STRIPS = 64; // 64 independent strips, one decoded thread per
constant uint TOP_TABLE_SIZE = 256;
constant uint LE_ALPHABET_SIZE = 256;
constant uint BC4_INDEX_ALPHABET_SIZE = 8; // bc4/bc5 index models: 8 prediction values
constant uint BC4_INDEX_CONTEXTS = 24; // bc4/bc5 index models: 3 endpoint-range families x 8
constant uint BC4_INDEX_DIM = 8; // bc4/bc5 index context: the 8 x 8 [left][top] matrix
constant uint BC4_INDEX_CONTEXTS = BC4_INDEX_DIM * BC4_INDEX_DIM;
constant uint LE_MAX_K = 8;
constant int COLOR_DELTA_OFFSET = 1 << 6; // 64: bias of the signed color deltas

Expand Down Expand Up @@ -547,17 +548,11 @@ uint8_t bc4_endpoint_predict(bool first_row, bool row_start, uint8_t prev, uint8
return (uint8_t)(prev + top - top_prev);
}

// mirror of bc4_index_family (src/bc_packed.c): 3 families from the endpoint range |e0 - e1|
uint8_t bc4_index_family(uint8_t e0, uint8_t e1)
// mirror of bc4_index_context (src/bc_packed.c): the 8 x 8 [left][top] matrix context, a
// missing neighbor (first row, row start) defaults to 0
uint8_t bc4_index_context(uint8_t left, uint8_t top)
{
uint32_t range = (e0 >= e1) ? (uint32_t)(e0 - e1) : (uint32_t)(e1 - e0);

if (range < 5)
return 0;
if (range < 40)
return 1;

return 2;
return (uint8_t)(left * BC4_INDEX_DIM + top);
}

template <uint NumChannels>
Expand All @@ -582,7 +577,7 @@ void bc45_decode_strip(const device uint8_t* input,
output += (uint64_t)grid_pos.y * image_size;

//-------------------------------------------------------------------------------------------------------------------------
// load the static models: per channel, the 2 endpoint models (256 symbols) then the 24
// load the static models: per channel, the 2 endpoint models (256 symbols) then the 64
// compact index models (8 symbols)
model_t endpoint[NumChannels][2];
model8_t index[NumChannels][BC4_INDEX_CONTEXTS];
Expand Down Expand Up @@ -689,16 +684,17 @@ void bc45_decode_strip(const device uint8_t* input,
}

// index payload: the first index in raw 3 bits, then each index xored with its
// intra-block prediction, contextual on the family and the prediction value
// intra-block prediction, contextual on the 8 x 8 [left][top] matrix
idx[0] = bs_read_bits(&compressed_stream, 3);

uint8_t family = bc4_index_family(e[0], e[1]);

for (uint i = 1; i < 16; ++i)
{
uint8_t p = ((i & 3) == 0) ? idx[i - 4] : idx[i - 1];
// the missing neighbor of the pixel (first row, row start) defaults to 0
uint8_t left = ((i & 3) == 0) ? 0 : idx[i - 1];
uint8_t top = (i < 4) ? 0 : idx[i - 4];
uint8_t p = ((i & 3) == 0) ? top : left;

idx[i] = (uint8_t)(p ^ decode_symbol8(&compressed_stream, &index[c][family * 8 + p]));
idx[i] = (uint8_t)(p ^ decode_symbol8(&compressed_stream, &index[c][bc4_index_context(left, top)]));
}

// the standard block: two endpoints + the 48-bit little-endian index field, as two aligned u32 words
Expand Down
67 changes: 31 additions & 36 deletions src/bc_packed.c
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,10 @@ static inline uint32_t le_symbol_cost(const le_model* model, uint8_t value)
#define COLOR_DICT_SIZE (256U)
#define COLOR_DICT_MASK (COLOR_DICT_SIZE - 1U)

// bc4/bc5 contextual index models: 3 endpoint-range families x 8 prediction values, and the
// channel count (BC4 = 1 channel, BC5 = red + green)
#define BC4_INDEX_FAMILIES (3)
#define BC4_INDEX_CONTEXTS (BC4_INDEX_FAMILIES * 8)
// bc4/bc5 contextual index models: an 8 x 8 [left][top] matrix keyed on the decoded 3-bit
// indices of the two neighbors of the pixel, and the channel count (BC4 = 1 channel, BC5 = red + green)
#define BC4_INDEX_DIM (8)
#define BC4_INDEX_CONTEXTS (BC4_INDEX_DIM * BC4_INDEX_DIM)
#define BCP_MAX_CHANNELS (2)


Expand Down Expand Up @@ -539,9 +539,9 @@ struct bcp_context
// matching blocks) pay no flag overhead. see the per-strip dictionary enable in init_static_models
uint64_t strip_dict_enable;

// bc4/bc5: per-channel static models — the 2 endpoint models (256 symbols) and the 24
// index models (8 symbols each, 3 endpoint-range families x 8 prediction values); each
// retains the full 516-byte le_model in memory, the stream only stores num_symbols entries
// bc4/bc5: per-channel static models — the 2 endpoint models (256 symbols) and the 64
// index models (8 symbols each, 8 x 8 [left][top] matrix); each retains the full 516-byte
// le_model in memory, the stream only stores num_symbols entries
le_model bc4_endpoint_model[BCP_MAX_CHANNELS][2]; // [channel][endpoint]
le_model bc4_index_model[BCP_MAX_CHANNELS][BC4_INDEX_CONTEXTS];
uint8_t bc4_strip_seed[BCP_MAX_CHANNELS][2]; // [channel][endpoint]
Expand Down Expand Up @@ -1399,19 +1399,12 @@ static inline uint8_t delta_decode_wrap(uint8_t prev, uint8_t delta)
}

//-----------------------------------------------------------------------------------------------------------------------------
// Index context family from the endpoint range |e0 - e1| (unsigned, the true contrast of the 8
// interpolated colors): range < 5 → family 0, range < 40 → family 1, else family 2. The index
// XOR distributions drift smoothly with the contrast, and these fitted thresholds minimize the
// index payload on the image suite (exp 21): 10/10 images improved, -0.24% stream over 8 / 16
static inline uint8_t bc4_index_family(uint8_t e0, uint8_t e1)
// Index context: the 8 x 8 [left][top] matrix of the decoded 3-bit indices — the left neighbor
// of the pixel (a row start has none) and the pixel directly above (the first row has none),
// the missing neighbor defaulting to 0
static inline uint8_t bc4_index_context(uint8_t left, uint8_t top)
{
uint32_t range = (e0 >= e1) ? (uint32_t)(e0 - e1) : (uint32_t)(e1 - e0);

if (range < 5)
return 0;
if (range < 40)
return 1;
return 2;
return (uint8_t)(left * BC4_INDEX_DIM + top);
}

//-----------------------------------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -1455,7 +1448,7 @@ static inline void bc4_pack_indices(const uint8_t idx[16], bc4_block* b)

//-----------------------------------------------------------------------------------------------------------------------------
// Per-channel histogram of the bc4/bc5 payload symbols: 2 endpoint models (256 symbols) +
// 24 index models (8 symbols each)
// 64 index models (8 symbols each)
typedef struct bc4_hists
{
uint32_t endpoint[2][256];
Expand Down Expand Up @@ -1538,14 +1531,15 @@ static void bc4_init_models(bcp_context* ctx, const void* image, uint32_t width_
}

bc4_unpack_indices(b, idx);
uint8_t family = bc4_index_family(b->endpoint[0], b->endpoint[1]);

for(uint32_t i=1; i<16; ++i)
{
// intra-block predictor: the pixel above at a row start, the left pixel otherwise
uint8_t p = ((i & 3) == 0) ? idx[i-4] : idx[i-1];
// the missing neighbor of the pixel (first row, row start) defaults to 0
uint8_t left = ((i & 3) == 0) ? 0 : idx[i-1];
uint8_t top = (i < 4) ? 0 : idx[i-4];
uint8_t p = ((i & 3) == 0) ? top : left;

hist[c].index[family * 8 + p][p ^ idx[i]]++;
hist[c].index[bc4_index_context(left, top)][p ^ idx[i]]++;
}

if (!first_row)
Expand Down Expand Up @@ -1587,7 +1581,7 @@ static size_t bcp_compress_bc45(bcp_context* ctx, const void* image, uint32_t wi

byte_stream stream = {.buffer = output, .length = output_length, .pos = 0};

// store models: per channel, the 2 endpoint models (256 symbols) then the 24 index models (8 symbols)
// store models: per channel, the 2 endpoint models (256 symbols) then the 64 index models (8 symbols)
for(uint32_t c=0; c<num_channels; ++c)
{
for(uint32_t e=0; e<2; ++e)
Expand Down Expand Up @@ -1678,18 +1672,19 @@ static size_t bcp_compress_bc45(bcp_context* ctx, const void* image, uint32_t wi
}

// index payload: the first index in raw 3 bits, then each index xored with its
// intra-block prediction, contextual on the family and the prediction value
// intra-block prediction, contextual on the 8 x 8 [left][top] matrix
bc4_unpack_indices(b, idx);

le_write_bits(&compressed_stream, idx[0], 3);

uint8_t family = bc4_index_family(b->endpoint[0], b->endpoint[1]);

for(uint32_t i=1; i<16; ++i)
{
uint8_t p = ((i & 3) == 0) ? idx[i-4] : idx[i-1];
// the missing neighbor of the pixel (first row, row start) defaults to 0
uint8_t left = ((i & 3) == 0) ? 0 : idx[i-1];
uint8_t top = (i < 4) ? 0 : idx[i-4];
uint8_t p = ((i & 3) == 0) ? top : left;

le_encode_symbol(&compressed_stream, &ctx->bc4_index_model[c][family * 8 + p], p ^ idx[i]);
le_encode_symbol(&compressed_stream, &ctx->bc4_index_model[c][bc4_index_context(left, top)], p ^ idx[i]);
}

if (!first_row)
Expand Down Expand Up @@ -1731,7 +1726,7 @@ static bool bcp_decompress_bc45(bcp_context* ctx, const void* input, size_t inpu

byte_stream stream = {.buffer = (uint8_t*)input, .length = input_length, .pos = 0};

// load models: per channel, the 2 endpoint models then the 24 index models
// load models: per channel, the 2 endpoint models then the 64 index models
for(uint32_t c=0; c<num_channels; ++c)
{
for(uint32_t e=0; e<2; ++e)
Expand Down Expand Up @@ -1816,16 +1811,16 @@ static bool bcp_decompress_bc45(bcp_context* ctx, const void* input, size_t inpu
}

// index payload: the first index in raw 3 bits, then each index xored with its
// intra-block prediction, contextual on the family and the prediction value
// intra-block prediction, contextual on the 8 x 8 [left][top] matrix
idx[0] = le_read_bits(&compressed_stream, 3);

uint8_t family = bc4_index_family(b->endpoint[0], b->endpoint[1]);

for(uint32_t i=1; i<16; ++i)
{
uint8_t p = ((i & 3) == 0) ? idx[i-4] : idx[i-1];
uint8_t left = ((i & 3) == 0) ? 0 : idx[i-1];
uint8_t top = (i < 4) ? 0 : idx[i-4];
uint8_t p = ((i & 3) == 0) ? top : left;

idx[i] = (uint8_t)(p ^ le_decode_symbol(&compressed_stream, &ctx->bc4_index_model[c][family * 8 + p]));
idx[i] = (uint8_t)(p ^ le_decode_symbol(&compressed_stream, &ctx->bc4_index_model[c][bc4_index_context(left, top)]));
}

bc4_pack_indices(idx, b);
Expand Down
Loading
Loading