init
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "hcie-tile"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
serde_json = "1.0"
|
||||
bincode = "1.3"
|
||||
@@ -0,0 +1,361 @@
|
||||
//! # hcie-tile
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Sparse tile-based layer storage designed to optimize memory usage and processing speed
|
||||
//! on large/high-resolution canvases (e.g., 4K or larger). Instead of maintaining flat, dense
|
||||
//! buffers of raw pixel data (which can exceed 33MB per layer), this crate chunks layer data
|
||||
//! into small 256x256 pixel tiles and only allocates memory for tiles that contain active pixels.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! - The canvas is logically subdivided into a grid of 256x256 tiles (`TILE_SIZE`).
|
||||
//! - Each tile holds a fixed-size `[u8; TILE_BYTES]` byte array for RGBA pixels.
|
||||
//! - A hash map is used to map 2D grid coordinates (`TileKey = (u32, u32)`) to active `Tile` objects.
|
||||
//! - Transparent tiles (where all alpha bytes are 0) are automatically pruned or omitted,
|
||||
//! dramatically saving memory and skipping blending iterations in renderer components.
|
||||
//! - Updates can be performed within specific sub-regions, culling the coordinate space to
|
||||
//! only update affected tiles.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const TILE_SIZE: u32 = 256;
|
||||
pub const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE * 4) as usize; // 262144
|
||||
|
||||
pub type TileKey = (u32, u32);
|
||||
|
||||
/// Represents a single 256x256 pixel tile holding raw RGBA pixel data.
|
||||
#[derive(Clone)]
|
||||
pub struct Tile {
|
||||
pub pixels: [u8; TILE_BYTES],
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
/// Creates a new transparent tile (all channels set to 0).
|
||||
pub fn new_transparent() -> Self {
|
||||
Self { pixels: [0u8; TILE_BYTES] }
|
||||
}
|
||||
|
||||
/// Creates a new solid white tile (RGB=255, Alpha=255).
|
||||
pub fn new_blank() -> Self {
|
||||
Self { pixels: [255u8; TILE_BYTES] }
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for Tile {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_bytes(&self.pixels)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for Tile {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct TileVisitor;
|
||||
impl<'de> serde::de::Visitor<'de> for TileVisitor {
|
||||
type Value = Tile;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "a byte sequence of exactly {} bytes representing tile pixel data", TILE_BYTES)
|
||||
}
|
||||
fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Tile, E> {
|
||||
if v.len() != TILE_BYTES {
|
||||
return Err(E::custom(format!(
|
||||
"expected {} bytes, got {}", TILE_BYTES, v.len()
|
||||
)));
|
||||
}
|
||||
let mut tile = Tile::new_transparent();
|
||||
tile.pixels.copy_from_slice(v);
|
||||
Ok(tile)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Tile, A::Error> {
|
||||
let mut tile = Tile::new_transparent();
|
||||
let mut i = 0;
|
||||
while let Some(byte) = seq.next_element()? {
|
||||
if i < TILE_BYTES { tile.pixels[i] = byte; }
|
||||
i += 1;
|
||||
}
|
||||
if i != TILE_BYTES {
|
||||
use serde::de::Error;
|
||||
return Err(A::Error::custom(format!(
|
||||
"expected {} bytes, got {}", TILE_BYTES, i
|
||||
)));
|
||||
}
|
||||
Ok(tile)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_bytes(TileVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sparse tile-based storage layer. Only allocates 256x256 tiles for regions with non-transparent content.
|
||||
#[derive(Clone)]
|
||||
pub struct TiledLayer {
|
||||
tiles: HashMap<TileKey, Tile>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl TiledLayer {
|
||||
/// Creates an empty tiled layer with the specified dimensions.
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
tiles: HashMap::new(),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a sparse tiled layer from a dense, flat RGBA pixel buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pixels` - Flat raw RGBA pixel slice.
|
||||
/// * `width` - Layer width.
|
||||
/// * `height` - Layer height.
|
||||
pub fn from_dense(pixels: &[u8], width: u32, height: u32) -> Self {
|
||||
let mut tl = Self::new(width, height);
|
||||
if width == 0 || height == 0 { return tl; }
|
||||
let tiles_x = div_ceil(width, TILE_SIZE);
|
||||
let tiles_y = div_ceil(height, TILE_SIZE);
|
||||
for ty in 0..tiles_y {
|
||||
for tx in 0..tiles_x {
|
||||
let key = (tx, ty);
|
||||
let mut tile = Tile::new_transparent();
|
||||
let mut has_content = false;
|
||||
for y in 0..TILE_SIZE {
|
||||
let gy = ty * TILE_SIZE + y;
|
||||
if gy >= height { break; }
|
||||
for x in 0..TILE_SIZE {
|
||||
let gx = tx * TILE_SIZE + x;
|
||||
if gx >= width { break; }
|
||||
let src_idx = ((gy * width + gx) as usize) * 4;
|
||||
let dst_idx = ((y * TILE_SIZE + x) as usize) * 4;
|
||||
tile.pixels[dst_idx..dst_idx + 4].copy_from_slice(&pixels[src_idx..src_idx + 4]);
|
||||
if pixels[src_idx + 3] != 0 {
|
||||
has_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_content {
|
||||
tl.tiles.insert(key, tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
tl
|
||||
}
|
||||
|
||||
/// Update only tiles overlapping the specified rectangular region [x0, y0) -> (x1, y1).
|
||||
/// Tiles outside this region are left untouched. On large canvases, this culls the scanned
|
||||
/// pixel space to avoid costly full-buffer sweeps.
|
||||
///
|
||||
/// When a tile already exists, only the pixels within the update region are
|
||||
/// overwritten, preserving untouched pixels and avoiding a full tile
|
||||
/// allocation/copy.
|
||||
pub fn update_tiles_in_region(
|
||||
&mut self,
|
||||
pixels: &[u8],
|
||||
layer_width: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
mut x1: u32,
|
||||
mut y1: u32,
|
||||
) {
|
||||
if layer_width == 0 || self.height == 0 { return; }
|
||||
x1 = x1.min(layer_width).min(self.width);
|
||||
y1 = y1.min(self.height);
|
||||
if x0 >= x1 || y0 >= y1 { return; }
|
||||
|
||||
let tile_start_x = x0 / TILE_SIZE;
|
||||
let tile_start_y = y0 / TILE_SIZE;
|
||||
let tile_end_x = div_ceil(x1, TILE_SIZE);
|
||||
let tile_end_y = div_ceil(y1, TILE_SIZE);
|
||||
|
||||
for ty in tile_start_y..tile_end_y {
|
||||
for tx in tile_start_x..tile_end_x {
|
||||
let key = (tx, ty);
|
||||
let tile_ox = tx * TILE_SIZE;
|
||||
let tile_oy = ty * TILE_SIZE;
|
||||
|
||||
// Determine the sub-rectangle of this tile that intersects the
|
||||
// update region and the layer bounds.
|
||||
let x_start_in_tile = x0.saturating_sub(tile_ox).min(TILE_SIZE);
|
||||
let y_start_in_tile = y0.saturating_sub(tile_oy).min(TILE_SIZE);
|
||||
let x_end_in_tile = (x1 - tile_ox).min(TILE_SIZE);
|
||||
let y_end_in_tile = (y1 - tile_oy).min(TILE_SIZE);
|
||||
if x_start_in_tile >= x_end_in_tile || y_start_in_tile >= y_end_in_tile {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut has_content = false;
|
||||
let entry = self.tiles.entry(key);
|
||||
match entry {
|
||||
std::collections::hash_map::Entry::Occupied(mut occupied) => {
|
||||
let tile = occupied.get_mut();
|
||||
for y in y_start_in_tile..y_end_in_tile {
|
||||
let gy = tile_oy + y;
|
||||
if gy >= self.height { break; }
|
||||
for x in x_start_in_tile..x_end_in_tile {
|
||||
let gx = tile_ox + x;
|
||||
if gx >= layer_width || gx >= self.width { break; }
|
||||
let src_idx = ((gy * layer_width + gx) as usize) * 4;
|
||||
let dst_idx = ((y * TILE_SIZE + x) as usize) * 4;
|
||||
tile.pixels[dst_idx..dst_idx + 4].copy_from_slice(&pixels[src_idx..src_idx + 4]);
|
||||
if pixels[src_idx + 3] != 0 {
|
||||
has_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !has_content {
|
||||
// Scan the rest of the tile for any surviving content
|
||||
// before deciding to drop it.
|
||||
has_content = tile_has_any_content(tile);
|
||||
}
|
||||
if !has_content {
|
||||
occupied.remove();
|
||||
}
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(vacant) => {
|
||||
let mut tile = Tile::new_transparent();
|
||||
for y in y_start_in_tile..y_end_in_tile {
|
||||
let gy = tile_oy + y;
|
||||
if gy >= self.height { break; }
|
||||
for x in x_start_in_tile..x_end_in_tile {
|
||||
let gx = tile_ox + x;
|
||||
if gx >= layer_width || gx >= self.width { break; }
|
||||
let src_idx = ((gy * layer_width + gx) as usize) * 4;
|
||||
let dst_idx = ((y * TILE_SIZE + x) as usize) * 4;
|
||||
tile.pixels[dst_idx..dst_idx + 4].copy_from_slice(&pixels[src_idx..src_idx + 4]);
|
||||
if pixels[src_idx + 3] != 0 {
|
||||
has_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_content {
|
||||
vacant.insert(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of allocated tiles.
|
||||
pub fn tile_count(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
/// Returns the layer width.
|
||||
pub fn width(&self) -> u32 { self.width }
|
||||
|
||||
/// Returns the layer height.
|
||||
pub fn height(&self) -> u32 { self.height }
|
||||
|
||||
/// Returns a reference to the active tiles map.
|
||||
pub fn tiles(&self) -> &HashMap<TileKey, Tile> { &self.tiles }
|
||||
|
||||
/// Gets a reference to a specific tile at coordinates (tx, ty), if it exists.
|
||||
pub fn get_tile(&self, tx: u32, ty: u32) -> Option<&Tile> {
|
||||
self.tiles.get(&(tx, ty))
|
||||
}
|
||||
|
||||
/// Computes the tile key (tx, ty) corresponding to the absolute pixel coordinate (x, y).
|
||||
pub fn tile_key(x: u32, y: u32) -> TileKey {
|
||||
(x / TILE_SIZE, y / TILE_SIZE)
|
||||
}
|
||||
|
||||
/// Gets the pixel at coordinates (x, y). Returns transparent black if the tile does not exist.
|
||||
pub fn get_pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
let key = Self::tile_key(x, y);
|
||||
match self.tiles.get(&key) {
|
||||
Some(tile) => {
|
||||
let lx = (x % TILE_SIZE) as usize;
|
||||
let ly = (y % TILE_SIZE) as usize;
|
||||
let idx = (ly * TILE_SIZE as usize + lx) * 4;
|
||||
[tile.pixels[idx], tile.pixels[idx + 1], tile.pixels[idx + 2], tile.pixels[idx + 3]]
|
||||
}
|
||||
None => [0u8; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the pixel at coordinates (x, y), dynamically allocating a tile if it doesn't already exist.
|
||||
pub fn set_pixel(&mut self, x: u32, y: u32, rgba: [u8; 4]) {
|
||||
if x >= self.width || y >= self.height { return; }
|
||||
let key = Self::tile_key(x, y);
|
||||
let tile = self.tiles.entry(key).or_insert_with(Tile::new_transparent);
|
||||
let lx = (x % TILE_SIZE) as usize;
|
||||
let ly = (y % TILE_SIZE) as usize;
|
||||
let idx = (ly * TILE_SIZE as usize + lx) * 4;
|
||||
tile.pixels[idx..idx + 4].copy_from_slice(&rgba);
|
||||
}
|
||||
|
||||
/// Expands the sparse tile storage into a flat, dense RGBA pixel buffer.
|
||||
pub fn to_dense(&self) -> Vec<u8> {
|
||||
let size = (self.width * self.height * 4) as usize;
|
||||
let mut out = vec![0u8; size];
|
||||
for ((tx, ty), tile) in &self.tiles {
|
||||
for y in 0..TILE_SIZE {
|
||||
let gy = ty * TILE_SIZE + y;
|
||||
if gy >= self.height { break; }
|
||||
for x in 0..TILE_SIZE {
|
||||
let gx = tx * TILE_SIZE + x;
|
||||
if gx >= self.width { break; }
|
||||
let src_idx = ((y * TILE_SIZE + x) as usize) * 4;
|
||||
let dst_idx = ((gy * self.width + gx) as usize) * 4;
|
||||
out[dst_idx..dst_idx + 4].copy_from_slice(&tile.pixels[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fast composite helper that copies active tile contents directly into an output buffer
|
||||
/// within a specified sub-region.
|
||||
pub fn composite_into(
|
||||
&self,
|
||||
output: &mut [u8],
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
region_x0: u32, region_y0: u32, region_x1: u32, region_y1: u32,
|
||||
) {
|
||||
if self.tiles.is_empty() { return; }
|
||||
let tile_start_x = region_x0 / TILE_SIZE;
|
||||
let tile_start_y = region_y0 / TILE_SIZE;
|
||||
let tile_end_x = div_ceil(region_x1, TILE_SIZE);
|
||||
let tile_end_y = div_ceil(region_y1, TILE_SIZE);
|
||||
|
||||
for ty in tile_start_y..tile_end_y {
|
||||
for tx in tile_start_x..tile_end_x {
|
||||
let key = (tx, ty);
|
||||
if let Some(tile) = self.tiles.get(&key) {
|
||||
let tile_ox = tx * TILE_SIZE;
|
||||
let tile_oy = ty * TILE_SIZE;
|
||||
for y in 0..TILE_SIZE {
|
||||
let gy = tile_oy + y;
|
||||
if gy >= self.height || gy >= canvas_h { break; }
|
||||
if gy < region_y0 || gy >= region_y1 { continue; }
|
||||
for x in 0..TILE_SIZE {
|
||||
let gx = tile_ox + x;
|
||||
if gx >= self.width || gx >= canvas_w { break; }
|
||||
if gx < region_x0 || gx >= region_x1 { continue; }
|
||||
let dst_idx = ((gy * canvas_w + gx) as usize) * 4;
|
||||
let src_idx = ((y * TILE_SIZE + x) as usize) * 4;
|
||||
output[dst_idx..dst_idx + 4].copy_from_slice(&tile.pixels[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any pixel in the tile has non-zero alpha.
|
||||
#[inline]
|
||||
fn tile_has_any_content(tile: &Tile) -> bool {
|
||||
for a in tile.pixels.chunks_exact(4).map(|c| c[3]) {
|
||||
if a != 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn div_ceil(a: u32, b: u32) -> u32 {
|
||||
(a + b - 1) / b
|
||||
}
|
||||
Reference in New Issue
Block a user