feat(shared): add palette-compressed chunk representation
This commit is contained in:
parent
705345b337
commit
3a3ecc13e2
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue