use std::collections::HashMap;
use uzor_urx_core::scene::ImageId;
pub(crate) struct GpuImageSlot {
bind_group: wgpu::BindGroup,
tick: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NativeImageCacheStats {
pub entries: usize,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
pub(crate) struct NativeImageCache {
slots: HashMap<ImageId, GpuImageSlot>,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
tick: u64,
cap: usize,
stats: NativeImageCacheStats,
}
impl NativeImageCache {
pub(crate) fn new(device: &wgpu::Device, cap: usize) -> Self {
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("uzor_urx_wgpu.native_image_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uzor_urx_wgpu.native_image_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,
},
],
});
Self {
slots: HashMap::new(),
bind_group_layout,
sampler,
tick: 0,
cap,
stats: NativeImageCacheStats::default(),
}
}
pub(crate) fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.bind_group_layout
}
pub(crate) fn begin_frame(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
pub(crate) fn get_or_upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, id: ImageId) -> Option<&GpuImageSlot> {
if let Some(slot) = self.slots.get_mut(&id) {
slot.tick = self.tick;
self.stats.hits += 1;
return self.slots.get(&id);
}
let data = uzor_urx_image::lookup_image(id)?;
self.stats.misses += 1;
if self.slots.len() >= self.cap {
let victim = self
.slots
.iter()
.filter(|(_, slot)| slot.tick < self.tick)
.min_by_key(|(_, slot)| slot.tick)
.map(|(k, _)| *k);
let victim_id = victim?;
self.slots.remove(&victim_id);
self.stats.evictions += 1;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_image"),
size: wgpu::Extent3d { width: data.width, height: data.height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&data.bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(data.width * 4),
rows_per_image: Some(data.height),
},
wgpu::Extent3d { width: data.width, height: data.height, depth_or_array_layers: 1 },
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("uzor_urx_wgpu.native_image_bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) },
],
});
self.slots.insert(id, GpuImageSlot { bind_group, tick: self.tick });
self.stats.entries = self.slots.len();
self.slots.get(&id)
}
pub(crate) fn bind_group_of(slot: &GpuImageSlot) -> &wgpu::BindGroup {
&slot.bind_group
}
pub(crate) fn stats(&self) -> NativeImageCacheStats {
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-image-cache-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 register_sample(w: u32, h: u32, fill: [u8; 4]) -> ImageId {
let mut bytes = vec![0u8; (w * h * 4) as usize];
for px in bytes.chunks_exact_mut(4) {
px.copy_from_slice(&fill);
}
let data = uzor_urx_image::ImageData::from_raw_premul(w, h, bytes).expect("size matches by construction");
uzor_urx_image::register_image(data)
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn repeat_lookup_is_a_cache_hit() {
let Some((device, queue)) = test_device() else { return };
let id = register_sample(4, 4, [10, 20, 30, 255]);
let mut cache = NativeImageCache::new(&device, 4);
cache.begin_frame();
let first = &cache.get_or_upload(&device, &queue, id).expect("first lookup must succeed").bind_group as *const _;
let second = &cache.get_or_upload(&device, &queue, id).expect("repeat lookup must hit, not fail").bind_group as *const _;
assert!(std::ptr::eq(first, second), "repeat lookup returns the SAME bind group, not a re-upload");
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.entries, 1);
uzor_urx_image::unregister_image(id);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn unregistered_id_is_a_clean_miss_not_a_panic() {
let Some((device, queue)) = test_device() else { return };
let mut cache = NativeImageCache::new(&device, 4);
cache.begin_frame();
assert!(cache.get_or_upload(&device, &queue, ImageId(u64::MAX)).is_none());
assert_eq!(cache.stats().misses, 0, "an unregistered id is not counted as a CACHE miss (nothing to evict/insert)");
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn tiny_cache_forced_eviction_never_touches_this_frame_slots() {
let Some((device, queue)) = test_device() else { return };
let mut cache = NativeImageCache::new(&device, 1);
cache.begin_frame();
let a = register_sample(2, 2, [10, 0, 0, 255]);
let b = register_sample(2, 2, [0, 10, 0, 255]);
cache.get_or_upload(&device, &queue, a).expect("one image must fit a cap=1 cache");
assert!(cache.get_or_upload(&device, &queue, b).is_none());
assert_eq!(cache.stats().evictions, 0);
assert!(cache.get_or_upload(&device, &queue, a).is_some());
cache.begin_frame();
assert!(cache.get_or_upload(&device, &queue, b).is_some(), "next frame, a is eviction-eligible — b must now fit");
assert_eq!(cache.stats().evictions, 1);
uzor_urx_image::unregister_image(a);
uzor_urx_image::unregister_image(b);
}
#[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 cache = NativeImageCache::new(&device, 4);
let _layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("image_bgl_smoke_test_layout"),
bind_group_layouts: &[Some(cache.bind_group_layout())],
immediate_size: 0,
});
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn bind_group_of_returns_the_slots_own_bind_group() {
let Some((device, queue)) = test_device() else { return };
let id = register_sample(3, 3, [5, 6, 7, 255]);
let mut cache = NativeImageCache::new(&device, 4);
cache.begin_frame();
let slot = cache.get_or_upload(&device, &queue, id).expect("lookup must succeed");
let _bg: &wgpu::BindGroup = NativeImageCache::bind_group_of(slot);
uzor_urx_image::unregister_image(id);
}
#[test]
fn stats_default_is_all_zero() {
let stats = NativeImageCacheStats::default();
assert_eq!(stats.entries, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.evictions, 0);
}
}