//! Image rendering for the Kitty Graphics Protocol. //! //! This module handles GPU-accelerated rendering of images in the terminal, //! supporting the Kitty Graphics Protocol for inline image display. use crate::gpu_types::{ImageUniforms, PaneId}; use crate::graphics::{ImageData, ImagePlacement, ImageStorage}; use std::collections::HashMap; // ═══════════════════════════════════════════════════════════════════════════════ // GPU IMAGE // ═══════════════════════════════════════════════════════════════════════════════ /// Cached GPU texture for an image. pub struct GpuImage { pub texture: wgpu::Texture, pub view: wgpu::TextureView, pub bind_group: wgpu::BindGroup, pub width: u32, pub height: u32, } // ═══════════════════════════════════════════════════════════════════════════════ // IMAGE RENDERER // ═══════════════════════════════════════════════════════════════════════════════ /// Manages GPU resources for image rendering. /// Handles uploading, caching, and preparing images for rendering. pub struct ImageRenderer { /// Bind group layout for uniforms. uniform_layout: wgpu::BindGroupLayout, /// Bind group layout for textures. texture_layout: wgpu::BindGroupLayout, /// Sampler for image textures. sampler: wgpu::Sampler, /// Cached GPU textures for images, keyed by (pane_id, image_id). textures: HashMap<(PaneId, u32), GpuImage>, /// Global uniform buffer for image renders. pub uniform_buffer: wgpu::Buffer, /// Bind group for image uniforms. uniform_bind_group: wgpu::BindGroup, /// Minimum offset alignment for uniform buffers. pub alignment: u64, } impl ImageRenderer { /// Create a new ImageRenderer with the necessary GPU resources. pub fn new(device: &wgpu::Device) -> Self { // Create sampler for images (linear filtering for smooth scaling) let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("Image Sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); // Create bind group layout for uniforms (binding 0) let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Image Uniform Layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: true, min_binding_size: None, }, count: None, }], }); // Create bind group layout for textures (binding 1, 2) let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Image Texture Layout"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 1, 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: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler( wgpu::SamplerBindingType::Filtering, ), count: None, }, ], }); // Create a large uniform buffer for all image renders in a frame // Max 256 images per frame (65536 / 256) let buffer_size = 65536; let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Image Uniform Buffer"), size: buffer_size, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); // Create the uniform bind group let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Image Uniform Bind Group"), layout: &uniform_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer( wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: std::num::NonZeroU64::new( std::mem::size_of::() as u64, ), }, ), }], }); let alignment = device.limits().min_uniform_buffer_offset_alignment as u64; Self { uniform_layout, texture_layout, sampler, textures: HashMap::new(), uniform_buffer, uniform_bind_group, alignment, } } /// Get the uniform bind group layout. pub fn uniform_layout(&self) -> &wgpu::BindGroupLayout { &self.uniform_layout } /// Get the texture bind group layout. pub fn texture_layout(&self) -> &wgpu::BindGroupLayout { &self.texture_layout } /// Get the uniform bind group. pub fn uniform_bind_group(&self) -> &wgpu::BindGroup { &self.uniform_bind_group } /// Get a GPU image by ID. pub fn get(&self, pane_id: PaneId, image_id: &u32) -> Option<&GpuImage> { self.textures.get(&(pane_id, *image_id)) } /// Upload an image to the GPU, creating or updating its texture. pub fn upload_image( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, pane_id: PaneId, image: &ImageData, ) { log::debug!( "upload_image: pane_id={:?}, id={}, width={}, height={}, data_len={}", pane_id, image.id, image.width, image.height, image.data.len() ); // Get current frame data (handles animation frames automatically) let data = image.current_frame_data(); // Check if we already have this image if let Some(existing) = self.textures.get(&(pane_id, image.id)) { if existing.width == image.width && existing.height == image.height { // Same dimensions, just update the data queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &existing.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, data, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(image.width * 4), rows_per_image: Some(image.height), }, wgpu::Extent3d { width: image.width, height: image.height, depth_or_array_layers: 1, }, ); return; } // Different dimensions, need to recreate } // Create new texture let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(&format!("Image {} (pane {:?})", image.id, pane_id)), size: wgpu::Extent3d { width: image.width, height: image.height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); // Upload the data queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, data, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(image.width * 4), rows_per_image: Some(image.height), }, wgpu::Extent3d { width: image.width, height: image.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(&format!( "Image {} (pane {:?}) Bind Group", image.id, pane_id )), layout: &self.texture_layout, entries: &[ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], }); self.textures.insert( (pane_id, image.id), GpuImage { texture, view, bind_group, width: image.width, height: image.height, }, ); log::debug!( "Uploaded image {} ({}x{}) to GPU", image.id, image.width, image.height ); } /// Remove an image from the GPU. pub fn remove_image(&mut self, pane_id: PaneId, image_id: u32) { if self.textures.remove(&(pane_id, image_id)).is_some() { log::debug!( "Removed image {} (pane {:?}) from GPU", image_id, pane_id ); } } /// Sync images from terminal's image storage to GPU. /// Uploads new/changed images and removes deleted ones. /// Also updates animation frames. pub fn sync_images( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, pane_id: PaneId, storage: &mut ImageStorage, ) { // Update animations and get list of changed image IDs let changed_ids = storage.update_animations(); log::debug!( "Sync images: pane_id={:?}, changed_ids={:?}, dirty={}", pane_id, changed_ids, storage.dirty ); // Re-upload frames that changed due to animation for id in &changed_ids { if let Some(image) = storage.get_image(*id) { self.upload_image(device, queue, pane_id, image); } } if !storage.dirty && changed_ids.is_empty() { log::debug!( "Sync images: skipping upload (not dirty, no animations)" ); return; } // Upload images that were marked as dirty (newly transmitted or modified) for id in &storage.dirty_images { log::debug!("Sync images: uploading dirty image id={:?}", id); if let Some(image) = storage.get_image(*id) { self.upload_image(device, queue, pane_id, image); } } storage.clear_dirty(); } /// Remove images from the GPU that are not present in the provided set of active images. /// active_images is a set of (pane_id, image_id) tuples. pub fn gc_images( &mut self, active_images: &std::collections::HashSet<(PaneId, u32)>, ) { let gpu_ids: Vec<(PaneId, u32)> = self.textures.keys().copied().collect(); let mut removed_count = 0; for id in gpu_ids { if !active_images.contains(&id) { log::debug!( "GC: removing image {:?} as it is no longer active", id ); self.remove_image(id.0, id.1); removed_count += 1; } } if removed_count > 0 { log::debug!("GC images: removed {} unused textures", removed_count); } } /// Prepare image renders for a pane. /// Returns a Vec of (image_id, uniforms) for deferred rendering. pub fn prepare_image_renders( &self, pane_id: PaneId, placements: &[ImagePlacement], pane_x: f32, pane_y: f32, cell_width: f32, cell_height: f32, screen_width: f32, screen_height: f32, scrollback_len: usize, scroll_offset: usize, visible_rows: usize, dim_factor: f32, ) -> Vec<(u32, ImageUniforms)> { log::debug!( "prepare_image_renders: pane={:?}, placements={}, scrollback={}, offset={}, rows={}", pane_id, placements.len(), scrollback_len, scroll_offset, visible_rows ); let mut renders = Vec::new(); for placement in placements { // Check if we have the GPU texture for this image let gpu_image = match self.textures.get(&(pane_id, placement.image_id)) { Some(img) => img, None => { log::debug!( "Image {} not found in GPU cache for pane {:?}", placement.image_id, pane_id ); continue; } }; // Convert absolute row to visible screen row // placement.row is absolute (scrollback_len_at_placement + cursor_row) // visible_row = absolute_row - scrollback_len + scroll_offset let absolute_row = placement.row as isize; let visible_row = absolute_row - scrollback_len as isize + scroll_offset as isize; // Check if image is visible on screen // Image spans from visible_row to visible_row + placement.rows let image_bottom = visible_row + placement.rows as isize; if image_bottom < 0 || visible_row >= visible_rows as isize { log::debug!( "Image {} culled: visible_row={}, image_bottom={}, visible_rows={}", placement.image_id, visible_row, image_bottom, visible_rows ); continue; // Image is completely off-screen } // Calculate display position in pixels let pos_x = pane_x + (placement.col as f32 * cell_width) + placement.x_offset as f32; let pos_y = pane_y + (visible_row as f32 * cell_height) + placement.y_offset as f32; log::debug!( "Image render: pane_x={} col={} cell_width={} x_offset={} => pos_x={}", pane_x, placement.col, cell_width, placement.x_offset, pos_x ); // Calculate display size in pixels let display_width = placement.cols as f32 * cell_width; let display_height = placement.rows as f32 * cell_height; // Calculate source rectangle in normalized coordinates let src_x = placement.src_x as f32 / gpu_image.width as f32; let src_y = placement.src_y as f32 / gpu_image.height as f32; let src_width = if placement.src_width == 0 { 1.0 - src_x } else { placement.src_width as f32 / gpu_image.width as f32 }; let src_height = if placement.src_height == 0 { 1.0 - src_y } else { placement.src_height as f32 / gpu_image.height as f32 }; let uniforms = ImageUniforms { screen_width, screen_height, pos_x, pos_y, display_width, display_height, src_x, src_y, src_width, src_height, dim_factor, _padding1: 0.0, }; renders.push((placement.image_id, uniforms)); } renders } }