Skip to main content

par_term_render/graphics_renderer/
upload.rs

1//! Texture upload / cache invalidation logic.
2//!
3//! Extracted from the graphics_renderer.rs root per ARC-009: the LRU texture
4//! cache and the GPU upload path that populates it.
5
6use crate::error::RenderError;
7use std::time::Instant;
8use wgpu::*;
9
10/// Maximum number of textures to cache before evicting least-recently-used entries.
11/// Secondary bound only — the byte budget below is the primary eviction trigger.
12const MAX_TEXTURE_CACHE_SIZE: usize = 100;
13
14/// Byte budget for cached texture pixel data (Rgba8Unorm, width*height*4 per entry).
15/// A 5K screenshot is ~56MB RGBA; without a byte budget a handful of them fits the
16/// 100-texture count cap yet costs hundreds of MB of GPU memory.
17const MAX_TEXTURE_CACHE_BYTES: u64 = 256 * 1024 * 1024;
18
19/// Pixel bytes held by one cached texture (Rgba8Unorm = 4 bytes per pixel).
20pub(super) fn texture_bytes(info: &SixelTextureInfo) -> u64 {
21    u64::from(info.width) * u64::from(info.height) * 4
22}
23
24/// Compute which cache entries to evict (oldest `last_used` first) so that a new
25/// texture of `incoming_bytes` fits both the byte budget and the count cap.
26/// Returns ids to evict, empty when nothing needs eviction. Pure so the policy is
27/// unit-testable without a wgpu device.
28fn plan_lru_evictions(
29    entries: &[(u64, Instant, u64)],
30    incoming_bytes: u64,
31    total_bytes: u64,
32) -> Vec<u64> {
33    let mut order: Vec<(Instant, u64, u64)> = entries
34        .iter()
35        .map(|(id, last_used, bytes)| (*last_used, *id, *bytes))
36        .collect();
37    order.sort_unstable_by_key(|(last_used, _, _)| *last_used);
38    let mut evict = Vec::new();
39    let mut bytes = total_bytes;
40    let mut count = entries.len();
41    let mut cursor = 0;
42    while cursor < order.len()
43        && (bytes + incoming_bytes > MAX_TEXTURE_CACHE_BYTES || count >= MAX_TEXTURE_CACHE_SIZE)
44    {
45        let (_, id, entry_bytes) = order[cursor];
46        bytes -= entry_bytes;
47        count -= 1;
48        evict.push(id);
49        cursor += 1;
50    }
51    evict
52}
53
54/// Metadata for a cached sixel texture
55pub(super) struct SixelTextureInfo {
56    pub(super) texture: Texture,
57    #[allow(dead_code)] // GPU lifetime: must outlive the bind_group which references this view
58    view: TextureView,
59    pub(super) bind_group: BindGroup,
60    pub(super) width: u32,
61    pub(super) height: u32,
62}
63
64/// Cached texture wrapper with LRU tracking
65pub(super) struct CachedTexture {
66    pub(super) texture: SixelTextureInfo,
67    /// Timestamp of last access for LRU eviction
68    pub(super) last_used: Instant,
69}
70
71impl super::GraphicsRenderer {
72    /// Create or get a cached texture for a sixel graphic
73    ///
74    /// # Arguments
75    /// * `device` - WGPU device for creating textures
76    /// * `queue` - WGPU queue for writing texture data
77    /// * `id` - Unique identifier for this sixel graphic
78    /// * `rgba_data` - RGBA pixel data (width * height * 4 bytes)
79    /// * `width` - Image width in pixels
80    /// * `height` - Image height in pixels
81    pub fn get_or_create_texture(
82        &mut self,
83        device: &Device,
84        queue: &Queue,
85        id: u64,
86        rgba_data: &[u8],
87        width: u32,
88        height: u32,
89    ) -> Result<(), RenderError> {
90        // Check if texture already exists in cache
91        // For animations, we need to update the texture data even if it exists
92        if let Some(cached) = self.texture_cache.get_mut(&id) {
93            // Update LRU timestamp on cache hit
94            cached.last_used = Instant::now();
95
96            // Kitty TGP virtual placements (high-bit flag set on the cache id;
97            // see par-term-render/src/renderer/graphics.rs) reuse the same
98            // image data every frame — they're static placements anchored by
99            // grid placeholder cells, not animations. Re-uploading the
100            // pixels per frame here costs ~640 KB × 60 fps for a 400×400
101            // image, saturating the GPU command queue and freezing the pane.
102            // For these IDs, treat the cache hit as final.
103            const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
104            if id & VIRTUAL_PLACEMENT_ID_FLAG != 0 {
105                return Ok(());
106            }
107
108            // Texture exists - update it if the data might have changed
109            // Validate data size
110            let expected_size = (width * height * 4) as usize;
111            if rgba_data.len() != expected_size {
112                return Err(RenderError::InvalidTextureData {
113                    expected: expected_size,
114                    actual: rgba_data.len(),
115                });
116            }
117
118            // Update existing texture with new pixel data (for animations)
119            queue.write_texture(
120                TexelCopyTextureInfo {
121                    texture: &cached.texture.texture,
122                    mip_level: 0,
123                    origin: Origin3d::ZERO,
124                    aspect: TextureAspect::All,
125                },
126                rgba_data,
127                TexelCopyBufferLayout {
128                    offset: 0,
129                    bytes_per_row: Some(4 * width),
130                    rows_per_image: Some(height),
131                },
132                Extent3d {
133                    width,
134                    height,
135                    depth_or_array_layers: 1,
136                },
137            );
138
139            return Ok(());
140        }
141
142        // Validate data size
143        let expected_size = (width * height * 4) as usize;
144        if rgba_data.len() != expected_size {
145            return Err(RenderError::InvalidTextureData {
146                expected: expected_size,
147                actual: rgba_data.len(),
148            });
149        }
150
151        // Evict least-recently-used textures until the incoming texture fits
152        // both the byte budget and the count cap
153        let incoming_bytes = expected_size as u64;
154        let entries: Vec<(u64, Instant, u64)> = self
155            .texture_cache
156            .iter()
157            .map(|(id, cached)| (*id, cached.last_used, texture_bytes(&cached.texture)))
158            .collect();
159        for lru_id in plan_lru_evictions(&entries, incoming_bytes, self.texture_cache_bytes) {
160            if let Some(evicted) = self.texture_cache.remove(&lru_id) {
161                self.texture_cache_bytes -= texture_bytes(&evicted.texture);
162                log::debug!(
163                    "[GRAPHICS] Evicting LRU texture: id={}, freed={} bytes, cache_bytes={}",
164                    lru_id,
165                    texture_bytes(&evicted.texture),
166                    self.texture_cache_bytes
167                );
168            }
169        }
170
171        // Create texture
172        let texture = device.create_texture(&TextureDescriptor {
173            label: Some(&format!("Sixel Texture {}", id)),
174            size: Extent3d {
175                width,
176                height,
177                depth_or_array_layers: 1,
178            },
179            mip_level_count: 1,
180            sample_count: 1,
181            dimension: TextureDimension::D2,
182            format: TextureFormat::Rgba8Unorm,
183            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
184            view_formats: &[],
185        });
186
187        // Write RGBA data to texture
188        queue.write_texture(
189            TexelCopyTextureInfo {
190                texture: &texture,
191                mip_level: 0,
192                origin: Origin3d::ZERO,
193                aspect: TextureAspect::All,
194            },
195            rgba_data,
196            TexelCopyBufferLayout {
197                offset: 0,
198                bytes_per_row: Some(4 * width),
199                rows_per_image: Some(height),
200            },
201            Extent3d {
202                width,
203                height,
204                depth_or_array_layers: 1,
205            },
206        );
207
208        let view = texture.create_view(&TextureViewDescriptor::default());
209
210        // Create bind group for this texture
211        let bind_group = device.create_bind_group(&BindGroupDescriptor {
212            label: Some(&format!("Sixel Bind Group {}", id)),
213            layout: &self.bind_group_layout,
214            entries: &[
215                BindGroupEntry {
216                    binding: 0,
217                    resource: BindingResource::TextureView(&view),
218                },
219                BindGroupEntry {
220                    binding: 1,
221                    resource: BindingResource::Sampler(&self.sampler),
222                },
223            ],
224        });
225
226        // Cache texture info with current timestamp
227        self.texture_cache.insert(
228            id,
229            CachedTexture {
230                texture: SixelTextureInfo {
231                    texture,
232                    view,
233                    bind_group,
234                    width,
235                    height,
236                },
237                last_used: Instant::now(),
238            },
239        );
240
241        log::debug!(
242            "[GRAPHICS] Created sixel texture: id={}, size={}x{}, cache_size={}/{}",
243            id,
244            width,
245            height,
246            self.texture_cache.len(),
247            MAX_TEXTURE_CACHE_SIZE
248        );
249
250        Ok(())
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use std::time::Duration;
258
259    fn mb(n: u64) -> u64 {
260        n * 1024 * 1024
261    }
262
263    fn entry(id: u64, age_secs: u64, w: u32, h: u32) -> (u64, Instant, u64) {
264        (
265            id,
266            Instant::now() - Duration::from_secs(age_secs),
267            u64::from(w) * u64::from(h) * 4,
268        )
269    }
270
271    #[test]
272    fn evicts_oldest_until_byte_budget_fits() {
273        // 3 x 100MB entries (300MB total), 256MB budget, 100MB incoming:
274        // evict the two oldest so 100 + 100 fits under 256MB
275        let entries = vec![
276            entry(1, 30, 5120, 5120),
277            entry(2, 20, 5120, 5120),
278            entry(3, 10, 5120, 5120),
279        ];
280        assert_eq!(plan_lru_evictions(&entries, mb(100), mb(300)), vec![1, 2]);
281    }
282
283    #[test]
284    fn evicts_exactly_one_for_count_cap() {
285        // 100 tiny entries at the count cap: one eviction frees a slot,
286        // the byte budget is not binding
287        let entries: Vec<_> = (1..=100u64).map(|i| entry(i, 100 - i, 16, 16)).collect();
288        assert_eq!(
289            plan_lru_evictions(&entries, 16 * 16 * 4, 100 * 16 * 16 * 4),
290            vec![1]
291        );
292    }
293
294    #[test]
295    fn oversized_incoming_on_empty_cache_proceeds_unbudgeted() {
296        // Nothing to evict: an oversized texture proceeds (same behavior class
297        // as the old count-only cap on an empty cache)
298        assert!(plan_lru_evictions(&[], mb(300), 0).is_empty());
299    }
300
301    #[test]
302    fn no_eviction_when_within_budget() {
303        let entries = vec![entry(1, 5, 100, 100), entry(2, 3, 100, 100)];
304        assert!(plan_lru_evictions(&entries, 100 * 100 * 4, 2 * 100 * 100 * 4).is_empty());
305    }
306}