diff --git a/Cargo.lock b/Cargo.lock index e76b343b412f94..e01dd467c280fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7610,6 +7610,7 @@ dependencies = [ "gpui_util", "gpui_wgpu", "http_client", + "image", "libc", "log", "notify-rust", @@ -7766,6 +7767,7 @@ dependencies = [ "etagere", "gpui", "gpui_util", + "image", "itertools 0.14.0", "log", "naga", diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a2c2affacd1ea1..567fc8444019d3 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -39,7 +39,7 @@ pub use test_app::*; #[cfg(any(test, feature = "test-support"))] pub use test_context::*; #[cfg(all( - any(target_os = "macos", target_os = "windows"), + not(all(target_arch = "wasm32", target_os = "unknown")), any(test, feature = "test-support") ))] pub use visual_test_context::*; @@ -74,7 +74,7 @@ mod test_app; #[cfg(any(test, feature = "test-support"))] mod test_context; #[cfg(all( - any(target_os = "macos", target_os = "windows"), + not(all(target_arch = "wasm32", target_os = "unknown")), any(test, feature = "test-support") ))] mod visual_test_context; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 90511beb5280c6..74ab6d42c73a4d 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -17,7 +17,7 @@ mod threaded_dispatcher; mod test; #[cfg(all( - any(target_os = "macos", target_os = "windows"), + not(all(target_arch = "wasm32", target_os = "unknown")), any(test, feature = "test-support") ))] mod visual_test; @@ -92,7 +92,7 @@ pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream} pub use threaded_dispatcher::ThreadedDispatcher; #[cfg(all( - any(target_os = "macos", target_os = "windows"), + not(all(target_arch = "wasm32", target_os = "unknown")), any(test, feature = "test-support") ))] pub use visual_test::VisualTestPlatform; diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index f6fcf734a50357..b680927eda20f5 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -34,6 +34,7 @@ pub struct VisualTestPlatform { foreground_executor: ForegroundExecutor, platform: Rc, clipboard: Mutex>, + #[cfg(target_os = "macos")] find_pasteboard: Mutex>, } @@ -54,6 +55,7 @@ impl VisualTestPlatform { foreground_executor, platform, clipboard: Mutex::new(None), + #[cfg(target_os = "macos")] find_pasteboard: Mutex::new(None), } } @@ -234,6 +236,16 @@ impl Platform for VisualTestPlatform { *self.find_pasteboard.lock() = Some(item); } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn read_from_primary(&self) -> Option { + None + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, _item: ClipboardItem) { + // The test platform does not simulate the X11/Wayland primary selection. + } + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { Task::ready(Ok(())) } diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 99568212e7c73f..922499a621df50 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -13,7 +13,7 @@ path = "src/gpui_linux.rs" [features] default = ["wayland", "x11"] -test-support = ["gpui/test-support"] +test-support = ["gpui/test-support", "gpui_wgpu?/test-support", "dep:image"] wayland = [ "bitflags", "gpui_wgpu", @@ -59,6 +59,8 @@ collections.workspace = true futures.workspace = true gpui.workspace = true gpui_util.workspace = true +# Only pulled under `test-support`: the render_to_image override names image::RgbaImage. +image = { workspace = true, optional = true } gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] } http_client.workspace = true libc.workspace = true diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 2d25416afb6716..e6c60775bf75a9 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1942,6 +1942,12 @@ impl PlatformWindow for WaylandWindow { } } + #[cfg(feature = "test-support")] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let mut state = self.borrow_mut(); + state.renderer.render_to_image(scene) + } + fn schedule_frame(&self) { self.0.schedule_frame(); } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 976606db0cf52a..a07c321c913603 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1744,6 +1744,12 @@ impl PlatformWindow for X11Window { } } + #[cfg(feature = "test-support")] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let mut inner = self.0.state.borrow_mut(); + inner.renderer.render_to_image(scene) + } + fn sprite_atlas(&self) -> Arc { let inner = self.0.state.borrow(); inner.renderer.sprite_atlas().clone() diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index a502ff138d6409..bcfc2adf16efcf 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -14,7 +14,7 @@ path = "src/gpui_platform.rs" [features] default = [] font-kit = ["gpui_macos/font-kit"] -test-support = ["gpui/test-support", "gpui_macos/test-support", "gpui_windows/test-support"] +test-support = ["gpui/test-support", "gpui_macos/test-support", "gpui_windows/test-support", "gpui_linux/test-support"] screen-capture = ["gpui/screen-capture", "gpui_macos/screen-capture", "gpui_windows/screen-capture", "gpui_linux/screen-capture"] runtime_shaders = ["gpui_macos/runtime_shaders"] wayland = ["gpui_linux/wayland"] diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 3d889ffb46a844..871079a139af93 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -14,6 +14,8 @@ path = "src/gpui_wgpu.rs" [features] default = [] font-kit = ["dep:font-kit"] +# Offscreen image readback for the GPU test renderer (render_to_image). +test-support = ["gpui/test-support", "dep:image"] [dependencies] gpui.workspace = true @@ -34,6 +36,9 @@ unicode-segmentation.workspace = true gpui_util.workspace = true wgpu.workspace = true +# Only pulled under `test-support`: render_to_image returns an image::RgbaImage. +image = { workspace = true, optional = true } + # Optional: only needed on platforms with multiple font sources (e.g. Linux) # WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 47ce2a3b8f5627..90374e6bbeb9d6 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1474,16 +1474,35 @@ impl WgpuRenderer { self.resources().surface.is_none(), "render_to_pixels needs a renderer from new_offscreen" ); + self.update_drawable_size(size); + self.render_readback(scene, pixels) + } + + /// Renders `scene` at the current surface size and reads it back as RGBA, + /// without acquiring, configuring or presenting the surface. + #[cfg(feature = "test-support")] + pub fn render_to_image(&mut self, scene: &Scene) -> Result { + let width = self.surface_config.width; + let height = self.surface_config.height; + let mut pixels = vec![0; width as usize * height as usize * 4]; + self.render_readback(scene, &mut pixels)?; + image::RgbaImage::from_raw(width, height, pixels) + .context("readback size does not match the surface size") + } + + /// Renders `scene` at the surface size into the readback texture and copies + /// it into `pixels` as tightly packed RGBA rows. + fn render_readback(&mut self, scene: &Scene, pixels: &mut [u8]) -> Result<()> { anyhow::ensure!(!self.device_lost(), "the GPU device was lost"); - let width = (size.width.0.max(1) as u32).min(self.max_texture_size); - let height = (size.height.0.max(1) as u32).min(self.max_texture_size); + let width = self.surface_config.width; + let height = self.surface_config.height; + let format = self.surface_config.format; let unpadded_bytes_per_row = width * 4; anyhow::ensure!( pixels.len() == unpadded_bytes_per_row as usize * height as usize, "pixel buffer does not match {width}x{height}" ); - self.update_drawable_size(size); self.atlas.before_frame(); self.ensure_intermediate_textures(); self.ensure_readback(width, height); @@ -1556,6 +1575,16 @@ impl WgpuRenderer { } readback.buffer.unmap(); + // A windowed renderer draws in the surface format, which is usually BGRA. + if matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ) { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + if let Some(error) = self.last_error.lock().unwrap().take() { anyhow::bail!("GPU error during offscreen frame: {error}"); } @@ -2456,7 +2485,166 @@ impl RenderingParameters { #[cfg(test)] mod tests { use super::*; - use gpui::{MonochromeSprite, PolychromeSprite, Quad, Shadow, SubpixelSprite, Underline}; + use gpui::{ + AtlasKey, ContentMask, FontId, GlyphId, Hsla, MonochromeSprite, PlatformAtlas, + PolychromeSprite, Quad, RenderGlyphParams, Shadow, SubpixelSprite, TransformationMatrix, + Underline, blue, point, px, red, size, white, + }; + use std::borrow::Cow; + + const RED: [u8; 4] = [255, 0, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 255]; + + fn scaled_bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new( + point(ScaledPixels(x), ScaledPixels(y)), + size(ScaledPixels(width), ScaledPixels(height)), + ) + } + + fn insert_quad(scene: &mut Scene, bounds: Bounds, color: Hsla) { + scene.insert_primitive(Quad { + bounds, + content_mask: ContentMask { bounds }, + background: color.into(), + ..Default::default() + }); + } + + fn render(renderer: &mut WgpuRenderer, scene: &Scene, width: i32, height: i32) -> Vec { + let mut pixels = vec![0; width as usize * height as usize * 4]; + renderer + .render_to_pixels( + scene, + size(DevicePixels(width), DevicePixels(height)), + &mut pixels, + ) + .expect("offscreen render should succeed"); + pixels + } + + fn solid_pixels(color: [u8; 4], width: usize, height: usize) -> Vec { + color.repeat(width * height) + } + + // Three pixels make a 12 byte row, which wgpu pads to 256 bytes, so a missed + // row stride shows up as garbage from the second row on. + #[test] + fn render_to_pixels_strips_row_padding_and_returns_rgba_for_each_target_format() + -> anyhow::Result<()> { + let context = WgpuContext::new_offscreen()?; + let mut scene = Scene::default(); + insert_quad(&mut scene, scaled_bounds(0., 0., 3., 2.), red()); + insert_quad(&mut scene, scaled_bounds(2., 0., 1., 2.), blue()); + scene.finish(); + let expected = [RED, RED, BLUE, RED, RED, BLUE].concat(); + + // Windowed renderers draw in the surface format, usually BGRA, while + // new_offscreen picks RGBA. Both must read back as RGBA. + for format in [ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Bgra8Unorm, + ] { + let mut renderer = + WgpuRenderer::new_offscreen(&context, size(DevicePixels(3), DevicePixels(2)))?; + renderer.surface_config.format = format; + let resources = renderer.resources.as_mut().context("GPU resources")?; + resources.pipelines = WgpuRenderer::create_pipelines( + &resources.device, + &resources.bind_group_layouts, + format, + renderer.surface_config.alpha_mode, + renderer.rendering_params.path_sample_count, + renderer.dual_source_blending, + renderer.uses_webgl_instance_data, + ); + + assert_eq!(render(&mut renderer, &scene, 3, 2), expected, "{format:?}"); + } + Ok(()) + } + + #[test] + fn render_to_pixels_reuses_and_resizes_the_readback() -> anyhow::Result<()> { + let context = WgpuContext::new_offscreen()?; + let mut renderer = + WgpuRenderer::new_offscreen(&context, size(DevicePixels(3), DevicePixels(2)))?; + let scene_of = |color: Hsla| { + let mut scene = Scene::default(); + insert_quad(&mut scene, scaled_bounds(0., 0., 64., 64.), color); + scene.finish(); + scene + }; + + let red_scene = scene_of(red()); + let blue_scene = scene_of(blue()); + assert_eq!( + render(&mut renderer, &red_scene, 3, 2), + solid_pixels(RED, 3, 2) + ); + assert_eq!( + render(&mut renderer, &blue_scene, 3, 2), + solid_pixels(BLUE, 3, 2) + ); + assert_eq!( + render(&mut renderer, &red_scene, 5, 3), + solid_pixels(RED, 5, 3) + ); + assert_eq!( + render(&mut renderer, &blue_scene, 2, 4), + solid_pixels(BLUE, 2, 4) + ); + Ok(()) + } + + // Glyphs are queued for upload when they are rasterized and only reach the + // GPU in atlas.before_frame(). A readback that skips it paints no text. + #[test] + fn render_to_pixels_flushes_glyph_uploads_queued_this_frame() -> anyhow::Result<()> { + let context = WgpuContext::new_offscreen()?; + let mut renderer = + WgpuRenderer::new_offscreen(&context, size(DevicePixels(4), DevicePixels(4)))?; + let key = AtlasKey::Glyph(RenderGlyphParams { + font_id: FontId(0), + glyph_id: GlyphId(0), + font_size: px(16.), + subpixel_variant: point(0, 0), + scale_factor: 1., + is_emoji: false, + subpixel_rendering: false, + dilation: 0, + }); + let tile = renderer + .sprite_atlas() + .get_or_insert_with(&key, &mut || { + Ok(Some(( + size(DevicePixels(4), DevicePixels(4)), + Cow::Owned(vec![255; 16]), + ))) + })? + .context("glyph tile")?; + + let bounds = scaled_bounds(0., 0., 4., 4.); + let mut scene = Scene::default(); + scene.insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + color: white(), + tile, + transformation: TransformationMatrix::unit(), + }); + scene.finish(); + + let pixels = render(&mut renderer, &scene, 4, 4); + let center = &pixels[(4 + 1) * 4..(4 + 2) * 4]; + assert!( + center.iter().all(|channel| *channel >= 250), + "glyph pixel should be white, got {center:?}" + ); + Ok(()) + } #[test] fn webgl_shader_is_valid_wgsl_without_storage_buffers() {