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}