From 511180679b3792387f9b5eabd6d11f4c1bc4ade6 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 1 Sep 2026 18:02:37 -0600 Subject: [PATCH] feat: Lancsoz downsampler when target size is relatively too small --- graphics/src/image.rs | 104 ++++++++++++++++++++++++++++++++++++++++ tiny_skia/src/raster.rs | 68 +++++++++++++++++++++++++- wgpu/src/image/cache.rs | 55 +++++++++++++++++++++ wgpu/src/image/mod.rs | 8 +++- 4 files changed, 233 insertions(+), 2 deletions(-) diff --git a/graphics/src/image.rs b/graphics/src/image.rs index e5fe9281..eda89951 100644 --- a/graphics/src/image.rs +++ b/graphics/src/image.rs @@ -1,6 +1,8 @@ //! Load and operate on images. #[cfg(feature = "image")] use crate::core::Bytes; +#[cfg(feature = "image")] +use crate::core::Size; use crate::core::Color; use crate::core::Radians; @@ -180,3 +182,105 @@ fn to_error(error: ::image::ImageError) -> image::Error { error => image::Error::Invalid(Arc::new(error)), } } + +/// resample a raster image to this target +#[cfg(feature = "image")] +pub fn downsample_target( + native: Size, + bounds: Size, +) -> Option> { + if !(bounds.width >= 1.0 && bounds.height >= 1.0) { + return None; + } + + // Round up to 4px so an animated resize does not make a copy per pixel + // TODO: maybe find a better approach + let quantize = |length: f32| (length.ceil() as u32).div_ceil(4) * 4; + + let target = Size::new( + quantize(bounds.width).min(native.width), + quantize(bounds.height).min(native.height), + ); + + let minified = native.width as f32 >= target.width as f32 * 1.25 + || native.height as f32 >= target.height as f32 * 1.25; + + // If the target is big then Lanczos will be too expensive, and sampler is good enough + let small = u64::from(target.width) * u64::from(target.height) <= 1 << 18; + + (minified && small).then_some(target) +} + +/// Resamples premultiplied pixels down to `target`. +#[cfg(feature = "image")] +pub fn downsample_premultiplied( + pixels: &[u8], + size: Size, + target: Size, +) -> Vec { + let image = + ::image::RgbaImage::from_raw(size.width, size.height, pixels.to_vec()) + .expect("pixels hold width * height RGBA pixels"); + + resize(&image, target).into_raw() +} + +#[cfg(feature = "image")] +fn resize(image: &::image::RgbaImage, target: Size) -> ::image::RgbaImage { + use ::image::imageops::{self, FilterType}; + use std::borrow::Cow; + + // if image is too big compared to target, box average it to double the target + // and then do Lanczos. The final result is almost the same, but lanczos is too + // expensive on large images. + let image = if image.width() >= target.width * 4 + && image.height() >= target.height * 4 + { + Cow::Owned(imageops::thumbnail( + image, + target.width * 2, + target.height * 2, + )) + } else { + Cow::Borrowed(image) + }; + + imageops::resize(&*image, target.width, target.height, FilterType::Lanczos3) +} + +/// Resamples RGBA pixels down to `target`. +/// +/// Premultiplies the image, to avoid fringing the edges of an icon when interpolating +/// transparent pixels. +#[cfg(feature = "image")] +pub fn downsample(image: &Buffer, target: Size) -> ::image::RgbaImage { + let mut image = ::image::RgbaImage::from_raw( + image.width(), + image.height(), + image.as_raw().to_vec(), + ) + .expect("buffer holds width * height RGBA pixels"); + + for pixel in image.pixels_mut() { + let alpha = u32::from(pixel[3]); + + for channel in &mut pixel.0[..3] { + *channel = ((u32::from(*channel) * alpha + 127) / 255) as u8; + } + } + + let mut image = resize(&image, target); + + for pixel in image.pixels_mut() { + let alpha = u32::from(pixel[3]); + + if alpha > 0 { + for channel in &mut pixel.0[..3] { + *channel = ((u32::from(*channel) * 255 + alpha / 2) / alpha) + .min(255) as u8; + } + } + } + + image +} diff --git a/tiny_skia/src/raster.rs b/tiny_skia/src/raster.rs index 7c820917..0535158a 100644 --- a/tiny_skia/src/raster.rs +++ b/tiny_skia/src/raster.rs @@ -51,7 +51,21 @@ impl Pipeline { ) { let mut cache = self.cache.borrow_mut(); - let Ok(mut image) = cache.allocate(handle) else { + let target = { + let Ok(image) = cache.allocate(handle) else { + return; + }; + + graphics::image::downsample_target( + Size::new(image.width(), image.height()), + bounds.size(), + ) + }; + + let Ok(mut image) = (match target { + Some(target) => cache.allocate_resampled(handle, target), + None => cache.allocate(handle), + }) else { return; }; @@ -110,6 +124,8 @@ impl Pipeline { struct Cache { entries: FxHashMap>, hits: FxHashSet, + resampled: FxHashMap<(raster::Id, u32, u32), Entry>, + resampled_hits: FxHashSet<(raster::Id, u32, u32)>, } impl Cache { @@ -166,9 +182,59 @@ impl Cache { Ok(ret) } + /// Like [`Self::allocate`], resampled to `target`. Call after + /// [`Self::allocate`] has decoded the image. + pub fn allocate_resampled( + &mut self, + handle: &raster::Handle, + target: Size, + ) -> Result, raster::Error> { + let key = (handle.id(), target.width, target.height); + + if !self.resampled.contains_key(&key) { + let native = self + .entries + .get(&handle.id()) + .and_then(Option::as_ref) + .ok_or(raster::Error::Empty)?; + + // Stored pixels are already premultiplied, so resample them as is. + let pixels = graphics::image::downsample_premultiplied( + bytemuck::cast_slice(&native.pixels), + Size::new(native.width, native.height), + target, + ); + + let _ = self.resampled.insert( + key, + Entry { + width: target.width, + height: target.height, + pixels: pixels + .chunks_exact(4) + .map(|p| u32::from_ne_bytes([p[0], p[1], p[2], p[3]])) + .collect(), + }, + ); + } + + let _ = self.resampled_hits.insert(key); + let entry = &self.resampled[&key]; + + Ok(tiny_skia::PixmapRef::from_bytes( + bytemuck::cast_slice(&entry.pixels), + entry.width, + entry.height, + ) + .expect("Build pixmap from image bytes")) + } + fn trim(&mut self) { self.entries.retain(|key, _| self.hits.contains(key)); + self.resampled + .retain(|key, _| self.resampled_hits.contains(key)); self.hits.clear(); + self.resampled_hits.clear(); } } diff --git a/wgpu/src/image/cache.rs b/wgpu/src/image/cache.rs index 88a48640..37f1e33d 100644 --- a/wgpu/src/image/cache.rs +++ b/wgpu/src/image/cache.rs @@ -5,6 +5,8 @@ use crate::image::atlas::{self, Atlas}; #[cfg(all(feature = "image", not(target_arch = "wasm32")))] use worker::Worker; +#[cfg(feature = "image")] +use rustc_hash::{FxHashMap, FxHashSet}; #[cfg(feature = "image")] use std::collections::HashMap; @@ -38,6 +40,9 @@ impl Cache { raster: Raster { cache: crate::image::raster::Cache::default(), pending: HashMap::new(), + resampled: FxHashMap::default(), + resampled_hits: FxHashSet::default(), + should_trim: false, belt: wgpu::util::StagingBelt::new( device.clone(), 2 * 1024 * 1024, @@ -209,7 +214,9 @@ impl Cache { encoder: &mut wgpu::CommandEncoder, belt: &mut wgpu::util::StagingBelt, handle: &core::image::Handle, + bounds: Size, ) -> Option<(&atlas::Entry, &Arc)> { + use crate::graphics::image::{downsample, downsample_target, load}; use crate::image::raster::Memory; self.receive(); @@ -223,6 +230,34 @@ impl Cache { None, )?; + if let Some(target) = downsample_target(memory.dimensions(), bounds) { + let key = (handle.id(), target.width, target.height); + + if !self.raster.resampled.contains_key(&key) { + let image = memory.host().or_else(|| load(handle).ok())?; + let image = downsample(&image, target); + + let entry = self.atlas.upload( + device, + encoder, + belt, + target.width, + target.height, + &image, + )?; + + let _ = self.raster.resampled.insert(key, entry); + self.raster.should_trim = true; + } + + let _ = self.raster.resampled_hits.insert(key); + + return Some(( + self.raster.resampled.get(&key)?, + self.atlas.bind_group(), + )); + } + if let Memory::Device { entry, bind_group, .. } = memory @@ -303,6 +338,23 @@ impl Cache { #[cfg(not(target_arch = "wasm32"))] self.worker.drop(_bind_group); }); + + if self.raster.should_trim { + let hits = &self.raster.resampled_hits; + let atlas = &mut self.atlas; + + self.raster.resampled.retain(|key, entry| { + let retain = hits.contains(key); + + if !retain { + atlas.remove(entry); + } + + retain + }); + self.raster.resampled_hits.clear(); + self.raster.should_trim = false; + } } #[cfg(feature = "svg")] @@ -376,6 +428,9 @@ impl Drop for Cache { struct Raster { cache: crate::image::raster::Cache, pending: HashMap>, + resampled: FxHashMap<(core::image::Id, u32, u32), atlas::Entry>, + resampled_hits: FxHashSet<(core::image::Id, u32, u32)>, + should_trim: bool, belt: wgpu::util::StagingBelt, } diff --git a/wgpu/src/image/mod.rs b/wgpu/src/image/mod.rs index d0665ee4..2b5f7ade 100644 --- a/wgpu/src/image/mod.rs +++ b/wgpu/src/image/mod.rs @@ -267,7 +267,13 @@ impl State { clip_bounds, } => { if let Some((atlas_entry, bind_group)) = cache - .upload_raster(device, encoder, belt, &image.handle) + .upload_raster( + device, + encoder, + belt, + &image.handle, + bounds.size() * scale, + ) { match atlas.as_mut() { None => {