diff --git a/crates/shared/src/world.rs b/crates/shared/src/world.rs index 9cc54eb..5870534 100644 --- a/crates/shared/src/world.rs +++ b/crates/shared/src/world.rs @@ -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, + /// The palette indices for all `CHUNK_VOLUME` voxels, packed `bits_per_index` bits each, low voxel first, into 64-bit words. + indices: Vec, + /// 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 = HashMap::new(); + let mut palette: Vec = 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); + } }