From 0c849a6b2dfbedbbaf6428fbbca07a8344b32be7 Mon Sep 17 00:00:00 2001 From: Todd Beauchamp Date: Thu, 22 Oct 2026 09:43:58 -0400 Subject: [PATCH 1/2] gpui: downscale oversized images before atlas upload The sprite atlas has no mipmaps and every renderer samples it bilinearly, so an image drawn far below its pixel size reads only the 2x2 texels nearest each screen pixel and skips the rest. A 512px editor badge drawn at 32px on a 100% Windows display read 4 of every 256 source pixels and came out jagged; Retina hid most of it at 8:1. paint_image now picks a whole number of halvings that brings the frame within 2x of its device-pixel draw size, box-filters a copy on the CPU (alpha-weighted, so transparent edges do not darken) and uploads that. RenderImageParams carries the level, so each image keeps at most a handful of variants however its draw size changes, and drop_image frees all of them. Images drawn at or near their own size upload as before. --- crates/gpui/src/assets.rs | 201 ++++++++++++++++++ crates/gpui/src/elements/img.rs | 65 ++++++ crates/gpui/src/window.rs | 59 +++-- crates/gpui_apple/src/metal_atlas.rs | 1 + crates/gpui_apple/src/metal_renderer.rs | 1 + crates/gpui_wgpu/src/wgpu_atlas.rs | 3 + crates/gpui_wgpu/tests/headless_primitives.rs | 2 + crates/gpui_windows/src/directx_atlas.rs | 1 + crates/gpui_windows/src/directx_renderer.rs | 1 + 9 files changed, 315 insertions(+), 19 deletions(-) diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index 0294782aa16..b863f812a34 100644 --- a/crates/gpui/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -214,6 +214,95 @@ pub struct ImageId(pub usize); pub struct RenderImageParams { pub image_id: ImageId, pub frame_index: usize, + /// How many times the frame was halved before upload; `0` is the frame as decoded. + /// + /// The atlas has no mipmaps and samples bilinearly, so drawing an image far below its + /// pixel size skips most of its pixels and aliases. `Window::paint_image` uploads a + /// box-filtered copy instead (see [`image_downscale_level`]). + pub downscale_level: u8, +} + +/// The number of halvings that bring an image of `source` pixels to within 2x of the +/// `target_width` x `target_height` device pixels it is drawn at, without dropping below them. +/// +/// Bilinear sampling reads 2x2 texels per pixel, so it only averages every source pixel +/// while the source is at most twice the drawn size. A whole number of halvings (like a mip +/// chain) keeps the variants of one image down to a handful, however the draw size changes +/// during a resize or animation. The smaller of the two axis ratios decides, so neither +/// axis is ever magnified. +pub(crate) fn image_downscale_level( + source: Size, + target_width: f32, + target_height: f32, +) -> u8 { + if !(target_width > 0.0 && target_height > 0.0) { + return 0; + } + let ratio = (source.width.0 as f32 / target_width).min(source.height.0 as f32 / target_height); + if ratio < 2.0 { + return 0; + } + (ratio.log2().floor() as u8).min(max_image_downscale_level(source)) +} + +/// The most halvings `source` allows before one side reaches a single pixel. +pub(crate) fn max_image_downscale_level(source: Size) -> u8 { + let longest = source.width.0.max(source.height.0).max(1) as u32; + longest.ilog2() as u8 +} + +/// The size of `source` after `level` halvings, rounding up so no side reaches zero. +pub(crate) fn downscaled_image_size(source: Size, level: u8) -> Size { + let halve = |side: i32| (0..level).fold(side.max(1) as u32, |side, _| side.div_ceil(2)) as i32; + size( + DevicePixels(halve(source.width.0)), + DevicePixels(halve(source.height.0)), + ) +} + +/// Halves straight-alpha BGRA pixels `level` times with a 2x2 box filter. +/// +/// Colors are weighted by alpha, so the color of a transparent pixel (often black) does not +/// darken the edge of an opaque one. An odd last row or column averages only the pixels it has. +pub(crate) fn downscale_bgra(bytes: &[u8], source: Size, level: u8) -> Vec { + let mut width = source.width.0.max(0) as usize; + let mut height = source.height.0.max(0) as usize; + let mut pixels = bytes.to_vec(); + for _ in 0..level { + if width <= 1 && height <= 1 { + break; + } + let half_width = width.div_ceil(2); + let half_height = height.div_ceil(2); + let mut half = vec![0u8; half_width * half_height * 4]; + for y in 0..half_height { + for x in 0..half_width { + let mut color = [0u32; 3]; + let mut alpha = 0u32; + let mut count = 0u32; + for source_y in (2 * y)..(2 * y + 2).min(height) { + for source_x in (2 * x)..(2 * x + 2).min(width) { + let pixel = &pixels[(source_y * width + source_x) * 4..][..4]; + let a = u32::from(pixel[3]); + for (sum, channel) in color.iter_mut().zip(&pixel[..3]) { + *sum += u32::from(*channel) * a; + } + alpha += a; + count += 1; + } + } + let out = &mut half[(y * half_width + x) * 4..][..4]; + for (channel, sum) in out.iter_mut().zip(color) { + *channel = (sum + alpha / 2).checked_div(alpha).unwrap_or(0) as u8; + } + out[3] = ((alpha + count / 2) / count) as u8; + } + } + pixels = half; + width = half_width; + height = half_height; + } + pixels } /// A cached and processed image, in BGRA format @@ -417,4 +506,116 @@ mod tests { assert!(result.is_ok()); test_load_from(&assets); } + + fn device_size(width: i32, height: i32) -> Size { + size(DevicePixels(width), DevicePixels(height)) + } + + #[test] + fn downscale_level_brings_the_source_within_twice_the_draw_size() { + let badge = device_size(512, 512); + // 32px at 100%, 125%, 150% and 200% scale. + assert_eq!(image_downscale_level(badge, 32.0, 32.0), 4); + assert_eq!(image_downscale_level(badge, 40.0, 40.0), 3); + assert_eq!(image_downscale_level(badge, 48.0, 48.0), 3); + assert_eq!(image_downscale_level(badge, 64.0, 64.0), 3); + for target in [24.0_f32, 32.0, 40.0, 48.0, 64.0, 100.0, 255.0] { + let level = image_downscale_level(badge, target, target); + let side = downscaled_image_size(badge, level).width.0 as f32; + assert!( + side >= target && side < 2.0 * target, + "{target}px: level {level} gives {side}px" + ); + } + } + + #[test] + fn downscale_level_leaves_images_at_or_near_their_size_alone() { + let image = device_size(200, 100); + assert_eq!(image_downscale_level(image, 200.0, 100.0), 0); + assert_eq!(image_downscale_level(image, 101.0, 51.0), 0); + assert_eq!(image_downscale_level(image, 400.0, 200.0), 0); + assert_eq!(image_downscale_level(image, 0.0, 0.0), 0); + assert_eq!(image_downscale_level(image, f32::NAN, 10.0), 0); + assert_eq!(image_downscale_level(device_size(0, 0), 10.0, 10.0), 0); + } + + #[test] + fn downscale_level_follows_the_less_shrunk_axis() { + // Squashed to a quarter of the width but only half the height: one halving, so the + // height is never magnified. + assert_eq!( + image_downscale_level(device_size(400, 400), 100.0, 200.0), + 1 + ); + } + + #[test] + fn downscale_level_stops_at_one_pixel() { + let image = device_size(8, 2); + assert_eq!(max_image_downscale_level(image), 3); + assert_eq!(image_downscale_level(image, 0.01, 0.01), 3); + assert_eq!(downscaled_image_size(image, 3), device_size(1, 1)); + } + + #[test] + fn downscaled_size_rounds_odd_sides_up() { + assert_eq!( + downscaled_image_size(device_size(5, 3), 1), + device_size(3, 2) + ); + assert_eq!( + downscaled_image_size(device_size(5, 3), 2), + device_size(2, 1) + ); + } + + #[test] + fn downscale_averages_every_source_pixel() { + // A 1px checkerboard aliases to a solid color under point or bilinear sampling at + // 4:1; a box filter gives the true average grey. + let mut bytes = Vec::new(); + for y in 0..4 { + for x in 0..4 { + let value = if (x + y) % 2 == 0 { 255 } else { 0 }; + bytes.extend_from_slice(&[value, value, value, 255]); + } + } + let out = downscale_bgra(&bytes, device_size(4, 4), 2); + assert_eq!(out, vec![128, 128, 128, 255]); + } + + #[test] + fn downscale_weights_color_by_alpha() { + // An opaque red pixel next to transparent black ones stays red, at a quarter of the + // alpha, instead of darkening toward black. Pixels are BGRA. + let bytes = [ + 0, 0, 255, 255, // + 0, 0, 0, 0, // + 0, 0, 0, 0, // + 0, 0, 0, 0, + ]; + assert_eq!( + downscale_bgra(&bytes, device_size(2, 2), 1), + vec![0, 0, 255, 64] + ); + assert_eq!( + downscale_bgra(&[0; 16], device_size(2, 2), 1), + vec![0, 0, 0, 0] + ); + } + + #[test] + fn downscale_averages_only_the_pixels_an_odd_edge_has() { + // 3x1: the second output pixel covers one source pixel, not a phantom black one. + let bytes = [ + 10, 20, 30, 255, // + 30, 40, 50, 255, // + 200, 100, 50, 255, + ]; + assert_eq!( + downscale_bgra(&bytes, device_size(3, 1), 1), + vec![20, 30, 40, 255, 200, 100, 50, 255] + ); + } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index ae84bf7ece9..7d0b95ce48f 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -959,6 +959,71 @@ mod tests { assert_eq!(corner_radius, Some(expected_corner_radius)); } + #[gpui::test] + fn oversized_image_uploads_a_copy_near_its_draw_size(cx: &mut TestAppContext) { + let window = cx.add_empty_window(); + let image = test_image_with_size(512, 512); + window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| { + img(ImageSource::Render(image.clone())) + .size(px(32.)) + .into_any_element() + }); + + let (tile_size, target) = window.update(|window, _| { + let sprite = window + .rendered_frame + .scene + .polychrome_sprites + .last() + .expect("image should paint a sprite"); + (sprite.tile.bounds.size, 32. * window.scale_factor()) + }); + // The 512px source would leave bilinear sampling reading 4 of every 256 pixels. + for side in [tile_size.width.0, tile_size.height.0] { + let side = side as f32; + assert!( + side >= target && side < 2. * target, + "a {target}px draw uploaded a {side}px tile" + ); + } + assert!(window.update(|window, _| window.has_image_atlas_entry(&image))); + + window.update(|window, _| window.drop_image(image.clone()).unwrap()); + assert!( + !window.update(|window, _| window.has_image_atlas_entry(&image)), + "dropping the image should free its downscaled copy" + ); + } + + #[gpui::test] + fn image_drawn_at_its_own_size_uploads_the_original(cx: &mut TestAppContext) { + let window = cx.add_empty_window(); + let scale_factor = window.update(|window, _| window.scale_factor()); + let side = (64. * scale_factor) as u32; + let image = test_image_with_size(side, side); + window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| { + img(ImageSource::Render(image.clone())) + .size(px(64.)) + .into_any_element() + }); + + let tile_size = window.update(|window, _| { + window + .rendered_frame + .scene + .polychrome_sprites + .last() + .expect("image should paint a sprite") + .tile + .bounds + .size + }); + assert_eq!( + (tile_size.width.0, tile_size.height.0), + (side as i32, side as i32) + ); + } + #[gpui::test] fn stale_frame_index_is_clamped_when_image_changes(cx: &mut TestAppContext) { let window = cx.add_empty_window(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f2824beb8e2..b4bc22ac09d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -18,9 +18,11 @@ use crate::{ TextStyleRefinement, ThermalState, TransformationMatrix, Transition, TransitionState, Underline, UnderlineStyle, UnicodeBidi, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + downscale_bgra, downscaled_image_size, gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}, + image_downscale_level, interactive::TouchEvent, - point, px, rems, size, transparent_black, + max_image_downscale_level, point, px, rems, size, transparent_black, util::{ atomic_incr_if_not_zero, ceil_to_device_pixel, floor_to_device_pixel, round_half_toward_zero, round_half_toward_zero_f64, round_stroke_to_device_pixel, @@ -5123,20 +5125,31 @@ impl Window { return Ok(()); } + let source_size = data.size(frame_index); + let scale_factor = self.scale_factor(); + let downscale_level = image_downscale_level( + source_size, + image_bounds.size.width.0 * scale_factor, + image_bounds.size.height.0 * scale_factor, + ); let params = RenderImageParams { image_id: data.id, frame_index, + downscale_level, }; let tile = self .sprite_atlas .get_or_insert_with(¶ms.into(), &mut || { + let bytes = data + .as_bytes(frame_index) + .expect("It's the caller's job to pass a valid frame index"); + if downscale_level == 0 { + return Ok(Some((source_size, Cow::Borrowed(bytes)))); + } Ok(Some(( - data.size(frame_index), - Cow::Borrowed( - data.as_bytes(frame_index) - .expect("It's the caller's job to pass a valid frame index"), - ), + downscaled_image_size(source_size, downscale_level), + Cow::Owned(downscale_bgra(bytes, source_size, downscale_level)), ))) })? .expect("Callback above only returns Some"); @@ -5224,29 +5237,37 @@ impl Window { /// Removes an image from the sprite atlas. pub fn drop_image(&mut self, data: Arc) -> Result<()> { for frame_index in 0..data.frame_count() { - let params = RenderImageParams { - image_id: data.id, - frame_index, - }; + // Every size the image was painted at left its own downscaled copy. + for downscale_level in 0..=max_image_downscale_level(data.size(frame_index)) { + let params = RenderImageParams { + image_id: data.id, + frame_index, + downscale_level, + }; - self.sprite_atlas.remove(¶ms.clone().into()); + self.sprite_atlas.remove(¶ms.into()); + } } Ok(()) } - /// Returns whether every frame of an image is present in the sprite atlas. + /// Returns whether every frame of an image is present in the sprite atlas, at any of the + /// downscale levels it may have been painted at. #[cfg(any(test, feature = "test-support"))] pub fn has_image_atlas_entry(&self, data: &RenderImage) -> bool { data.frame_count() > 0 && (0..data.frame_count()).all(|frame_index| { - self.sprite_atlas.contains( - &RenderImageParams { - image_id: data.id, - frame_index, - } - .into(), - ) + (0..=max_image_downscale_level(data.size(frame_index))).any(|downscale_level| { + self.sprite_atlas.contains( + &RenderImageParams { + image_id: data.id, + frame_index, + downscale_level, + } + .into(), + ) + }) }) } diff --git a/crates/gpui_apple/src/metal_atlas.rs b/crates/gpui_apple/src/metal_atlas.rs index cf03fd84b71..66aec473a94 100644 --- a/crates/gpui_apple/src/metal_atlas.rs +++ b/crates/gpui_apple/src/metal_atlas.rs @@ -325,6 +325,7 @@ mod tests { AtlasKey::Image(gpui::RenderImageParams { image_id: gpui::ImageId(image_id), frame_index, + downscale_level: 0, }) } diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index 986553b1386..163b280be45 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -2966,6 +2966,7 @@ mod tests { let key = AtlasKey::Image(RenderImageParams { image_id: ImageId(0), frame_index: 0, + downscale_level: 0, }); let tile = renderer .sprite_atlas() diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index e9dce6fce4b..c0853675dda 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -501,6 +501,7 @@ mod tests { let key = AtlasKey::Image(RenderImageParams { image_id: ImageId(1), frame_index: 0, + downscale_level: 0, }); let size = Size { width: DevicePixels(1), @@ -535,6 +536,7 @@ mod tests { AtlasKey::Image(RenderImageParams { image_id: ImageId(image_id), frame_index: 0, + downscale_level: 0, }) }; let insert = |key: &AtlasKey, size: Size| { @@ -573,6 +575,7 @@ mod tests { AtlasKey::Image(RenderImageParams { image_id: ImageId(image_id), frame_index: 0, + downscale_level: 0, }) }; let insert = |key: &AtlasKey| { diff --git a/crates/gpui_wgpu/tests/headless_primitives.rs b/crates/gpui_wgpu/tests/headless_primitives.rs index 179779652b5..53a24fa51ff 100644 --- a/crates/gpui_wgpu/tests/headless_primitives.rs +++ b/crates/gpui_wgpu/tests/headless_primitives.rs @@ -120,6 +120,7 @@ fn every_primitive_kind_renders() { AtlasKey::Image(RenderImageParams { image_id: gpui::ImageId(1), frame_index: 0, + downscale_level: 0, }), // BGRA red, opaque. (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), @@ -323,6 +324,7 @@ fn smoothed_primitives_share_one_contour() { AtlasKey::Image(RenderImageParams { image_id: gpui::ImageId(2), frame_index: 0, + downscale_level: 0, }), (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), ); diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index 232f57fcc53..591f587d1a8 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -391,6 +391,7 @@ mod tests { AtlasKey::Image(RenderImageParams { image_id: ImageId(image_id), frame_index: 0, + downscale_level: 0, }) } diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 1a6d97465d1..0c291d76753 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -3018,6 +3018,7 @@ mod tests { AtlasKey::Image(RenderImageParams { image_id: ImageId(1), frame_index: 0, + downscale_level: 0, }), // BGRA red, opaque. (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), From 04cf99f1d1f7f9e262ba2a4256c1de8045ea035d Mon Sep 17 00:00:00 2001 From: Todd Beauchamp Date: Thu, 22 Oct 2026 14:21:03 -0400 Subject: [PATCH 2/2] gpui: read the source directly in the first downscale pass --- crates/gpui/src/assets.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index b863f812a34..31783dda6c4 100644 --- a/crates/gpui/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -267,7 +267,7 @@ pub(crate) fn downscaled_image_size(source: Size, level: u8) -> Si pub(crate) fn downscale_bgra(bytes: &[u8], source: Size, level: u8) -> Vec { let mut width = source.width.0.max(0) as usize; let mut height = source.height.0.max(0) as usize; - let mut pixels = bytes.to_vec(); + let mut pixels = Cow::Borrowed(bytes); for _ in 0..level { if width <= 1 && height <= 1 { break; @@ -298,11 +298,11 @@ pub(crate) fn downscale_bgra(bytes: &[u8], source: Size, level: u8 out[3] = ((alpha + count / 2) / count) as u8; } } - pixels = half; + pixels = Cow::Owned(half); width = half_width; height = half_height; } - pixels + pixels.into_owned() } /// A cached and processed image, in BGRA format