use std::collections::HashMap;
use uzor_urx_core::gradient_lut::{build_lut, hash_stops, GradientLut, LUT_SIZE};
struct LutSlot {
row: u32,
tick: u64,
}
struct PendingUpload {
row: u32,
lut: GradientLut,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GradientLutAtlasStats {
pub entries: usize,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
pub(crate) struct GradientLutAtlas {
texture: wgpu::Texture,
bind_group_layout: wgpu::BindGroupLayout,
bind_group: wgpu::BindGroup,
rows: HashMap<u64, LutSlot>,
pending: Vec<PendingUpload>,
next_free_row: u32,
tick: u64,
cap: u32,
stats: GradientLutAtlasStats,
}
impl GradientLutAtlas {
pub(crate) fn new(device: &wgpu::Device, cap: u32) -> Self {
let cap = cap.max(1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_gradient_lut"),
size: wgpu::Extent3d { width: LUT_SIZE as u32, height: cap, 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: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uzor_urx_wgpu.native_gradient_lut_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
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_gradient_lut_bg"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }],
});
Self {
texture,
bind_group_layout,
bind_group,
rows: HashMap::new(),
pending: Vec::new(),
next_free_row: 0,
tick: 0,
cap,
stats: GradientLutAtlasStats::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, stops: &peniko::ColorStops, extend: peniko::Extend) -> Option<u32> {
let key = hash_stops(stops, extend);
if let Some(slot) = self.rows.get_mut(&key) {
slot.tick = self.tick;
self.stats.hits += 1;
return Some(slot.row);
}
self.stats.misses += 1;
let row = if self.next_free_row < self.cap {
let r = self.next_free_row;
self.next_free_row += 1;
r
} else {
let victim = self
.rows
.iter()
.filter(|(_, slot)| slot.tick < self.tick)
.min_by_key(|(_, slot)| slot.tick)
.map(|(k, slot)| (*k, slot.row));
let (victim_key, victim_row) = victim?;
self.rows.remove(&victim_key);
self.stats.evictions += 1;
victim_row
};
let lut = build_lut(stops);
self.pending.push(PendingUpload { row, lut });
self.rows.insert(key, LutSlot { row, tick: self.tick });
self.stats.entries = self.rows.len();
Some(row)
}
pub(crate) fn flush_uploads(&mut self, queue: &wgpu::Queue) {
for upload in self.pending.drain(..) {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d { x: 0, y: upload.row, z: 0 },
aspect: wgpu::TextureAspect::All,
},
bytemuck::bytes_of(&upload.lut),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(LUT_SIZE as u32 * 4),
rows_per_image: Some(1),
},
wgpu::Extent3d { width: LUT_SIZE as u32, height: 1, depth_or_array_layers: 1 },
);
}
}
pub(crate) fn stats(&self) -> GradientLutAtlasStats {
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-gradient-lut-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 stops(c0: [u8; 4], c1: [u8; 4]) -> peniko::ColorStops {
use peniko::ColorStop;
let v = vec![
ColorStop { offset: 0.0, color: peniko::Color::from_rgba8(c0[0], c0[1], c0[2], c0[3]).into() },
ColorStop { offset: 1.0, color: peniko::Color::from_rgba8(c1[0], c1[1], c1[2], c1[3]).into() },
];
peniko::ColorStops::from(&v[..])
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn repeat_key_is_a_cache_hit_with_the_same_row() {
let Some((device, _queue)) = test_device() else { return };
let mut atlas = GradientLutAtlas::new(&device, 4);
atlas.begin_frame();
let s = stops([255, 0, 0, 255], [0, 0, 255, 255]);
let first = atlas.get_or_insert(&s, peniko::Extend::Pad).expect("first insert must succeed");
let second = atlas.get_or_insert(&s, peniko::Extend::Pad).expect("repeat key must hit, not fail");
assert_eq!(first, second, "repeat key returns the SAME row");
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 built_row_is_byte_identical_to_cpu_build_lut() {
let Some((device, _queue)) = test_device() else { return };
let mut atlas = GradientLutAtlas::new(&device, 4);
atlas.begin_frame();
let s = stops([10, 20, 30, 255], [200, 150, 100, 255]);
let _ = atlas.get_or_insert(&s, peniko::Extend::Pad).expect("insert must succeed");
assert_eq!(atlas.pending.len(), 1);
let expected = build_lut(&s);
assert_eq!(atlas.pending[0].lut, expected, "atlas-built LUT must be byte-identical to CPU's own build_lut");
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn different_extend_mode_is_a_distinct_row() {
let Some((device, _queue)) = test_device() else { return };
let mut atlas = GradientLutAtlas::new(&device, 4);
atlas.begin_frame();
let s = stops([255, 0, 0, 255], [0, 0, 255, 255]);
let pad_row = atlas.get_or_insert(&s, peniko::Extend::Pad).unwrap();
let repeat_row = atlas.get_or_insert(&s, peniko::Extend::Repeat).unwrap();
assert_ne!(pad_row, repeat_row, "same stops but different extend mode must hash to a DIFFERENT row");
assert_eq!(atlas.stats().entries, 2);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn tiny_atlas_forced_eviction_never_touches_this_frame_rows() {
let Some((device, _queue)) = test_device() else { return };
let mut atlas = GradientLutAtlas::new(&device, 1);
atlas.begin_frame();
let a = stops([255, 0, 0, 255], [0, 255, 0, 255]);
let row_a = atlas.get_or_insert(&a, peniko::Extend::Pad).expect("one gradient must fit a cap=1 atlas");
let b = stops([0, 0, 255, 255], [255, 255, 0, 255]);
let second = atlas.get_or_insert(&b, peniko::Extend::Pad);
assert!(second.is_none(), "no eviction candidate exists this frame — must report atlas-full, not corrupt a");
assert_eq!(atlas.stats().evictions, 0);
let a_again = atlas.get_or_insert(&a, peniko::Extend::Pad).expect("a must still be a hit");
assert_eq!(a_again, row_a);
atlas.begin_frame();
let second_attempt = atlas.get_or_insert(&b, peniko::Extend::Pad);
assert!(second_attempt.is_some(), "next frame, a is eviction-eligible — b must now fit");
assert_eq!(atlas.stats().evictions, 1);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn flush_uploads_drains_exactly_the_queued_rows() {
let Some((device, queue)) = test_device() else { return };
let mut atlas = GradientLutAtlas::new(&device, 8);
atlas.begin_frame();
let _ = atlas.get_or_insert(&stops([1, 1, 1, 255], [2, 2, 2, 255]), peniko::Extend::Pad);
let _ = atlas.get_or_insert(&stops([3, 3, 3, 255], [4, 4, 4, 255]), peniko::Extend::Pad);
assert_eq!(atlas.pending.len(), 2);
atlas.flush_uploads(&queue);
assert!(atlas.pending.is_empty(), "flush_uploads must drain every queued row");
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 = GradientLutAtlas::new(&device, 4);
let _layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gradient_lut_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 stats_default_is_all_zero() {
let stats = GradientLutAtlasStats::default();
assert_eq!(stats.entries, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.evictions, 0);
}
}