feat(shared): add palette-compressed chunk representation

This commit is contained in:
Serkyo 2026-07-08 02:16:18 +02:00
parent 705345b337
commit 3a3ecc13e2

View file

@ -5,6 +5,7 @@
use bytemuck::{Pod, Zeroable};
use glam::Vec3;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, hash_map::Entry};
/// The size of a chunk along one axis in blocks.
pub const CHUNK_SIZE: usize = 32;
@ -70,6 +71,114 @@ impl Default for Chunk {
}
}
/// The palette-compressed representation of a chunk's voxel data, used as the stored and transmitted form.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PalettedChunk {
/// The distinct materials present in the chunk. A stored voxel index refers to a position in this table.
palette: Vec<BlockId>,
/// The palette indices for all `CHUNK_VOLUME` voxels, packed `bits_per_index` bits each, low voxel first, into 64-bit words.
indices: Vec<u64>,
/// The width in bits of a single packed palette index, equal to `ceil(log2(palette.len()))` with a floor of one.
bits_per_index: u32,
}
impl PalettedChunk {
/// Encodes a dense [`Chunk`] into its palette-compressed form.
#[must_use]
pub fn from_chunk(chunk: &Chunk) -> Self {
// Assign a palette slot to each distinct material in first-encounter order.
let mut lookup: HashMap<BlockId, usize> = HashMap::new();
let mut palette: Vec<BlockId> = Vec::new();
for &block in &*chunk.blocks {
if let Entry::Vacant(entry) = lookup.entry(block) {
entry.insert(palette.len());
palette.push(block);
}
}
let bits_per_index = Self::bits_for_palette(palette.len());
let mut indices = vec![0u64; Self::packed_word_count(bits_per_index)];
for (voxel, &block) in chunk.blocks.iter().enumerate() {
// Every block was inserted into `lookup` above, so this cannot miss.
let index = lookup[&block] as u64;
Self::write_packed(&mut indices, voxel, bits_per_index, index);
}
Self {
palette,
indices,
bits_per_index,
}
}
/// Decodes the palette-compressed form back into a dense [`Chunk`].
#[must_use]
pub fn to_chunk(&self) -> Chunk {
let mut blocks = vec![BlockId::AIR; CHUNK_VOLUME].into_boxed_slice();
for (voxel, slot) in blocks.iter_mut().enumerate() {
// A stored index was produced from a palette position, so it is always in range for `palette`.
#[expect(clippy::cast_possible_truncation)]
let index = Self::read_packed(&self.indices, voxel, self.bits_per_index) as usize;
*slot = self.palette[index];
}
Chunk { blocks }
}
/// Returns the number of bits required to store a palette index for a palette of `len` entries.
fn bits_for_palette(len: usize) -> u32 {
if len <= 1 {
1
} else {
// Bit width of the largest index `len - 1`.
usize::BITS - (len - 1).leading_zeros()
}
}
/// Returns the number of 64-bit words needed to pack every voxel index at `bits_per_index` bits.
fn packed_word_count(bits_per_index: u32) -> usize {
let total_bits = CHUNK_VOLUME * bits_per_index as usize;
total_bits.div_ceil(u64::BITS as usize)
}
/// Writes `value` as the `voxel`-th index of width `bits_per_index` into the packed `words` buffer.
fn write_packed(words: &mut [u64], voxel: usize, bits_per_index: u32, value: u64) {
let bit_offset = voxel * bits_per_index as usize;
let word = bit_offset / 64;
let bit_in_word = bit_offset % 64;
// Low part: the bits that fit in the current word at `bit_in_word` and above. Bits shifted past bit 63 are dropped and handled by the spill below.
words[word] |= value << bit_in_word;
// High part: only present when the index straddles the word boundary.
let bits_in_first = 64 - bit_in_word;
if bits_per_index as usize > bits_in_first {
words[word + 1] |= value >> bits_in_first;
}
}
/// Reads the `voxel`-th index of width `bits_per_index` from the packed `words` buffer, reassembling any value that straddles a 64-bit word boundary.
fn read_packed(words: &[u64], voxel: usize, bits_per_index: u32) -> u64 {
let bit_offset = voxel * bits_per_index as usize;
let word = bit_offset / 64;
let bit_in_word = bit_offset % 64;
// `bits_per_index` never reaches 64 (a chunk holds at most `CHUNK_VOLUME` distinct materials, so at most 15 bits), so this shift cannot overflow.
let mask = (1u64 << bits_per_index) - 1;
let bits_in_first = 64 - bit_in_word;
let low = words[word] >> bit_in_word;
if bits_per_index as usize <= bits_in_first {
low & mask
} else {
// Reassemble a straddling index: low bits from the current word, high bits from the next.
let high = words[word + 1] << bits_in_first;
(low | high) & mask
}
}
}
/// The three-dimensional spatial coordinates of a chunk in the world.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChunkPos {
@ -135,7 +244,7 @@ impl EntityPos {
#[cfg(test)]
mod tests {
use super::{CHUNK_SIZE, ChunkPos, EntityPos};
use super::*;
use glam::Vec3;
// `CHUNK_SIZE` is 32, exactly representable, so the widening cannot lose precision here.
@ -204,4 +313,67 @@ mod tests {
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
}
/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size.
fn chunk_cycling(distinct: usize) -> Chunk {
let mut chunk = Chunk::default();
for (i, block) in chunk.blocks.iter_mut().enumerate() {
// `distinct` is a small test constant, so the modulo result always fits in a u16.
#[expect(clippy::cast_possible_truncation)]
let id = (i % distinct) as u16;
*block = BlockId(id);
}
chunk
}
#[test]
fn bit_width_matches_palette_size() {
// The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide.
assert_eq!(PalettedChunk::bits_for_palette(1), 1);
assert_eq!(PalettedChunk::bits_for_palette(2), 1);
assert_eq!(PalettedChunk::bits_for_palette(3), 2);
assert_eq!(PalettedChunk::bits_for_palette(4), 2);
assert_eq!(PalettedChunk::bits_for_palette(5), 3);
assert_eq!(PalettedChunk::bits_for_palette(8), 3);
assert_eq!(PalettedChunk::bits_for_palette(9), 4);
assert_eq!(PalettedChunk::bits_for_palette(16), 4);
assert_eq!(PalettedChunk::bits_for_palette(17), 5);
}
#[test]
fn round_trip_preserves_all_voxels() {
// Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1).
for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] {
let original = chunk_cycling(distinct);
let paletted = PalettedChunk::from_chunk(&original);
assert_eq!(
paletted.palette.len(),
distinct,
"palette must hold exactly the distinct materials for {distinct}"
);
let restored = paletted.to_chunk();
assert_eq!(
original.blocks, restored.blocks,
"round trip must preserve every voxel for {distinct} materials"
);
}
}
#[test]
fn all_air_chunk_has_single_entry_palette() {
let paletted = PalettedChunk::from_chunk(&Chunk::default());
assert_eq!(paletted.palette, vec![BlockId::AIR]);
assert_eq!(paletted.bits_per_index, 1);
assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks);
}
#[test]
fn preserves_index_straddling_word_boundary() {
// With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling.
let mut original = chunk_cycling(5);
original.blocks[21] = BlockId(4);
let restored = PalettedChunk::from_chunk(&original).to_chunk();
assert_eq!(restored.blocks[21], BlockId(4));
assert_eq!(original.blocks, restored.blocks);
}
}