use rustial_engine::DecodedImage;
use rustial_engine::RasterMipChain;
use rustial_engine::TileId;
use std::collections::HashMap;
pub const ATLAS_PAGE_SIZE: u32 = 4096;
pub const TILE_SIZE: u32 = 256;
pub const SLOTS_PER_SIDE: u32 = ATLAS_PAGE_SIZE / TILE_SIZE;
pub const SLOTS_PER_PAGE: u32 = SLOTS_PER_SIDE * SLOTS_PER_SIDE;
const HALF_TEXEL: f32 = 0.5 / ATLAS_PAGE_SIZE as f32;
#[inline]
fn atlas_page_mip_count() -> u32 {
ATLAS_PAGE_SIZE.ilog2() + 1
}
#[derive(Debug, Clone, Copy)]
pub struct AtlasRegion {
pub page: usize,
pub col: u32,
pub row: u32,
}
impl AtlasRegion {
#[inline]
pub fn remap_uv(&self, u: f32, v: f32) -> [f32; 2] {
let inv = 1.0 / SLOTS_PER_SIDE as f32;
let base_u = (self.col as f32 + u) * inv;
let base_v = (self.row as f32 + v) * inv;
let inset_u = base_u + HALF_TEXEL * (1.0 - 2.0 * u);
let inset_v = base_v + HALF_TEXEL * (1.0 - 2.0 * v);
[inset_u, inset_v]
}
}
struct PendingUpload {
page_idx: usize,
col: u32,
row: u32,
mip_chain: RasterMipChain,
}
pub(crate) struct AtlasPage {
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
pub(crate) occupied: Vec<bool>,
used_this_frame: Vec<bool>,
}
impl AtlasPage {
fn occupied_count_inner(&self) -> u32 {
self.occupied.iter().filter(|&&o| o).count() as u32
}
}
impl AtlasPage {
fn new(device: &wgpu::Device) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("tile_atlas_page"),
size: wgpu::Extent3d {
width: ATLAS_PAGE_SIZE,
height: ATLAS_PAGE_SIZE,
depth_or_array_layers: 1,
},
mip_level_count: atlas_page_mip_count(),
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let n = SLOTS_PER_PAGE as usize;
Self {
texture,
view,
occupied: vec![false; n],
used_this_frame: vec![false; n],
}
}
fn alloc_slot(&mut self) -> Option<(u32, u32)> {
for (i, occ) in self.occupied.iter_mut().enumerate() {
if !*occ {
*occ = true;
let col = (i as u32) % SLOTS_PER_SIDE;
let row = (i as u32) / SLOTS_PER_SIDE;
return Some((col, row));
}
}
None
}
fn mark_used(&mut self, col: u32, row: u32) {
let idx = (row * SLOTS_PER_SIDE + col) as usize;
if idx < self.used_this_frame.len() {
self.used_this_frame[idx] = true;
}
}
fn free_slot(&mut self, col: u32, row: u32) {
let idx = (row * SLOTS_PER_SIDE + col) as usize;
if idx < self.occupied.len() {
self.occupied[idx] = false;
}
}
fn evict_unused(&mut self) {
for i in 0..self.occupied.len() {
if self.occupied[i] && !self.used_this_frame[i] {
self.occupied[i] = false;
}
}
self.used_this_frame.iter_mut().for_each(|f| *f = false);
}
#[allow(dead_code)]
#[cfg(test)]
fn occupied_count(&self) -> usize {
self.occupied_count_inner() as usize
}
}
pub struct TileAtlas {
pub(crate) pages: Vec<AtlasPage>,
regions: HashMap<TileId, AtlasRegion>,
pending_uploads: Vec<PendingUpload>,
bytes_uploaded_this_frame: u64,
}
impl Default for TileAtlas {
fn default() -> Self {
Self::new()
}
}
impl TileAtlas {
pub fn new() -> Self {
Self {
pages: Vec::new(),
regions: HashMap::new(),
pending_uploads: Vec::new(),
bytes_uploaded_this_frame: 0,
}
}
#[inline]
pub fn get(&self, id: &TileId) -> Option<&AtlasRegion> {
self.regions.get(id)
}
#[inline]
pub fn contains(&self, id: &TileId) -> bool {
self.regions.contains_key(id)
}
#[inline]
pub fn len(&self) -> usize {
self.regions.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.regions.is_empty()
}
#[inline]
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn insert(
&mut self,
device: &wgpu::Device,
id: TileId,
image: &DecodedImage,
) -> AtlasRegion {
if let Some(region) = self.regions.get(&id) {
return *region;
}
let (page_idx, col, row) = self.alloc_slot(device);
let mip_chain = image
.build_mip_chain_rgba8()
.expect("TileAtlas::insert requires valid RGBA8 tile imagery");
self.pending_uploads.push(PendingUpload {
page_idx,
col,
row,
mip_chain,
});
let region = AtlasRegion {
page: page_idx,
col,
row,
};
self.regions.insert(id, region);
region
}
pub fn flush_uploads(&mut self, queue: &wgpu::Queue) -> usize {
self.bytes_uploaded_this_frame = 0;
let count = self.pending_uploads.len();
for upload in self.pending_uploads.drain(..) {
let page_texture = &self.pages[upload.page_idx].texture;
for (level, mip) in upload.mip_chain.levels().iter().enumerate() {
let mip_level = level as u32;
let slot_size = (TILE_SIZE >> mip_level).max(1);
let copy_w = mip.width.min(slot_size);
let copy_h = mip.height.min(slot_size);
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: page_texture,
mip_level,
origin: wgpu::Origin3d {
x: upload.col * slot_size,
y: upload.row * slot_size,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
&mip.data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * mip.width),
rows_per_image: Some(mip.height),
},
wgpu::Extent3d {
width: copy_w,
height: copy_h,
depth_or_array_layers: 1,
},
);
self.bytes_uploaded_this_frame += (copy_w as u64) * (copy_h as u64) * 4;
}
}
count
}
pub fn mark_used(&mut self, id: &TileId) {
if let Some(region) = self.regions.get(id) {
if region.page < self.pages.len() {
self.pages[region.page].mark_used(region.col, region.row);
}
}
}
pub fn end_frame(&mut self) {
for page in &mut self.pages {
page.evict_unused();
}
self.regions.retain(|_id, region| {
let page = &self.pages[region.page];
let idx = (region.row * SLOTS_PER_SIDE + region.col) as usize;
page.occupied[idx]
});
if self.fragmentation_ratio() > 0.30 {
self.compact();
}
}
pub fn remove(&mut self, id: &TileId) {
if let Some(region) = self.regions.remove(id) {
if region.page < self.pages.len() {
self.pages[region.page].free_slot(region.col, region.row);
}
}
}
pub fn fragmentation_ratio(&self) -> f32 {
if self.pages.is_empty() {
return 0.0;
}
let total_slots = self.pages.len() as f32 * SLOTS_PER_PAGE as f32;
let occupied_slots = self.regions.len() as f32;
1.0 - occupied_slots / total_slots
}
fn compact(&mut self) {
while let Some(last) = self.pages.last() {
if last.occupied_count_inner() == 0 {
self.pages.pop();
} else {
break;
}
}
}
pub fn diagnostics(&self) -> AtlasDiagnostics {
let total_slots = self.pages.len() as u32 * SLOTS_PER_PAGE;
let occupied_slots = self.regions.len() as u32;
AtlasDiagnostics {
page_count: self.pages.len() as u32,
total_slots,
occupied_slots,
bytes_uploaded_this_frame: self.bytes_uploaded_this_frame,
fragmentation_ratio: self.fragmentation_ratio(),
pending_uploads: self.pending_uploads.len() as u32,
}
}
fn alloc_slot(&mut self, device: &wgpu::Device) -> (usize, u32, u32) {
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some((col, row)) = page.alloc_slot() {
return (i, col, row);
}
}
let mut page = AtlasPage::new(device);
let (col, row) = page.alloc_slot().expect("fresh page must have free slots");
let idx = self.pages.len();
self.pages.push(page);
(idx, col, row)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtlasDiagnostics {
pub page_count: u32,
pub total_slots: u32,
pub occupied_slots: u32,
pub bytes_uploaded_this_frame: u64,
pub fragmentation_ratio: f32,
pub pending_uploads: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remap_uv_origin_slot() {
let region = AtlasRegion {
page: 0,
col: 0,
row: 0,
};
let [u, v] = region.remap_uv(0.0, 0.0);
assert!(u > 0.0, "half-texel inset should push u above 0");
assert!(v > 0.0, "half-texel inset should push v above 0");
assert!(u < HALF_TEXEL * 2.0);
assert!(v < HALF_TEXEL * 2.0);
let [u, v] = region.remap_uv(1.0, 1.0);
let slot_edge = 1.0 / SLOTS_PER_SIDE as f32;
assert!(
u < slot_edge,
"half-texel inset should pull u below slot edge"
);
assert!(
v < slot_edge,
"half-texel inset should pull v below slot edge"
);
}
#[test]
fn remap_uv_midpoint_is_exact() {
let region = AtlasRegion {
page: 0,
col: 3,
row: 5,
};
let [u, v] = region.remap_uv(0.5, 0.5);
let inv = 1.0 / SLOTS_PER_SIDE as f32;
let expected_u = (3.0 + 0.5) * inv;
let expected_v = (5.0 + 0.5) * inv;
assert!((u - expected_u).abs() < 1e-7);
assert!((v - expected_v).abs() < 1e-7);
}
#[test]
fn remap_uv_symmetry() {
let region = AtlasRegion {
page: 0,
col: 7,
row: 7,
};
let [u0, _] = region.remap_uv(0.0, 0.5);
let [u1, _] = region.remap_uv(1.0, 0.5);
let centre = (u0 + u1) / 2.0;
let inv = 1.0 / SLOTS_PER_SIDE as f32;
let expected_centre = (7.0 + 0.5) * inv;
assert!((centre - expected_centre).abs() < 1e-7);
}
#[test]
fn slots_per_page_correct() {
assert_eq!(SLOTS_PER_SIDE, 16);
assert_eq!(SLOTS_PER_PAGE, 256);
}
#[test]
fn half_texel_magnitude() {
assert!((HALF_TEXEL - 0.5 / 4096.0).abs() < 1e-8);
}
#[test]
fn fragmentation_empty_atlas() {
let atlas = TileAtlas::new();
assert_eq!(atlas.fragmentation_ratio(), 0.0);
}
#[test]
fn diagnostics_empty_atlas() {
let atlas = TileAtlas::new();
let diag = atlas.diagnostics();
assert_eq!(diag.page_count, 0);
assert_eq!(diag.total_slots, 0);
assert_eq!(diag.occupied_slots, 0);
assert_eq!(diag.bytes_uploaded_this_frame, 0);
assert_eq!(diag.fragmentation_ratio, 0.0);
assert_eq!(diag.pending_uploads, 0);
}
#[test]
fn diagnostics_default_eq() {
let a = TileAtlas::new().diagnostics();
let b = TileAtlas::default().diagnostics();
assert_eq!(a, b);
}
}