diff --git a/README.md b/README.md index cb7a21d..d5a0bea 100644 --- a/README.md +++ b/README.md @@ -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. @@ -269,17 +276,15 @@ 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 @@ -287,7 +292,7 @@ As BC1, the stream does not include width, height or format: the BC4 / BC5 decom ```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. @@ -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. @@ -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 diff --git a/src/bc_decompress.metal b/src/bc_decompress.metal index c0ec61b..d5a1639 100644 --- a/src/bc_decompress.metal +++ b/src/bc_decompress.metal @@ -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 @@ -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 @@ -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 @@ -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]; @@ -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 diff --git a/src/bc_packed.c b/src/bc_packed.c index e97252b..a8e3948 100644 --- a/src/bc_packed.c +++ b/src/bc_packed.c @@ -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) @@ -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] @@ -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); } //----------------------------------------------------------------------------------------------------------------------------- @@ -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]; @@ -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) @@ -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; cendpoint[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) @@ -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; cendpoint[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); diff --git a/tests/synthetic_tests.c b/tests/synthetic_tests.c index 63e847f..665496a 100644 --- a/tests/synthetic_tests.c +++ b/tests/synthetic_tests.c @@ -549,8 +549,8 @@ static greatest_test_res bc45_both(const bc4_block* image8, const bc4_block* ima } //----------------------------------------------------------------------------------------------------------------------------- -// one solid block value: identical endpoints (range 0, family 0) and constant indices, -// the degenerate single-symbol models +// one solid block value: identical endpoints and constant indices, the degenerate +// single-symbol models TEST bc45_flat(void) { bc4_block image[TEST_BC45_BLOCKS]; @@ -576,7 +576,7 @@ TEST bc45_flat(void) } //----------------------------------------------------------------------------------------------------------------------------- -// alternating endpoint pairs (range 8, family 1) and index patterns across the block grid +// alternating endpoint pairs and index patterns across the block grid TEST bc45_checkerboard(void) { bc4_block image[TEST_BC45_BLOCKS]; @@ -590,7 +590,7 @@ TEST bc45_checkerboard(void) if ((bx + by) % 2 == 0) { image[i].endpoint[0] = 0x30; - image[i].endpoint[1] = 0x28; // range 8 → family 1 + image[i].endpoint[1] = 0x28; for(uint32_t j=0; j<6; ++j) image[i].indices[j] = (uint8_t)((bx + by + j) * 0x55); } @@ -617,7 +617,7 @@ TEST bc45_checkerboard(void) } //----------------------------------------------------------------------------------------------------------------------------- -// smooth endpoint gradient (wide ranges, family 2) with position-derived indices +// smooth endpoint gradient (wide ranges) with position-derived indices TEST bc45_gradient(void) { bc4_block image[TEST_BC45_BLOCKS]; @@ -648,8 +648,8 @@ TEST bc45_gradient(void) } //----------------------------------------------------------------------------------------------------------------------------- -// pseudo-random blocks: all families, near-uniform symbols (the raw-mode fallback) and full -// 24-context usage in a single image +// pseudo-random blocks: near-uniform symbols (the raw-mode fallback) and full +// 64-context usage in a single image TEST bc45_random_splitmix32(void) { bc4_block image[TEST_BC45_BLOCKS];