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/// This prevents unbounded GPU memory growth when displaying many inline images.
12const MAX_TEXTURE_CACHE_SIZE: usize = 100;
13
14/// Metadata for a cached sixel texture
15pub(super) struct SixelTextureInfo {
16    pub(super) texture: Texture,
17    #[allow(dead_code)] // GPU lifetime: must outlive the bind_group which references this view
18    view: TextureView,
19    pub(super) bind_group: BindGroup,
20    pub(super) width: u32,
21    pub(super) height: u32,
22}
23
24/// Cached texture wrapper with LRU tracking
25pub(super) struct CachedTexture {
26    pub(super) texture: SixelTextureInfo,
27    /// Timestamp of last access for LRU eviction
28    pub(super) last_used: Instant,
29}
30
31impl super::GraphicsRenderer {
32    /// Create or get a cached texture for a sixel graphic
33    ///
34    /// # Arguments
35    /// * `device` - WGPU device for creating textures
36    /// * `queue` - WGPU queue for writing texture data
37    /// * `id` - Unique identifier for this sixel graphic
38    /// * `rgba_data` - RGBA pixel data (width * height * 4 bytes)
39    /// * `width` - Image width in pixels
40    /// * `height` - Image height in pixels
41    pub fn get_or_create_texture(
42        &mut self,
43        device: &Device,
44        queue: &Queue,
45        id: u64,
46        rgba_data: &[u8],
47        width: u32,
48        height: u32,
49    ) -> Result<(), RenderError> {
50        // Check if texture already exists in cache
51        // For animations, we need to update the texture data even if it exists
52        if let Some(cached) = self.texture_cache.get_mut(&id) {
53            // Update LRU timestamp on cache hit
54            cached.last_used = Instant::now();
55
56            // Kitty TGP virtual placements (high-bit flag set on the cache id;
57            // see par-term-render/src/renderer/graphics.rs) reuse the same
58            // image data every frame — they're static placements anchored by
59            // grid placeholder cells, not animations. Re-uploading the
60            // pixels per frame here costs ~640 KB × 60 fps for a 400×400
61            // image, saturating the GPU command queue and freezing the pane.
62            // For these IDs, treat the cache hit as final.
63            const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
64            if id & VIRTUAL_PLACEMENT_ID_FLAG != 0 {
65                return Ok(());
66            }
67
68            // Texture exists - update it if the data might have changed
69            // Validate data size
70            let expected_size = (width * height * 4) as usize;
71            if rgba_data.len() != expected_size {
72                return Err(RenderError::InvalidTextureData {
73                    expected: expected_size,
74                    actual: rgba_data.len(),
75                });
76            }
77
78            // Update existing texture with new pixel data (for animations)
79            queue.write_texture(
80                TexelCopyTextureInfo {
81                    texture: &cached.texture.texture,
82                    mip_level: 0,
83                    origin: Origin3d::ZERO,
84                    aspect: TextureAspect::All,
85                },
86                rgba_data,
87                TexelCopyBufferLayout {
88                    offset: 0,
89                    bytes_per_row: Some(4 * width),
90                    rows_per_image: Some(height),
91                },
92                Extent3d {
93                    width,
94                    height,
95                    depth_or_array_layers: 1,
96                },
97            );
98
99            return Ok(());
100        }
101
102        // Validate data size
103        let expected_size = (width * height * 4) as usize;
104        if rgba_data.len() != expected_size {
105            return Err(RenderError::InvalidTextureData {
106                expected: expected_size,
107                actual: rgba_data.len(),
108            });
109        }
110
111        // Evict least-recently-used texture if cache is full
112        if self.texture_cache.len() >= MAX_TEXTURE_CACHE_SIZE
113            && let Some((&lru_id, _)) = self
114                .texture_cache
115                .iter()
116                .min_by_key(|(_, cached)| cached.last_used)
117        {
118            log::debug!(
119                "[GRAPHICS] Evicting LRU texture: id={}, cache_size={}",
120                lru_id,
121                self.texture_cache.len()
122            );
123            self.texture_cache.remove(&lru_id);
124        }
125
126        // Create texture
127        let texture = device.create_texture(&TextureDescriptor {
128            label: Some(&format!("Sixel Texture {}", id)),
129            size: Extent3d {
130                width,
131                height,
132                depth_or_array_layers: 1,
133            },
134            mip_level_count: 1,
135            sample_count: 1,
136            dimension: TextureDimension::D2,
137            format: TextureFormat::Rgba8Unorm,
138            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
139            view_formats: &[],
140        });
141
142        // Write RGBA data to texture
143        queue.write_texture(
144            TexelCopyTextureInfo {
145                texture: &texture,
146                mip_level: 0,
147                origin: Origin3d::ZERO,
148                aspect: TextureAspect::All,
149            },
150            rgba_data,
151            TexelCopyBufferLayout {
152                offset: 0,
153                bytes_per_row: Some(4 * width),
154                rows_per_image: Some(height),
155            },
156            Extent3d {
157                width,
158                height,
159                depth_or_array_layers: 1,
160            },
161        );
162
163        let view = texture.create_view(&TextureViewDescriptor::default());
164
165        // Create bind group for this texture
166        let bind_group = device.create_bind_group(&BindGroupDescriptor {
167            label: Some(&format!("Sixel Bind Group {}", id)),
168            layout: &self.bind_group_layout,
169            entries: &[
170                BindGroupEntry {
171                    binding: 0,
172                    resource: BindingResource::TextureView(&view),
173                },
174                BindGroupEntry {
175                    binding: 1,
176                    resource: BindingResource::Sampler(&self.sampler),
177                },
178            ],
179        });
180
181        // Cache texture info with current timestamp
182        self.texture_cache.insert(
183            id,
184            CachedTexture {
185                texture: SixelTextureInfo {
186                    texture,
187                    view,
188                    bind_group,
189                    width,
190                    height,
191                },
192                last_used: Instant::now(),
193            },
194        );
195
196        log::debug!(
197            "[GRAPHICS] Created sixel texture: id={}, size={}x{}, cache_size={}/{}",
198            id,
199            width,
200            height,
201            self.texture_cache.len(),
202            MAX_TEXTURE_CACHE_SIZE
203        );
204
205        Ok(())
206    }
207}