use std::collections::HashMap;
use etagere::{size2, AllocId, BucketedAtlasAllocator};
use uzor_urx_glyph::{GlyphBitmap, GlyphKey};
struct AtlasSlot {
alloc_id: AllocId,
uv_rect: [f32; 4],
tick: u64,
}
struct PendingUpload {
px_rect: [u32; 4],
alpha: Vec<u8>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AtlasStats {
pub entries: usize,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
pub(crate) struct NativeGlyphAtlas {
texture: wgpu::Texture,
bind_group_layout: wgpu::BindGroupLayout,
bind_group: wgpu::BindGroup,
allocator: BucketedAtlasAllocator,
slots: HashMap<GlyphKey, AtlasSlot>,
pending: Vec<PendingUpload>,
tick: u64,
width: u32,
height: u32,
stats: AtlasStats,
}
impl NativeGlyphAtlas {
pub(crate) fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_glyph_atlas"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("uzor_urx_wgpu.native_glyph_atlas_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let gamma_lut_bytes =
uzor_urx_core::text_gamma::build_text_gamma_lut(&uzor_urx_core::text_gamma::TEXT_GAMMA_CURVE);
let gamma_lut_w = uzor_urx_core::text_gamma::TEXT_GAMMA_LUT_SIZE as u32;
let gamma_lut_h = uzor_urx_core::text_gamma::TEXT_GAMMA_BINS as u32;
let gamma_lut_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_glyph_gamma_lut"),
size: wgpu::Extent3d { width: gamma_lut_w, height: gamma_lut_h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let gamma_lut_flat: Vec<u8> = gamma_lut_bytes.iter().flatten().copied().collect();
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &gamma_lut_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&gamma_lut_flat,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(gamma_lut_w), rows_per_image: Some(gamma_lut_h) },
wgpu::Extent3d { width: gamma_lut_w, height: gamma_lut_h, depth_or_array_layers: 1 },
);
let gamma_lut_view = gamma_lut_texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uzor_urx_wgpu.native_glyph_atlas_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("uzor_urx_wgpu.native_glyph_atlas_bg"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&gamma_lut_view) },
],
});
let allocator = BucketedAtlasAllocator::new(size2(width as i32, height as i32));
Self {
texture,
bind_group_layout,
bind_group,
allocator,
slots: HashMap::new(),
pending: Vec::new(),
tick: 0,
width,
height,
stats: AtlasStats::default(),
}
}
pub(crate) fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.bind_group_layout
}
pub(crate) fn bind_group(&self) -> &wgpu::BindGroup {
&self.bind_group
}
pub(crate) fn begin_frame(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
pub(crate) fn get_or_insert(&mut self, key: GlyphKey, bitmap: &GlyphBitmap) -> Option<[f32; 4]> {
if let Some(slot) = self.slots.get_mut(&key) {
slot.tick = self.tick;
self.stats.hits += 1;
return Some(slot.uv_rect);
}
self.stats.misses += 1;
let padded_w = bitmap.width + 2;
let padded_h = bitmap.height + 2;
let requested = size2(padded_w as i32, padded_h as i32);
let alloc = match self.allocator.allocate(requested) {
Some(alloc) => alloc,
None => {
let victim = self
.slots
.iter()
.filter(|(_, slot)| slot.tick < self.tick)
.min_by_key(|(_, slot)| slot.tick)
.map(|(key, slot)| (*key, slot.alloc_id));
let (victim_key, victim_alloc) = victim?;
self.allocator.deallocate(victim_alloc);
self.slots.remove(&victim_key);
self.stats.evictions += 1;
self.allocator.allocate(requested)?
}
};
let px_x = alloc.rectangle.min.x as u32 + 1;
let px_y = alloc.rectangle.min.y as u32 + 1;
let px_rect = [px_x, px_y, bitmap.width, bitmap.height];
let uv_rect = [
px_x as f32 / self.width as f32,
px_y as f32 / self.height as f32,
bitmap.width as f32 / self.width as f32,
bitmap.height as f32 / self.height as f32,
];
self.pending.push(PendingUpload { px_rect, alpha: bitmap.alpha.clone() });
self.slots.insert(key, AtlasSlot { alloc_id: alloc.id, uv_rect, tick: self.tick });
self.stats.entries = self.slots.len();
Some(uv_rect)
}
pub(crate) fn flush_uploads(&mut self, queue: &wgpu::Queue) {
for upload in self.pending.drain(..) {
let [x, y, w, h] = upload.px_rect;
if w == 0 || h == 0 {
continue;
}
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d { x, y, z: 0 },
aspect: wgpu::TextureAspect::All,
},
&upload.alpha,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
}
}
pub(crate) fn stats(&self) -> AtlasStats {
self.stats
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
force_fallback_adapter: false,
compatible_surface: None,
}))
.ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("uzor-urx-wgpu-atlas-test"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::Off,
experimental_features: wgpu::ExperimentalFeatures::default(),
}))
.ok()
}
fn bitmap(w: u32, h: u32, fill: u8) -> GlyphBitmap {
GlyphBitmap { width: w, height: h, left: 0, top: 0, alpha: vec![fill; (w * h) as usize] }
}
fn key(glyph_id: u32) -> GlyphKey {
GlyphKey::new(dummy_font_id(), glyph_id, 32.0, 0)
}
fn dummy_font_id() -> uzor_urx_glyph::FontId {
static FONT_ID: std::sync::OnceLock<uzor_urx_glyph::FontId> = std::sync::OnceLock::new();
*FONT_ID.get_or_init(|| {
let bytes =
std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../uzor-fonts/fonts/DejaVuSans.ttf"))
.expect("uzor-fonts ships DejaVuSans.ttf for exactly this kind of test-only registration");
uzor_urx_glyph::register_font(bytes).expect("DejaVuSans.ttf is a valid font")
})
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn repeat_key_is_a_cache_hit_with_no_duplicate_upload() {
let Some((device, queue)) = test_device() else { return };
let mut atlas = NativeGlyphAtlas::new(&device, &queue, 256, 256);
atlas.begin_frame();
let k = key(36);
let bm = bitmap(10, 12, 200);
let first = atlas.get_or_insert(k, &bm).expect("first insert must succeed in an empty 256x256 atlas");
assert_eq!(atlas.pending.len(), 1, "first insert queues exactly one upload");
let queued = &atlas.pending[0];
assert_eq!(queued.px_rect[2], bm.width, "queued upload's px_rect must carry the UNPADDED glyph width");
assert_eq!(queued.px_rect[3], bm.height, "queued upload's px_rect must carry the UNPADDED glyph height");
let second = atlas.get_or_insert(k, &bm).expect("repeat key must hit, not fail");
assert_eq!(first, second, "repeat key returns the SAME uv_rect");
assert_eq!(atlas.pending.len(), 1, "repeat key must NOT queue a second upload");
let stats = atlas.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.entries, 1);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn tiny_atlas_forced_eviction_never_touches_this_frame_slots() {
let Some((device, queue)) = test_device() else { return };
let mut atlas = NativeGlyphAtlas::new(&device, &queue, 8, 8);
atlas.begin_frame();
let a = key(1);
let bm_a = bitmap(6, 6, 100);
let uv_a = atlas.get_or_insert(a, &bm_a).expect("one 6x6 (8x8 padded) must exactly fill an 8x8 atlas");
let b = key(2);
let bm_b = bitmap(6, 6, 100);
let second = atlas.get_or_insert(b, &bm_b);
assert!(second.is_none(), "no eviction candidate exists this frame — must report atlas-full, not corrupt a");
assert_eq!(atlas.stats().evictions, 0, "nothing was evicted — the full-this-frame path never evicts");
let a_again = atlas.get_or_insert(a, &bm_a).expect("a must still be a hit");
assert_eq!(a_again, uv_a);
atlas.begin_frame();
let second_attempt = atlas.get_or_insert(b, &bm_b);
assert!(second_attempt.is_some(), "next frame, a is eviction-eligible — b must now fit");
assert_eq!(atlas.stats().evictions, 1, "a must have been evicted to make room for b");
atlas.begin_frame();
let misses_before = atlas.stats().misses;
let a_third = atlas.get_or_insert(a, &bm_a);
assert!(a_third.is_some(), "a can be re-inserted by evicting the now-stale b");
assert_eq!(atlas.stats().misses, misses_before + 1);
assert_eq!(atlas.stats().evictions, 2);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn flush_uploads_drains_exactly_the_queued_rects() {
let Some((device, queue)) = test_device() else { return };
let mut atlas = NativeGlyphAtlas::new(&device, &queue, 256, 256);
atlas.begin_frame();
let _ = atlas.get_or_insert(key(10), &bitmap(8, 8, 255));
let _ = atlas.get_or_insert(key(11), &bitmap(9, 9, 255));
let _ = atlas.get_or_insert(key(12), &bitmap(7, 7, 255));
assert_eq!(atlas.pending.len(), 3);
atlas.flush_uploads(&queue);
assert!(atlas.pending.is_empty(), "flush_uploads must drain every queued rect");
atlas.flush_uploads(&queue);
assert!(atlas.pending.is_empty());
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn bind_group_layout_is_usable_in_a_pipeline_layout() {
let Some((device, queue)) = test_device() else { return };
let atlas = NativeGlyphAtlas::new(&device, &queue, 64, 64);
let _layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("atlas_bgl_smoke_test_layout"),
bind_group_layouts: &[Some(atlas.bind_group_layout())],
immediate_size: 0,
});
assert!(std::ptr::eq(atlas.bind_group(), atlas.bind_group()));
}
#[test]
fn atlas_stats_default_is_all_zero() {
let stats = AtlasStats::default();
assert_eq!(stats.entries, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.evictions, 0);
}
}