From cb8d2710daa903ef3eee9e83eb489fd2fa0e327b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 24 Oct 2025 17:23:40 +0200 Subject: [PATCH 01/28] Draft multi-threaded image rendering in `iced_wgpu` --- Cargo.lock | 1 + benches/wgpu.rs | 3 +- examples/gallery/src/main.rs | 45 +++- examples/integration/src/main.rs | 3 +- graphics/src/compositor.rs | 7 +- graphics/src/lib.rs | 2 + graphics/src/shell.rs | 45 ++++ renderer/src/fallback.rs | 21 +- runtime/src/window.rs | 6 + tiny_skia/src/window/compositor.rs | 3 +- wgpu/Cargo.toml | 1 + wgpu/src/engine.rs | 16 +- wgpu/src/image/atlas.rs | 105 ++++---- wgpu/src/image/atlas/allocation.rs | 15 +- wgpu/src/image/cache.rs | 374 ++++++++++++++++++++++++++--- wgpu/src/image/mod.rs | 365 ++++++++++++++++++---------- wgpu/src/image/raster.rs | 109 ++++----- wgpu/src/image/vector.rs | 5 +- wgpu/src/lib.rs | 10 +- wgpu/src/window/compositor.rs | 10 +- winit/src/lib.rs | 30 ++- winit/src/proxy.rs | 15 ++ 22 files changed, 886 insertions(+), 305 deletions(-) create mode 100644 graphics/src/shell.rs diff --git a/Cargo.lock b/Cargo.lock index c43fa494..f1bea206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2620,6 +2620,7 @@ version = "0.14.0-dev" dependencies = [ "bitflags 2.9.4", "bytemuck", + "bytes", "cryoglyph", "futures", "glam", diff --git a/benches/wgpu.rs b/benches/wgpu.rs index 35696abc..d09277ed 100644 --- a/benches/wgpu.rs +++ b/benches/wgpu.rs @@ -72,7 +72,7 @@ fn benchmark<'a>( view: impl Fn(usize) -> Element<'a, (), Theme, Renderer>, ) { use iced_wgpu::graphics; - use iced_wgpu::graphics::Antialiasing; + use iced_wgpu::graphics::{Antialiasing, Shell}; use iced_wgpu::wgpu; use iced_winit::core; use iced_winit::runtime; @@ -85,6 +85,7 @@ fn benchmark<'a>( queue.clone(), format, Some(Antialiasing::MSAAx4), + Shell::headless(), ); let mut renderer = Renderer::new(engine, Font::DEFAULT, Pixels::from(16)); diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 182fd606..42e916db 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -18,7 +18,7 @@ use iced::{ Subscription, Task, Theme, color, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; fn main() -> iced::Result { iced::application::timed( @@ -35,6 +35,8 @@ fn main() -> iced::Result { struct Gallery { images: Vec, previews: HashMap, + visible: HashSet, + downloaded: HashSet, viewer: Viewer, now: Instant, } @@ -43,6 +45,7 @@ struct Gallery { enum Message { ImagesListed(Result, Error>), ImagePoppedIn(Id), + ImagePoppedOut(Id), ImageDownloaded(Result), ThumbnailDownloaded(Id, Result), ThumbnailHovered(Id, bool), @@ -58,6 +61,8 @@ impl Gallery { Self { images: Vec::new(), previews: HashMap::new(), + visible: HashSet::new(), + downloaded: HashSet::new(), viewer: Viewer::new(), now: Instant::now(), }, @@ -102,6 +107,14 @@ impl Gallery { return Task::none(); }; + let _ = self.visible.insert(id); + + if self.downloaded.contains(&id) { + return Task::none(); + } + + let _ = self.downloaded.insert(id); + Task::sip( image.download(Size::Thumbnail { width: Preview::WIDTH, @@ -111,6 +124,11 @@ impl Gallery { Message::ThumbnailDownloaded.with(id), ) } + Message::ImagePoppedOut(id) => { + let _ = self.visible.remove(&id); + + Task::none() + } Message::ImageDownloaded(Ok(rgba)) => { self.viewer.show(rgba, self.now); @@ -181,7 +199,17 @@ impl Gallery { let images = self .images .iter() - .map(|image| card(image, self.previews.get(&image.id), self.now)) + .map(|image| { + card( + image, + if self.visible.contains(&image.id) { + self.previews.get(&image.id) + } else { + None + }, + self.now, + ) + }) .chain((self.images.len()..=Image::LIMIT).map(|_| placeholder())); let gallery = grid(images) @@ -248,7 +276,7 @@ fn card<'a>( .on_enter(Message::ThumbnailHovered(metadata.id, true)) .on_exit(Message::ThumbnailHovered(metadata.id, false)); - if let Some(preview) = preview { + let card: Element<'_, _> = if let Some(preview) = preview { let is_thumbnail = matches!(preview, Preview::Ready { .. }); button(card) @@ -257,10 +285,13 @@ fn card<'a>( .style(button::text) .into() } else { - sensor(card) - .on_show(|_| Message::ImagePoppedIn(metadata.id)) - .into() - } + card.into() + }; + + sensor(card) + .on_show(|_| Message::ImagePoppedIn(metadata.id)) + .on_hide(Message::ImagePoppedOut(metadata.id)) + .into() } fn placeholder<'a>() -> Element<'a, Message> { diff --git a/examples/integration/src/main.rs b/examples/integration/src/main.rs index b98b8273..5084742b 100644 --- a/examples/integration/src/main.rs +++ b/examples/integration/src/main.rs @@ -4,7 +4,7 @@ mod scene; use controls::Controls; use scene::Scene; -use iced_wgpu::graphics::Viewport; +use iced_wgpu::graphics::{Shell, Viewport}; use iced_wgpu::{Engine, Renderer, wgpu}; use iced_winit::Clipboard; use iced_winit::conversion; @@ -150,6 +150,7 @@ pub fn main() -> Result<(), winit::error::EventLoopError> { queue.clone(), format, None, + Shell::headless(), ); Renderer::new(engine, Font::default(), Pixels::from(16)) diff --git a/graphics/src/compositor.rs b/graphics/src/compositor.rs index e0c8eb46..3ec99542 100644 --- a/graphics/src/compositor.rs +++ b/graphics/src/compositor.rs @@ -2,7 +2,7 @@ //! surfaces. use crate::core::Color; use crate::futures::{MaybeSend, MaybeSync}; -use crate::{Error, Settings, Viewport}; +use crate::{Error, Settings, Shell, Viewport}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use thiserror::Error; @@ -21,8 +21,9 @@ pub trait Compositor: Sized { fn new( settings: Settings, compatible_window: W, + shell: Shell, ) -> impl Future> { - Self::with_backend(settings, compatible_window, None) + Self::with_backend(settings, compatible_window, shell, None) } /// Creates a new [`Compositor`] with a backend preference. @@ -32,6 +33,7 @@ pub trait Compositor: Sized { fn with_backend( _settings: Settings, _compatible_window: W, + _shell: Shell, _backend: Option<&str>, ) -> impl Future>; @@ -153,6 +155,7 @@ impl Compositor for () { async fn with_backend( _settings: Settings, _compatible_window: W, + _shell: Shell, _preferred_backend: Option<&str>, ) -> Result { Ok(()) diff --git a/graphics/src/lib.rs b/graphics/src/lib.rs index ed6ec87f..ff5f39c4 100644 --- a/graphics/src/lib.rs +++ b/graphics/src/lib.rs @@ -21,6 +21,7 @@ pub mod gradient; pub mod image; pub mod layer; pub mod mesh; +pub mod shell; pub mod text; #[cfg(feature = "geometry")] @@ -35,6 +36,7 @@ pub use image::Image; pub use layer::Layer; pub use mesh::Mesh; pub use settings::Settings; +pub use shell::Shell; pub use text::Text; pub use viewport::Viewport; diff --git a/graphics/src/shell.rs b/graphics/src/shell.rs new file mode 100644 index 00000000..55004d7d --- /dev/null +++ b/graphics/src/shell.rs @@ -0,0 +1,45 @@ +//! Control the windowing runtime from a renderer. +use std::sync::Arc; + +/// A windowing shell. +#[derive(Clone)] +pub struct Shell(Arc); + +impl Shell { + /// Creates a new [`Shell`]. + pub fn new(notifier: impl Notifier) -> Self { + Self(Arc::new(notifier)) + } + + /// Creates a headless [`Shell`]. + pub fn headless() -> Self { + struct Headless; + + impl Notifier for Headless { + fn request_redraw(&self) {} + + fn invalidate_layout(&self) {} + } + + Self::new(Headless) + } + + /// Requests for all windows of the [`Shell`] to be redrawn. + pub fn request_redraw(&self) { + self.0.request_redraw(); + } + + /// Requests for all layouts of the [`Shell`] to be recomputed. + pub fn invalidate_layout(&self) { + self.0.invalidate_layout(); + } +} + +/// A type that can notify a shell of certain events. +pub trait Notifier: Send + Sync + 'static { + /// Requests for all windows of the [`Shell`] to be redrawn. + fn request_redraw(&self); + + /// Requests for all layouts of the [`Shell`] to be recomputed. + fn invalidate_layout(&self); +} diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index 5e775e38..d488e6ea 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -6,9 +6,9 @@ use crate::core::{ self, Background, Color, Font, Image, Pixels, Point, Rectangle, Size, Svg, Transformation, }; -use crate::graphics; use crate::graphics::compositor; use crate::graphics::mesh; +use crate::graphics::{self, Shell}; use std::borrow::Cow; @@ -216,6 +216,7 @@ where async fn with_backend( settings: graphics::Settings, compatible_window: W, + shell: Shell, backend: Option<&str>, ) -> Result { use std::env; @@ -242,8 +243,13 @@ where let mut errors = vec![]; for backend in candidates.iter().map(Option::as_deref) { - match A::with_backend(settings, compatible_window.clone(), backend) - .await + match A::with_backend( + settings, + compatible_window.clone(), + shell.clone(), + backend, + ) + .await { Ok(compositor) => return Ok(Self::Primary(compositor)), Err(error) => { @@ -251,8 +257,13 @@ where } } - match B::with_backend(settings, compatible_window.clone(), backend) - .await + match B::with_backend( + settings, + compatible_window.clone(), + shell.clone(), + backend, + ) + .await { Ok(compositor) => return Ok(Self::Secondary(compositor)), Err(error) => { diff --git a/runtime/src/window.rs b/runtime/src/window.rs index f2ed4e5e..057acb8f 100644 --- a/runtime/src/window.rs +++ b/runtime/src/window.rs @@ -174,6 +174,12 @@ pub enum Action { /// Set the window size increment. SetResizeIncrements(Id, Option), + + /// Redraws all the windows. + RedrawAll, + + /// Recomputes the layouts of all the windows. + RelayoutAll, } /// Subscribes to the frames of the window of the running application. diff --git a/tiny_skia/src/window/compositor.rs b/tiny_skia/src/window/compositor.rs index 9ede982d..b7511d71 100644 --- a/tiny_skia/src/window/compositor.rs +++ b/tiny_skia/src/window/compositor.rs @@ -2,7 +2,7 @@ use crate::core::{Color, Rectangle, Size}; use crate::graphics::compositor::{self, Information}; use crate::graphics::damage; use crate::graphics::error::{self, Error}; -use crate::graphics::{self, Viewport}; +use crate::graphics::{self, Shell, Viewport}; use crate::{Layer, Renderer, Settings}; use std::collections::VecDeque; @@ -31,6 +31,7 @@ impl crate::graphics::Compositor for Compositor { async fn with_backend( settings: graphics::Settings, compatible_window: W, + _shell: Shell, backend: Option<&str>, ) -> Result { match backend { diff --git a/wgpu/Cargo.toml b/wgpu/Cargo.toml index 7160421b..d4f36964 100644 --- a/wgpu/Cargo.toml +++ b/wgpu/Cargo.toml @@ -31,6 +31,7 @@ iced_graphics.workspace = true bitflags.workspace = true bytemuck.workspace = true +bytes.workspace = true futures.workspace = true glam.workspace = true cryoglyph.workspace = true diff --git a/wgpu/src/engine.rs b/wgpu/src/engine.rs index 574223a4..4bc30b7f 100644 --- a/wgpu/src/engine.rs +++ b/wgpu/src/engine.rs @@ -1,4 +1,4 @@ -use crate::graphics::Antialiasing; +use crate::graphics::{Antialiasing, Shell}; use crate::primitive; use crate::quad; use crate::text; @@ -18,6 +18,7 @@ pub struct Engine { #[cfg(any(feature = "image", feature = "svg"))] pub(crate) image_pipeline: crate::image::Pipeline, pub(crate) primitive_storage: Arc>, + _shell: Shell, } impl Engine { @@ -27,6 +28,7 @@ impl Engine { queue: wgpu::Queue, format: wgpu::TextureFormat, antialiasing: Option, // TODO: Initialize AA pipelines lazily + shell: Shell, ) -> Self { Self { format, @@ -52,14 +54,16 @@ impl Engine { device, queue, + _shell: shell, } } #[cfg(any(feature = "image", feature = "svg"))] - pub fn create_image_cache( - &self, - device: &wgpu::Device, - ) -> crate::image::Cache { - self.image_pipeline.create_cache(device) + pub fn create_image_cache(&self) -> crate::image::Cache { + self.image_pipeline.create_cache( + &self.device, + &self.queue, + &self._shell, + ) } } diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index 4e01fbe5..6cc28ce0 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -10,20 +10,20 @@ pub use layer::Layer; use allocator::Allocator; -pub const SIZE: u32 = 2048; +pub const DEFAULT_SIZE: u32 = 512; +pub const MAX_SIZE: u32 = 2048; use crate::core::Size; use crate::graphics::color; -use std::sync::Arc; - #[derive(Debug)] pub struct Atlas { + size: u32, backend: wgpu::Backend, texture: wgpu::Texture, texture_view: wgpu::TextureView, texture_bind_group: wgpu::BindGroup, - texture_layout: Arc, + texture_layout: wgpu::BindGroupLayout, layers: Vec, } @@ -31,8 +31,19 @@ impl Atlas { pub fn new( device: &wgpu::Device, backend: wgpu::Backend, - texture_layout: Arc, + texture_layout: wgpu::BindGroupLayout, ) -> Self { + Self::with_size(device, backend, texture_layout, DEFAULT_SIZE) + } + + pub fn with_size( + device: &wgpu::Device, + backend: wgpu::Backend, + texture_layout: wgpu::BindGroupLayout, + size: u32, + ) -> Self { + let size = size.min(MAX_SIZE); + let layers = match backend { // On the GL backend we start with 2 layers, to help wgpu figure // out that this texture is `GL_TEXTURE_2D_ARRAY` rather than `GL_TEXTURE_2D` @@ -42,8 +53,8 @@ impl Atlas { }; let extent = wgpu::Extent3d { - width: SIZE, - height: SIZE, + width: size, + height: size, depth_or_array_layers: layers.len() as u32, }; @@ -80,6 +91,7 @@ impl Atlas { }); Atlas { + size, backend, texture, texture_view, @@ -93,14 +105,11 @@ impl Atlas { &self.texture_bind_group } - pub fn layer_count(&self) -> usize { - self.layers.len() - } - pub fn upload( &mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + belt: &mut wgpu::util::StagingBelt, width: u32, height: u32, data: &[u8], @@ -111,7 +120,7 @@ impl Atlas { // We grow the internal texture after allocating if necessary let new_layers = self.layers.len() - current_size; - self.grow(new_layers, device, encoder); + self.grow(new_layers, device, encoder, self.backend); entry }; @@ -127,7 +136,13 @@ impl Atlas { let padded_width = (4 * width + padding) as usize; let padded_data_size = padded_width * height as usize; - let mut padded_data = vec![0; padded_data_size]; + let buffer_slice = belt.allocate( + wgpu::BufferSize::new(padded_data_size as u64).unwrap(), + wgpu::BufferSize::new(8 * 4).unwrap(), + device, + ); + + let mut padded_data = buffer_slice.get_mapped_range_mut(); for row in 0..height as usize { let offset = row * padded_width; @@ -140,13 +155,12 @@ impl Atlas { match &entry { Entry::Contiguous(allocation) => { self.upload_allocation( - &padded_data, + buffer_slice.buffer(), width, height, padding, - 0, + buffer_slice.offset() as usize, allocation, - device, encoder, ); } @@ -156,13 +170,12 @@ impl Atlas { let offset = (y * padded_width as u32 + 4 * x) as usize; self.upload_allocation( - &padded_data, + buffer_slice.buffer(), width, height, padding, - offset, + offset + buffer_slice.offset() as usize, &fragment.allocation, - device, encoder, ); } @@ -172,7 +185,7 @@ impl Atlas { if log::log_enabled!(log::Level::Debug) { log::debug!( "Atlas layers: {} (busy: {}, allocations: {})", - self.layer_count(), + self.layers.len(), self.layers.iter().filter(|layer| !layer.is_empty()).count(), self.layers.iter().map(Layer::allocations).sum::(), ); @@ -198,7 +211,7 @@ impl Atlas { fn allocate(&mut self, width: u32, height: u32) -> Option { // Allocate one layer if texture fits perfectly - if width == SIZE && height == SIZE { + if width == self.size && height == self.size { let mut empty_layers = self .layers .iter_mut() @@ -208,27 +221,31 @@ impl Atlas { if let Some((i, layer)) = empty_layers.next() { *layer = Layer::Full; - return Some(Entry::Contiguous(Allocation::Full { layer: i })); + return Some(Entry::Contiguous(Allocation::Full { + layer: i, + size: self.size, + })); } self.layers.push(Layer::Full); return Some(Entry::Contiguous(Allocation::Full { layer: self.layers.len() - 1, + size: self.size, })); } // Split big textures across multiple layers - if width > SIZE || height > SIZE { + if width > self.size || height > self.size { let mut fragments = Vec::new(); let mut y = 0; while y < height { - let height = std::cmp::min(height - y, SIZE); + let height = std::cmp::min(height - y, self.size); let mut x = 0; while x < width { - let width = std::cmp::min(width - x, SIZE); + let width = std::cmp::min(width - x, self.size); let allocation = self.allocate(width, height)?; @@ -255,7 +272,7 @@ impl Atlas { for (i, layer) in self.layers.iter_mut().enumerate() { match layer { Layer::Empty => { - let mut allocator = Allocator::new(SIZE); + let mut allocator = Allocator::new(self.size); if let Some(region) = allocator.allocate(width, height) { *layer = Layer::Busy(allocator); @@ -263,6 +280,7 @@ impl Atlas { return Some(Entry::Contiguous(Allocation::Partial { region, layer: i, + atlas_size: self.size, })); } } @@ -271,6 +289,7 @@ impl Atlas { return Some(Entry::Contiguous(Allocation::Partial { region, layer: i, + atlas_size: self.size, })); } } @@ -279,7 +298,7 @@ impl Atlas { } // Create new layer with atlas allocator - let mut allocator = Allocator::new(SIZE); + let mut allocator = Allocator::new(self.size); if let Some(region) = allocator.allocate(width, height) { self.layers.push(Layer::Busy(allocator)); @@ -287,6 +306,7 @@ impl Atlas { return Some(Entry::Contiguous(Allocation::Partial { region, layer: self.layers.len() - 1, + atlas_size: self.size, })); } @@ -298,10 +318,10 @@ impl Atlas { log::debug!("Deallocating atlas: {allocation:?}"); match allocation { - Allocation::Full { layer } => { + Allocation::Full { layer, .. } => { self.layers[*layer] = Layer::Empty; } - Allocation::Partial { layer, region } => { + Allocation::Partial { layer, region, .. } => { let layer = &mut self.layers[*layer]; if let Layer::Busy(allocator) = layer { @@ -316,18 +336,15 @@ impl Atlas { } fn upload_allocation( - &mut self, - data: &[u8], + &self, + buffer: &wgpu::Buffer, image_width: u32, image_height: u32, padding: u32, offset: usize, allocation: &Allocation, - device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, ) { - use wgpu::util::DeviceExt; - let (x, y) = allocation.position(); let Size { width, height } = allocation.size(); let layer = allocation.layer(); @@ -338,16 +355,9 @@ impl Atlas { depth_or_array_layers: 1, }; - let buffer = - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("image upload buffer"), - contents: data, - usage: wgpu::BufferUsages::COPY_SRC, - }); - encoder.copy_buffer_to_texture( wgpu::TexelCopyBufferInfo { - buffer: &buffer, + buffer, layout: wgpu::TexelCopyBufferLayout { offset: offset as u64, bytes_per_row: Some(4 * image_width + padding), @@ -373,6 +383,7 @@ impl Atlas { amount: usize, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + backend: wgpu::Backend, ) { if amount == 0 { return; @@ -383,7 +394,7 @@ impl Atlas { // some unused memory on GL, but it's better than not being able to grow the atlas past a depth // of 6! // https://github.com/gfx-rs/wgpu/blob/004e3efe84a320d9331371ed31fa50baa2414911/wgpu-hal/src/gles/mod.rs#L371 - let depth_or_array_layers = match self.backend { + let depth_or_array_layers = match backend { wgpu::Backend::Gl if self.layers.len() == 6 => 7, _ => self.layers.len() as u32, }; @@ -391,8 +402,8 @@ impl Atlas { let new_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("iced_wgpu::image texture atlas"), size: wgpu::Extent3d { - width: SIZE, - height: SIZE, + width: self.size, + height: self.size, depth_or_array_layers, }, mip_level_count: 1, @@ -440,8 +451,8 @@ impl Atlas { aspect: wgpu::TextureAspect::default(), }, wgpu::Extent3d { - width: SIZE, - height: SIZE, + width: self.size, + height: self.size, depth_or_array_layers: 1, }, ); diff --git a/wgpu/src/image/atlas/allocation.rs b/wgpu/src/image/atlas/allocation.rs index 11289771..2e8a7b14 100644 --- a/wgpu/src/image/atlas/allocation.rs +++ b/wgpu/src/image/atlas/allocation.rs @@ -1,14 +1,16 @@ use crate::core::Size; -use crate::image::atlas::{self, allocator}; +use crate::image::atlas::allocator; #[derive(Debug)] pub enum Allocation { Partial { layer: usize, region: allocator::Region, + atlas_size: u32, }, Full { layer: usize, + size: u32, }, } @@ -23,14 +25,21 @@ impl Allocation { pub fn size(&self) -> Size { match self { Allocation::Partial { region, .. } => region.size(), - Allocation::Full { .. } => Size::new(atlas::SIZE, atlas::SIZE), + Allocation::Full { size, .. } => Size::new(*size, *size), } } pub fn layer(&self) -> usize { match self { Allocation::Partial { layer, .. } => *layer, - Allocation::Full { layer } => *layer, + Allocation::Full { layer, .. } => *layer, + } + } + + pub fn atlas_size(&self) -> u32 { + match self { + Allocation::Partial { atlas_size, .. } => *atlas_size, + Allocation::Full { size, .. } => *size, } } } diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 94f7071d..32617f01 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -1,47 +1,79 @@ use crate::core::{self, Size}; +use crate::graphics::Shell; use crate::image::atlas::{self, Atlas}; -use std::sync::Arc; +use std::collections::BTreeSet; +use std::sync::mpsc; +use std::thread; #[derive(Debug)] pub struct Cache { atlas: Atlas, #[cfg(feature = "image")] - raster: crate::image::raster::Cache, + raster: Raster, #[cfg(feature = "svg")] vector: crate::image::vector::Cache, + #[cfg(feature = "image")] + jobs: mpsc::SyncSender, + #[cfg(feature = "image")] + work: mpsc::Receiver, + #[cfg(feature = "image")] + worker_: Option>, } impl Cache { pub fn new( device: &wgpu::Device, + queue: &wgpu::Queue, backend: wgpu::Backend, - layout: Arc, + layout: wgpu::BindGroupLayout, + shell: &Shell, ) -> Self { + #[cfg(feature = "image")] + let (worker, jobs, work) = + Worker::new(device, queue, backend, layout.clone(), shell); + + #[cfg(feature = "image")] + let handle = thread::spawn(move || worker.run()); + Self { atlas: Atlas::new(device, backend, layout), #[cfg(feature = "image")] - raster: crate::image::raster::Cache::default(), + raster: Raster { + cache: crate::image::raster::Cache::default(), + pending: BTreeSet::new(), + jobs: jobs.clone(), + }, #[cfg(feature = "svg")] vector: crate::image::vector::Cache::default(), + #[cfg(feature = "image")] + jobs, + #[cfg(feature = "image")] + work, + #[cfg(feature = "image")] + worker_: Some(handle), } } - pub fn bind_group(&self) -> &wgpu::BindGroup { - self.atlas.bind_group() - } - - pub fn layer_count(&self) -> usize { - self.atlas.layer_count() - } - #[cfg(feature = "image")] pub fn measure_image(&mut self, handle: &core::image::Handle) -> Size { - self.raster.load(handle).dimensions() + self.receive(); + + if let Some(memory) = load_image( + &mut self.raster.cache, + &mut self.raster.pending, + &mut self.raster.jobs, + handle, + ) { + return memory.dimensions(); + } + + Size::new(0, 0) } #[cfg(feature = "svg")] pub fn measure_svg(&mut self, handle: &core::svg::Handle) -> Size { + // TODO: Concurrency self.vector.load(handle).viewport_dimensions() } @@ -50,9 +82,63 @@ impl Cache { &mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + belt: &mut wgpu::util::StagingBelt, handle: &core::image::Handle, - ) -> Option<&atlas::Entry> { - self.raster.upload(device, encoder, handle, &mut self.atlas) + ) -> Option<(&atlas::Entry, &wgpu::BindGroup)> { + use crate::image::raster::Memory; + + self.receive(); + + let memory = load_image( + &mut self.raster.cache, + &mut self.raster.pending, + &mut self.raster.jobs, + handle, + )?; + + if let Memory::Device { entry, bind_group } = memory { + return Some(( + entry, + bind_group.as_ref().unwrap_or(self.atlas.bind_group()), + )); + } + + let image = memory.host()?; + + const MAX_SYNC_SIZE: usize = 2 * 1024 * 1024; + + if image.len() < MAX_SYNC_SIZE { + let entry = self.atlas.upload( + device, + encoder, + belt, + image.width(), + image.height(), + &image, + )?; + + *memory = Memory::Device { + entry, + bind_group: None, + }; + + if let Memory::Device { entry, .. } = memory { + return Some((entry, self.atlas.bind_group())); + } + } + + if !self.raster.pending.contains(&handle.id()) { + let _ = self.jobs.send(Job::Upload { + handle: handle.clone(), + rgba: image.clone().into_raw(), + width: image.width(), + height: image.height(), + }); + + let _ = self.raster.pending.insert(handle.id()); + } + + None } #[cfg(feature = "svg")] @@ -60,27 +146,261 @@ impl Cache { &mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + belt: &mut wgpu::util::StagingBelt, handle: &core::svg::Handle, color: Option, size: [f32; 2], scale: f32, - ) -> Option<&atlas::Entry> { - self.vector.upload( - device, - encoder, - handle, - color, - size, - scale, - &mut self.atlas, - ) + ) -> Option<(&atlas::Entry, &wgpu::BindGroup)> { + // TODO: Concurrency + self.vector + .upload( + device, + encoder, + belt, + handle, + color, + size, + scale, + &mut self.atlas, + ) + .map(|entry| (entry, self.atlas.bind_group())) } pub fn trim(&mut self) { #[cfg(feature = "image")] - self.raster.trim(&mut self.atlas); + self.raster.cache.trim(&mut self.atlas, |bind_group| { + let _ = self.jobs.send(Job::Drop(bind_group)); + }); #[cfg(feature = "svg")] - self.vector.trim(&mut self.atlas); + self.vector.trim(&mut self.atlas); // TODO: Concurrency + } + + fn receive(&mut self) { + use crate::image::raster::Memory; + + while let Ok(work) = self.work.try_recv() { + match work { + Work::Upload { + handle, + entry, + bind_group, + } => { + self.raster.cache.insert( + &handle, + Memory::Device { + entry, + bind_group: Some(bind_group), + }, + ); + + let _ = self.raster.pending.remove(&handle.id()); + } + Work::Error { handle, error } => { + self.raster.cache.insert(&handle, Memory::error(error)); + } + } + } + } +} + +impl Drop for Cache { + fn drop(&mut self) { + // Stop worker gracefully + let (sender, _) = mpsc::sync_channel(1); + self.jobs = sender.clone(); + self.raster.jobs = sender; + + let _ = self.worker_.take().unwrap().join(); + } +} + +#[cfg(feature = "image")] +#[derive(Debug)] +struct Raster { + cache: crate::image::raster::Cache, + pending: BTreeSet, + jobs: mpsc::SyncSender, +} + +#[cfg(feature = "image")] +fn load_image<'a>( + cache: &'a mut crate::image::raster::Cache, + pending: &mut BTreeSet, + jobs: &mut mpsc::SyncSender, + handle: &core::image::Handle, +) -> Option<&'a mut crate::image::raster::Memory> { + use crate::image::raster::Memory; + + if !cache.contains(handle) { + // Load RGBA handles synchronously, since it's very cheap + if let core::image::Handle::Rgba { .. } = handle { + cache.insert(handle, Memory::load(handle)); + } else { + let _ = jobs.send(Job::Load(handle.clone())); + let _ = pending.insert(handle.id()); + } + } + + cache.get_mut(handle) +} + +#[cfg(feature = "image")] +enum Job { + Load(core::image::Handle), + Upload { + handle: core::image::Handle, + rgba: core::image::Bytes, + width: u32, + height: u32, + }, + Drop(wgpu::BindGroup), +} + +#[cfg(feature = "image")] +enum Work { + Upload { + handle: core::image::Handle, + entry: atlas::Entry, + bind_group: wgpu::BindGroup, + }, + Error { + handle: core::image::Handle, + error: crate::graphics::image::image_rs::error::ImageError, + }, +} + +#[cfg(feature = "image")] +struct Worker { + device: wgpu::Device, + queue: wgpu::Queue, + backend: wgpu::Backend, + texture_layout: wgpu::BindGroupLayout, + shell: Shell, + belt: wgpu::util::StagingBelt, + jobs: mpsc::Receiver, + output: mpsc::SyncSender, +} + +#[cfg(feature = "image")] +impl Worker { + fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + backend: wgpu::Backend, + texture_layout: wgpu::BindGroupLayout, + shell: &Shell, + ) -> (Self, mpsc::SyncSender, mpsc::Receiver) { + let (jobs_sender, jobs_receiver) = mpsc::sync_channel(1_000); + let (work_sender, work_receiver) = mpsc::sync_channel(1_000); + + ( + Self { + device: device.clone(), + queue: queue.clone(), + backend, + texture_layout, + shell: shell.clone(), + belt: wgpu::util::StagingBelt::new(4 * 1024 * 1024), + jobs: jobs_receiver, + output: work_sender, + }, + jobs_sender, + work_receiver, + ) + } + + fn run(mut self) { + while let Ok(job) = self.jobs.recv() { + match job { + Job::Load(handle) => { + match crate::graphics::image::load(&handle) { + Ok(image) => self.upload( + handle, + image.width(), + image.height(), + image.into_raw(), + Shell::invalidate_layout, + ), + Err(error) => { + let _ = + self.output.send(Work::Error { handle, error }); + } + } + } + Job::Upload { + handle, + rgba, + width, + height, + } => { + self.upload( + handle, + width, + height, + rgba, + Shell::request_redraw, + ); + } + Job::Drop(bind_group) => { + drop(bind_group); + } + } + } + } + + fn upload( + &mut self, + handle: core::image::Handle, + width: u32, + height: u32, + rgba: core::image::Bytes, + callback: fn(&Shell), + ) { + let mut encoder = self.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("raster image upload"), + }, + ); + + let mut atlas = Atlas::with_size( + &self.device, + self.backend, + self.texture_layout.clone(), + width.max(height), + ); + + let Some(entry) = atlas.upload( + &self.device, + &mut encoder, + &mut self.belt, + width, + height, + &rgba, + ) else { + return; + }; + + let output = self.output.clone(); + let shell = self.shell.clone(); + + self.belt.finish(); + let submission = self.queue.submit([encoder.finish()]); + self.belt.recall(); + + self.queue.on_submitted_work_done(move || { + let _ = output.send(Work::Upload { + handle, + entry, + bind_group: atlas.bind_group().clone(), + }); + + callback(&shell); + }); + + let _ = self + .device + .poll(wgpu::PollType::WaitForSubmissionIndex(submission)); } } diff --git a/wgpu/src/image/mod.rs b/wgpu/src/image/mod.rs index 51d2acef..d530d9b4 100644 --- a/wgpu/src/image/mod.rs +++ b/wgpu/src/image/mod.rs @@ -11,11 +11,11 @@ mod vector; use crate::Buffer; use crate::core::{Rectangle, Size, Transformation}; +use crate::graphics::Shell; use bytemuck::{Pod, Zeroable}; use std::mem; -use std::sync::Arc; pub use crate::graphics::Image; @@ -27,7 +27,7 @@ pub struct Pipeline { backend: wgpu::Backend, nearest_sampler: wgpu::Sampler, linear_sampler: wgpu::Sampler, - texture_layout: Arc, + texture_layout: wgpu::BindGroupLayout, constant_layout: wgpu::BindGroupLayout, } @@ -196,13 +196,24 @@ impl Pipeline { backend, nearest_sampler, linear_sampler, - texture_layout: Arc::new(texture_layout), + texture_layout, constant_layout, } } - pub fn create_cache(&self, device: &wgpu::Device) -> Cache { - Cache::new(device, self.backend, self.texture_layout.clone()) + pub fn create_cache( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + shell: &Shell, + ) -> Cache { + Cache::new( + device, + queue, + self.backend, + self.texture_layout.clone(), + shell, + ) } } @@ -228,16 +239,50 @@ impl State { transformation: Transformation, scale: f32, ) { - let nearest_instances: &mut Vec = &mut Vec::new(); - let linear_instances: &mut Vec = &mut Vec::new(); + if self.layers.len() <= self.prepare_layer { + self.layers.push(Layer::new( + device, + &pipeline.constant_layout, + &pipeline.nearest_sampler, + &pipeline.linear_sampler, + )); + } + + let layer = &mut self.layers[self.prepare_layer]; + layer.prepare(device, encoder, belt, transformation, scale); + + let mut atlas = None; + let nearest_instances = &mut Vec::new(); + let linear_instances = &mut Vec::new(); for image in images { match &image { #[cfg(feature = "image")] Image::Raster(image, bounds) => { - if let Some(atlas_entry) = - cache.upload_raster(device, encoder, &image.handle) + if let Some((atlas_entry, bind_group)) = cache + .upload_raster(device, encoder, belt, &image.handle) { + match atlas.as_mut() { + None => { + atlas = Some(bind_group.clone()); + } + Some(atlas) if atlas != bind_group => { + layer.push( + device, + encoder, + belt, + atlas, + nearest_instances, + linear_instances, + ); + + *atlas = bind_group.clone(); + nearest_instances.clear(); + linear_instances.clear(); + } + _ => {} + } + add_instances( [bounds.x, bounds.y], [bounds.width, bounds.height], @@ -257,20 +302,44 @@ impl State { } } #[cfg(not(feature = "image"))] - Image::Raster { .. } => {} + Image::Raster { .. } => continue, #[cfg(feature = "svg")] Image::Vector(svg, bounds) => { let size = [bounds.width, bounds.height]; - if let Some(atlas_entry) = cache.upload_vector( - device, - encoder, - &svg.handle, - svg.color, - size, - scale, - ) { + if let Some((atlas_entry, bind_group)) = cache + .upload_vector( + device, + encoder, + belt, + &svg.handle, + svg.color, + size, + scale, + ) + { + match atlas.as_mut() { + None => { + atlas = Some(bind_group.clone()); + } + Some(atlas) if atlas != bind_group => { + layer.push( + device, + encoder, + belt, + atlas, + nearest_instances, + linear_instances, + ); + + *atlas = bind_group.clone(); + nearest_instances.clear(); + linear_instances.clear(); + } + _ => {} + } + add_instances( [bounds.x, bounds.y], size, @@ -283,42 +352,27 @@ impl State { } } #[cfg(not(feature = "svg"))] - Image::Vector { .. } => {} + Image::Vector { .. } => continue, } } - if nearest_instances.is_empty() && linear_instances.is_empty() { - return; - } - - if self.layers.len() <= self.prepare_layer { - self.layers.push(Layer::new( + if !nearest_instances.is_empty() || !linear_instances.is_empty() { + layer.push( device, - &pipeline.constant_layout, - &pipeline.nearest_sampler, - &pipeline.linear_sampler, - )); + encoder, + belt, + &atlas.expect("atlas should be defined"), + nearest_instances, + linear_instances, + ); } - let layer = &mut self.layers[self.prepare_layer]; - - layer.prepare( - device, - encoder, - belt, - nearest_instances, - linear_instances, - transformation, - scale, - ); - self.prepare_layer += 1; } pub fn render<'a>( &'a self, pipeline: &'a Pipeline, - cache: &'a Cache, layer: usize, bounds: Rectangle, render_pass: &mut wgpu::RenderPass<'a>, @@ -333,13 +387,15 @@ impl State { bounds.height, ); - render_pass.set_bind_group(1, cache.bind_group(), &[]); - layer.render(render_pass); } } pub fn trim(&mut self) { + for layer in &mut self.layers[..self.prepare_layer] { + layer.clear(); + } + self.prepare_layer = 0; } } @@ -347,8 +403,18 @@ impl State { #[derive(Debug)] struct Layer { uniforms: wgpu::Buffer, - nearest: Data, - linear: Data, + instances: Buffer, + total: usize, + nearest: Vec, + nearest_layout: wgpu::BindGroup, + linear: Vec, + linear_layout: wgpu::BindGroup, +} + +#[derive(Debug)] +struct Group { + atlas: wgpu::BindGroup, + instance_count: usize, } impl Layer { @@ -365,16 +431,69 @@ impl Layer { mapped_at_creation: false, }); - let nearest = - Data::new(device, constant_layout, nearest_sampler, &uniforms); + let instances = Buffer::new( + device, + "iced_wgpu::image instance buffer", + Instance::INITIAL, + wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + ); - let linear = - Data::new(device, constant_layout, linear_sampler, &uniforms); + let nearest_layout = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("iced_wgpu::image constants bind group"), + layout: constant_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer( + wgpu::BufferBinding { + buffer: &uniforms, + offset: 0, + size: None, + }, + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler( + nearest_sampler, + ), + }, + ], + }); + + let linear_layout = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("iced_wgpu::image constants bind group"), + layout: constant_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer( + wgpu::BufferBinding { + buffer: &uniforms, + offset: 0, + size: None, + }, + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler( + linear_sampler, + ), + }, + ], + }); Self { uniforms, - nearest, - linear, + instances, + total: 0, + nearest: Vec::new(), + nearest_layout, + linear: Vec::new(), + linear_layout, } } @@ -383,8 +502,6 @@ impl Layer { device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, belt: &mut wgpu::util::StagingBelt, - nearest_instances: &[Instance], - linear_instances: &[Instance], transformation: Transformation, scale_factor: f32, ) { @@ -404,94 +521,79 @@ impl Layer { device, ) .copy_from_slice(bytes); - - self.nearest - .upload(device, encoder, belt, nearest_instances); - - self.linear.upload(device, encoder, belt, linear_instances); } - fn render<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) { - self.nearest.render(render_pass); - self.linear.render(render_pass); - } -} - -#[derive(Debug)] -struct Data { - constants: wgpu::BindGroup, - instances: Buffer, - instance_count: usize, -} - -impl Data { - pub fn new( - device: &wgpu::Device, - constant_layout: &wgpu::BindGroupLayout, - sampler: &wgpu::Sampler, - uniforms: &wgpu::Buffer, - ) -> Self { - let constants = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("iced_wgpu::image constants bind group"), - layout: constant_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::Buffer( - wgpu::BufferBinding { - buffer: uniforms, - offset: 0, - size: None, - }, - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(sampler), - }, - ], - }); - - let instances = Buffer::new( - device, - "iced_wgpu::image instance buffer", - Instance::INITIAL, - wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - ); - - Self { - constants, - instances, - instance_count: 0, - } - } - - fn upload( + fn push( &mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, belt: &mut wgpu::util::StagingBelt, - instances: &[Instance], + atlas: &wgpu::BindGroup, + nearest: &[Instance], + linear: &[Instance], ) { - self.instance_count = instances.len(); + let new = nearest.len() + linear.len(); - if self.instance_count == 0 { - return; + let _ = self.instances.resize(device, self.total + new); + + if !nearest.is_empty() { + self.total += self + .instances + .write(device, encoder, belt, self.total, nearest); + + self.nearest.push(Group { + atlas: atlas.clone(), + instance_count: nearest.len(), + }); } - let _ = self.instances.resize(device, instances.len()); - let _ = self.instances.write(device, encoder, belt, 0, instances); + if !linear.is_empty() { + self.total += self + .instances + .write(device, encoder, belt, self.total, linear); + + self.linear.push(Group { + atlas: atlas.clone(), + instance_count: linear.len(), + }); + } } fn render<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) { - if self.instance_count == 0 { - return; - } - - render_pass.set_bind_group(0, &self.constants, &[]); render_pass.set_vertex_buffer(0, self.instances.slice(..)); - render_pass.draw(0..6, 0..self.instance_count as u32); + let mut offset = 0; + + if !self.nearest.is_empty() { + render_pass.set_bind_group(0, &self.nearest_layout, &[]); + + for group in &self.nearest { + render_pass.set_bind_group(1, &group.atlas, &[]); + render_pass + .draw(0..6, offset..offset + group.instance_count as u32); + + offset += group.instance_count as u32; + } + } + + if !self.linear.is_empty() { + render_pass.set_bind_group(0, &self.linear_layout, &[]); + + for group in &self.linear { + render_pass.set_bind_group(1, &group.atlas, &[]); + render_pass + .draw(0..6, offset..offset + group.instance_count as u32); + + offset += group.instance_count as u32; + } + } + } + + fn clear(&mut self) { + self.instances.clear(); + self.nearest.clear(); + self.linear.clear(); + self.total = 0; } } @@ -597,6 +699,7 @@ fn add_instance( let (x, y) = allocation.position(); let Size { width, height } = allocation.size(); let layer = allocation.layer(); + let atlas_size = allocation.atlas_size(); let instance = Instance { _position: position, @@ -605,12 +708,12 @@ fn add_instance( _rotation: rotation, _opacity: opacity, _position_in_atlas: [ - (x as f32 + 0.5) / atlas::SIZE as f32, - (y as f32 + 0.5) / atlas::SIZE as f32, + (x as f32 + 0.5) / atlas_size as f32, + (y as f32 + 0.5) / atlas_size as f32, ], _size_in_atlas: [ - (width as f32 - 1.0) / atlas::SIZE as f32, - (height as f32 - 1.0) / atlas::SIZE as f32, + (width as f32 - 1.0) / atlas_size as f32, + (height as f32 - 1.0) / atlas_size as f32, ], _layer: layer as u32, _snap: snap as u32, diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 470c39e2..57662433 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -6,13 +6,18 @@ use crate::image::atlas::{self, Atlas}; use rustc_hash::{FxHashMap, FxHashSet}; +type Image = image_rs::ImageBuffer, image::Bytes>; + /// Entry in cache corresponding to an image handle #[derive(Debug)] pub enum Memory { /// Image data on host - Host(image_rs::ImageBuffer, image::Bytes>), + Host(Image), /// Storage entry - Device(atlas::Entry), + Device { + entry: atlas::Entry, + bind_group: Option, + }, /// Image not found NotFound, /// Invalid image data @@ -20,7 +25,20 @@ pub enum Memory { } impl Memory { - /// Width and height of image + pub fn load(handle: &image::Handle) -> Self { + match graphics::image::load(handle) { + Ok(image) => Self::Host(image), + Err(error) => Self::error(error), + } + } + + pub fn error(error: image_rs::error::ImageError) -> Self { + match error { + image_rs::error::ImageError::IoError(_) => Self::NotFound, + _ => Self::Invalid, + } + } + pub fn dimensions(&self) -> Size { match self { Memory::Host(image) => { @@ -28,14 +46,20 @@ impl Memory { Size::new(width, height) } - Memory::Device(entry) => entry.size(), + Memory::Device { entry, .. } => entry.size(), Memory::NotFound => Size::new(1, 1), Memory::Invalid => Size::new(1, 1), } } + + pub fn host(&self) -> Option { + match self { + Memory::Host(image) => Some(image.clone()), + Memory::Device { .. } | Memory::NotFound | Memory::Invalid => None, + } + } } -/// Caches image raster data #[derive(Debug, Default)] pub struct Cache { map: FxHashMap, @@ -44,51 +68,28 @@ pub struct Cache { } impl Cache { - /// Load image - pub fn load(&mut self, handle: &image::Handle) -> &mut Memory { - if self.contains(handle) { - return self.get(handle).unwrap(); - } + pub fn get_mut(&mut self, handle: &image::Handle) -> Option<&mut Memory> { + let _ = self.hits.insert(handle.id()); - let memory = match graphics::image::load(handle) { - Ok(image) => Memory::Host(image), - Err(image_rs::error::ImageError::IoError(_)) => Memory::NotFound, - Err(_) => Memory::Invalid, - }; + self.map.get_mut(&handle.id()) + } + + pub fn insert(&mut self, handle: &image::Handle, memory: Memory) { + let _ = self.map.insert(handle.id(), memory); + let _ = self.hits.insert(handle.id()); self.should_trim = true; - - self.insert(handle, memory); - self.get(handle).unwrap() } - /// Load image and upload raster data - pub fn upload( + pub fn contains(&self, handle: &image::Handle) -> bool { + self.map.contains_key(&handle.id()) + } + + pub fn trim( &mut self, - device: &wgpu::Device, - encoder: &mut wgpu::CommandEncoder, - handle: &image::Handle, atlas: &mut Atlas, - ) -> Option<&atlas::Entry> { - let memory = self.load(handle); - - if let Memory::Host(image) = memory { - let (width, height) = image.dimensions(); - - let entry = atlas.upload(device, encoder, width, height, image)?; - - *memory = Memory::Device(entry); - } - - if let Memory::Device(allocation) = memory { - Some(allocation) - } else { - None - } - } - - /// Trim cache misses from cache - pub fn trim(&mut self, atlas: &mut Atlas) { + on_drop: impl Fn(wgpu::BindGroup), + ) { // Only trim if new entries have landed in the `Cache` if !self.should_trim { return; @@ -99,8 +100,12 @@ impl Cache { self.map.retain(|k, memory| { let retain = hits.contains(k); - if !retain && let Memory::Device(entry) = memory { - atlas.remove(entry); + if !retain && let Memory::Device { entry, bind_group } = memory { + if let Some(bind_group) = bind_group.take() { + on_drop(bind_group); + } else { + atlas.remove(entry); + } } retain @@ -109,18 +114,4 @@ impl Cache { self.hits.clear(); self.should_trim = false; } - - fn get(&mut self, handle: &image::Handle) -> Option<&mut Memory> { - let _ = self.hits.insert(handle.id()); - - self.map.get_mut(&handle.id()) - } - - fn insert(&mut self, handle: &image::Handle, memory: Memory) { - let _ = self.map.insert(handle.id(), memory); - } - - fn contains(&self, handle: &image::Handle) -> bool { - self.map.contains_key(&handle.id()) - } } diff --git a/wgpu/src/image/vector.rs b/wgpu/src/image/vector.rs index e55ade38..9949cf39 100644 --- a/wgpu/src/image/vector.rs +++ b/wgpu/src/image/vector.rs @@ -94,6 +94,7 @@ impl Cache { &mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + belt: &mut wgpu::util::StagingBelt, handle: &svg::Handle, color: Option, [width, height]: [f32; 2], @@ -167,8 +168,8 @@ impl Cache { }); } - let allocation = - atlas.upload(device, encoder, width, height, &rgba)?; + let allocation = atlas + .upload(device, encoder, belt, width, height, &rgba)?; log::debug!("allocating {id} {width}x{height}"); diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index 6b34889e..93733fc5 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -65,8 +65,8 @@ use crate::core::renderer; use crate::core::{ Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, }; -use crate::graphics::Viewport; use crate::graphics::text::{Editor, Paragraph}; +use crate::graphics::{Shell, Viewport}; /// A [`wgpu`] graphics renderer for [`iced`]. /// @@ -117,9 +117,7 @@ impl Renderer { image: image::State::new(), #[cfg(any(feature = "svg", feature = "image"))] - image_cache: std::cell::RefCell::new( - engine.create_image_cache(&engine.device), - ), + image_cache: std::cell::RefCell::new(engine.create_image_cache()), // TODO: Resize belt smartly (?) // It would be great if the `StagingBelt` API exposed methods @@ -460,8 +458,6 @@ impl Renderer { #[cfg(any(feature = "svg", feature = "image"))] let mut image_layer = 0; - #[cfg(any(feature = "svg", feature = "image"))] - let image_cache = self.image_cache.borrow(); let scale_factor = viewport.scale_factor(); let physical_bounds = Rectangle::::from(Rectangle::with_size( @@ -632,7 +628,6 @@ impl Renderer { let render_span = debug::render(debug::Primitive::Image); self.image.render( &self.engine.image_pipeline, - &image_cache, image_layer, scissor_rect, &mut render_pass, @@ -910,6 +905,7 @@ impl renderer::Headless for Renderer { wgpu::TextureFormat::Rgba8Unorm }, Some(graphics::Antialiasing::MSAAx4), + Shell::headless(), ); Some(Self::new(engine, default_font, default_text_size)) diff --git a/wgpu/src/window/compositor.rs b/wgpu/src/window/compositor.rs index c1ba7df2..8a0bb72a 100644 --- a/wgpu/src/window/compositor.rs +++ b/wgpu/src/window/compositor.rs @@ -3,7 +3,7 @@ use crate::core::Color; use crate::graphics::color; use crate::graphics::compositor; use crate::graphics::error; -use crate::graphics::{self, Viewport}; +use crate::graphics::{self, Shell, Viewport}; use crate::settings::{self, Settings}; use crate::{Engine, Renderer}; @@ -50,6 +50,7 @@ impl Compositor { pub async fn request( settings: Settings, compatible_window: Option, + shell: Shell, ) -> Result { let instance = wgpu::util::new_instance_with_webgpu_detection( &wgpu::InstanceDescriptor { @@ -181,6 +182,7 @@ impl Compositor { queue, format, settings.antialiasing, + shell, ); return Ok(Compositor { @@ -206,8 +208,9 @@ impl Compositor { pub async fn new( settings: Settings, compatible_window: W, + shell: Shell, ) -> Result { - Compositor::request(settings, Some(compatible_window)).await + Compositor::request(settings, Some(compatible_window), shell).await } /// Presents the given primitives with the given [`Compositor`]. @@ -260,6 +263,7 @@ impl graphics::Compositor for Compositor { async fn with_backend( settings: graphics::Settings, compatible_window: W, + shell: Shell, backend: Option<&str>, ) -> Result { match backend { @@ -274,7 +278,7 @@ impl graphics::Compositor for Compositor { settings.present_mode = present_mode; } - Ok(new(settings, compatible_window).await?) + Ok(new(settings, compatible_window, shell).await?) } Some(backend) => Err(graphics::Error::GraphicsAdapterNotFound { backend: "wgpu", diff --git a/winit/src/lib.rs b/winit/src/lib.rs index 75269d6a..cd163f16 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -49,7 +49,7 @@ use crate::futures::futures::task; use crate::futures::futures::{Future, StreamExt}; use crate::futures::subscription; use crate::futures::{Executor, Runtime}; -use crate::graphics::{Compositor, compositor}; +use crate::graphics::{Compositor, Shell, compositor}; use crate::runtime::system; use crate::runtime::user_interface::{self, UserInterface}; use crate::runtime::{Action, Task}; @@ -587,8 +587,10 @@ async fn run_instance

( let default_fonts = default_fonts.clone(); async move { + let shell = Shell::new(proxy.clone()); + let mut compositor = - ::Compositor::new(graphics_settings, window).await; + ::Compositor::new(graphics_settings, window, shell).await; if let Ok(compositor) = &mut compositor { for font in default_fonts { @@ -824,7 +826,7 @@ async fn run_instance

( .get_mut(&id) .expect("Get user interface"); - let draw_span = debug::draw(id); + let interact_span = debug::interact(id); let mut change_count = 0; let state = loop { @@ -947,7 +949,9 @@ async fn run_instance

( user_interfaces.get_mut(&id).unwrap(); } }; + interact_span.finish(); + let draw_span = debug::draw(id); interface.draw( &mut window.renderer, window.state.theme(), @@ -1646,6 +1650,26 @@ fn run_action<'a, P, C>( let _ = window.raw.set_cursor_hittest(true); } } + window::Action::RedrawAll => { + for (_id, window) in window_manager.iter_mut() { + window.raw.request_redraw(); + } + } + window::Action::RelayoutAll => { + for (id, window) in window_manager.iter_mut() { + if let Some(ui) = interfaces.remove(&id) { + let _ = interfaces.insert( + id, + ui.relayout( + window.state.logical_size(), + &mut window.renderer, + ), + ); + } + + window.raw.request_redraw(); + } + } }, Action::System(action) => match action { system::Action::GetInformation(_channel) => { diff --git a/winit/src/proxy.rs b/winit/src/proxy.rs index 92758c5f..5a56659f 100644 --- a/winit/src/proxy.rs +++ b/winit/src/proxy.rs @@ -4,7 +4,9 @@ use crate::futures::futures::{ select, task::{Context, Poll}, }; +use crate::graphics::shell; use crate::runtime::Action; +use crate::runtime::window; use std::pin::Pin; /// An event loop proxy with backpressure that implements `Sink`. @@ -134,3 +136,16 @@ impl Sink> for Proxy { Poll::Ready(Ok(())) } } + +impl shell::Notifier for Proxy +where + T: Send, +{ + fn request_redraw(&self) { + self.send_action(Action::Window(window::Action::RedrawAll)); + } + + fn invalidate_layout(&self) { + self.send_action(Action::Window(window::Action::RelayoutAll)); + } +} From 47a3fc0b9ad76fe9e2e9f6481d45cfb840865184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 24 Oct 2025 17:40:30 +0200 Subject: [PATCH 02/28] Avoid moving the `Atlas` into `on_submitted_work_done` --- wgpu/src/image/cache.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 32617f01..795800b8 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -205,6 +205,7 @@ impl Cache { } } +#[cfg(feature = "image")] impl Drop for Cache { fn drop(&mut self) { // Stop worker gracefully @@ -389,11 +390,13 @@ impl Worker { let submission = self.queue.submit([encoder.finish()]); self.belt.recall(); + let bind_group = atlas.bind_group().clone(); + self.queue.on_submitted_work_done(move || { let _ = output.send(Work::Upload { handle, entry, - bind_group: atlas.bind_group().clone(), + bind_group, }); callback(&shell); From 5216253998cadc29b0933ac4f30a8101b423941a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 24 Oct 2025 18:14:29 +0200 Subject: [PATCH 03/28] Fall back to synchronous rendering on Wasm --- src/application.rs | 11 +++++++++-- wgpu/src/image/cache.rs | 25 ++++++++++++++++--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/application.rs b/src/application.rs index 143dfcc4..f007e9dc 100644 --- a/src/application.rs +++ b/src/application.rs @@ -198,10 +198,17 @@ impl Application

{ #[cfg(feature = "tester")] let program = iced_tester::attach(self); - #[cfg(all(feature = "debug", not(feature = "tester")))] + #[cfg(all( + feature = "debug", + not(feature = "tester"), + not(target_arch = "wasm32") + ))] let program = iced_devtools::attach(self); - #[cfg(not(any(feature = "tester", feature = "debug")))] + #[cfg(not(any( + feature = "tester", + all(feature = "debug", not(target_arch = "wasm32")) + )))] let program = self; Ok(shell::run(program)?) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 795800b8..858c6b4c 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -17,7 +17,7 @@ pub struct Cache { jobs: mpsc::SyncSender, #[cfg(feature = "image")] work: mpsc::Receiver, - #[cfg(feature = "image")] + #[cfg(all(feature = "image", not(target_arch = "wasm32")))] worker_: Option>, } @@ -29,11 +29,14 @@ impl Cache { layout: wgpu::BindGroupLayout, shell: &Shell, ) -> Self { - #[cfg(feature = "image")] + #[cfg(all(feature = "image", not(target_arch = "wasm32")))] let (worker, jobs, work) = Worker::new(device, queue, backend, layout.clone(), shell); - #[cfg(feature = "image")] + #[cfg(all(feature = "image", target_arch = "wasm32"))] + let (jobs, work) = (mpsc::sync_channel(0).0, mpsc::sync_channel(0).1); + + #[cfg(all(feature = "image", not(target_arch = "wasm32")))] let handle = thread::spawn(move || worker.run()); Self { @@ -50,7 +53,7 @@ impl Cache { jobs, #[cfg(feature = "image")] work, - #[cfg(feature = "image")] + #[cfg(all(feature = "image", not(target_arch = "wasm32")))] worker_: Some(handle), } } @@ -107,7 +110,8 @@ impl Cache { const MAX_SYNC_SIZE: usize = 2 * 1024 * 1024; - if image.len() < MAX_SYNC_SIZE { + // TODO: Concurrent Wasm support + if image.len() < MAX_SYNC_SIZE || cfg!(target_arch = "wasm32") { let entry = self.atlas.upload( device, encoder, @@ -205,7 +209,7 @@ impl Cache { } } -#[cfg(feature = "image")] +#[cfg(all(feature = "image", not(target_arch = "wasm32")))] impl Drop for Cache { fn drop(&mut self) { // Stop worker gracefully @@ -235,8 +239,11 @@ fn load_image<'a>( use crate::image::raster::Memory; if !cache.contains(handle) { - // Load RGBA handles synchronously, since it's very cheap - if let core::image::Handle::Rgba { .. } = handle { + if cfg!(target_arch = "wasm32") { + // TODO: Concurrent support for Wasm + cache.insert(handle, Memory::load(handle)); + } else if let core::image::Handle::Rgba { .. } = handle { + // Load RGBA handles synchronously, since it's very cheap cache.insert(handle, Memory::load(handle)); } else { let _ = jobs.send(Job::Load(handle.clone())); @@ -284,7 +291,7 @@ struct Worker { output: mpsc::SyncSender, } -#[cfg(feature = "image")] +#[cfg(all(feature = "image", not(target_arch = "wasm32")))] impl Worker { fn new( device: &wgpu::Device, From 1b5136415483293207f44862ee0778128fec63bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 24 Oct 2025 18:21:03 +0200 Subject: [PATCH 04/28] Fix standalone `svg` feature --- wgpu/src/image/cache.rs | 15 +++++++++------ wgpu/src/image/vector.rs | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 858c6b4c..61777318 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -2,9 +2,11 @@ use crate::core::{self, Size}; use crate::graphics::Shell; use crate::image::atlas::{self, Atlas}; +#[cfg(feature = "image")] use std::collections::BTreeSet; + +#[cfg(feature = "image")] use std::sync::mpsc; -use std::thread; #[derive(Debug)] pub struct Cache { @@ -18,26 +20,26 @@ pub struct Cache { #[cfg(feature = "image")] work: mpsc::Receiver, #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - worker_: Option>, + worker_: Option>, } impl Cache { pub fn new( device: &wgpu::Device, - queue: &wgpu::Queue, + _queue: &wgpu::Queue, backend: wgpu::Backend, layout: wgpu::BindGroupLayout, - shell: &Shell, + _shell: &Shell, ) -> Self { #[cfg(all(feature = "image", not(target_arch = "wasm32")))] let (worker, jobs, work) = - Worker::new(device, queue, backend, layout.clone(), shell); + Worker::new(device, _queue, backend, layout.clone(), _shell); #[cfg(all(feature = "image", target_arch = "wasm32"))] let (jobs, work) = (mpsc::sync_channel(0).0, mpsc::sync_channel(0).1); #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - let handle = thread::spawn(move || worker.run()); + let handle = std::thread::spawn(move || worker.run()); Self { atlas: Atlas::new(device, backend, layout), @@ -181,6 +183,7 @@ impl Cache { self.vector.trim(&mut self.atlas); // TODO: Concurrency } + #[cfg(feature = "image")] fn receive(&mut self) { use crate::image::raster::Memory; diff --git a/wgpu/src/image/vector.rs b/wgpu/src/image/vector.rs index 9949cf39..2fa6ba39 100644 --- a/wgpu/src/image/vector.rs +++ b/wgpu/src/image/vector.rs @@ -176,6 +176,7 @@ impl Cache { let _ = self.svg_hits.insert(id); let _ = self.rasterized_hits.insert(key); let _ = self.rasterized.insert(key, allocation); + self.should_trim = true; self.rasterized.get(&key) } From 6fa54f7f6bbb79224e75ad3606738dbd933ccc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 24 Oct 2025 20:06:26 +0200 Subject: [PATCH 05/28] Decode on the fly in `gallery` example Use `release` mode. Image decoding is terribly slow in `debug` mode! --- examples/gallery/src/civitai.rs | 38 +++++++++++++------------- examples/gallery/src/main.rs | 47 ++++++++++++++++----------------- wgpu/src/image/cache.rs | 2 +- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 97f0ad89..09fd0f91 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -1,4 +1,3 @@ -use bytes::Bytes; use serde::Deserialize; use sipper::{Straw, sipper}; use tokio::task; @@ -54,14 +53,14 @@ impl Image { rgba: Rgba { width, height, - pixels: Bytes::from(pixels), + pixels: Bytes(pixels.into()), }, }) }) .await? } - pub fn download(self, size: Size) -> impl Straw { + pub fn download(self, size: Size) -> impl Straw { sipper(async move |mut sender| { let client = reqwest::Client::new(); @@ -97,21 +96,7 @@ impl Image { .bytes() .await?; - let image = task::spawn_blocking(move || { - Ok::<_, Error>( - image::ImageReader::new(io::Cursor::new(bytes)) - .with_guessed_format()? - .decode()? - .to_rgba8(), - ) - }) - .await??; - - Ok(Rgba { - width: image.width(), - height: image.height(), - pixels: Bytes::from(image.into_raw()), - }) + Ok(Bytes(bytes)) }) } } @@ -142,6 +127,23 @@ impl fmt::Debug for Rgba { } } +#[derive(Clone)] +pub struct Bytes(bytes::Bytes); + +impl From for bytes::Bytes { + fn from(value: Bytes) -> Self { + value.0 + } +} + +impl fmt::Debug for Bytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Compressed") + .field("bytes", &self.0.len()) + .finish() + } +} + #[derive(Debug, Clone, Copy)] pub enum Size { Original, diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 42e916db..03768604 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -4,7 +4,7 @@ //! some smooth animations. mod civitai; -use crate::civitai::{Error, Id, Image, Rgba, Size}; +use crate::civitai::{Bytes, Error, Id, Image, Rgba, Size}; use iced::animation; use iced::time::{Instant, milliseconds}; @@ -46,8 +46,8 @@ enum Message { ImagesListed(Result, Error>), ImagePoppedIn(Id), ImagePoppedOut(Id), - ImageDownloaded(Result), - ThumbnailDownloaded(Id, Result), + ImageDownloaded(Result), + ThumbnailDownloaded(Id, Result), ThumbnailHovered(Id, bool), BlurhashDecoded(Id, civitai::Blurhash), Open(Id), @@ -110,6 +110,13 @@ impl Gallery { let _ = self.visible.insert(id); if self.downloaded.contains(&id) { + if let Some(Preview::Ready { thumbnail, .. }) = + self.previews.get_mut(&id) + { + thumbnail.fade_in = + Animation::new(false).slow().go(true, now); + } + return Task::none(); } @@ -129,17 +136,17 @@ impl Gallery { Task::none() } - Message::ImageDownloaded(Ok(rgba)) => { - self.viewer.show(rgba, self.now); + Message::ImageDownloaded(Ok(bytes)) => { + self.viewer.show(bytes, self.now); Task::none() } - Message::ThumbnailDownloaded(id, Ok(rgba)) => { + Message::ThumbnailDownloaded(id, Ok(bytes)) => { let thumbnail = if let Some(preview) = self.previews.remove(&id) { - preview.load(rgba, self.now) + preview.load(bytes, self.now) } else { - Preview::ready(rgba, self.now) + Preview::ready(bytes, self.now) }; let _ = self.previews.insert(id, thumbnail); @@ -339,21 +346,21 @@ impl Preview { } } - fn ready(rgba: Rgba, now: Instant) -> Self { + fn ready(bytes: Bytes, now: Instant) -> Self { Self::Ready { blurhash: None, - thumbnail: Thumbnail::new(rgba, now), + thumbnail: Thumbnail::new(bytes, now), } } - fn load(self, rgba: Rgba, now: Instant) -> Self { + fn load(self, bytes: Bytes, now: Instant) -> Self { let Self::Loading { blurhash } = self else { return self; }; Self::Ready { blurhash: Some(blurhash), - thumbnail: Thumbnail::new(rgba, now), + thumbnail: Thumbnail::new(bytes, now), } } @@ -387,13 +394,9 @@ impl Preview { } impl Thumbnail { - pub fn new(rgba: Rgba, now: Instant) -> Self { + pub fn new(bytes: Bytes, now: Instant) -> Self { Self { - handle: image::Handle::from_rgba( - rgba.width, - rgba.height, - rgba.pixels, - ), + handle: image::Handle::from_bytes(bytes), fade_in: Animation::new(false).slow().go(true, now), zoom: Animation::new(false) .quick() @@ -426,12 +429,8 @@ impl Viewer { self.background_fade_in.go_mut(true, now); } - fn show(&mut self, rgba: Rgba, now: Instant) { - self.image = Some(image::Handle::from_rgba( - rgba.width, - rgba.height, - rgba.pixels, - )); + fn show(&mut self, bytes: Bytes, now: Instant) { + self.image = Some(image::Handle::from_bytes(bytes)); self.background_fade_in.go_mut(true, now); self.image_fade_in.go_mut(true, now); } diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 61777318..cc0e7d69 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -248,7 +248,7 @@ fn load_image<'a>( } else if let core::image::Handle::Rgba { .. } = handle { // Load RGBA handles synchronously, since it's very cheap cache.insert(handle, Memory::load(handle)); - } else { + } else if !pending.contains(&handle.id()) { let _ = jobs.send(Job::Load(handle.clone())); let _ = pending.insert(handle.id()); } From 23039e758edc8fc58515c3c02be6c77401f5227d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 25 Oct 2025 00:07:13 +0200 Subject: [PATCH 06/28] Introduce explicit `image::allocate` API --- core/src/image.rs | 52 +++++++++++++ core/src/renderer.rs | 8 ++ core/src/renderer/null.rs | 9 +++ examples/gallery/src/main.rs | 141 ++++++++++++++++++++++++++--------- renderer/src/fallback.rs | 8 ++ runtime/src/image.rs | 24 ++++++ runtime/src/lib.rs | 6 ++ runtime/src/task.rs | 8 +- src/lib.rs | 7 ++ test/src/emulator.rs | 4 + tiny_skia/src/lib.rs | 10 +++ wgpu/src/image/cache.rs | 89 ++++++++++++++++++---- wgpu/src/image/raster.rs | 13 +++- wgpu/src/lib.rs | 11 +++ winit/src/lib.rs | 16 ++++ 15 files changed, 355 insertions(+), 51 deletions(-) create mode 100644 runtime/src/image.rs diff --git a/core/src/image.rs b/core/src/image.rs index f985636a..4b72004e 100644 --- a/core/src/image.rs +++ b/core/src/image.rs @@ -4,8 +4,10 @@ pub use bytes::Bytes; use crate::{Radians, Rectangle, Size}; use rustc_hash::FxHasher; + use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; /// A raster image that can be drawn. #[derive(Debug, Clone, PartialEq)] @@ -227,6 +229,56 @@ pub enum FilterMethod { Nearest, } +/// A memory allocation of a [`Handle`], often in GPU memory. +/// +/// Renderers tend to decode and upload image data concurrently to +/// avoid blocking the user interface. This means that when you use a +/// [`Handle`] in a widget, there may be a slight frame delay until it +/// is finally visible. If you are animating images, this can cause +/// undesirable flicker. +/// +/// When you obtain an [`Allocation`] explicitly, you get the guarantee +/// that using a [`Handle`] will draw the corresponding [`Image`] +/// immediately in the next frame. +/// +/// This guarantee is valid as long as you hold an [`Allocation`]. +/// Only when you drop all its clones, the renderer may choose to free +/// the memory of the [`Handle`]. Be careful! +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Allocation(Arc); + +/// Some memory taken by an [`Allocation`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Memory(Handle); + +impl Allocation { + /// Returns a weak reference to the [`Memory`] of the [`Allocation`]. + pub fn downgrade(&self) -> Weak { + Arc::downgrade(&self.0) + } + + /// Upgrades a [`Weak`] memory reference to an [`Allocation`]. + pub fn upgrade(weak: &Weak) -> Option { + Weak::upgrade(weak).map(Allocation) + } + + /// Returns the [`Handle`] of this [`Allocation`]. + pub fn handle(&self) -> &Handle { + &self.0.0 + } +} + +/// Creates a new [`Allocation`] for the given handle. +/// +/// This should only be used internally by renderer implementations. +/// +/// # Safety +/// Must only be created once the [`Handle`] is allocated in memory. +#[allow(unsafe_code)] +pub unsafe fn allocate(handle: &Handle) -> Allocation { + Allocation(Arc::new(Memory(handle.clone()))) +} + /// A [`Renderer`] that can render raster graphics. /// /// [renderer]: crate::renderer diff --git a/core/src/renderer.rs b/core/src/renderer.rs index 84d48304..6e24f661 100644 --- a/core/src/renderer.rs +++ b/core/src/renderer.rs @@ -2,6 +2,7 @@ #[cfg(debug_assertions)] mod null; +use crate::image; use crate::{ Background, Border, Color, Font, Pixels, Rectangle, Shadow, Size, Transformation, Vector, @@ -62,6 +63,13 @@ pub trait Renderer { /// Resets the [`Renderer`] to start drawing in the `new_bounds` from scratch. fn reset(&mut self, new_bounds: Rectangle); + + /// Creates an [`image::Allocation`] for the given [`image::Handle`] and calls the given callback with it. + fn allocate_image( + &mut self, + handle: &image::Handle, + callback: impl FnOnce(image::Allocation) + Send + 'static, + ); } /// A polygon with four sides. diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index ff5baa3b..8b3fbb0c 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -24,6 +24,15 @@ impl Renderer for () { _background: impl Into, ) { } + + fn allocate_image( + &mut self, + handle: &image::Handle, + callback: impl FnOnce(image::Allocation) + Send + 'static, + ) { + #[allow(unsafe_code)] + callback(unsafe { image::allocate(handle) }); + } } impl text::Renderer for () { diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 03768604..87a8e811 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -46,8 +46,9 @@ enum Message { ImagesListed(Result, Error>), ImagePoppedIn(Id), ImagePoppedOut(Id), - ImageDownloaded(Result), + ImageDownloaded(Result), ThumbnailDownloaded(Id, Result), + ThumbnailAllocated(Id, image::Allocation), ThumbnailHovered(Id, bool), BlurhashDecoded(Id, civitai::Blurhash), Open(Id), @@ -110,14 +111,16 @@ impl Gallery { let _ = self.visible.insert(id); if self.downloaded.contains(&id) { - if let Some(Preview::Ready { thumbnail, .. }) = + let Some(Preview::Ready { thumbnail, .. }) = self.previews.get_mut(&id) - { - thumbnail.fade_in = - Animation::new(false).slow().go(true, now); - } + else { + return Task::none(); + }; - return Task::none(); + return image::allocate(image::Handle::from_bytes( + thumbnail.bytes.clone(), + )) + .map(Message::ThumbnailAllocated.with(id)); } let _ = self.downloaded.insert(id); @@ -134,22 +137,56 @@ impl Gallery { Message::ImagePoppedOut(id) => { let _ = self.visible.remove(&id); + if let Some(Preview::Ready { + thumbnail, + blurhash, + }) = self.previews.get_mut(&id) + { + thumbnail.reset(); + + if let Some(blurhash) = blurhash { + blurhash.reset(); + } + } + Task::none() } - Message::ImageDownloaded(Ok(bytes)) => { - self.viewer.show(bytes, self.now); + Message::ImageDownloaded(Ok(allocation)) => { + self.viewer.show(allocation, self.now); Task::none() } Message::ThumbnailDownloaded(id, Ok(bytes)) => { - let thumbnail = if let Some(preview) = self.previews.remove(&id) - { - preview.load(bytes, self.now) + let preview = if let Some(preview) = self.previews.remove(&id) { + preview.load(bytes.clone()) } else { - Preview::ready(bytes, self.now) + Preview::ready(bytes.clone()) }; - let _ = self.previews.insert(id, thumbnail); + let _ = self.previews.insert(id, preview); + + image::allocate(image::Handle::from_bytes(bytes)) + .map(Message::ThumbnailAllocated.with(id)) + } + Message::ThumbnailAllocated(id, allocation) => { + if !self.visible.contains(&id) { + return Task::none(); + } + + let Some(Preview::Ready { + thumbnail, + blurhash, + .. + }) = self.previews.get_mut(&id) + else { + return Task::none(); + }; + + if let Some(blurhash) = blurhash { + blurhash.show(now); + } + + thumbnail.show(allocation, now); Task::none() } @@ -181,10 +218,12 @@ impl Gallery { self.viewer.open(self.now); - Task::perform( - image.download(Size::Original), - Message::ImageDownloaded, - ) + Task::future(image.download(Size::Original)) + .and_then(|bytes| { + image::allocate(image::Handle::from_bytes(bytes)) + .map(Ok) + }) + .map(Message::ImageDownloaded) } Message::Close => { self.viewer.close(self.now); @@ -238,9 +277,11 @@ fn card<'a>( ) -> Element<'a, Message> { let image = if let Some(preview) = preview { let thumbnail: Element<'_, _> = - if let Preview::Ready { thumbnail, .. } = &preview { + if let Preview::Ready { thumbnail, .. } = &preview + && let Some(allocation) = &thumbnail.allocation + { float( - image(&thumbnail.handle) + image(allocation.handle()) .width(Fill) .content_fit(ContentFit::Cover) .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)), @@ -320,8 +361,21 @@ struct Blurhash { fade_in: Animation, } +impl Blurhash { + pub fn show(&mut self, now: Instant) { + self.fade_in.go_mut(true, now); + } + + pub fn reset(&mut self) { + self.fade_in = Animation::new(false) + .easing(animation::Easing::EaseIn) + .slow(); + } +} + struct Thumbnail { - handle: image::Handle, + bytes: Bytes, + allocation: Option, fade_in: Animation, zoom: Animation, } @@ -346,21 +400,21 @@ impl Preview { } } - fn ready(bytes: Bytes, now: Instant) -> Self { + fn ready(bytes: Bytes) -> Self { Self::Ready { blurhash: None, - thumbnail: Thumbnail::new(bytes, now), + thumbnail: Thumbnail::new(bytes), } } - fn load(self, bytes: Bytes, now: Instant) -> Self { + fn load(self, bytes: Bytes) -> Self { let Self::Loading { blurhash } = self else { return self; }; Self::Ready { blurhash: Some(blurhash), - thumbnail: Thumbnail::new(bytes, now), + thumbnail: Thumbnail::new(bytes), } } @@ -387,26 +441,45 @@ impl Preview { blurhash: Some(blurhash), thumbnail, .. - } if thumbnail.fade_in.is_animating(now) => Some(blurhash), + } if !thumbnail.fade_in.value() + || thumbnail.fade_in.is_animating(now) => + { + Some(blurhash) + } Self::Ready { .. } => None, } } } impl Thumbnail { - pub fn new(bytes: Bytes, now: Instant) -> Self { + pub fn new(bytes: Bytes) -> Self { Self { - handle: image::Handle::from_bytes(bytes), - fade_in: Animation::new(false).slow().go(true, now), + bytes, + allocation: None, + fade_in: Animation::new(false) + .easing(animation::Easing::EaseIn) + .slow(), zoom: Animation::new(false) .quick() .easing(animation::Easing::EaseInOut), } } + + pub fn reset(&mut self) { + self.allocation = None; + self.fade_in = Animation::new(false) + .easing(animation::Easing::EaseIn) + .slow(); + } + + pub fn show(&mut self, allocation: image::Allocation, now: Instant) { + self.allocation = Some(allocation); + self.fade_in.go_mut(true, now); + } } struct Viewer { - image: Option, + image: Option, background_fade_in: Animation, image_fade_in: Animation, } @@ -429,8 +502,8 @@ impl Viewer { self.background_fade_in.go_mut(true, now); } - fn show(&mut self, bytes: Bytes, now: Instant) { - self.image = Some(image::Handle::from_bytes(bytes)); + fn show(&mut self, allocation: image::Allocation, now: Instant) { + self.image = Some(allocation); self.background_fade_in.go_mut(true, now); self.image_fade_in.go_mut(true, now); } @@ -452,8 +525,8 @@ impl Viewer { return None; } - let image = self.image.as_ref().map(|handle| { - image(handle) + let image = self.image.as_ref().map(|allocation| { + image(allocation.handle()) .width(Fill) .height(Fill) .opacity(self.image_fade_in.interpolate(0.0, 1.0, now)) diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index d488e6ea..9f2d3c09 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -69,6 +69,14 @@ where fn end_transformation(&mut self) { delegate!(self, renderer, renderer.end_transformation()); } + + fn allocate_image( + &mut self, + handle: &image::Handle, + callback: impl FnOnce(image::Allocation) + Send + 'static, + ) { + delegate!(self, renderer, renderer.allocate_image(handle, callback)); + } } impl core::text::Renderer for Renderer diff --git a/runtime/src/image.rs b/runtime/src/image.rs new file mode 100644 index 00000000..d35f7006 --- /dev/null +++ b/runtime/src/image.rs @@ -0,0 +1,24 @@ +//! Allocate images explicitly to control presentation. +use crate::core::image::Handle; +use crate::futures::futures::channel::oneshot; +use crate::task::{self, Task}; + +pub use crate::core::image::Allocation; + +/// An image action. +#[derive(Debug)] +pub enum Action { + /// Allocates the given [`Handle`]. + Allocate(Handle, oneshot::Sender), +} + +/// Allocates an image [`Handle`]. +/// +/// When you obtain an [`Allocation`] explicitly, you get the guarantee +/// that using a [`Handle`] will draw the corresponding image immediately +/// in the next frame. +pub fn allocate(handle: Handle) -> Task { + task::oneshot(|sender| { + crate::Action::Image(Action::Allocate(handle, sender)) + }) +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 691d6d73..dd85139f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -11,6 +11,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] pub mod clipboard; pub mod font; +pub mod image; pub mod keyboard; pub mod system; pub mod task; @@ -55,6 +56,9 @@ pub enum Action { /// Run a system action. System(system::Action), + /// An image action. + Image(image::Action), + /// Recreate all user interfaces and redraw all windows. Reload, @@ -81,6 +85,7 @@ impl Action { Action::Clipboard(action) => Err(Action::Clipboard(action)), Action::Window(action) => Err(Action::Window(action)), Action::System(action) => Err(Action::System(action)), + Action::Image(action) => Err(Action::Image(action)), Action::Reload => Err(Action::Reload), Action::Exit => Err(Action::Exit), } @@ -105,6 +110,7 @@ where } Action::Window(_) => write!(f, "Action::Window"), Action::System(action) => write!(f, "Action::System({action:?})"), + Action::Image(_) => write!(f, "Action::Image"), Action::Reload => write!(f, "Action::Reload"), Action::Exit => write!(f, "Action::Exit"), } diff --git a/runtime/src/task.rs b/runtime/src/task.rs index 0ed78b54..97f64b61 100644 --- a/runtime/src/task.rs +++ b/runtime/src/task.rs @@ -379,14 +379,16 @@ impl Task> { /// The success value is provided to the closure to create the subsequent [`Task`]. pub fn and_then( self, - f: impl Fn(T) -> Task + MaybeSend + 'static, - ) -> Task + f: impl Fn(T) -> Task> + MaybeSend + 'static, + ) -> Task> where T: MaybeSend + 'static, E: MaybeSend + 'static, A: MaybeSend + 'static, { - self.then(move |option| option.map_or_else(|_| Task::none(), &f)) + self.then(move |result| { + result.map_or_else(|error| Task::done(Err(error)), &f) + }) } } diff --git a/src/lib.rs b/src/lib.rs index 129fc682..88d26ee1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -625,6 +625,13 @@ pub mod widget { pub use iced_runtime::widget::*; pub use iced_widget::*; + #[cfg(feature = "image")] + pub mod image { + //! Images display raster graphics in different formats (PNG, JPG, etc.). + pub use iced_runtime::image::{Allocation, allocate}; + pub use iced_widget::image::*; + } + // We hide the re-exported modules by `iced_widget` mod core {} mod graphics {} diff --git a/test/src/emulator.rs b/test/src/emulator.rs index 0bd653d7..cc013a45 100644 --- a/test/src/emulator.rs +++ b/test/src/emulator.rs @@ -257,6 +257,10 @@ impl Emulator

{ // TODO dbg!(action); } + iced_runtime::Action::Image(action) => { + // TODO + dbg!(action); + } runtime::Action::Exit => { // TODO } diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index 085468dc..fe24078b 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -228,6 +228,16 @@ impl core::Renderer for Renderer { fn reset(&mut self, new_bounds: Rectangle) { self.layers.reset(new_bounds); } + + fn allocate_image( + &mut self, + handle: &core::image::Handle, + callback: impl FnOnce(core::image::Allocation) + Send + 'static, + ) { + // TODO: Concurrency + #[allow(unsafe_code)] + callback(unsafe { core::image::allocate(handle) }); + } } impl core::text::Renderer for Renderer { diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index cc0e7d69..f3253e63 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -3,12 +3,11 @@ use crate::graphics::Shell; use crate::image::atlas::{self, Atlas}; #[cfg(feature = "image")] -use std::collections::BTreeSet; +use std::collections::HashMap; #[cfg(feature = "image")] use std::sync::mpsc; -#[derive(Debug)] pub struct Cache { atlas: Atlas, #[cfg(feature = "image")] @@ -46,7 +45,7 @@ impl Cache { #[cfg(feature = "image")] raster: Raster { cache: crate::image::raster::Cache::default(), - pending: BTreeSet::new(), + pending: HashMap::new(), jobs: jobs.clone(), }, #[cfg(feature = "svg")] @@ -60,6 +59,44 @@ impl Cache { } } + #[cfg(feature = "image")] + pub fn allocate_image( + &mut self, + handle: &core::image::Handle, + callback: impl FnOnce(core::image::Allocation) + Send + 'static, + ) { + use crate::image::raster::Memory; + + let callback = Box::new(callback); + + if let Some(callbacks) = self.raster.pending.get_mut(&handle.id()) { + callbacks.push(callback); + return; + } + + if let Some(Memory::Device { allocation, .. }) = + self.raster.cache.get_mut(handle) + { + if let Some(allocation) = allocation + .as_ref() + .and_then(core::image::Allocation::upgrade) + { + callback(allocation); + return; + } + + #[allow(unsafe_code)] + let new = unsafe { core::image::allocate(handle) }; + *allocation = Some(new.downgrade()); + callback(new); + + return; + } + + let _ = self.raster.pending.insert(handle.id(), vec![callback]); + let _ = self.raster.jobs.send(Job::Load(handle.clone())); + } + #[cfg(feature = "image")] pub fn measure_image(&mut self, handle: &core::image::Handle) -> Size { self.receive(); @@ -69,6 +106,7 @@ impl Cache { &mut self.raster.pending, &mut self.raster.jobs, handle, + None, ) { return memory.dimensions(); } @@ -99,9 +137,13 @@ impl Cache { &mut self.raster.pending, &mut self.raster.jobs, handle, + None, )?; - if let Memory::Device { entry, bind_group } = memory { + if let Memory::Device { + entry, bind_group, .. + } = memory + { return Some(( entry, bind_group.as_ref().unwrap_or(self.atlas.bind_group()), @@ -126,6 +168,7 @@ impl Cache { *memory = Memory::Device { entry, bind_group: None, + allocation: None, }; if let Memory::Device { entry, .. } = memory { @@ -133,7 +176,7 @@ impl Cache { } } - if !self.raster.pending.contains(&handle.id()) { + if !self.raster.pending.contains_key(&handle.id()) { let _ = self.jobs.send(Job::Upload { handle: handle.clone(), rgba: image.clone().into_raw(), @@ -141,7 +184,7 @@ impl Cache { height: image.height(), }); - let _ = self.raster.pending.insert(handle.id()); + let _ = self.raster.pending.insert(handle.id(), Vec::new()); } None @@ -194,15 +237,32 @@ impl Cache { entry, bind_group, } => { + let callbacks = self.raster.pending.remove(&handle.id()); + + let allocation = if let Some(callbacks) = callbacks { + #[allow(unsafe_code)] + let allocation = + unsafe { core::image::allocate(&handle) }; + + let reference = allocation.downgrade(); + + for callback in callbacks { + callback(allocation.clone()); + } + + Some(reference) + } else { + None + }; + self.raster.cache.insert( &handle, Memory::Device { entry, bind_group: Some(bind_group), + allocation, }, ); - - let _ = self.raster.pending.remove(&handle.id()); } Work::Error { handle, error } => { self.raster.cache.insert(&handle, Memory::error(error)); @@ -225,19 +285,22 @@ impl Drop for Cache { } #[cfg(feature = "image")] -#[derive(Debug)] struct Raster { cache: crate::image::raster::Cache, - pending: BTreeSet, + pending: HashMap>, jobs: mpsc::SyncSender, } +#[cfg(feature = "image")] +type Callback = Box; + #[cfg(feature = "image")] fn load_image<'a>( cache: &'a mut crate::image::raster::Cache, - pending: &mut BTreeSet, + pending: &mut HashMap>, jobs: &mut mpsc::SyncSender, handle: &core::image::Handle, + callback: Option, ) -> Option<&'a mut crate::image::raster::Memory> { use crate::image::raster::Memory; @@ -248,9 +311,9 @@ fn load_image<'a>( } else if let core::image::Handle::Rgba { .. } = handle { // Load RGBA handles synchronously, since it's very cheap cache.insert(handle, Memory::load(handle)); - } else if !pending.contains(&handle.id()) { + } else if !pending.contains_key(&handle.id()) { let _ = jobs.send(Job::Load(handle.clone())); - let _ = pending.insert(handle.id()); + let _ = pending.insert(handle.id(), Vec::from_iter(callback)); } } diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 57662433..5d54196e 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -5,6 +5,7 @@ use crate::graphics::image::image_rs; use crate::image::atlas::{self, Atlas}; use rustc_hash::{FxHashMap, FxHashSet}; +use std::sync::Weak; type Image = image_rs::ImageBuffer, image::Bytes>; @@ -17,6 +18,7 @@ pub enum Memory { Device { entry: atlas::Entry, bind_group: Option, + allocation: Option>, }, /// Image not found NotFound, @@ -100,7 +102,16 @@ impl Cache { self.map.retain(|k, memory| { let retain = hits.contains(k); - if !retain && let Memory::Device { entry, bind_group } = memory { + if !retain + && let Memory::Device { + entry, + bind_group, + allocation: memory, + } = memory + && memory + .as_ref() + .is_none_or(|memory| memory.strong_count() == 0) + { if let Some(bind_group) = bind_group.take() { on_drop(bind_group); } else { diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index 93733fc5..6991564f 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -696,6 +696,17 @@ impl core::Renderer for Renderer { fn reset(&mut self, new_bounds: Rectangle) { self.layers.reset(new_bounds); } + + fn allocate_image( + &mut self, + _handle: &core::image::Handle, + _callback: impl FnOnce(core::image::Allocation) + Send + 'static, + ) { + #[cfg(feature = "image")] + self.image_cache + .get_mut() + .allocate_image(_handle, _callback); + } } impl core::text::Renderer for Renderer { diff --git a/winit/src/lib.rs b/winit/src/lib.rs index cd163f16..764fc1c3 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -50,6 +50,7 @@ use crate::futures::futures::{Future, StreamExt}; use crate::futures::subscription; use crate::futures::{Executor, Runtime}; use crate::graphics::{Compositor, Shell, compositor}; +use crate::runtime::image; use crate::runtime::system; use crate::runtime::user_interface::{self, UserInterface}; use crate::runtime::{Action, Task}; @@ -1730,6 +1731,21 @@ fn run_action<'a, P, C>( } } } + Action::Image(action) => match action { + image::Action::Allocate(handle, sender) => { + use core::Renderer as _; + + // TODO: Shared image cache in compositor + if let Some((_id, window)) = window_manager.iter_mut().next() { + window.renderer.allocate_image( + &handle, + move |allocation| { + let _ = sender.send(allocation); + }, + ); + } + } + }, Action::LoadFont { bytes, channel } => { if let Some(compositor) = compositor { // TODO: Error handling (?) From c896cd8d31cbb8594595468f9509fde5b7a00ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 25 Oct 2025 00:56:01 +0200 Subject: [PATCH 07/28] Decode images in parallel in `gallery` example --- examples/gallery/src/civitai.rs | 6 +++ examples/gallery/src/main.rs | 66 +++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 09fd0f91..322716f9 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -130,6 +130,12 @@ impl fmt::Debug for Rgba { #[derive(Clone)] pub struct Bytes(bytes::Bytes); +impl Bytes { + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + impl From for bytes::Bytes { fn from(value: Bytes) -> Self { value.0 diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 87a8e811..ccd03589 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -111,16 +111,21 @@ impl Gallery { let _ = self.visible.insert(id); if self.downloaded.contains(&id) { - let Some(Preview::Ready { thumbnail, .. }) = - self.previews.get_mut(&id) + let Some(Preview::Ready { + thumbnail, + blurhash, + }) = self.previews.get_mut(&id) else { return Task::none(); }; - return image::allocate(image::Handle::from_bytes( - thumbnail.bytes.clone(), - )) - .map(Message::ThumbnailAllocated.with(id)); + if let Some(blurhash) = blurhash { + blurhash.show(now); + } + + return to_rgba(thumbnail.bytes.clone()) + .then(image::allocate) + .map(Message::ThumbnailAllocated.with(id)); } let _ = self.downloaded.insert(id); @@ -165,7 +170,8 @@ impl Gallery { let _ = self.previews.insert(id, preview); - image::allocate(image::Handle::from_bytes(bytes)) + to_rgba(bytes) + .then(image::allocate) .map(Message::ThumbnailAllocated.with(id)) } Message::ThumbnailAllocated(id, allocation) => { @@ -173,19 +179,12 @@ impl Gallery { return Task::none(); } - let Some(Preview::Ready { - thumbnail, - blurhash, - .. - }) = self.previews.get_mut(&id) + let Some(Preview::Ready { thumbnail, .. }) = + self.previews.get_mut(&id) else { return Task::none(); }; - if let Some(blurhash) = blurhash { - blurhash.show(now); - } - thumbnail.show(allocation, now); Task::none() @@ -369,7 +368,7 @@ impl Blurhash { pub fn reset(&mut self) { self.fade_in = Animation::new(false) .easing(animation::Easing::EaseIn) - .slow(); + .very_quick(); } } @@ -427,9 +426,15 @@ impl Preview { fn is_animating(&self, now: Instant) -> bool { match &self { Self::Loading { blurhash } => blurhash.fade_in.is_animating(now), - Self::Ready { thumbnail, .. } => { + Self::Ready { + thumbnail, + blurhash, + } => { thumbnail.fade_in.is_animating(now) || thumbnail.zoom.is_animating(now) + || blurhash.as_ref().is_some_and(|blurhash| { + blurhash.fade_in.is_animating(now) + }) } } } @@ -469,7 +474,7 @@ impl Thumbnail { self.allocation = None; self.fade_in = Animation::new(false) .easing(animation::Easing::EaseIn) - .slow(); + .quick(); } pub fn show(&mut self, allocation: image::Allocation, now: Instant) { @@ -547,3 +552,26 @@ impl Viewer { )) } } + +fn to_rgba(bytes: Bytes) -> Task { + use tokio::task; + + Task::future(async move { + task::spawn_blocking(move || { + match ::image::load_from_memory(bytes.as_slice()) { + Ok(image) => { + let rgba = image.to_rgba8(); + + image::Handle::from_rgba( + rgba.width(), + rgba.height(), + rgba.into_raw(), + ) + } + _ => image::Handle::from_bytes(bytes), + } + }) + .await + .unwrap() + }) +} From 44e68aa4b64647c349191ac92ba4ff29678dc8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 25 Oct 2025 22:53:44 +0200 Subject: [PATCH 08/28] Add border radius support for `image` --- core/src/border.rs | 6 +- core/src/image.rs | 13 ++ examples/gallery/src/civitai.rs | 4 +- examples/gallery/src/main.rs | 23 ++-- wgpu/src/buffer.rs | 2 +- wgpu/src/image/atlas.rs | 12 +- wgpu/src/image/cache.rs | 13 +- wgpu/src/image/mod.rs | 223 ++++++++++++++++++-------------- wgpu/src/image/raster.rs | 6 +- wgpu/src/image/vector.rs | 6 +- wgpu/src/layer.rs | 8 +- wgpu/src/lib.rs | 4 +- wgpu/src/shader/image.wgsl | 58 ++++++--- widget/src/image.rs | 63 +++------ widget/src/image/viewer.rs | 3 + 15 files changed, 256 insertions(+), 188 deletions(-) diff --git a/core/src/border.rs b/core/src/border.rs index cf73284c..232211db 100644 --- a/core/src/border.rs +++ b/core/src/border.rs @@ -241,9 +241,9 @@ impl From for Radius { } } -impl From for Radius { - fn from(w: u16) -> Self { - Self::from(f32::from(w)) +impl From for Radius { + fn from(w: u32) -> Self { + Self::from(w as f32) } } diff --git a/core/src/image.rs b/core/src/image.rs index 4b72004e..b92fdb0d 100644 --- a/core/src/image.rs +++ b/core/src/image.rs @@ -1,6 +1,7 @@ //! Load and draw raster graphics. pub use bytes::Bytes; +use crate::border; use crate::{Radians, Rectangle, Size}; use rustc_hash::FxHasher; @@ -15,6 +16,16 @@ pub struct Image { /// The handle of the image. pub handle: H, + /// The clip bounds of the [`Image`]. + /// + /// Anything outside this [`Rectangle`] will not be drawn. + pub clip_bounds: Rectangle, + + /// The border radius of the [`Image`]. + /// + /// Currently, this will only be applied to the `clip_bounds`. + pub border_radius: border::Radius, + /// The filter method of the image. pub filter_method: FilterMethod, @@ -38,6 +49,8 @@ impl Image { pub fn new(handle: impl Into) -> Self { Self { handle: handle.into(), + clip_bounds: Rectangle::INFINITE, + border_radius: border::Radius::default(), filter_method: FilterMethod::default(), rotation: Radians(0.0), opacity: 1.0, diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 322716f9..4cca8b47 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -81,7 +81,9 @@ impl Image { .url .split("/") .map(|part| { - if part.starts_with("width=") { + if part.starts_with("width=") + || part.starts_with("original=") + { format!("width={}", width * 2) // High DPI } else { part.to_owned() diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index ccd03589..10f1704f 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,6 +7,7 @@ mod civitai; use crate::civitai::{Bytes, Error, Id, Image, Rgba, Size}; use iced::animation; +use iced::border; use iced::time::{Instant, milliseconds}; use iced::widget::{ button, container, float, grid, image, mouse_area, opaque, scrollable, @@ -283,7 +284,8 @@ fn card<'a>( image(allocation.handle()) .width(Fill) .content_fit(ContentFit::Cover) - .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)), + .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)) + .border_radius(BORDER_RADIUS), ) .scale(thumbnail.zoom.interpolate(1.0, 1.1, now)) .translate(move |bounds, viewport| { @@ -298,7 +300,7 @@ fn card<'a>( blur_radius: thumbnail.zoom.interpolate(0.0, 20.0, now), ..Shadow::default() }, - ..float::Style::default() + shadow_border_radius: border::radius(BORDER_RADIUS), }) .into() } else { @@ -309,7 +311,8 @@ fn card<'a>( let blurhash = image(&blurhash.handle) .width(Fill) .content_fit(ContentFit::Cover) - .opacity(blurhash.fade_in.interpolate(0.0, 1.0, now)); + .opacity(blurhash.fade_in.interpolate(0.0, 1.0, now)) + .border_radius(BORDER_RADIUS); stack![blurhash, thumbnail].into() } else { @@ -319,7 +322,7 @@ fn card<'a>( space::horizontal().into() }; - let card = mouse_area(container(image).style(container::dark)) + let card = mouse_area(container(image).style(rounded)) .on_enter(Message::ThumbnailHovered(metadata.id, true)) .on_exit(Message::ThumbnailHovered(metadata.id, false)); @@ -342,7 +345,7 @@ fn card<'a>( } fn placeholder<'a>() -> Element<'a, Message> { - container(space()).style(container::dark).into() + container(space()).style(rounded).into() } enum Preview { @@ -554,10 +557,8 @@ impl Viewer { } fn to_rgba(bytes: Bytes) -> Task { - use tokio::task; - Task::future(async move { - task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { match ::image::load_from_memory(bytes.as_slice()) { Ok(image) => { let rgba = image.to_rgba8(); @@ -575,3 +576,9 @@ fn to_rgba(bytes: Bytes) -> Task { .unwrap() }) } + +fn rounded(theme: &Theme) -> container::Style { + container::dark(theme).border(border::rounded(BORDER_RADIUS)) +} + +const BORDER_RADIUS: u32 = 20; diff --git a/wgpu/src/buffer.rs b/wgpu/src/buffer.rs index dba35e48..172922da 100644 --- a/wgpu/src/buffer.rs +++ b/wgpu/src/buffer.rs @@ -42,7 +42,7 @@ impl Buffer { } pub fn resize(&mut self, device: &wgpu::Device, new_count: usize) -> bool { - let new_size = (std::mem::size_of::() * new_count) as u64; + let new_size = next_copy_size::(new_count); if self.size < new_size { self.raw = device.create_buffer(&wgpu::BufferDescriptor { diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index 6cc28ce0..5f7265ae 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -16,13 +16,15 @@ pub const MAX_SIZE: u32 = 2048; use crate::core::Size; use crate::graphics::color; +use std::sync::Arc; + #[derive(Debug)] pub struct Atlas { size: u32, backend: wgpu::Backend, texture: wgpu::Texture, texture_view: wgpu::TextureView, - texture_bind_group: wgpu::BindGroup, + texture_bind_group: Arc, texture_layout: wgpu::BindGroupLayout, layers: Vec, } @@ -95,13 +97,13 @@ impl Atlas { backend, texture, texture_view, - texture_bind_group, + texture_bind_group: Arc::new(texture_bind_group), texture_layout, layers, } } - pub fn bind_group(&self) -> &wgpu::BindGroup { + pub fn bind_group(&self) -> &Arc { &self.texture_bind_group } @@ -466,7 +468,7 @@ impl Atlas { }); self.texture_bind_group = - device.create_bind_group(&wgpu::BindGroupDescriptor { + Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("iced_wgpu::image texture atlas bind group"), layout: &self.texture_layout, entries: &[wgpu::BindGroupEntry { @@ -475,6 +477,6 @@ impl Atlas { &self.texture_view, ), }], - }); + })); } } diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index f3253e63..46caa227 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; #[cfg(feature = "image")] use std::sync::mpsc; +use std::sync::Arc; + pub struct Cache { atlas: Atlas, #[cfg(feature = "image")] @@ -127,7 +129,7 @@ impl Cache { encoder: &mut wgpu::CommandEncoder, belt: &mut wgpu::util::StagingBelt, handle: &core::image::Handle, - ) -> Option<(&atlas::Entry, &wgpu::BindGroup)> { + ) -> Option<(&atlas::Entry, &Arc)> { use crate::image::raster::Memory; self.receive(); @@ -198,9 +200,9 @@ impl Cache { belt: &mut wgpu::util::StagingBelt, handle: &core::svg::Handle, color: Option, - size: [f32; 2], + size: Size, scale: f32, - ) -> Option<(&atlas::Entry, &wgpu::BindGroup)> { + ) -> Option<(&atlas::Entry, &Arc)> { // TODO: Concurrency self.vector .upload( @@ -321,6 +323,7 @@ fn load_image<'a>( } #[cfg(feature = "image")] +#[derive(Debug)] enum Job { Load(core::image::Handle), Upload { @@ -329,7 +332,7 @@ enum Job { width: u32, height: u32, }, - Drop(wgpu::BindGroup), + Drop(Arc), } #[cfg(feature = "image")] @@ -337,7 +340,7 @@ enum Work { Upload { handle: core::image::Handle, entry: atlas::Entry, - bind_group: wgpu::BindGroup, + bind_group: Arc, }, Error { handle: core::image::Handle, diff --git a/wgpu/src/image/mod.rs b/wgpu/src/image/mod.rs index d530d9b4..d1e39c92 100644 --- a/wgpu/src/image/mod.rs +++ b/wgpu/src/image/mod.rs @@ -10,12 +10,14 @@ mod raster; mod vector; use crate::Buffer; +use crate::core::border; use crate::core::{Rectangle, Size, Transformation}; use crate::graphics::Shell; use bytemuck::{Pod, Zeroable}; use std::mem; +use std::sync::Arc; pub use crate::graphics::Image; @@ -131,24 +133,26 @@ impl Pipeline { array_stride: mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &wgpu::vertex_attr_array!( - // Position - 0 => Float32x2, // Center - 1 => Float32x2, - // Scale - 2 => Float32x2, + 0 => Float32x2, + // Clip bounds + 1 => Float32x4, + // Border radius + 2 => Float32x4, + // Tile + 3 => Float32x4, // Rotation - 3 => Float32, - // Opacity 4 => Float32, + // Opacity + 5 => Float32, // Atlas position - 5 => Float32x2, - // Atlas scale 6 => Float32x2, + // Atlas scale + 7 => Float32x2, // Layer - 7 => Sint32, + 8 => Sint32, // Snap - 8 => Uint32, + 9 => Uint32, ), }], compilation_options: @@ -221,6 +225,8 @@ impl Pipeline { pub struct State { layers: Vec, prepare_layer: usize, + nearest_instances: Vec, + linear_instances: Vec, } impl State { @@ -249,11 +255,8 @@ impl State { } let layer = &mut self.layers[self.prepare_layer]; - layer.prepare(device, encoder, belt, transformation, scale); - let mut atlas = None; - let nearest_instances = &mut Vec::new(); - let linear_instances = &mut Vec::new(); + let mut atlas: Option> = None; for image in images { match &image { @@ -268,34 +271,30 @@ impl State { } Some(atlas) if atlas != bind_group => { layer.push( - device, - encoder, - belt, atlas, - nearest_instances, - linear_instances, + &self.nearest_instances, + &self.linear_instances, ); - *atlas = bind_group.clone(); - nearest_instances.clear(); - linear_instances.clear(); + *atlas = Arc::clone(bind_group); } _ => {} } add_instances( - [bounds.x, bounds.y], - [bounds.width, bounds.height], + *bounds, + image.clip_bounds, + image.border_radius, f32::from(image.rotation), image.opacity, image.snap, atlas_entry, match image.filter_method { crate::core::image::FilterMethod::Nearest => { - nearest_instances + &mut self.nearest_instances } crate::core::image::FilterMethod::Linear => { - linear_instances + &mut self.linear_instances } }, ); @@ -306,8 +305,6 @@ impl State { #[cfg(feature = "svg")] Image::Vector(svg, bounds) => { - let size = [bounds.width, bounds.height]; - if let Some((atlas_entry, bind_group)) = cache .upload_vector( device, @@ -315,7 +312,7 @@ impl State { belt, &svg.handle, svg.color, - size, + bounds.size(), scale, ) { @@ -325,29 +322,25 @@ impl State { } Some(atlas) if atlas != bind_group => { layer.push( - device, - encoder, - belt, atlas, - nearest_instances, - linear_instances, + &self.nearest_instances, + &self.linear_instances, ); *atlas = bind_group.clone(); - nearest_instances.clear(); - linear_instances.clear(); } _ => {} } add_instances( - [bounds.x, bounds.y], - size, + *bounds, + Rectangle::INFINITE, + border::radius(0), f32::from(svg.rotation), svg.opacity, true, atlas_entry, - nearest_instances, + &mut self.nearest_instances, ); } } @@ -356,18 +349,23 @@ impl State { } } - if !nearest_instances.is_empty() || !linear_instances.is_empty() { - layer.push( - device, - encoder, - belt, - &atlas.expect("atlas should be defined"), - nearest_instances, - linear_instances, - ); + if let Some(atlas) = &atlas { + layer.push(atlas, &self.nearest_instances, &self.linear_instances); } + layer.prepare( + device, + encoder, + belt, + transformation, + scale, + &self.nearest_instances, + &self.linear_instances, + ); + self.prepare_layer += 1; + self.nearest_instances.clear(); + self.linear_instances.clear(); } pub fn render<'a>( @@ -404,16 +402,17 @@ impl State { struct Layer { uniforms: wgpu::Buffer, instances: Buffer, - total: usize, nearest: Vec, nearest_layout: wgpu::BindGroup, + nearest_total: usize, linear: Vec, linear_layout: wgpu::BindGroup, + linear_total: usize, } #[derive(Debug)] struct Group { - atlas: wgpu::BindGroup, + atlas: Arc, instance_count: usize, } @@ -489,11 +488,12 @@ impl Layer { Self { uniforms, instances, - total: 0, nearest: Vec::new(), nearest_layout, + nearest_total: 0, linear: Vec::new(), linear_layout, + linear_total: 0, } } @@ -504,6 +504,8 @@ impl Layer { belt: &mut wgpu::util::StagingBelt, transformation: Transformation, scale_factor: f32, + nearest: &[Instance], + linear: &[Instance], ) { let uniforms = Uniforms { transform: transformation.into(), @@ -521,41 +523,48 @@ impl Layer { device, ) .copy_from_slice(bytes); + + let _ = self + .instances + .resize(device, self.nearest_total + self.linear_total); + + let mut offset = 0; + + if !nearest.is_empty() { + offset += self.instances.write(device, encoder, belt, 0, nearest); + } + + if !linear.is_empty() { + let _ = self.instances.write(device, encoder, belt, offset, linear); + } } fn push( &mut self, - device: &wgpu::Device, - encoder: &mut wgpu::CommandEncoder, - belt: &mut wgpu::util::StagingBelt, - atlas: &wgpu::BindGroup, + atlas: &Arc, nearest: &[Instance], linear: &[Instance], ) { - let new = nearest.len() + linear.len(); - - let _ = self.instances.resize(device, self.total + new); - - if !nearest.is_empty() { - self.total += self - .instances - .write(device, encoder, belt, self.total, nearest); + let new_nearest = nearest.len() - self.nearest_total; + if new_nearest > 0 { self.nearest.push(Group { atlas: atlas.clone(), - instance_count: nearest.len(), + instance_count: new_nearest, }); + + self.nearest_total = nearest.len(); } - if !linear.is_empty() { - self.total += self - .instances - .write(device, encoder, belt, self.total, linear); + let new_linear = linear.len() - self.linear_total; + if new_linear > 0 { self.linear.push(Group { atlas: atlas.clone(), - instance_count: linear.len(), + instance_count: new_linear, }); + + self.linear_total = linear.len(); } } @@ -568,7 +577,7 @@ impl Layer { render_pass.set_bind_group(0, &self.nearest_layout, &[]); for group in &self.nearest { - render_pass.set_bind_group(1, &group.atlas, &[]); + render_pass.set_bind_group(1, group.atlas.as_ref(), &[]); render_pass .draw(0..6, offset..offset + group.instance_count as u32); @@ -580,7 +589,7 @@ impl Layer { render_pass.set_bind_group(0, &self.linear_layout, &[]); for group in &self.linear { - render_pass.set_bind_group(1, &group.atlas, &[]); + render_pass.set_bind_group(1, group.atlas.as_ref(), &[]); render_pass .draw(0..6, offset..offset + group.instance_count as u32); @@ -590,19 +599,21 @@ impl Layer { } fn clear(&mut self) { - self.instances.clear(); self.nearest.clear(); + self.nearest_total = 0; + self.linear.clear(); - self.total = 0; + self.linear_total = 0; } } #[repr(C)] #[derive(Debug, Clone, Copy, Zeroable, Pod)] struct Instance { - _position: [f32; 2], _center: [f32; 2], - _size: [f32; 2], + _clip_bounds: [f32; 4], + _border_radius: [f32; 4], + _tile: [f32; 4], _rotation: f32, _opacity: f32, _position_in_atlas: [f32; 2], @@ -626,8 +637,9 @@ struct Uniforms { } fn add_instances( - image_position: [f32; 2], - image_size: [f32; 2], + bounds: Rectangle, + clip_bounds: Rectangle, + border_radius: border::Radius, rotation: f32, opacity: f32, snap: bool, @@ -635,16 +647,26 @@ fn add_instances( instances: &mut Vec, ) { let center = [ - image_position[0] + image_size[0] / 2.0, - image_position[1] + image_size[1] / 2.0, + bounds.x + bounds.width / 2.0, + bounds.y + bounds.height / 2.0, ]; + let clip_bounds = [ + clip_bounds.x, + clip_bounds.y, + clip_bounds.width, + clip_bounds.height, + ]; + + let border_radius = border_radius.into(); + match entry { atlas::Entry::Contiguous(allocation) => { add_instance( - image_position, center, - image_size, + clip_bounds, + border_radius, + [bounds.x, bounds.y, bounds.width, bounds.height], rotation, opacity, snap, @@ -653,32 +675,35 @@ fn add_instances( ); } atlas::Entry::Fragmented { fragments, size } => { - let scaling_x = image_size[0] / size.width as f32; - let scaling_y = image_size[1] / size.height as f32; + let scaling_x = bounds.width / size.width as f32; + let scaling_y = bounds.height / size.height as f32; for fragment in fragments { let allocation = &fragment.allocation; - - let [x, y] = image_position; let (fragment_x, fragment_y) = fragment.position; + let Size { width: fragment_width, height: fragment_height, } = allocation.size(); - let position = [ - x + fragment_x as f32 * scaling_x, - y + fragment_y as f32 * scaling_y, - ]; - - let size = [ + let tile = [ + bounds.x + fragment_x as f32 * scaling_x, + bounds.y + fragment_y as f32 * scaling_y, fragment_width as f32 * scaling_x, fragment_height as f32 * scaling_y, ]; add_instance( - position, center, size, rotation, opacity, snap, - allocation, instances, + center, + clip_bounds, + border_radius, + tile, + rotation, + opacity, + snap, + allocation, + instances, ); } } @@ -687,9 +712,10 @@ fn add_instances( #[inline] fn add_instance( - position: [f32; 2], center: [f32; 2], - size: [f32; 2], + clip_bounds: [f32; 4], + border_radius: [f32; 4], + tile: [f32; 4], rotation: f32, opacity: f32, snap: bool, @@ -702,9 +728,10 @@ fn add_instance( let atlas_size = allocation.atlas_size(); let instance = Instance { - _position: position, _center: center, - _size: size, + _clip_bounds: clip_bounds, + _border_radius: border_radius, + _tile: tile, _rotation: rotation, _opacity: opacity, _position_in_atlas: [ diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 5d54196e..0cccad06 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -5,7 +5,7 @@ use crate::graphics::image::image_rs; use crate::image::atlas::{self, Atlas}; use rustc_hash::{FxHashMap, FxHashSet}; -use std::sync::Weak; +use std::sync::{Arc, Weak}; type Image = image_rs::ImageBuffer, image::Bytes>; @@ -17,7 +17,7 @@ pub enum Memory { /// Storage entry Device { entry: atlas::Entry, - bind_group: Option, + bind_group: Option>, allocation: Option>, }, /// Image not found @@ -90,7 +90,7 @@ impl Cache { pub fn trim( &mut self, atlas: &mut Atlas, - on_drop: impl Fn(wgpu::BindGroup), + on_drop: impl Fn(Arc), ) { // Only trim if new entries have landed in the `Cache` if !self.should_trim { diff --git a/wgpu/src/image/vector.rs b/wgpu/src/image/vector.rs index 2fa6ba39..31fb2fa6 100644 --- a/wgpu/src/image/vector.rs +++ b/wgpu/src/image/vector.rs @@ -97,15 +97,15 @@ impl Cache { belt: &mut wgpu::util::StagingBelt, handle: &svg::Handle, color: Option, - [width, height]: [f32; 2], + size: Size, scale: f32, atlas: &mut Atlas, ) -> Option<&atlas::Entry> { let id = handle.id(); let (width, height) = ( - (scale * width).ceil() as u32, - (scale * height).ceil() as u32, + (scale * size.width).ceil() as u32, + (scale * size.height).ceil() as u32, ); let color = color.map(Color::into_rgba8); diff --git a/wgpu/src/layer.rs b/wgpu/src/layer.rs index 5ddb8461..7a0e57b8 100644 --- a/wgpu/src/layer.rs +++ b/wgpu/src/layer.rs @@ -143,7 +143,13 @@ impl Layer { bounds: Rectangle, transformation: Transformation, ) { - let image = Image::Raster(image, bounds * transformation); + let image = Image::Raster( + core::Image { + clip_bounds: image.clip_bounds * transformation, + ..image + }, + bounds * transformation, + ); self.images.push(image); } diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index 6991564f..f78998bb 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -311,8 +311,10 @@ impl Renderer { self.layers.merge(); for layer in self.layers.iter() { + let clip_bounds = layer.bounds * scale_factor; + if physical_bounds - .intersection(&(layer.bounds * scale_factor)) + .intersection(&clip_bounds) .and_then(Rectangle::snap) .is_none() { diff --git a/wgpu/src/shader/image.wgsl b/wgpu/src/shader/image.wgsl index bc922838..b2754d61 100644 --- a/wgpu/src/shader/image.wgsl +++ b/wgpu/src/shader/image.wgsl @@ -9,22 +9,25 @@ struct Globals { struct VertexInput { @builtin(vertex_index) vertex_index: u32, - @location(0) pos: vec2, - @location(1) center: vec2, - @location(2) scale: vec2, - @location(3) rotation: f32, - @location(4) opacity: f32, - @location(5) atlas_pos: vec2, - @location(6) atlas_scale: vec2, - @location(7) layer: i32, - @location(8) snap: u32, + @location(0) center: vec2, + @location(1) clip_bounds: vec4, + @location(2) border_radius: vec4, + @location(3) tile: vec4, + @location(4) rotation: f32, + @location(5) opacity: f32, + @location(6) atlas_pos: vec2, + @location(7) atlas_scale: vec2, + @location(8) layer: i32, + @location(9) snap: u32, } struct VertexOutput { @builtin(position) position: vec4, - @location(0) uv: vec2, - @location(1) layer: f32, // this should be an i32, but naga currently reads that as requiring interpolation. - @location(2) opacity: f32, + @location(0) clip_bounds: vec4, + @location(1) border_radius: vec4, + @location(2) uv: vec2, + @location(3) layer: f32, // this should be an i32, but naga currently reads that as requiring interpolation. + @location(4) opacity: f32, } @vertex @@ -39,8 +42,11 @@ fn vs_main(input: VertexInput) -> VertexOutput { out.layer = f32(input.layer); out.opacity = input.opacity; + let tile = input.tile; + let center = input.center; + // Calculate the vertex position and move the center to the origin - v_pos = input.pos + v_pos * input.scale - input.center; + v_pos = tile.xy + v_pos * tile.zw - center; // Apply the rotation around the center of the image let cos_rot = cos(input.rotation); @@ -53,19 +59,39 @@ fn vs_main(input: VertexInput) -> VertexOutput { ); // Calculate the final position of the vertex - out.position = vec4(vec2(globals.scale_factor), 1.0, 1.0) * (vec4(input.center, 0.0, 0.0) + rotate * vec4(v_pos, 0.0, 1.0)); + out.position = vec4(vec2(globals.scale_factor), 1.0, 1.0) * (vec4(center, 0.0, 0.0) + rotate * vec4(v_pos, 0.0, 1.0)); if bool(input.snap) { out.position = round(out.position); } out.position = globals.transform * out.position; + out.clip_bounds = globals.scale_factor * input.clip_bounds; + out.border_radius = globals.scale_factor * input.border_radius; return out; } @fragment fn fs_main(input: VertexOutput) -> @location(0) vec4 { - // Sample the texture at the given UV coordinate and layer. - return textureSample(u_texture, u_sampler, input.uv, i32(input.layer)) * vec4(1.0, 1.0, 1.0, input.opacity); + let fragment = input.position.xy; + let position = input.clip_bounds.xy; + let scale = input.clip_bounds.zw; + + let d = rounded_box_sdf( + 2.0 * (fragment - position - scale / 2.0), + scale, + input.border_radius * 2.0, + ); + + let antialias: f32 = clamp(0.5 - d, 0.0, 1.0); + + return textureSample(u_texture, u_sampler, input.uv, i32(input.layer)) * vec4(1.0, 1.0, 1.0, antialias * input.opacity); +} + +fn rounded_box_sdf(p: vec2, size: vec2, corners: vec4) -> f32 { + var box_half = select(corners.yz, corners.xw, p.x > 0.0); + var corner = select(box_half.y, box_half.x, p.y > 0.0); + var q = abs(p) - size + corner; + return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - corner; } diff --git a/widget/src/image.rs b/widget/src/image.rs index 18b7a3a3..709915aa 100644 --- a/widget/src/image.rs +++ b/widget/src/image.rs @@ -19,6 +19,7 @@ pub mod viewer; pub use viewer::Viewer; +use crate::core::border; use crate::core::image; use crate::core::layout; use crate::core::mouse; @@ -59,6 +60,7 @@ pub struct Image { width: Length, height: Length, crop: Option>, + border_radius: border::Radius, content_fit: ContentFit, filter_method: FilterMethod, rotation: Rotation, @@ -75,6 +77,7 @@ impl Image { width: Length::Shrink, height: Length::Shrink, crop: None, + border_radius: border::Radius::default(), content_fit: ContentFit::default(), filter_method: FilterMethod::default(), rotation: Rotation::default(), @@ -164,6 +167,18 @@ impl Image { self.crop = Some(region); self } + + /// Sets the [`border::Radius`] of the [`Image`]. + /// + /// Currently, it will only be applied around the rectangular bounding box + /// of the [`Image`]. + pub fn border_radius( + mut self, + border_radius: impl Into, + ) -> Self { + self.border_radius = border_radius.into(); + self + } } /// Computes the layout of an [`Image`]. @@ -281,10 +296,6 @@ where Rectangle::new(position + crop_offset, final_size) } -fn must_clip(bounds: Rectangle, drawing_bounds: Rectangle) -> bool { - drawing_bounds.width > bounds.width || drawing_bounds.height > bounds.height -} - fn crop(size: Size, region: Option>) -> Size { if let Some(region) = region { Size::new( @@ -300,9 +311,9 @@ fn crop(size: Size, region: Option>) -> Size { pub fn draw( renderer: &mut Renderer, layout: Layout<'_>, - viewport: &Rectangle, handle: &Handle, crop: Option>, + border_radius: border::Radius, content_fit: ContentFit, filter_method: FilterMethod, rotation: Rotation, @@ -323,45 +334,11 @@ pub fn draw( scale, ); - if must_clip(bounds, drawing_bounds) { - if let Some(bounds) = bounds.intersection(viewport) { - renderer.with_layer(bounds, |renderer| { - render( - renderer, - handle, - filter_method, - rotation, - opacity, - drawing_bounds, - ); - }); - } - } else { - render( - renderer, - handle, - filter_method, - rotation, - opacity, - drawing_bounds, - ); - } -} - -fn render( - renderer: &mut Renderer, - handle: &Handle, - filter_method: FilterMethod, - rotation: Rotation, - opacity: f32, - drawing_bounds: Rectangle, -) where - Renderer: image::Renderer, - Handle: Clone, -{ renderer.draw_image( image::Image { handle: handle.clone(), + clip_bounds: bounds, + border_radius, filter_method, rotation: rotation.radians(), opacity, @@ -411,14 +388,14 @@ where _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, - viewport: &Rectangle, + _viewport: &Rectangle, ) { draw( renderer, layout, - viewport, &self.handle, self.crop, + self.border_radius, self.content_fit, self.filter_method, self.rotation, diff --git a/widget/src/image/viewer.rs b/widget/src/image/viewer.rs index 29da5d6d..e9b153e5 100644 --- a/widget/src/image/viewer.rs +++ b/widget/src/image/viewer.rs @@ -1,4 +1,5 @@ //! Zoom and pan on an image. +use crate::core::border; use crate::core::image::{self, FilterMethod}; use crate::core::layout; use crate::core::mouse; @@ -347,6 +348,8 @@ where renderer.draw_image( Image { handle: self.handle.clone(), + clip_bounds: Rectangle::INFINITE, + border_radius: border::Radius::default(), filter_method: self.filter_method, rotation: Radians(0.0), opacity: 1.0, From 42d592d87b2b155c246fd09a006cdf480df7d8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sat, 25 Oct 2025 23:11:41 +0200 Subject: [PATCH 09/28] Filter out videos from `gallery` example --- examples/gallery/src/civitai.rs | 6 +++++- examples/gallery/src/main.rs | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 4cca8b47..01e6046b 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -38,7 +38,11 @@ impl Image { .json() .await?; - Ok(response.items) + Ok(response + .items + .into_iter() + .filter(|image| !image.url.ends_with(".mp4")) + .collect()) } pub async fn blurhash( diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 10f1704f..cb37e6a1 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -256,7 +256,14 @@ impl Gallery { self.now, ) }) - .chain((self.images.len()..=Image::LIMIT).map(|_| placeholder())); + .chain( + if self.images.is_empty() { + 0..Image::LIMIT + } else { + 0..0 + } + .map(|_| placeholder()), + ); let gallery = grid(images) .fluid(Preview::WIDTH) From 1c055e26c0e375a03f564cc18e552d374a35f827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 00:06:20 +0200 Subject: [PATCH 10/28] Apply `Transformation` to `Image::border_radius` --- tiny_skia/src/layer.rs | 10 +++++++++- wgpu/src/layer.rs | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tiny_skia/src/layer.rs b/tiny_skia/src/layer.rs index 00097f83..37be9c68 100644 --- a/tiny_skia/src/layer.rs +++ b/tiny_skia/src/layer.rs @@ -132,7 +132,15 @@ impl Layer { bounds: Rectangle, transformation: Transformation, ) { - let image = Image::Raster(image, bounds * transformation); + let image = Image::Raster( + core::Image { + clip_bounds: image.clip_bounds * transformation, + border_radius: image.border_radius + * transformation.scale_factor(), + ..image + }, + bounds * transformation, + ); self.images.push(image); } diff --git a/wgpu/src/layer.rs b/wgpu/src/layer.rs index 7a0e57b8..cfa634a4 100644 --- a/wgpu/src/layer.rs +++ b/wgpu/src/layer.rs @@ -146,6 +146,8 @@ impl Layer { let image = Image::Raster( core::Image { clip_bounds: image.clip_bounds * transformation, + border_radius: image.border_radius + * transformation.scale_factor(), ..image }, bounds * transformation, From 8b12b313a9abf22b7bf68e27289722877bdb89a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 04:22:11 +0100 Subject: [PATCH 11/28] Share `reqwest::Client` in `gallery` example --- examples/gallery/src/civitai.rs | 12 +++++------- examples/gallery/src/main.rs | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 01e6046b..94ca69c4 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -4,7 +4,9 @@ use tokio::task; use std::fmt; use std::io; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; + +static CLIENT: LazyLock = LazyLock::new(reqwest::Client::new); #[derive(Debug, Clone, Deserialize)] pub struct Image { @@ -17,14 +19,12 @@ impl Image { pub const LIMIT: usize = 96; pub async fn list() -> Result, Error> { - let client = reqwest::Client::new(); - #[derive(Deserialize)] struct Response { items: Vec, } - let response: Response = client + let response: Response = CLIENT .get("https://civitai.com/api/v1/images") .query(&[ ("sort", "Most Reactions"), @@ -66,8 +66,6 @@ impl Image { pub fn download(self, size: Size) -> impl Straw { sipper(async move |mut sender| { - let client = reqwest::Client::new(); - if let Size::Thumbnail { width, height } = size { let image = self.clone(); @@ -78,7 +76,7 @@ impl Image { })); } - let bytes = client + let bytes = CLIENT .get(match size { Size::Original => self.url, Size::Thumbnail { width, .. } => self diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index cb37e6a1..99bd2cca 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -588,4 +588,4 @@ fn rounded(theme: &Theme) -> container::Style { container::dark(theme).border(border::rounded(BORDER_RADIUS)) } -const BORDER_RADIUS: u32 = 20; +const BORDER_RADIUS: u32 = 10; From b072006f1a231d68cd63622b24019ef6c667ba4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 04:44:10 +0100 Subject: [PATCH 12/28] Remove unused `bytes` dependency in `iced_wgpu` --- Cargo.lock | 1 - wgpu/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1bea206..c43fa494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2620,7 +2620,6 @@ version = "0.14.0-dev" dependencies = [ "bitflags 2.9.4", "bytemuck", - "bytes", "cryoglyph", "futures", "glam", diff --git a/wgpu/Cargo.toml b/wgpu/Cargo.toml index d4f36964..7160421b 100644 --- a/wgpu/Cargo.toml +++ b/wgpu/Cargo.toml @@ -31,7 +31,6 @@ iced_graphics.workspace = true bitflags.workspace = true bytemuck.workspace = true -bytes.workspace = true futures.workspace = true glam.workspace = true cryoglyph.workspace = true From 44cfb27e416b1f50df5862572910773b534c1d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 09:18:19 +0100 Subject: [PATCH 13/28] Fix cache eviction logic of active image allocations --- core/src/image.rs | 10 ++++++---- wgpu/src/image/raster.rs | 16 ++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/core/src/image.rs b/core/src/image.rs index b92fdb0d..267bb0ee 100644 --- a/core/src/image.rs +++ b/core/src/image.rs @@ -192,10 +192,12 @@ impl From<&Handle> for Handle { impl std::fmt::Debug for Handle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Path(_, path) => write!(f, "Path({path:?})"), - Self::Bytes(_, _) => write!(f, "Bytes(...)"), - Self::Rgba { width, height, .. } => { - write!(f, "Pixels({width} * {height})") + Self::Path(id, path) => write!(f, "Path({id:?}, {path:?})"), + Self::Bytes(id, _) => write!(f, "Bytes({id:?}, ...)"), + Self::Rgba { + id, width, height, .. + } => { + write!(f, "Pixels({id:?}, {width} * {height})") } } } diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 0cccad06..5e333546 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -100,17 +100,21 @@ impl Cache { let hits = &self.hits; self.map.retain(|k, memory| { + // Retain active allocations + if let Memory::Device { allocation, .. } = memory + && allocation + .as_ref() + .is_some_and(|allocation| allocation.strong_count() > 0) + { + return true; + } + let retain = hits.contains(k); if !retain && let Memory::Device { - entry, - bind_group, - allocation: memory, + entry, bind_group, .. } = memory - && memory - .as_ref() - .is_none_or(|memory| memory.strong_count() == 0) { if let Some(bind_group) = bind_group.take() { on_drop(bind_group); From b408961d77b333b6f4ea0d1b1802b50502330021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 20:23:26 +0100 Subject: [PATCH 14/28] Add side-channel to `image` worker for early exiting --- wgpu/src/image/cache.rs | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 46caa227..8eea9771 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -19,6 +19,8 @@ pub struct Cache { #[cfg(feature = "image")] jobs: mpsc::SyncSender, #[cfg(feature = "image")] + quit: mpsc::SyncSender<()>, + #[cfg(feature = "image")] work: mpsc::Receiver, #[cfg(all(feature = "image", not(target_arch = "wasm32")))] worker_: Option>, @@ -33,7 +35,7 @@ impl Cache { _shell: &Shell, ) -> Self { #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - let (worker, jobs, work) = + let (worker, jobs, quit, work) = Worker::new(device, _queue, backend, layout.clone(), _shell); #[cfg(all(feature = "image", target_arch = "wasm32"))] @@ -55,6 +57,8 @@ impl Cache { #[cfg(feature = "image")] jobs, #[cfg(feature = "image")] + quit, + #[cfg(feature = "image")] work, #[cfg(all(feature = "image", not(target_arch = "wasm32")))] worker_: Some(handle), @@ -277,11 +281,8 @@ impl Cache { #[cfg(all(feature = "image", not(target_arch = "wasm32")))] impl Drop for Cache { fn drop(&mut self) { - // Stop worker gracefully - let (sender, _) = mpsc::sync_channel(1); - self.jobs = sender.clone(); - self.raster.jobs = sender; - + let _ = self.quit.try_send(()); + let _ = self.jobs.send(Job::Quit); let _ = self.worker_.take().unwrap().join(); } } @@ -333,6 +334,7 @@ enum Job { height: u32, }, Drop(Arc), + Quit, } #[cfg(feature = "image")] @@ -358,6 +360,7 @@ struct Worker { belt: wgpu::util::StagingBelt, jobs: mpsc::Receiver, output: mpsc::SyncSender, + quit: mpsc::Receiver<()>, } #[cfg(all(feature = "image", not(target_arch = "wasm32")))] @@ -368,8 +371,14 @@ impl Worker { backend: wgpu::Backend, texture_layout: wgpu::BindGroupLayout, shell: &Shell, - ) -> (Self, mpsc::SyncSender, mpsc::Receiver) { + ) -> ( + Self, + mpsc::SyncSender, + mpsc::SyncSender<()>, + mpsc::Receiver, + ) { let (jobs_sender, jobs_receiver) = mpsc::sync_channel(1_000); + let (quit_sender, quit_receiver) = mpsc::sync_channel(1); let (work_sender, work_receiver) = mpsc::sync_channel(1_000); ( @@ -382,14 +391,24 @@ impl Worker { belt: wgpu::util::StagingBelt::new(4 * 1024 * 1024), jobs: jobs_receiver, output: work_sender, + quit: quit_receiver, }, jobs_sender, + quit_sender, work_receiver, ) } fn run(mut self) { - while let Ok(job) = self.jobs.recv() { + loop { + if self.quit.try_recv().is_ok() { + return; + } + + let Ok(job) = self.jobs.recv() else { + return; + }; + match job { Job::Load(handle) => { match crate::graphics::image::load(&handle) { @@ -423,6 +442,7 @@ impl Worker { Job::Drop(bind_group) => { drop(bind_group); } + Job::Quit => return, } } } From 22488c537c9f3edcf670cd5bb90f56677f79ce7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 21:48:10 +0100 Subject: [PATCH 15/28] Create `worker` module to contain all the feature flag chaos --- wgpu/src/image/cache.rs | 415 +++++++++++++++++++------------------ wgpu/src/image/raster.rs | 2 +- wgpu/src/shader/image.wgsl | 4 +- 3 files changed, 220 insertions(+), 201 deletions(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 8eea9771..b05e8ccd 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -2,11 +2,11 @@ use crate::core::{self, Size}; use crate::graphics::Shell; use crate::image::atlas::{self, Atlas}; -#[cfg(feature = "image")] -use std::collections::HashMap; +#[cfg(all(feature = "image", not(target_arch = "wasm32")))] +use worker::Worker; #[cfg(feature = "image")] -use std::sync::mpsc; +use std::collections::HashMap; use std::sync::Arc; @@ -16,14 +16,8 @@ pub struct Cache { raster: Raster, #[cfg(feature = "svg")] vector: crate::image::vector::Cache, - #[cfg(feature = "image")] - jobs: mpsc::SyncSender, - #[cfg(feature = "image")] - quit: mpsc::SyncSender<()>, - #[cfg(feature = "image")] - work: mpsc::Receiver, #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - worker_: Option>, + worker: Worker, } impl Cache { @@ -35,33 +29,20 @@ impl Cache { _shell: &Shell, ) -> Self { #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - let (worker, jobs, quit, work) = + let worker = Worker::new(device, _queue, backend, layout.clone(), _shell); - #[cfg(all(feature = "image", target_arch = "wasm32"))] - let (jobs, work) = (mpsc::sync_channel(0).0, mpsc::sync_channel(0).1); - - #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - let handle = std::thread::spawn(move || worker.run()); - Self { atlas: Atlas::new(device, backend, layout), #[cfg(feature = "image")] raster: Raster { cache: crate::image::raster::Cache::default(), pending: HashMap::new(), - jobs: jobs.clone(), }, #[cfg(feature = "svg")] vector: crate::image::vector::Cache::default(), - #[cfg(feature = "image")] - jobs, - #[cfg(feature = "image")] - quit, - #[cfg(feature = "image")] - work, #[cfg(all(feature = "image", not(target_arch = "wasm32")))] - worker_: Some(handle), + worker, } } @@ -100,7 +81,7 @@ impl Cache { } let _ = self.raster.pending.insert(handle.id(), vec![callback]); - let _ = self.raster.jobs.send(Job::Load(handle.clone())); + self.worker.load(handle); } #[cfg(feature = "image")] @@ -110,7 +91,7 @@ impl Cache { if let Some(memory) = load_image( &mut self.raster.cache, &mut self.raster.pending, - &mut self.raster.jobs, + &self.worker, handle, None, ) { @@ -141,7 +122,7 @@ impl Cache { let memory = load_image( &mut self.raster.cache, &mut self.raster.pending, - &mut self.raster.jobs, + &self.worker, handle, None, )?; @@ -183,14 +164,8 @@ impl Cache { } if !self.raster.pending.contains_key(&handle.id()) { - let _ = self.jobs.send(Job::Upload { - handle: handle.clone(), - rgba: image.clone().into_raw(), - width: image.width(), - height: image.height(), - }); - let _ = self.raster.pending.insert(handle.id(), Vec::new()); + self.worker.upload(handle, image); } None @@ -225,7 +200,7 @@ impl Cache { pub fn trim(&mut self) { #[cfg(feature = "image")] self.raster.cache.trim(&mut self.atlas, |bind_group| { - let _ = self.jobs.send(Job::Drop(bind_group)); + self.worker.drop(bind_group); }); #[cfg(feature = "svg")] @@ -236,9 +211,9 @@ impl Cache { fn receive(&mut self) { use crate::image::raster::Memory; - while let Ok(work) = self.work.try_recv() { + while let Ok(work) = self.worker.try_recv() { match work { - Work::Upload { + worker::Work::Upload { handle, entry, bind_group, @@ -270,7 +245,7 @@ impl Cache { }, ); } - Work::Error { handle, error } => { + worker::Work::Error { handle, error } => { self.raster.cache.insert(&handle, Memory::error(error)); } } @@ -281,9 +256,7 @@ impl Cache { #[cfg(all(feature = "image", not(target_arch = "wasm32")))] impl Drop for Cache { fn drop(&mut self) { - let _ = self.quit.try_send(()); - let _ = self.jobs.send(Job::Quit); - let _ = self.worker_.take().unwrap().join(); + self.worker.quit(); } } @@ -291,7 +264,6 @@ impl Drop for Cache { struct Raster { cache: crate::image::raster::Cache, pending: HashMap>, - jobs: mpsc::SyncSender, } #[cfg(feature = "image")] @@ -301,7 +273,7 @@ type Callback = Box; fn load_image<'a>( cache: &'a mut crate::image::raster::Cache, pending: &mut HashMap>, - jobs: &mut mpsc::SyncSender, + worker: &Worker, handle: &core::image::Handle, callback: Option, ) -> Option<&'a mut crate::image::raster::Memory> { @@ -315,74 +287,45 @@ fn load_image<'a>( // Load RGBA handles synchronously, since it's very cheap cache.insert(handle, Memory::load(handle)); } else if !pending.contains_key(&handle.id()) { - let _ = jobs.send(Job::Load(handle.clone())); let _ = pending.insert(handle.id(), Vec::from_iter(callback)); + worker.load(handle); } } cache.get_mut(handle) } -#[cfg(feature = "image")] -#[derive(Debug)] -enum Job { - Load(core::image::Handle), - Upload { - handle: core::image::Handle, - rgba: core::image::Bytes, - width: u32, - height: u32, - }, - Drop(Arc), - Quit, -} - -#[cfg(feature = "image")] -enum Work { - Upload { - handle: core::image::Handle, - entry: atlas::Entry, - bind_group: Arc, - }, - Error { - handle: core::image::Handle, - error: crate::graphics::image::image_rs::error::ImageError, - }, -} - -#[cfg(feature = "image")] -struct Worker { - device: wgpu::Device, - queue: wgpu::Queue, - backend: wgpu::Backend, - texture_layout: wgpu::BindGroupLayout, - shell: Shell, - belt: wgpu::util::StagingBelt, - jobs: mpsc::Receiver, - output: mpsc::SyncSender, - quit: mpsc::Receiver<()>, -} - #[cfg(all(feature = "image", not(target_arch = "wasm32")))] -impl Worker { - fn new( - device: &wgpu::Device, - queue: &wgpu::Queue, - backend: wgpu::Backend, - texture_layout: wgpu::BindGroupLayout, - shell: &Shell, - ) -> ( - Self, - mpsc::SyncSender, - mpsc::SyncSender<()>, - mpsc::Receiver, - ) { - let (jobs_sender, jobs_receiver) = mpsc::sync_channel(1_000); - let (quit_sender, quit_receiver) = mpsc::sync_channel(1); - let (work_sender, work_receiver) = mpsc::sync_channel(1_000); +mod worker { + use crate::core::image; + use crate::graphics::Shell; + use crate::image::atlas::{self, Atlas}; + use crate::image::raster; - ( - Self { + use std::sync::Arc; + use std::sync::mpsc; + use std::thread; + + pub struct Worker { + jobs: mpsc::SyncSender, + quit: mpsc::SyncSender<()>, + work: mpsc::Receiver, + handle: Option>, + } + + impl Worker { + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + backend: wgpu::Backend, + texture_layout: wgpu::BindGroupLayout, + shell: &Shell, + ) -> Self { + let (jobs_sender, jobs_receiver) = mpsc::sync_channel(1_000); + let (quit_sender, quit_receiver) = mpsc::sync_channel(1); + let (work_sender, work_receiver) = mpsc::sync_channel(1_000); + + let instance = Instance { device: device.clone(), queue: queue.clone(), backend, @@ -392,114 +335,190 @@ impl Worker { jobs: jobs_receiver, output: work_sender, quit: quit_receiver, - }, - jobs_sender, - quit_sender, - work_receiver, - ) - } - - fn run(mut self) { - loop { - if self.quit.try_recv().is_ok() { - return; - } - - let Ok(job) = self.jobs.recv() else { - return; }; - match job { - Job::Load(handle) => { - match crate::graphics::image::load(&handle) { - Ok(image) => self.upload( - handle, - image.width(), - image.height(), - image.into_raw(), - Shell::invalidate_layout, - ), - Err(error) => { - let _ = - self.output.send(Work::Error { handle, error }); - } - } - } - Job::Upload { - handle, - rgba, - width, - height, - } => { - self.upload( - handle, - width, - height, - rgba, - Shell::request_redraw, - ); - } - Job::Drop(bind_group) => { - drop(bind_group); - } - Job::Quit => return, + let handle = thread::spawn(move || instance.run()); + + Self { + jobs: jobs_sender, + quit: quit_sender, + work: work_receiver, + handle: Some(handle), } } + + pub fn load(&self, handle: &image::Handle) { + let _ = self.jobs.send(Job::Load(handle.clone())); + } + + pub fn upload(&self, handle: &image::Handle, image: raster::Image) { + let _ = self.jobs.send(Job::Upload { + handle: handle.clone(), + width: image.width(), + height: image.height(), + rgba: image.into_raw(), + }); + } + + pub fn drop(&self, bind_group: Arc) { + let _ = self.jobs.send(Job::Drop(bind_group)); + } + + pub fn try_recv(&self) -> Result { + self.work.try_recv() + } + + pub fn quit(&mut self) { + let _ = self.quit.try_send(()); + let _ = self.jobs.send(Job::Quit); + let _ = self.handle.take().map(thread::JoinHandle::join); + } } - fn upload( - &mut self, - handle: core::image::Handle, - width: u32, - height: u32, - rgba: core::image::Bytes, - callback: fn(&Shell), - ) { - let mut encoder = self.device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { - label: Some("raster image upload"), - }, - ); + pub struct Instance { + device: wgpu::Device, + queue: wgpu::Queue, + backend: wgpu::Backend, + texture_layout: wgpu::BindGroupLayout, + shell: Shell, + belt: wgpu::util::StagingBelt, + jobs: mpsc::Receiver, + output: mpsc::SyncSender, + quit: mpsc::Receiver<()>, + } - let mut atlas = Atlas::with_size( - &self.device, - self.backend, - self.texture_layout.clone(), - width.max(height), - ); + #[cfg(feature = "image")] + #[derive(Debug)] + enum Job { + Load(image::Handle), + Upload { + handle: image::Handle, + rgba: image::Bytes, + width: u32, + height: u32, + }, + Drop(Arc), + Quit, + } - let Some(entry) = atlas.upload( - &self.device, - &mut encoder, - &mut self.belt, - width, - height, - &rgba, - ) else { - return; - }; + #[cfg(feature = "image")] + pub enum Work { + Upload { + handle: image::Handle, + entry: atlas::Entry, + bind_group: Arc, + }, + Error { + handle: image::Handle, + error: crate::graphics::image::image_rs::error::ImageError, + }, + } - let output = self.output.clone(); - let shell = self.shell.clone(); + #[cfg(all(feature = "image", not(target_arch = "wasm32")))] + impl Instance { + fn run(mut self) { + loop { + if self.quit.try_recv().is_ok() { + return; + } - self.belt.finish(); - let submission = self.queue.submit([encoder.finish()]); - self.belt.recall(); + let Ok(job) = self.jobs.recv() else { + return; + }; - let bind_group = atlas.bind_group().clone(); + match job { + Job::Load(handle) => { + match crate::graphics::image::load(&handle) { + Ok(image) => self.upload( + handle, + image.width(), + image.height(), + image.into_raw(), + Shell::invalidate_layout, + ), + Err(error) => { + let _ = self + .output + .send(Work::Error { handle, error }); + } + } + } + Job::Upload { + handle, + rgba, + width, + height, + } => { + self.upload( + handle, + width, + height, + rgba, + Shell::request_redraw, + ); + } + Job::Drop(bind_group) => { + drop(bind_group); + } + Job::Quit => return, + } + } + } - self.queue.on_submitted_work_done(move || { - let _ = output.send(Work::Upload { - handle, - entry, - bind_group, + fn upload( + &mut self, + handle: image::Handle, + width: u32, + height: u32, + rgba: image::Bytes, + callback: fn(&Shell), + ) { + let mut encoder = self.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("raster image upload"), + }, + ); + + let mut atlas = Atlas::with_size( + &self.device, + self.backend, + self.texture_layout.clone(), + width.max(height), + ); + + let Some(entry) = atlas.upload( + &self.device, + &mut encoder, + &mut self.belt, + width, + height, + &rgba, + ) else { + return; + }; + + let output = self.output.clone(); + let shell = self.shell.clone(); + + self.belt.finish(); + let submission = self.queue.submit([encoder.finish()]); + self.belt.recall(); + + let bind_group = atlas.bind_group().clone(); + + self.queue.on_submitted_work_done(move || { + let _ = output.send(Work::Upload { + handle, + entry, + bind_group, + }); + + callback(&shell); }); - callback(&shell); - }); - - let _ = self - .device - .poll(wgpu::PollType::WaitForSubmissionIndex(submission)); + let _ = self + .device + .poll(wgpu::PollType::WaitForSubmissionIndex(submission)); + } } } diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 5e333546..4300e935 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -7,7 +7,7 @@ use crate::image::atlas::{self, Atlas}; use rustc_hash::{FxHashMap, FxHashSet}; use std::sync::{Arc, Weak}; -type Image = image_rs::ImageBuffer, image::Bytes>; +pub type Image = image_rs::ImageBuffer, image::Bytes>; /// Entry in cache corresponding to an image handle #[derive(Debug)] diff --git a/wgpu/src/shader/image.wgsl b/wgpu/src/shader/image.wgsl index b2754d61..a356cc8b 100644 --- a/wgpu/src/shader/image.wgsl +++ b/wgpu/src/shader/image.wgsl @@ -82,9 +82,9 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { 2.0 * (fragment - position - scale / 2.0), scale, input.border_radius * 2.0, - ); + ) / 2.0; - let antialias: f32 = clamp(0.5 - d, 0.0, 1.0); + let antialias: f32 = clamp(1.0 - d, 0.0, 1.0); return textureSample(u_texture, u_sampler, input.uv, i32(input.layer)) * vec4(1.0, 1.0, 1.0, antialias * input.opacity); } From 71a8bc17d07a4020e1f1251b9b7490260e9ebbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 26 Oct 2025 21:57:30 +0100 Subject: [PATCH 16/28] Remove all `worker` calls in Wasm --- wgpu/src/image/cache.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index b05e8ccd..1fc6b9a5 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -81,6 +81,8 @@ impl Cache { } let _ = self.raster.pending.insert(handle.id(), vec![callback]); + + #[cfg(not(target_arch = "wasm32"))] self.worker.load(handle); } @@ -91,6 +93,7 @@ impl Cache { if let Some(memory) = load_image( &mut self.raster.cache, &mut self.raster.pending, + #[cfg(not(target_arch = "wasm32"))] &self.worker, handle, None, @@ -122,6 +125,7 @@ impl Cache { let memory = load_image( &mut self.raster.cache, &mut self.raster.pending, + #[cfg(not(target_arch = "wasm32"))] &self.worker, handle, None, @@ -165,6 +169,8 @@ impl Cache { if !self.raster.pending.contains_key(&handle.id()) { let _ = self.raster.pending.insert(handle.id(), Vec::new()); + + #[cfg(not(target_arch = "wasm32"))] self.worker.upload(handle, image); } @@ -199,8 +205,9 @@ impl Cache { pub fn trim(&mut self) { #[cfg(feature = "image")] - self.raster.cache.trim(&mut self.atlas, |bind_group| { - self.worker.drop(bind_group); + self.raster.cache.trim(&mut self.atlas, |_bind_group| { + #[cfg(not(target_arch = "wasm32"))] + self.worker.drop(_bind_group); }); #[cfg(feature = "svg")] @@ -209,9 +216,10 @@ impl Cache { #[cfg(feature = "image")] fn receive(&mut self) { - use crate::image::raster::Memory; - + #[cfg(not(target_arch = "wasm32"))] while let Ok(work) = self.worker.try_recv() { + use crate::image::raster::Memory; + match work { worker::Work::Upload { handle, @@ -273,7 +281,7 @@ type Callback = Box; fn load_image<'a>( cache: &'a mut crate::image::raster::Cache, pending: &mut HashMap>, - worker: &Worker, + #[cfg(not(target_arch = "wasm32"))] worker: &Worker, handle: &core::image::Handle, callback: Option, ) -> Option<&'a mut crate::image::raster::Memory> { @@ -288,6 +296,8 @@ fn load_image<'a>( cache.insert(handle, Memory::load(handle)); } else if !pending.contains_key(&handle.id()) { let _ = pending.insert(handle.id(), Vec::from_iter(callback)); + + #[cfg(not(target_arch = "wasm32"))] worker.load(handle); } } From 0001b18408abf542e7b526a071556fac0e389f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 27 Oct 2025 00:01:39 +0100 Subject: [PATCH 17/28] Remove redundant `cfg` in `image::cache` --- wgpu/src/image/cache.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 1fc6b9a5..4ac46708 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -397,7 +397,6 @@ mod worker { quit: mpsc::Receiver<()>, } - #[cfg(feature = "image")] #[derive(Debug)] enum Job { Load(image::Handle), @@ -411,7 +410,6 @@ mod worker { Quit, } - #[cfg(feature = "image")] pub enum Work { Upload { handle: image::Handle, @@ -424,7 +422,6 @@ mod worker { }, } - #[cfg(all(feature = "image", not(target_arch = "wasm32")))] impl Instance { fn run(mut self) { loop { From c0fb3ea9e8642c045c0716d85c8ac317bf451439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 27 Oct 2025 00:02:29 +0100 Subject: [PATCH 18/28] Take `Into` in `image::allocate` --- runtime/src/image.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/src/image.rs b/runtime/src/image.rs index d35f7006..2e2cfa69 100644 --- a/runtime/src/image.rs +++ b/runtime/src/image.rs @@ -17,8 +17,8 @@ pub enum Action { /// When you obtain an [`Allocation`] explicitly, you get the guarantee /// that using a [`Handle`] will draw the corresponding image immediately /// in the next frame. -pub fn allocate(handle: Handle) -> Task { +pub fn allocate(handle: impl Into) -> Task { task::oneshot(|sender| { - crate::Action::Image(Action::Allocate(handle, sender)) + crate::Action::Image(Action::Allocate(handle.into(), sender)) }) } From 3e8e088cddbc1191f9bbbd8419cae1176bf054dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 27 Oct 2025 10:41:20 +0100 Subject: [PATCH 19/28] Calculate minimum bounding box in `image` vertex shader --- wgpu/src/shader/image.wgsl | 61 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/wgpu/src/shader/image.wgsl b/wgpu/src/shader/image.wgsl index a356cc8b..84b812ee 100644 --- a/wgpu/src/shader/image.wgsl +++ b/wgpu/src/shader/image.wgsl @@ -34,33 +34,60 @@ struct VertexOutput { fn vs_main(input: VertexInput) -> VertexOutput { var out: VertexOutput; - // Generate a vertex position in the range [0, 1] from the vertex index. - var v_pos = vertex_position(input.vertex_index); + // Generate a vertex position in the range [0, 1] from the vertex index + let corner = vertex_position(input.vertex_index); - // Map the vertex position to the atlas texture. - out.uv = vec2(v_pos * input.atlas_scale + input.atlas_pos); + // Map the vertex position to the atlas texture out.layer = f32(input.layer); out.opacity = input.opacity; let tile = input.tile; let center = input.center; - // Calculate the vertex position and move the center to the origin - v_pos = tile.xy + v_pos * tile.zw - center; - - // Apply the rotation around the center of the image - let cos_rot = cos(input.rotation); - let sin_rot = sin(input.rotation); - let rotate = mat4x4( - vec4(cos_rot, sin_rot, 0.0, 0.0), - vec4(-sin_rot, cos_rot, 0.0, 0.0), - vec4(0.0, 0.0, 1.0, 0.0), - vec4(0.0, 0.0, 0.0, 1.0) + // List the unrotated tile corners + let corners = array, 4>( + tile.xy, // Top left + tile.xy + vec2(tile.z, 0.0), // Top right + tile.xy + vec2(0.0, tile.w), // Bottom left + tile.xy + tile.zw // Bottom right ); - // Calculate the final position of the vertex - out.position = vec4(vec2(globals.scale_factor), 1.0, 1.0) * (vec4(center, 0.0, 0.0) + rotate * vec4(v_pos, 0.0, 1.0)); + // Rotate tile corners around center + let cos_r = cos(-input.rotation); // Clockwise + let sin_r = sin(-input.rotation); + var rotated = array, 4>(); + for (var i = 0u; i < 4u; i++) { + let c = corners[i] - input.center; + rotated[i] = vec2(c.x * cos_r - c.y * sin_r, c.x * sin_r + c.y * cos_r) + input.center; + } + + // Find bounding box of rotated tile + var min_xy = rotated[0]; + var max_xy = rotated[0]; + for (var i = 1u; i < 4u; i++) { + min_xy = min(min_xy, rotated[i]); + max_xy = max(max_xy, rotated[i]); + } + let rotated_bounds = vec4(min_xy, max_xy - min_xy); + + // Intersect with clip bounds + let clip_min = max(rotated_bounds.xy, input.clip_bounds.xy); + let clip_max = min(rotated_bounds.xy + rotated_bounds.zw, input.clip_bounds.xy + input.clip_bounds.zw); + let clipped_tile = vec4(clip_min, max(vec2(0.0), clip_max - clip_min)); + + // Calculate the vertex position + let v_pos = clipped_tile.xy + corner * clipped_tile.zw; + out.position = vec4(vec2(globals.scale_factor), 1.0, 1.0) * vec4(v_pos, 0.0, 1.0); + + // Calculate rotated UV + let uv = input.atlas_pos + (v_pos - tile.xy) / tile.zw * input.atlas_scale; + let uv_center = input.atlas_pos + input.atlas_scale / 2.0; + + let d = uv - uv_center; + out.uv = vec2(d.x * cos_r - d.y * sin_r, d.x * sin_r + d.y * cos_r) + uv_center; + + // Snap position to the pixel grid if bool(input.snap) { out.position = round(out.position); } From 704144728f6c99a76f906c92c0ab600ffeec7e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 27 Oct 2025 12:23:36 +0100 Subject: [PATCH 20/28] Discard fragments with out-of-bounds `uv` coordinates --- wgpu/src/shader/image.wgsl | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/wgpu/src/shader/image.wgsl b/wgpu/src/shader/image.wgsl index 84b812ee..72a5c60e 100644 --- a/wgpu/src/shader/image.wgsl +++ b/wgpu/src/shader/image.wgsl @@ -23,11 +23,12 @@ struct VertexInput { struct VertexOutput { @builtin(position) position: vec4, - @location(0) clip_bounds: vec4, - @location(1) border_radius: vec4, - @location(2) uv: vec2, - @location(3) layer: f32, // this should be an i32, but naga currently reads that as requiring interpolation. - @location(4) opacity: f32, + @location(0) @interpolate(flat) clip_bounds: vec4, + @location(1) @interpolate(flat) border_radius: vec4, + @location(2) @interpolate(flat) atlas: vec4, + @location(3) @interpolate(flat) layer: i32, + @location(4) @interpolate(flat) opacity: f32, + @location(5) uv: vec2, } @vertex @@ -37,10 +38,6 @@ fn vs_main(input: VertexInput) -> VertexOutput { // Generate a vertex position in the range [0, 1] from the vertex index let corner = vertex_position(input.vertex_index); - // Map the vertex position to the atlas texture - out.layer = f32(input.layer); - out.opacity = input.opacity; - let tile = input.tile; let center = input.center; @@ -95,6 +92,9 @@ fn vs_main(input: VertexInput) -> VertexOutput { out.position = globals.transform * out.position; out.clip_bounds = globals.scale_factor * input.clip_bounds; out.border_radius = globals.scale_factor * input.border_radius; + out.atlas = vec4(input.atlas_pos, input.atlas_pos + input.atlas_scale); + out.layer = input.layer; + out.opacity = input.opacity; return out; } @@ -112,8 +112,9 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { ) / 2.0; let antialias: f32 = clamp(1.0 - d, 0.0, 1.0); + let inside = all(input.uv >= input.atlas.xy) && all(input.uv <= input.atlas.zw); - return textureSample(u_texture, u_sampler, input.uv, i32(input.layer)) * vec4(1.0, 1.0, 1.0, antialias * input.opacity); + return textureSample(u_texture, u_sampler, input.uv, input.layer) * vec4(1.0, 1.0, 1.0, antialias * input.opacity * f32(inside)); } fn rounded_box_sdf(p: vec2, size: vec2, corners: vec4) -> f32 { From ce126f5ea3a3ac2373b14aec91de860dfc0af139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Mon, 27 Oct 2025 16:45:09 +0100 Subject: [PATCH 21/28] Implement atlas padding in `image` pipeline --- wgpu/src/image/atlas.rs | 162 +++++++++++++++++++---------- wgpu/src/image/atlas/allocation.rs | 7 ++ wgpu/src/image/atlas/allocator.rs | 45 +++++++- wgpu/src/image/mod.rs | 8 +- 4 files changed, 159 insertions(+), 63 deletions(-) diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index 5f7265ae..38328619 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -10,7 +10,7 @@ pub use layer::Layer; use allocator::Allocator; -pub const DEFAULT_SIZE: u32 = 512; +pub const DEFAULT_SIZE: u32 = 2048; pub const MAX_SIZE: u32 = 2048; use crate::core::Size; @@ -114,7 +114,7 @@ impl Atlas { belt: &mut wgpu::util::StagingBelt, width: u32, height: u32, - data: &[u8], + pixels: &[u8], ) -> Option { let entry = { let current_size = self.layers.len(); @@ -129,56 +129,25 @@ impl Atlas { log::debug!("Allocated atlas entry: {entry:?}"); - // It is a webgpu requirement that: - // BufferCopyView.layout.bytes_per_row % wgpu::COPY_BYTES_PER_ROW_ALIGNMENT == 0 - // So we calculate padded_width by rounding width up to the next - // multiple of wgpu::COPY_BYTES_PER_ROW_ALIGNMENT. - let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; - let padding = (align - (4 * width) % align) % align; - let padded_width = (4 * width + padding) as usize; - let padded_data_size = padded_width * height as usize; - - let buffer_slice = belt.allocate( - wgpu::BufferSize::new(padded_data_size as u64).unwrap(), - wgpu::BufferSize::new(8 * 4).unwrap(), - device, - ); - - let mut padded_data = buffer_slice.get_mapped_range_mut(); - - for row in 0..height as usize { - let offset = row * padded_width; - - padded_data[offset..offset + 4 * width as usize].copy_from_slice( - &data[row * 4 * width as usize..(row + 1) * 4 * width as usize], - ); - } - match &entry { Entry::Contiguous(allocation) => { self.upload_allocation( - buffer_slice.buffer(), - width, - height, - padding, - buffer_slice.offset() as usize, - allocation, - encoder, + pixels, width, 0, allocation, device, encoder, belt, ); } Entry::Fragmented { fragments, .. } => { for fragment in fragments { let (x, y) = fragment.position; - let offset = (y * padded_width as u32 + 4 * x) as usize; + let offset = (y * width + 4 * x) as usize; self.upload_allocation( - buffer_slice.buffer(), + pixels, width, - height, - padding, - offset + buffer_slice.offset() as usize, + offset, &fragment.allocation, + device, encoder, + belt, ); } } @@ -339,44 +308,129 @@ impl Atlas { fn upload_allocation( &self, - buffer: &wgpu::Buffer, + pixels: &[u8], image_width: u32, - image_height: u32, - padding: u32, offset: usize, allocation: &Allocation, + device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, + belt: &mut wgpu::util::StagingBelt, ) { let (x, y) = allocation.position(); let Size { width, height } = allocation.size(); let layer = allocation.layer(); + let padding = allocation.padding(); - let extent = wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }; + // It is a webgpu requirement that: + // BufferCopyView.layout.bytes_per_row % wgpu::COPY_BYTES_PER_ROW_ALIGNMENT == 0 + // So we calculate padded_width by rounding width up to the next + // multiple of wgpu::COPY_BYTES_PER_ROW_ALIGNMENT. + let bytes_per_row = (4 * (width + padding.width * 2)) + .next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + as usize; + let total_bytes = + bytes_per_row * (height + padding.height * 2) as usize; + let buffer_slice = belt.allocate( + wgpu::BufferSize::new(total_bytes as u64).unwrap(), + wgpu::BufferSize::new(8 * 4).unwrap(), + device, + ); + + let mut padded_data = buffer_slice.get_mapped_range_mut(); + let padding_width = padding.width as usize; + let padding_height = padding.height as usize; + + // Copy image rows + for row in 0..height as usize { + let offset = offset + row * 4 * image_width as usize; + let start = (row + padding_height) * bytes_per_row; + let stride = 4 * width as usize; + + padded_data + [start + 4 * padding_width..start + 4 * padding_width + stride] + .copy_from_slice(&pixels[offset..offset + stride]); + + // Add padding to the sides, if needed + for i in 0..padding_width { + padded_data[start + 4 * i..start + 4 * (i + 1)] + .copy_from_slice(&pixels[offset..offset + 4]); + + padded_data[start + stride + 4 * (padding_width + i) + ..start + stride + 4 * (padding_width + i + 1)] + .copy_from_slice( + &pixels[offset + stride - 4..offset + stride], + ); + } + } + + // Add padding on top and bottom + for row in 0..padding_height { + let start = row * bytes_per_row; + let end = (padding_height + height as usize + row) * bytes_per_row; + let end_offset = + offset + height as usize * 4 * image_width as usize; + + // Top + padded_data[start + 4 * padding_width + ..start + 4 * (padding_width + width as usize)] + .copy_from_slice(&pixels[offset..offset + 4 * width as usize]); + + // Bottom + padded_data[end + 4 * padding_width + ..end + 4 * (padding_width + width as usize)] + .copy_from_slice( + &pixels[end_offset - 4 * width as usize..end_offset], + ); + + // Corners + for i in 0..padding_width { + padded_data[start + 4 * i..start + 4 * (i + 1)] + .copy_from_slice(&pixels[offset..4]); + + padded_data[start + 4 * (width as usize + padding_width + i) + ..start + 4 * (width as usize + padding_width + i + 1)] + .copy_from_slice( + &pixels[offset + 4 * (width - 1) as usize + ..offset + 4 * width as usize], + ); + + padded_data[end + 4 * i..end + 4 * (i + 1)].copy_from_slice( + &pixels[end_offset - 4 * width as usize + ..end_offset - 4 * (width as usize - 1)], + ); + + padded_data[end + 4 * (width as usize + padding_width + i) + ..end + 4 * (width as usize + padding_width + i + 1)] + .copy_from_slice(&pixels[end_offset - 4..end_offset]); + } + } + + // Copy actual image encoder.copy_buffer_to_texture( wgpu::TexelCopyBufferInfo { - buffer, + buffer: buffer_slice.buffer(), layout: wgpu::TexelCopyBufferLayout { - offset: offset as u64, - bytes_per_row: Some(4 * image_width + padding), - rows_per_image: Some(image_height), + offset: buffer_slice.offset(), + bytes_per_row: Some(bytes_per_row as u32), + rows_per_image: Some(height + padding.height * 2), }, }, wgpu::TexelCopyTextureInfo { texture: &self.texture, mip_level: 0, origin: wgpu::Origin3d { - x, - y, + x: x - padding.width, + y: y - padding.height, z: layer as u32, }, aspect: wgpu::TextureAspect::default(), }, - extent, + wgpu::Extent3d { + width: width + padding.width * 2, + height: height + padding.height * 2, + depth_or_array_layers: 1, + }, ); } diff --git a/wgpu/src/image/atlas/allocation.rs b/wgpu/src/image/atlas/allocation.rs index 2e8a7b14..295a7a13 100644 --- a/wgpu/src/image/atlas/allocation.rs +++ b/wgpu/src/image/atlas/allocation.rs @@ -29,6 +29,13 @@ impl Allocation { } } + pub fn padding(&self) -> Size { + match self { + Allocation::Partial { region, .. } => region.padding(), + Allocation::Full { .. } => Size::new(0, 0), + } + } + pub fn layer(&self) -> usize { match self { Allocation::Partial { layer, .. } => *layer, diff --git a/wgpu/src/image/atlas/allocator.rs b/wgpu/src/image/atlas/allocator.rs index a51ac1f5..263619f6 100644 --- a/wgpu/src/image/atlas/allocator.rs +++ b/wgpu/src/image/atlas/allocator.rs @@ -1,3 +1,5 @@ +use crate::core; + use guillotiere::{AtlasAllocator, Size}; pub struct Allocator { @@ -6,6 +8,8 @@ pub struct Allocator { } impl Allocator { + const PADDING: u32 = 1; + pub fn new(size: u32) -> Allocator { let raw = AtlasAllocator::new(Size::new(size as i32, size as i32)); @@ -16,12 +20,38 @@ impl Allocator { } pub fn allocate(&mut self, width: u32, height: u32) -> Option { - let allocation = - self.raw.allocate(Size::new(width as i32, height as i32))?; + let size = self.raw.size(); + + let padded_width = width + Self::PADDING * 2; + let padded_height = height + Self::PADDING * 2; + + let pad_width = padded_width as i32 <= size.width; + let pad_height = padded_height as i32 <= size.height; + + let mut allocation = self.raw.allocate(Size::new( + if pad_width { padded_width } else { width } as i32, + if pad_height { padded_height } else { height } as i32, + ))?; + + if pad_width { + allocation.rectangle.min.x += Self::PADDING as i32; + allocation.rectangle.max.x -= Self::PADDING as i32; + } + + if pad_height { + allocation.rectangle.min.y += Self::PADDING as i32; + allocation.rectangle.max.y -= Self::PADDING as i32; + } self.allocations += 1; - Some(Region { allocation }) + Some(Region { + allocation, + padding: core::Size::new( + if pad_width { Self::PADDING } else { 0 }, + if pad_height { Self::PADDING } else { 0 }, + ), + }) } pub fn deallocate(&mut self, region: &Region) { @@ -41,6 +71,7 @@ impl Allocator { pub struct Region { allocation: guillotiere::Allocation, + padding: core::Size, } impl Region { @@ -50,10 +81,14 @@ impl Region { (rectangle.min.x as u32, rectangle.min.y as u32) } - pub fn size(&self) -> crate::core::Size { + pub fn size(&self) -> core::Size { let size = self.allocation.rectangle.size(); - crate::core::Size::new(size.width as u32, size.height as u32) + core::Size::new(size.width as u32, size.height as u32) + } + + pub fn padding(&self) -> crate::core::Size { + self.padding } } diff --git a/wgpu/src/image/mod.rs b/wgpu/src/image/mod.rs index d1e39c92..21164741 100644 --- a/wgpu/src/image/mod.rs +++ b/wgpu/src/image/mod.rs @@ -735,12 +735,12 @@ fn add_instance( _rotation: rotation, _opacity: opacity, _position_in_atlas: [ - (x as f32 + 0.5) / atlas_size as f32, - (y as f32 + 0.5) / atlas_size as f32, + x as f32 / atlas_size as f32, + y as f32 / atlas_size as f32, ], _size_in_atlas: [ - (width as f32 - 1.0) / atlas_size as f32, - (height as f32 - 1.0) / atlas_size as f32, + width as f32 / atlas_size as f32, + height as f32 / atlas_size as f32, ], _layer: layer as u32, _snap: snap as u32, From 2bb7b5042190bd6b9261efbc31a13ef8b4ebca77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 09:56:26 +0100 Subject: [PATCH 22/28] Fix a bunch of bogus calculations in `image::atlas` --- wgpu/src/image/atlas.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index 38328619..d966aecc 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -138,7 +138,7 @@ impl Atlas { Entry::Fragmented { fragments, .. } => { for fragment in fragments { let (x, y) = fragment.position; - let offset = (y * width + 4 * x) as usize; + let offset = 4 * (y * width + x) as usize; self.upload_allocation( pixels, @@ -323,7 +323,7 @@ impl Atlas { // It is a webgpu requirement that: // BufferCopyView.layout.bytes_per_row % wgpu::COPY_BYTES_PER_ROW_ALIGNMENT == 0 - // So we calculate padded_width by rounding width up to the next + // So we calculate bytes_per_row by rounding width up to the next // multiple of wgpu::COPY_BYTES_PER_ROW_ALIGNMENT. let bytes_per_row = (4 * (width + padding.width * 2)) .next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) @@ -368,8 +368,8 @@ impl Atlas { for row in 0..padding_height { let start = row * bytes_per_row; let end = (padding_height + height as usize + row) * bytes_per_row; - let end_offset = - offset + height as usize * 4 * image_width as usize; + let last = + offset + (height - 1) as usize * 4 * image_width as usize; // Top padded_data[start + 4 * padding_width @@ -379,14 +379,12 @@ impl Atlas { // Bottom padded_data[end + 4 * padding_width ..end + 4 * (padding_width + width as usize)] - .copy_from_slice( - &pixels[end_offset - 4 * width as usize..end_offset], - ); + .copy_from_slice(&pixels[last..last + 4 * width as usize]); // Corners for i in 0..padding_width { padded_data[start + 4 * i..start + 4 * (i + 1)] - .copy_from_slice(&pixels[offset..4]); + .copy_from_slice(&pixels[offset..offset + 4]); padded_data[start + 4 * (width as usize + padding_width + i) ..start + 4 * (width as usize + padding_width + i + 1)] @@ -395,14 +393,15 @@ impl Atlas { ..offset + 4 * width as usize], ); - padded_data[end + 4 * i..end + 4 * (i + 1)].copy_from_slice( - &pixels[end_offset - 4 * width as usize - ..end_offset - 4 * (width as usize - 1)], - ); + padded_data[end + 4 * i..end + 4 * (i + 1)] + .copy_from_slice(&pixels[last..last + 4]); padded_data[end + 4 * (width as usize + padding_width + i) ..end + 4 * (width as usize + padding_width + i + 1)] - .copy_from_slice(&pixels[end_offset - 4..end_offset]); + .copy_from_slice( + &pixels[last + 4 * (width - 1) as usize + ..last + 4 * width as usize], + ); } } From 67730a8bfa86634a49395602fbb55c661f87d03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 10:13:58 +0100 Subject: [PATCH 23/28] Improve readability of `image::atlas` padding logic --- wgpu/src/image/atlas.rs | 88 +++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index d966aecc..c3a800f5 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -337,70 +337,80 @@ impl Atlas { device, ); - let mut padded_data = buffer_slice.get_mapped_range_mut(); - let padding_width = padding.width as usize; - let padding_height = padding.height as usize; + const PIXEL: usize = 4; + + let mut fragment = buffer_slice.get_mapped_range_mut(); + let pad_w = padding.width as usize; + let pad_h = padding.height as usize; + let stride = PIXEL * width as usize; // Copy image rows for row in 0..height as usize { - let offset = offset + row * 4 * image_width as usize; - let start = (row + padding_height) * bytes_per_row; - let stride = 4 * width as usize; + let src = offset + row * PIXEL * image_width as usize; + let dst = (row + pad_h) * bytes_per_row; - padded_data - [start + 4 * padding_width..start + 4 * padding_width + stride] - .copy_from_slice(&pixels[offset..offset + stride]); + fragment[dst + PIXEL * pad_w..dst + PIXEL * pad_w + stride] + .copy_from_slice(&pixels[src..src + stride]); // Add padding to the sides, if needed - for i in 0..padding_width { - padded_data[start + 4 * i..start + 4 * (i + 1)] - .copy_from_slice(&pixels[offset..offset + 4]); + for i in 0..pad_w { + fragment[dst + PIXEL * i..dst + PIXEL * (i + 1)] + .copy_from_slice(&pixels[src..src + PIXEL]); - padded_data[start + stride + 4 * (padding_width + i) - ..start + stride + 4 * (padding_width + i + 1)] + fragment[dst + stride + PIXEL * (pad_w + i) + ..dst + stride + PIXEL * (pad_w + i + 1)] .copy_from_slice( - &pixels[offset + stride - 4..offset + stride], + &pixels[src + stride - PIXEL..src + stride], ); } } // Add padding on top and bottom - for row in 0..padding_height { - let start = row * bytes_per_row; - let end = (padding_height + height as usize + row) * bytes_per_row; - let last = - offset + (height - 1) as usize * 4 * image_width as usize; + for row in 0..pad_h { + let dst_top = row * bytes_per_row; + let dst_bottom = (pad_h + height as usize + row) * bytes_per_row; + let src_top = offset; + let src_bottom = + offset + (height - 1) as usize * PIXEL * image_width as usize; // Top - padded_data[start + 4 * padding_width - ..start + 4 * (padding_width + width as usize)] - .copy_from_slice(&pixels[offset..offset + 4 * width as usize]); + fragment[dst_top + PIXEL * pad_w + ..dst_top + PIXEL * (pad_w + width as usize)] + .copy_from_slice( + &pixels[src_top..src_top + PIXEL * width as usize], + ); // Bottom - padded_data[end + 4 * padding_width - ..end + 4 * (padding_width + width as usize)] - .copy_from_slice(&pixels[last..last + 4 * width as usize]); + fragment[dst_bottom + PIXEL * pad_w + ..dst_bottom + PIXEL * (pad_w + width as usize)] + .copy_from_slice( + &pixels[src_bottom..src_bottom + PIXEL * width as usize], + ); // Corners - for i in 0..padding_width { - padded_data[start + 4 * i..start + 4 * (i + 1)] - .copy_from_slice(&pixels[offset..offset + 4]); + for i in 0..pad_w { + // Top left + fragment[dst_top + PIXEL * i..dst_top + PIXEL * (i + 1)] + .copy_from_slice(&pixels[offset..offset + PIXEL]); - padded_data[start + 4 * (width as usize + padding_width + i) - ..start + 4 * (width as usize + padding_width + i + 1)] + // Top right + fragment[dst_top + PIXEL * (width as usize + pad_w + i) + ..dst_top + PIXEL * (width as usize + pad_w + i + 1)] .copy_from_slice( - &pixels[offset + 4 * (width - 1) as usize - ..offset + 4 * width as usize], + &pixels[offset + PIXEL * (width - 1) as usize + ..offset + PIXEL * width as usize], ); - padded_data[end + 4 * i..end + 4 * (i + 1)] - .copy_from_slice(&pixels[last..last + 4]); + // Bottom left + fragment[dst_bottom + PIXEL * i..dst_bottom + PIXEL * (i + 1)] + .copy_from_slice(&pixels[src_bottom..src_bottom + PIXEL]); - padded_data[end + 4 * (width as usize + padding_width + i) - ..end + 4 * (width as usize + padding_width + i + 1)] + // Bottom right + fragment[dst_bottom + PIXEL * (width as usize + pad_w + i) + ..dst_bottom + PIXEL * (width as usize + pad_w + i + 1)] .copy_from_slice( - &pixels[last + 4 * (width - 1) as usize - ..last + 4 * width as usize], + &pixels[src_bottom + PIXEL * (width - 1) as usize + ..src_bottom + PIXEL * width as usize], ); } } From 3cc5bd5dbc613590c555d3ec37e5f9759da13004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 10:20:04 +0100 Subject: [PATCH 24/28] Use shorter names for `width` and `height` in `image::atlas` --- wgpu/src/image/atlas.rs | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/wgpu/src/image/atlas.rs b/wgpu/src/image/atlas.rs index c3a800f5..38de2462 100644 --- a/wgpu/src/image/atlas.rs +++ b/wgpu/src/image/atlas.rs @@ -340,12 +340,14 @@ impl Atlas { const PIXEL: usize = 4; let mut fragment = buffer_slice.get_mapped_range_mut(); + let w = width as usize; + let h = height as usize; let pad_w = padding.width as usize; let pad_h = padding.height as usize; - let stride = PIXEL * width as usize; + let stride = PIXEL * w; // Copy image rows - for row in 0..height as usize { + for row in 0..h { let src = offset + row * PIXEL * image_width as usize; let dst = (row + pad_h) * bytes_per_row; @@ -368,24 +370,18 @@ impl Atlas { // Add padding on top and bottom for row in 0..pad_h { let dst_top = row * bytes_per_row; - let dst_bottom = (pad_h + height as usize + row) * bytes_per_row; + let dst_bottom = (pad_h + h + row) * bytes_per_row; let src_top = offset; - let src_bottom = - offset + (height - 1) as usize * PIXEL * image_width as usize; + let src_bottom = offset + (h - 1) * PIXEL * image_width as usize; // Top - fragment[dst_top + PIXEL * pad_w - ..dst_top + PIXEL * (pad_w + width as usize)] - .copy_from_slice( - &pixels[src_top..src_top + PIXEL * width as usize], - ); + fragment[dst_top + PIXEL * pad_w..dst_top + PIXEL * (pad_w + w)] + .copy_from_slice(&pixels[src_top..src_top + PIXEL * w]); // Bottom - fragment[dst_bottom + PIXEL * pad_w - ..dst_bottom + PIXEL * (pad_w + width as usize)] - .copy_from_slice( - &pixels[src_bottom..src_bottom + PIXEL * width as usize], - ); + fragment + [dst_bottom + PIXEL * pad_w..dst_bottom + PIXEL * (pad_w + w)] + .copy_from_slice(&pixels[src_bottom..src_bottom + PIXEL * w]); // Corners for i in 0..pad_w { @@ -394,11 +390,10 @@ impl Atlas { .copy_from_slice(&pixels[offset..offset + PIXEL]); // Top right - fragment[dst_top + PIXEL * (width as usize + pad_w + i) - ..dst_top + PIXEL * (width as usize + pad_w + i + 1)] + fragment[dst_top + PIXEL * (w + pad_w + i) + ..dst_top + PIXEL * (w + pad_w + i + 1)] .copy_from_slice( - &pixels[offset + PIXEL * (width - 1) as usize - ..offset + PIXEL * width as usize], + &pixels[offset + PIXEL * (w - 1)..offset + PIXEL * w], ); // Bottom left @@ -406,11 +401,11 @@ impl Atlas { .copy_from_slice(&pixels[src_bottom..src_bottom + PIXEL]); // Bottom right - fragment[dst_bottom + PIXEL * (width as usize + pad_w + i) - ..dst_bottom + PIXEL * (width as usize + pad_w + i + 1)] + fragment[dst_bottom + PIXEL * (w + pad_w + i) + ..dst_bottom + PIXEL * (w + pad_w + i + 1)] .copy_from_slice( - &pixels[src_bottom + PIXEL * (width - 1) as usize - ..src_bottom + PIXEL * width as usize], + &pixels[src_bottom + PIXEL * (w - 1) + ..src_bottom + PIXEL * w], ); } } From 7c11ccb0466594bbb4abaa83f16814f699e429c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 19:44:46 +0100 Subject: [PATCH 25/28] Move `core::Image::clip_bounds` to `graphics::Image` --- core/src/image.rs | 25 ++++++++++++------------- core/src/renderer/null.rs | 16 ++++++++++++++-- core/src/svg.rs | 2 +- graphics/src/image.rs | 19 +++++++++++++++---- renderer/src/fallback.rs | 22 ++++++++++++++++++---- tiny_skia/src/engine.rs | 12 ++++++------ tiny_skia/src/geometry.rs | 12 ++++++++++-- tiny_skia/src/layer.rs | 34 ++++++++++++++++++++++++---------- tiny_skia/src/lib.rs | 18 ++++++++++++++---- wgpu/src/geometry.rs | 14 ++++++++++++-- wgpu/src/image/mod.rs | 16 ++++++++++++---- wgpu/src/layer.rs | 34 ++++++++++++++++++++++++---------- wgpu/src/lib.rs | 18 ++++++++++++++---- widget/src/image.rs | 2 +- widget/src/image/viewer.rs | 4 ++-- widget/src/svg.rs | 29 ++++++++++------------------- 16 files changed, 189 insertions(+), 88 deletions(-) diff --git a/core/src/image.rs b/core/src/image.rs index 267bb0ee..65342dae 100644 --- a/core/src/image.rs +++ b/core/src/image.rs @@ -16,22 +16,17 @@ pub struct Image { /// The handle of the image. pub handle: H, - /// The clip bounds of the [`Image`]. - /// - /// Anything outside this [`Rectangle`] will not be drawn. - pub clip_bounds: Rectangle, - - /// The border radius of the [`Image`]. - /// - /// Currently, this will only be applied to the `clip_bounds`. - pub border_radius: border::Radius, - /// The filter method of the image. pub filter_method: FilterMethod, /// The rotation to be applied to the image; on its center. pub rotation: Radians, + /// The border radius of the [`Image`]. + /// + /// Currently, this will only be applied to the `clip_bounds`. + pub border_radius: border::Radius, + /// The opacity of the image. /// /// 0 means transparent. 1 means opaque. @@ -49,10 +44,9 @@ impl Image { pub fn new(handle: impl Into) -> Self { Self { handle: handle.into(), - clip_bounds: Rectangle::INFINITE, - border_radius: border::Radius::default(), filter_method: FilterMethod::default(), rotation: Radians(0.0), + border_radius: border::Radius::default(), opacity: 1.0, snap: false, } @@ -307,5 +301,10 @@ pub trait Renderer: crate::Renderer { fn measure_image(&self, handle: &Self::Handle) -> Size; /// Draws an [`Image`] inside the provided `bounds`. - fn draw_image(&mut self, image: Image, bounds: Rectangle); + fn draw_image( + &mut self, + image: Image, + bounds: Rectangle, + clip_bounds: Rectangle, + ); } diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 8b3fbb0c..87b6ed19 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -217,7 +217,13 @@ impl image::Renderer for () { Size::default() } - fn draw_image(&mut self, _image: Image, _bounds: Rectangle) {} + fn draw_image( + &mut self, + _image: Image, + _bounds: Rectangle, + _clip_bounds: Rectangle, + ) { + } } impl svg::Renderer for () { @@ -225,5 +231,11 @@ impl svg::Renderer for () { Size::default() } - fn draw_svg(&mut self, _svg: svg::Svg, _bounds: Rectangle) {} + fn draw_svg( + &mut self, + _svg: svg::Svg, + _bounds: Rectangle, + _clip_bounds: Rectangle, + ) { + } } diff --git a/core/src/svg.rs b/core/src/svg.rs index ac19b223..4a25b7e7 100644 --- a/core/src/svg.rs +++ b/core/src/svg.rs @@ -155,5 +155,5 @@ pub trait Renderer: crate::Renderer { fn measure_svg(&self, handle: &Handle) -> Size; /// Draws an SVG with the given [`Handle`], an optional [`Color`] filter, and inside the provided `bounds`. - fn draw_svg(&mut self, svg: Svg, bounds: Rectangle); + fn draw_svg(&mut self, svg: Svg, bounds: Rectangle, clip_bounds: Rectangle); } diff --git a/graphics/src/image.rs b/graphics/src/image.rs index 171edd80..6f74b721 100644 --- a/graphics/src/image.rs +++ b/graphics/src/image.rs @@ -7,21 +7,32 @@ use crate::core::image; use crate::core::svg; /// A raster or vector image. +#[allow(missing_docs)] #[derive(Debug, Clone, PartialEq)] pub enum Image { /// A raster image. - Raster(image::Image, Rectangle), + Raster { + image: image::Image, + bounds: Rectangle, + clip_bounds: Rectangle, + }, /// A vector image. - Vector(svg::Svg, Rectangle), + Vector { + svg: svg::Svg, + bounds: Rectangle, + clip_bounds: Rectangle, + }, } impl Image { /// Returns the bounds of the [`Image`]. pub fn bounds(&self) -> Rectangle { match self { - Image::Raster(image, bounds) => bounds.rotate(image.rotation), - Image::Vector(svg, bounds) => bounds.rotate(svg.rotation), + Image::Raster { image, bounds, .. } => { + bounds.rotate(image.rotation) + } + Image::Vector { svg, bounds, .. } => bounds.rotate(svg.rotation), } } } diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index 9f2d3c09..01e49390 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -158,8 +158,17 @@ where delegate!(self, renderer, renderer.measure_image(handle)) } - fn draw_image(&mut self, image: Image, bounds: Rectangle) { - delegate!(self, renderer, renderer.draw_image(image, bounds)); + fn draw_image( + &mut self, + image: Image, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { + delegate!( + self, + renderer, + renderer.draw_image(image, bounds, clip_bounds) + ); } } @@ -172,8 +181,13 @@ where delegate!(self, renderer, renderer.measure_svg(handle)) } - fn draw_svg(&mut self, svg: Svg, bounds: Rectangle) { - delegate!(self, renderer, renderer.draw_svg(svg, bounds)); + fn draw_svg( + &mut self, + svg: Svg, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { + delegate!(self, renderer, renderer.draw_svg(svg, bounds, clip_bounds)); } } diff --git a/tiny_skia/src/engine.rs b/tiny_skia/src/engine.rs index 2b829b0f..69423361 100644 --- a/tiny_skia/src/engine.rs +++ b/tiny_skia/src/engine.rs @@ -550,7 +550,7 @@ impl Engine { ) { match image { #[cfg(feature = "image")] - Image::Raster(raster, bounds) => { + Image::Raster { image, bounds, .. } => { let physical_bounds = *bounds * _transformation; if !_clip_bounds.intersects(&physical_bounds) { @@ -561,7 +561,7 @@ impl Engine { .then_some(_clip_mask as &_); let center = physical_bounds.center(); - let radians = f32::from(raster.rotation); + let radians = f32::from(image.rotation); let transform = into_transform(_transformation).post_rotate_at( radians.to_degrees(), @@ -570,17 +570,17 @@ impl Engine { ); self.raster_pipeline.draw( - &raster.handle, - raster.filter_method, + &image.handle, + image.filter_method, *bounds, - raster.opacity, + image.opacity, _pixels, transform, clip_mask, ); } #[cfg(feature = "svg")] - Image::Vector(svg, bounds) => { + Image::Vector { svg, bounds, .. } => { let physical_bounds = *bounds * _transformation; if !_clip_bounds.intersects(&physical_bounds) { diff --git a/tiny_skia/src/geometry.rs b/tiny_skia/src/geometry.rs index a1c0d8a6..a45d9522 100644 --- a/tiny_skia/src/geometry.rs +++ b/tiny_skia/src/geometry.rs @@ -307,7 +307,11 @@ impl geometry::frame::Backend for Frame { image.rotation += external_rotation; - self.images.push(graphics::Image::Raster(image, bounds)); + self.images.push(graphics::Image::Raster { + image, + bounds, + clip_bounds: self.clip_bounds, + }); } fn draw_svg(&mut self, bounds: Rectangle, svg: impl Into) { @@ -318,7 +322,11 @@ impl geometry::frame::Backend for Frame { svg.rotation += external_rotation; - self.images.push(Image::Vector(svg, bounds)); + self.images.push(Image::Vector { + svg, + bounds, + clip_bounds: self.clip_bounds, + }); } } diff --git a/tiny_skia/src/layer.rs b/tiny_skia/src/layer.rs index 37be9c68..951f4b14 100644 --- a/tiny_skia/src/layer.rs +++ b/tiny_skia/src/layer.rs @@ -117,11 +117,19 @@ impl Layer { pub fn draw_image(&mut self, image: Image, transformation: Transformation) { match image { - Image::Raster(raster, bounds) => { - self.draw_raster(raster, bounds, transformation); + Image::Raster { + image, + bounds, + clip_bounds, + } => { + self.draw_raster(image, bounds, clip_bounds, transformation); } - Image::Vector(svg, bounds) => { - self.draw_svg(svg, bounds, transformation); + Image::Vector { + svg, + bounds, + clip_bounds, + } => { + self.draw_svg(svg, bounds, clip_bounds, transformation); } } } @@ -130,17 +138,18 @@ impl Layer { &mut self, image: core::Image, bounds: Rectangle, + clip_bounds: Rectangle, transformation: Transformation, ) { - let image = Image::Raster( - core::Image { - clip_bounds: image.clip_bounds * transformation, + let image = Image::Raster { + image: core::Image { border_radius: image.border_radius * transformation.scale_factor(), ..image }, - bounds * transformation, - ); + bounds: bounds * transformation, + clip_bounds: clip_bounds * transformation, + }; self.images.push(image); } @@ -149,9 +158,14 @@ impl Layer { &mut self, svg: Svg, bounds: Rectangle, + clip_bounds: Rectangle, transformation: Transformation, ) { - let svg = Image::Vector(svg, bounds * transformation); + let svg = Image::Vector { + svg, + bounds: bounds * transformation, + clip_bounds: clip_bounds * transformation, + }; self.images.push(svg); } diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index fe24078b..159f8155 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -364,9 +364,14 @@ impl core::image::Renderer for Renderer { self.engine.raster_pipeline.dimensions(handle) } - fn draw_image(&mut self, image: core::Image, bounds: Rectangle) { + fn draw_image( + &mut self, + image: core::Image, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { let (layer, transformation) = self.layers.current_mut(); - layer.draw_raster(image, bounds, transformation); + layer.draw_raster(image, bounds, clip_bounds, transformation); } } @@ -379,9 +384,14 @@ impl core::svg::Renderer for Renderer { self.engine.vector_pipeline.viewport_dimensions(handle) } - fn draw_svg(&mut self, svg: core::Svg, bounds: Rectangle) { + fn draw_svg( + &mut self, + svg: core::Svg, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { let (layer, transformation) = self.layers.current_mut(); - layer.draw_svg(svg, bounds, transformation); + layer.draw_svg(svg, bounds, clip_bounds, transformation); } } diff --git a/wgpu/src/geometry.rs b/wgpu/src/geometry.rs index 872d9fe1..f82adedb 100644 --- a/wgpu/src/geometry.rs +++ b/wgpu/src/geometry.rs @@ -431,8 +431,14 @@ impl geometry::frame::Backend for Frame { self.transforms.current.transform_rectangle(bounds); image.rotation += external_rotation; + image.border_radius = + image.border_radius * self.transforms.current.scale().0; - self.images.push(Image::Raster(image, bounds)); + self.images.push(Image::Raster { + image, + bounds, + clip_bounds: self.clip_bounds, + }); } fn draw_svg(&mut self, bounds: Rectangle, svg: impl Into) { @@ -443,7 +449,11 @@ impl geometry::frame::Backend for Frame { svg.rotation += external_rotation; - self.images.push(Image::Vector(svg, bounds)); + self.images.push(Image::Vector { + svg, + bounds, + clip_bounds: self.clip_bounds, + }); } } diff --git a/wgpu/src/image/mod.rs b/wgpu/src/image/mod.rs index 21164741..ba98f443 100644 --- a/wgpu/src/image/mod.rs +++ b/wgpu/src/image/mod.rs @@ -261,7 +261,11 @@ impl State { for image in images { match &image { #[cfg(feature = "image")] - Image::Raster(image, bounds) => { + Image::Raster { + image, + bounds, + clip_bounds, + } => { if let Some((atlas_entry, bind_group)) = cache .upload_raster(device, encoder, belt, &image.handle) { @@ -283,7 +287,7 @@ impl State { add_instances( *bounds, - image.clip_bounds, + *clip_bounds, image.border_radius, f32::from(image.rotation), image.opacity, @@ -304,7 +308,11 @@ impl State { Image::Raster { .. } => continue, #[cfg(feature = "svg")] - Image::Vector(svg, bounds) => { + Image::Vector { + svg, + bounds, + clip_bounds, + } => { if let Some((atlas_entry, bind_group)) = cache .upload_vector( device, @@ -334,7 +342,7 @@ impl State { add_instances( *bounds, - Rectangle::INFINITE, + *clip_bounds, border::radius(0), f32::from(svg.rotation), svg.opacity, diff --git a/wgpu/src/layer.rs b/wgpu/src/layer.rs index cfa634a4..f7ee2a3a 100644 --- a/wgpu/src/layer.rs +++ b/wgpu/src/layer.rs @@ -128,11 +128,19 @@ impl Layer { pub fn draw_image(&mut self, image: Image, transformation: Transformation) { match image { - Image::Raster(image, bounds) => { - self.draw_raster(image, bounds, transformation); + Image::Raster { + image, + bounds, + clip_bounds, + } => { + self.draw_raster(image, bounds, clip_bounds, transformation); } - Image::Vector(svg, bounds) => { - self.draw_svg(svg, bounds, transformation); + Image::Vector { + svg, + bounds, + clip_bounds, + } => { + self.draw_svg(svg, bounds, clip_bounds, transformation); } } } @@ -141,17 +149,18 @@ impl Layer { &mut self, image: core::Image, bounds: Rectangle, + clip_bounds: Rectangle, transformation: Transformation, ) { - let image = Image::Raster( - core::Image { - clip_bounds: image.clip_bounds * transformation, + let image = Image::Raster { + image: core::Image { border_radius: image.border_radius * transformation.scale_factor(), ..image }, - bounds * transformation, - ); + bounds: bounds * transformation, + clip_bounds: clip_bounds * transformation, + }; self.images.push(image); } @@ -160,9 +169,14 @@ impl Layer { &mut self, svg: Svg, bounds: Rectangle, + clip_bounds: Rectangle, transformation: Transformation, ) { - let svg = Image::Vector(svg, bounds * transformation); + let svg = Image::Vector { + svg, + bounds: bounds * transformation, + clip_bounds: clip_bounds * transformation, + }; self.images.push(svg); } diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index f78998bb..e0a7e969 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -777,9 +777,14 @@ impl core::image::Renderer for Renderer { self.image_cache.borrow_mut().measure_image(handle) } - fn draw_image(&mut self, image: core::Image, bounds: Rectangle) { + fn draw_image( + &mut self, + image: core::Image, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { let (layer, transformation) = self.layers.current_mut(); - layer.draw_raster(image, bounds, transformation); + layer.draw_raster(image, bounds, clip_bounds, transformation); } } @@ -789,9 +794,14 @@ impl core::svg::Renderer for Renderer { self.image_cache.borrow_mut().measure_svg(handle) } - fn draw_svg(&mut self, svg: core::Svg, bounds: Rectangle) { + fn draw_svg( + &mut self, + svg: core::Svg, + bounds: Rectangle, + clip_bounds: Rectangle, + ) { let (layer, transformation) = self.layers.current_mut(); - layer.draw_svg(svg, bounds, transformation); + layer.draw_svg(svg, bounds, clip_bounds, transformation); } } diff --git a/widget/src/image.rs b/widget/src/image.rs index 709915aa..e40c9b6d 100644 --- a/widget/src/image.rs +++ b/widget/src/image.rs @@ -337,7 +337,6 @@ pub fn draw( renderer.draw_image( image::Image { handle: handle.clone(), - clip_bounds: bounds, border_radius, filter_method, rotation: rotation.radians(), @@ -345,6 +344,7 @@ pub fn draw( snap: true, }, drawing_bounds, + bounds, ); } diff --git a/widget/src/image/viewer.rs b/widget/src/image/viewer.rs index e9b153e5..2fab9013 100644 --- a/widget/src/image/viewer.rs +++ b/widget/src/image/viewer.rs @@ -314,7 +314,7 @@ where _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, - _viewport: &Rectangle, + viewport: &Rectangle, ) { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); @@ -348,7 +348,6 @@ where renderer.draw_image( Image { handle: self.handle.clone(), - clip_bounds: Rectangle::INFINITE, border_radius: border::Radius::default(), filter_method: self.filter_method, rotation: Radians(0.0), @@ -356,6 +355,7 @@ where snap: true, }, drawing_bounds, + *viewport, ); }); }; diff --git a/widget/src/svg.rs b/widget/src/svg.rs index dabe499f..9ed3d870 100644 --- a/widget/src/svg.rs +++ b/widget/src/svg.rs @@ -240,25 +240,16 @@ where let style = theme.style(&self.class, status); - let render = |renderer: &mut Renderer| { - renderer.draw_svg( - svg::Svg { - handle: self.handle.clone(), - color: style.color, - rotation: self.rotation.radians(), - opacity: self.opacity, - }, - drawing_bounds, - ); - }; - - if adjusted_fit.width > bounds.width - || adjusted_fit.height > bounds.height - { - renderer.with_layer(bounds, render); - } else { - render(renderer); - } + renderer.draw_svg( + svg::Svg { + handle: self.handle.clone(), + color: style.color, + rotation: self.rotation.radians(), + opacity: self.opacity, + }, + drawing_bounds, + bounds, + ); } } From 867fe819c098a1d583e0d3e46ddf7eb1f292c2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 21:19:25 +0100 Subject: [PATCH 26/28] Add explicit error handling to image loading --- core/src/image.rs | 56 +++++++++++-- core/src/renderer.rs | 4 +- core/src/renderer/null.rs | 18 ++++- examples/gallery/src/civitai.rs | 8 +- examples/gallery/src/main.rs | 11 ++- graphics/src/image.rs | 28 +++++-- renderer/src/fallback.rs | 13 +++- runtime/src/image.rs | 6 +- runtime/src/task.rs | 14 ++++ src/lib.rs | 2 +- tiny_skia/src/lib.rs | 24 +++++- tiny_skia/src/raster.rs | 108 +++++++++++++++---------- wgpu/src/image/cache.rs | 134 +++++++++++++++++++++++++++----- wgpu/src/image/raster.rs | 19 +---- wgpu/src/lib.rs | 17 +++- widget/src/image.rs | 5 +- widget/src/image/viewer.rs | 8 +- 17 files changed, 357 insertions(+), 118 deletions(-) diff --git a/core/src/image.rs b/core/src/image.rs index 65342dae..9540140c 100644 --- a/core/src/image.rs +++ b/core/src/image.rs @@ -7,6 +7,7 @@ use crate::{Radians, Rectangle, Size}; use rustc_hash::FxHasher; use std::hash::{Hash, Hasher}; +use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; @@ -258,7 +259,10 @@ pub struct Allocation(Arc); /// Some memory taken by an [`Allocation`]. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Memory(Handle); +pub struct Memory { + handle: Handle, + size: Size, +} impl Allocation { /// Returns a weak reference to the [`Memory`] of the [`Allocation`]. @@ -273,7 +277,12 @@ impl Allocation { /// Returns the [`Handle`] of this [`Allocation`]. pub fn handle(&self) -> &Handle { - &self.0.0 + &self.0.handle + } + + /// Returns the [`Size`] of the image of this [`Allocation`]. + pub fn size(&self) -> Size { + self.0.size } } @@ -284,8 +293,11 @@ impl Allocation { /// # Safety /// Must only be created once the [`Handle`] is allocated in memory. #[allow(unsafe_code)] -pub unsafe fn allocate(handle: &Handle) -> Allocation { - Allocation(Arc::new(Memory(handle.clone()))) +pub unsafe fn allocate(handle: &Handle, size: Size) -> Allocation { + Allocation(Arc::new(Memory { + handle: handle.clone(), + size, + })) } /// A [`Renderer`] that can render raster graphics. @@ -297,10 +309,27 @@ pub trait Renderer: crate::Renderer { /// [`Handle`]: Self::Handle type Handle: Clone; + /// Loads an image and returns an explicit [`Allocation`] to it. + /// + /// If the image is not already loaded, this method will block! You should + /// generally not use it in drawing logic if you want to avoid frame drops. + fn load_image(&self, handle: &Self::Handle) -> Result; + /// Returns the dimensions of an image for the given [`Handle`]. - fn measure_image(&self, handle: &Self::Handle) -> Size; + /// + /// If the image is not already loaded, the [`Renderer`] may choose to return + /// `None`, load the image in the background, and then trigger a relayout. + /// + /// If you need a measurement right away, consider using [`Renderer::load_image`]. + fn measure_image(&self, handle: &Self::Handle) -> Option>; /// Draws an [`Image`] inside the provided `bounds`. + /// + /// If the image is not already loaded, the [`Renderer`] may choose to render + /// nothing, load the image in the background, and then trigger a redraw. + /// + /// If you need to draw an image right away, consider using [`Renderer::load_image`] + /// and hold on to an [`Allocation`] first. fn draw_image( &mut self, image: Image, @@ -308,3 +337,20 @@ pub trait Renderer: crate::Renderer { clip_bounds: Rectangle, ); } + +/// An image loading error. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + /// The image data was invalid or could not be decoded. + #[error("the image data was invalid or could not be decoded: {0}")] + Invalid(Arc), + /// The image file was not found. + #[error("the image file could not be opened: {0}")] + Inaccessible(Arc), + /// Loading images is unsupported. + #[error("loading images is unsupported")] + Unsupported, + /// Not enough memory to allocate the image. + #[error("not enough memory to allocate the image")] + OutOfMemory, +} diff --git a/core/src/renderer.rs b/core/src/renderer.rs index 6e24f661..89157c56 100644 --- a/core/src/renderer.rs +++ b/core/src/renderer.rs @@ -68,7 +68,9 @@ pub trait Renderer { fn allocate_image( &mut self, handle: &image::Handle, - callback: impl FnOnce(image::Allocation) + Send + 'static, + callback: impl FnOnce(Result) + + Send + + 'static, ); } diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 87b6ed19..3c17265d 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -28,10 +28,12 @@ impl Renderer for () { fn allocate_image( &mut self, handle: &image::Handle, - callback: impl FnOnce(image::Allocation) + Send + 'static, + callback: impl FnOnce(Result) + + Send + + 'static, ) { #[allow(unsafe_code)] - callback(unsafe { image::allocate(handle) }); + callback(Ok(unsafe { image::allocate(handle, Size::new(100, 100)) })); } } @@ -213,8 +215,16 @@ impl text::Editor for () { impl image::Renderer for () { type Handle = image::Handle; - fn measure_image(&self, _handle: &Self::Handle) -> Size { - Size::default() + fn load_image( + &self, + handle: &Self::Handle, + ) -> Result { + #[allow(unsafe_code)] + Ok(unsafe { image::allocate(handle, Size::new(100, 100)) }) + } + + fn measure_image(&self, _handle: &Self::Handle) -> Option> { + Some(Size::new(100, 100)) } fn draw_image( diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 94ca69c4..f7475525 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -166,7 +166,7 @@ pub enum Error { RequestFailed(Arc), IOFailed(Arc), JoinFailed(Arc), - ImageDecodingFailed(Arc), + ImageDecodingFailed, BlurhashDecodingFailed(Arc), } @@ -188,12 +188,6 @@ impl From for Error { } } -impl From for Error { - fn from(error: image::ImageError) -> Self { - Self::ImageDecodingFailed(Arc::new(error)) - } -} - impl From for Error { fn from(error: blurhash::Error) -> Self { Self::BlurhashDecodingFailed(Arc::new(error)) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 99bd2cca..f6ed0077 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -49,7 +49,7 @@ enum Message { ImagePoppedOut(Id), ImageDownloaded(Result), ThumbnailDownloaded(Id, Result), - ThumbnailAllocated(Id, image::Allocation), + ThumbnailAllocated(Id, Result), ThumbnailHovered(Id, bool), BlurhashDecoded(Id, civitai::Blurhash), Open(Id), @@ -175,7 +175,7 @@ impl Gallery { .then(image::allocate) .map(Message::ThumbnailAllocated.with(id)) } - Message::ThumbnailAllocated(id, allocation) => { + Message::ThumbnailAllocated(id, Ok(allocation)) => { if !self.visible.contains(&id) { return Task::none(); } @@ -221,7 +221,7 @@ impl Gallery { Task::future(image.download(Size::Original)) .and_then(|bytes| { image::allocate(image::Handle::from_bytes(bytes)) - .map(Ok) + .map_err(|_| Error::ImageDecodingFailed) }) .map(Message::ImageDownloaded) } @@ -236,6 +236,11 @@ impl Gallery { | Message::ThumbnailDownloaded(_, Err(error)) => { dbg!(error); + Task::none() + } + Message::ThumbnailAllocated(_, Err(error)) => { + dbg!(error); + Task::none() } } diff --git a/graphics/src/image.rs b/graphics/src/image.rs index 6f74b721..c844aa9e 100644 --- a/graphics/src/image.rs +++ b/graphics/src/image.rs @@ -6,6 +6,8 @@ use crate::core::Rectangle; use crate::core::image; use crate::core::svg; +use std::sync::Arc; + /// A raster or vector image. #[allow(missing_docs)] #[derive(Debug, Clone, PartialEq)] @@ -37,14 +39,14 @@ impl Image { } } +/// An image buffer. +pub type Buffer = ::image::ImageBuffer<::image::Rgba, image::Bytes>; + #[cfg(feature = "image")] /// Tries to load an image by its [`Handle`]. /// /// [`Handle`]: image::Handle -pub fn load( - handle: &image::Handle, -) -> ::image::ImageResult<::image::ImageBuffer<::image::Rgba, image::Bytes>> -{ +pub fn load(handle: &image::Handle) -> Result { use bitflags::bitflags; bitflags! { @@ -96,7 +98,7 @@ pub fn load( let (width, height, pixels) = match handle { image::Handle::Path(_, path) => { - let image = ::image::open(path)?; + let image = ::image::open(path).map_err(to_error)?; let operation = std::fs::File::open(path) .ok() @@ -113,7 +115,8 @@ pub fn load( ) } image::Handle::Bytes(_, bytes) => { - let image = ::image::load_from_memory(bytes)?; + let image = ::image::load_from_memory(bytes).map_err(to_error)?; + let operation = Operation::from_exif(&mut std::io::Cursor::new(bytes)) .ok() @@ -138,10 +141,19 @@ pub fn load( if let Some(image) = ::image::ImageBuffer::from_raw(width, height, pixels) { Ok(image) } else { - Err(::image::error::ImageError::Limits( + Err(to_error(::image::error::ImageError::Limits( ::image::error::LimitError::from_kind( ::image::error::LimitErrorKind::DimensionError, ), - )) + ))) + } +} + +fn to_error(error: ::image::ImageError) -> image::Error { + match error { + ::image::ImageError::IoError(error) => { + image::Error::Inaccessible(Arc::new(error)) + } + error => image::Error::Invalid(Arc::new(error)), } } diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index 01e49390..16cec4a3 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -73,7 +73,9 @@ where fn allocate_image( &mut self, handle: &image::Handle, - callback: impl FnOnce(image::Allocation) + Send + 'static, + callback: impl FnOnce(Result) + + Send + + 'static, ) { delegate!(self, renderer, renderer.allocate_image(handle, callback)); } @@ -154,7 +156,14 @@ where { type Handle = A::Handle; - fn measure_image(&self, handle: &Self::Handle) -> Size { + fn load_image( + &self, + handle: &Self::Handle, + ) -> Result { + delegate!(self, renderer, renderer.load_image(handle)) + } + + fn measure_image(&self, handle: &Self::Handle) -> Option> { delegate!(self, renderer, renderer.measure_image(handle)) } diff --git a/runtime/src/image.rs b/runtime/src/image.rs index 2e2cfa69..53b736cc 100644 --- a/runtime/src/image.rs +++ b/runtime/src/image.rs @@ -3,13 +3,13 @@ use crate::core::image::Handle; use crate::futures::futures::channel::oneshot; use crate::task::{self, Task}; -pub use crate::core::image::Allocation; +pub use crate::core::image::{Allocation, Error}; /// An image action. #[derive(Debug)] pub enum Action { /// Allocates the given [`Handle`]. - Allocate(Handle, oneshot::Sender), + Allocate(Handle, oneshot::Sender>), } /// Allocates an image [`Handle`]. @@ -17,7 +17,7 @@ pub enum Action { /// When you obtain an [`Allocation`] explicitly, you get the guarantee /// that using a [`Handle`] will draw the corresponding image immediately /// in the next frame. -pub fn allocate(handle: impl Into) -> Task { +pub fn allocate(handle: impl Into) -> Task> { task::oneshot(|sender| { crate::Action::Image(Action::Allocate(handle.into(), sender)) }) diff --git a/runtime/src/task.rs b/runtime/src/task.rs index 97f64b61..209c1176 100644 --- a/runtime/src/task.rs +++ b/runtime/src/task.rs @@ -390,6 +390,20 @@ impl Task> { result.map_or_else(|error| Task::done(Err(error)), &f) }) } + + /// Maps the error type of this [`Task`] to a different one using the given + /// function. + pub fn map_err( + self, + f: impl Fn(E) -> E2 + MaybeSend + 'static, + ) -> Task> + where + T: MaybeSend + 'static, + E: MaybeSend + 'static, + E2: MaybeSend + 'static, + { + self.map(move |result| result.map_err(&f)) + } } impl Default for Task { diff --git a/src/lib.rs b/src/lib.rs index 88d26ee1..b3933670 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -628,7 +628,7 @@ pub mod widget { #[cfg(feature = "image")] pub mod image { //! Images display raster graphics in different formats (PNG, JPG, etc.). - pub use iced_runtime::image::{Allocation, allocate}; + pub use iced_runtime::image::{Allocation, Error, allocate}; pub use iced_widget::image::*; } diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index 159f8155..0d04c033 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -232,11 +232,17 @@ impl core::Renderer for Renderer { fn allocate_image( &mut self, handle: &core::image::Handle, - callback: impl FnOnce(core::image::Allocation) + Send + 'static, + callback: impl FnOnce(Result) + + Send + + 'static, ) { - // TODO: Concurrency + #[cfg(feature = "image")] #[allow(unsafe_code)] - callback(unsafe { core::image::allocate(handle) }); + // TODO: Concurrency + callback(self.engine.raster_pipeline.load(handle)); + + #[cfg(not(feature = "image"))] + callback(Err(core::image::Error::Unsupported)) } } @@ -360,7 +366,17 @@ impl graphics::mesh::Renderer for Renderer { impl core::image::Renderer for Renderer { type Handle = core::image::Handle; - fn measure_image(&self, handle: &Self::Handle) -> crate::core::Size { + fn load_image( + &self, + handle: &Self::Handle, + ) -> Result { + self.engine.raster_pipeline.load(handle) + } + + fn measure_image( + &self, + handle: &Self::Handle, + ) -> Option> { self.engine.raster_pipeline.dimensions(handle) } diff --git a/tiny_skia/src/raster.rs b/tiny_skia/src/raster.rs index c40f55b2..56ed8c47 100644 --- a/tiny_skia/src/raster.rs +++ b/tiny_skia/src/raster.rs @@ -18,12 +18,24 @@ impl Pipeline { } } - pub fn dimensions(&self, handle: &raster::Handle) -> Size { - if let Some(image) = self.cache.borrow_mut().allocate(handle) { - Size::new(image.width(), image.height()) - } else { - Size::new(0, 0) - } + pub fn load( + &self, + handle: &raster::Handle, + ) -> Result { + let mut cache = self.cache.borrow_mut(); + let image = cache.allocate(handle)?; + + #[allow(unsafe_code)] + Ok(unsafe { + raster::allocate(handle, Size::new(image.width(), image.height())) + }) + } + + pub fn dimensions(&self, handle: &raster::Handle) -> Option> { + let mut cache = self.cache.borrow_mut(); + let image = cache.allocate(handle).ok()?; + + Some(Size::new(image.width(), image.height())) } pub fn draw( @@ -36,34 +48,34 @@ impl Pipeline { transform: tiny_skia::Transform, clip_mask: Option<&tiny_skia::Mask>, ) { - if let Some(image) = self.cache.borrow_mut().allocate(handle) { - let width_scale = bounds.width / image.width() as f32; - let height_scale = bounds.height / image.height() as f32; + let mut cache = self.cache.borrow_mut(); - let transform = transform.pre_scale(width_scale, height_scale); + let Ok(image) = cache.allocate(handle) else { + return; + }; - let quality = match filter_method { - raster::FilterMethod::Linear => { - tiny_skia::FilterQuality::Bilinear - } - raster::FilterMethod::Nearest => { - tiny_skia::FilterQuality::Nearest - } - }; + let width_scale = bounds.width / image.width() as f32; + let height_scale = bounds.height / image.height() as f32; - pixels.draw_pixmap( - (bounds.x / width_scale) as i32, - (bounds.y / height_scale) as i32, - image, - &tiny_skia::PixmapPaint { - quality, - opacity, - ..Default::default() - }, - transform, - clip_mask, - ); - } + let transform = transform.pre_scale(width_scale, height_scale); + + let quality = match filter_method { + raster::FilterMethod::Linear => tiny_skia::FilterQuality::Bilinear, + raster::FilterMethod::Nearest => tiny_skia::FilterQuality::Nearest, + }; + + pixels.draw_pixmap( + (bounds.x / width_scale) as i32, + (bounds.y / height_scale) as i32, + image, + &tiny_skia::PixmapPaint { + quality, + opacity, + ..Default::default() + }, + transform, + clip_mask, + ); } pub fn trim_cache(&mut self) { @@ -81,11 +93,18 @@ impl Cache { pub fn allocate( &mut self, handle: &raster::Handle, - ) -> Option> { + ) -> Result, raster::Error> { let id = handle.id(); if let hash_map::Entry::Vacant(entry) = self.entries.entry(id) { - let image = graphics::image::load(handle).ok()?; + let image = match graphics::image::load(handle) { + Ok(image) => image, + Err(error) => { + let _ = entry.insert(None); + + return Err(error); + } + }; let mut buffer = vec![0u32; image.width() as usize * image.height() as usize]; @@ -106,14 +125,21 @@ impl Cache { } let _ = self.hits.insert(id); - self.entries.get(&id).unwrap().as_ref().map(|entry| { - tiny_skia::PixmapRef::from_bytes( - bytemuck::cast_slice(&entry.pixels), - entry.width, - entry.height, - ) - .expect("Build pixmap from image bytes") - }) + + Ok(self + .entries + .get(&id) + .unwrap() + .as_ref() + .map(|entry| { + tiny_skia::PixmapRef::from_bytes( + bytemuck::cast_slice(&entry.pixels), + entry.width, + entry.height, + ) + .expect("Build pixmap from image bytes") + }) + .expect("Image should be allocated")) } fn trim(&mut self) { diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 4ac46708..f832b239 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -38,6 +38,7 @@ impl Cache { raster: Raster { cache: crate::image::raster::Cache::default(), pending: HashMap::new(), + belt: wgpu::util::StagingBelt::new(2 * 1024 * 1024), }, #[cfg(feature = "svg")] vector: crate::image::vector::Cache::default(), @@ -50,7 +51,9 @@ impl Cache { pub fn allocate_image( &mut self, handle: &core::image::Handle, - callback: impl FnOnce(core::image::Allocation) + Send + 'static, + callback: impl FnOnce(Result) + + Send + + 'static, ) { use crate::image::raster::Memory; @@ -61,21 +64,22 @@ impl Cache { return; } - if let Some(Memory::Device { allocation, .. }) = - self.raster.cache.get_mut(handle) + if let Some(Memory::Device { + allocation, entry, .. + }) = self.raster.cache.get_mut(handle) { if let Some(allocation) = allocation .as_ref() .and_then(core::image::Allocation::upgrade) { - callback(allocation); + callback(Ok(allocation)); return; } #[allow(unsafe_code)] - let new = unsafe { core::image::allocate(handle) }; + let new = unsafe { core::image::allocate(handle, entry.size()) }; *allocation = Some(new.downgrade()); - callback(new); + callback(Ok(new)); return; } @@ -87,21 +91,104 @@ impl Cache { } #[cfg(feature = "image")] - pub fn measure_image(&mut self, handle: &core::image::Handle) -> Size { + pub fn load_image( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + handle: &core::image::Handle, + ) -> Result { + use crate::image::raster::Memory; + + if !self.raster.cache.contains(handle) { + self.raster.cache.insert(handle, Memory::load(handle)); + } + + match self.raster.cache.get_mut(handle).unwrap() { + Memory::Host(image) => { + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("raster image upload"), + }, + ); + + let entry = self.atlas.upload( + device, + &mut encoder, + &mut self.raster.belt, + image.width(), + image.height(), + image, + ); + + self.raster.belt.finish(); + let submission = queue.submit([encoder.finish()]); + self.raster.belt.recall(); + + let Some(entry) = entry else { + return Err(core::image::Error::OutOfMemory); + }; + + let _ = device + .poll(wgpu::PollType::WaitForSubmissionIndex(submission)); + + #[allow(unsafe_code)] + let allocation = unsafe { + core::image::allocate( + handle, + Size::new(image.width(), image.height()), + ) + }; + + self.raster.cache.insert( + handle, + Memory::Device { + entry, + bind_group: None, + allocation: Some(allocation.downgrade()), + }, + ); + + Ok(allocation) + } + Memory::Device { + entry, allocation, .. + } => { + if let Some(allocation) = allocation + .as_ref() + .and_then(core::image::Allocation::upgrade) + { + return Ok(allocation); + } + + #[allow(unsafe_code)] + let new = + unsafe { core::image::allocate(handle, entry.size()) }; + + *allocation = Some(new.downgrade()); + + Ok(new) + } + Memory::Error(error) => Err(error.clone()), + } + } + + #[cfg(feature = "image")] + pub fn measure_image( + &mut self, + handle: &core::image::Handle, + ) -> Option> { self.receive(); - if let Some(memory) = load_image( + let image = load_image( &mut self.raster.cache, &mut self.raster.pending, #[cfg(not(target_arch = "wasm32"))] &self.worker, handle, None, - ) { - return memory.dimensions(); - } + )?; - Size::new(0, 0) + Some(image.dimensions()) } #[cfg(feature = "svg")] @@ -230,13 +317,14 @@ impl Cache { let allocation = if let Some(callbacks) = callbacks { #[allow(unsafe_code)] - let allocation = - unsafe { core::image::allocate(&handle) }; + let allocation = unsafe { + core::image::allocate(&handle, entry.size()) + }; let reference = allocation.downgrade(); for callback in callbacks { - callback(allocation.clone()); + callback(Ok(allocation.clone())); } Some(reference) @@ -254,7 +342,15 @@ impl Cache { ); } worker::Work::Error { handle, error } => { - self.raster.cache.insert(&handle, Memory::error(error)); + let callbacks = self.raster.pending.remove(&handle.id()); + + if let Some(callbacks) = callbacks { + for callback in callbacks { + callback(Err(error.clone())); + } + } + + self.raster.cache.insert(&handle, Memory::Error(error)); } } } @@ -272,10 +368,12 @@ impl Drop for Cache { struct Raster { cache: crate::image::raster::Cache, pending: HashMap>, + belt: wgpu::util::StagingBelt, } #[cfg(feature = "image")] -type Callback = Box; +type Callback = + Box) + Send>; #[cfg(feature = "image")] fn load_image<'a>( @@ -418,7 +516,7 @@ mod worker { }, Error { handle: image::Handle, - error: crate::graphics::image::image_rs::error::ImageError, + error: image::Error, }, } diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 4300e935..5320a3c1 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -20,24 +20,14 @@ pub enum Memory { bind_group: Option>, allocation: Option>, }, - /// Image not found - NotFound, - /// Invalid image data - Invalid, + Error(image::Error), } impl Memory { pub fn load(handle: &image::Handle) -> Self { match graphics::image::load(handle) { Ok(image) => Self::Host(image), - Err(error) => Self::error(error), - } - } - - pub fn error(error: image_rs::error::ImageError) -> Self { - match error { - image_rs::error::ImageError::IoError(_) => Self::NotFound, - _ => Self::Invalid, + Err(error) => Self::Error(error), } } @@ -49,15 +39,14 @@ impl Memory { Size::new(width, height) } Memory::Device { entry, .. } => entry.size(), - Memory::NotFound => Size::new(1, 1), - Memory::Invalid => Size::new(1, 1), + Memory::Error(_) => Size::new(1, 1), } } pub fn host(&self) -> Option { match self { Memory::Host(image) => Some(image.clone()), - Memory::Device { .. } | Memory::NotFound | Memory::Invalid => None, + Memory::Device { .. } | Memory::Error(_) => None, } } } diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index e0a7e969..01de8800 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -702,7 +702,9 @@ impl core::Renderer for Renderer { fn allocate_image( &mut self, _handle: &core::image::Handle, - _callback: impl FnOnce(core::image::Allocation) + Send + 'static, + _callback: impl FnOnce(Result) + + Send + + 'static, ) { #[cfg(feature = "image")] self.image_cache @@ -773,7 +775,18 @@ impl core::text::Renderer for Renderer { impl core::image::Renderer for Renderer { type Handle = core::image::Handle; - fn measure_image(&self, handle: &Self::Handle) -> core::Size { + fn load_image( + &self, + handle: &Self::Handle, + ) -> Result { + self.image_cache.borrow_mut().load_image( + &self.engine.device, + &self.engine.queue, + handle, + ) + } + + fn measure_image(&self, handle: &Self::Handle) -> Option> { self.image_cache.borrow_mut().measure_image(handle) } diff --git a/widget/src/image.rs b/widget/src/image.rs index e40c9b6d..c5108d81 100644 --- a/widget/src/image.rs +++ b/widget/src/image.rs @@ -197,7 +197,8 @@ where Renderer: image::Renderer, { // The raw w/h of the underlying image - let image_size = crop(renderer.measure_image(handle), region); + let image_size = + crop(renderer.measure_image(handle).unwrap_or_default(), region); // The rotated size of the image let rotated_size = rotation.apply(image_size); @@ -239,7 +240,7 @@ fn drawing_bounds( where Renderer: image::Renderer, { - let original_size = renderer.measure_image(handle); + let original_size = renderer.measure_image(handle).unwrap_or_default(); let image_size = crop(original_size, region); let rotated_size = rotation.apply(image_size); let adjusted_fit = content_fit.fit(rotated_size, bounds.size()); diff --git a/widget/src/image/viewer.rs b/widget/src/image/viewer.rs index 2fab9013..209b3728 100644 --- a/widget/src/image/viewer.rs +++ b/widget/src/image/viewer.rs @@ -123,7 +123,9 @@ where limits: &layout::Limits, ) -> layout::Node { // The raw w/h of the underlying image - let image_size = renderer.measure_image(&self.handle); + let image_size = + renderer.measure_image(&self.handle).unwrap_or_default(); + let image_size = Size::new(image_size.width as f32, image_size.height as f32); @@ -436,7 +438,9 @@ pub fn scaled_image_size( where Renderer: image::Renderer, { - let Size { width, height } = renderer.measure_image(handle); + let Size { width, height } = + renderer.measure_image(handle).unwrap_or_default(); + let image_size = Size::new(width as f32, height as f32); let adjusted_fit = content_fit.fit(image_size, bounds); From 74c8641e2c32592430dd5f2ea18a17b5608ee541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 21:31:15 +0100 Subject: [PATCH 27/28] Fix missing feature flags in `graphics::image` --- graphics/src/image.rs | 6 ++++-- wgpu/src/image/raster.rs | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/graphics/src/image.rs b/graphics/src/image.rs index c844aa9e..c9d4e45f 100644 --- a/graphics/src/image.rs +++ b/graphics/src/image.rs @@ -6,8 +6,6 @@ use crate::core::Rectangle; use crate::core::image; use crate::core::svg; -use std::sync::Arc; - /// A raster or vector image. #[allow(missing_docs)] #[derive(Debug, Clone, PartialEq)] @@ -40,6 +38,7 @@ impl Image { } /// An image buffer. +#[cfg(feature = "image")] pub type Buffer = ::image::ImageBuffer<::image::Rgba, image::Bytes>; #[cfg(feature = "image")] @@ -149,7 +148,10 @@ pub fn load(handle: &image::Handle) -> Result { } } +#[cfg(feature = "image")] fn to_error(error: ::image::ImageError) -> image::Error { + use std::sync::Arc; + match error { ::image::ImageError::IoError(error) => { image::Error::Inaccessible(Arc::new(error)) diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 5320a3c1..5c4ad3be 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -1,13 +1,12 @@ use crate::core::Size; use crate::core::image; use crate::graphics; -use crate::graphics::image::image_rs; use crate::image::atlas::{self, Atlas}; use rustc_hash::{FxHashMap, FxHashSet}; use std::sync::{Arc, Weak}; -pub type Image = image_rs::ImageBuffer, image::Bytes>; +pub type Image = graphics::image::Buffer; /// Entry in cache corresponding to an image handle #[derive(Debug)] From 59fe141efd8b385591c4988c773f286b8b8adceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 28 Oct 2025 21:47:14 +0100 Subject: [PATCH 28/28] Add `debug` log to image cache eviction --- wgpu/src/image/raster.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/wgpu/src/image/raster.rs b/wgpu/src/image/raster.rs index 5c4ad3be..881e75e3 100644 --- a/wgpu/src/image/raster.rs +++ b/wgpu/src/image/raster.rs @@ -87,7 +87,7 @@ impl Cache { let hits = &self.hits; - self.map.retain(|k, memory| { + self.map.retain(|id, memory| { // Retain active allocations if let Memory::Device { allocation, .. } = memory && allocation @@ -97,17 +97,20 @@ impl Cache { return true; } - let retain = hits.contains(k); + let retain = hits.contains(id); - if !retain - && let Memory::Device { + if !retain { + log::debug!("Dropping image allocation: {id:?}"); + + if let Memory::Device { entry, bind_group, .. } = memory - { - if let Some(bind_group) = bind_group.take() { - on_drop(bind_group); - } else { - atlas.remove(entry); + { + if let Some(bind_group) = bind_group.take() { + on_drop(bind_group); + } else { + atlas.remove(entry); + } } }