Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/gpui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions crates/gpui/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions crates/gpui/src/platform/visual_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct VisualTestPlatform {
foreground_executor: ForegroundExecutor,
platform: Rc<dyn Platform>,
clipboard: Mutex<Option<ClipboardItem>>,
#[cfg(target_os = "macos")]
find_pasteboard: Mutex<Option<ClipboardItem>>,
}

Expand All @@ -54,6 +55,7 @@ impl VisualTestPlatform {
foreground_executor,
platform,
clipboard: Mutex::new(None),
#[cfg(target_os = "macos")]
find_pasteboard: Mutex::new(None),
}
}
Expand Down Expand Up @@ -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<ClipboardItem> {
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<Result<()>> {
Task::ready(Ok(()))
}
Expand Down
4 changes: 3 additions & 1 deletion crates/gpui_linux/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/gpui_linux/src/linux/wayland/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,12 @@ impl PlatformWindow for WaylandWindow {
}
}

#[cfg(feature = "test-support")]
fn render_to_image(&self, scene: &Scene) -> anyhow::Result<image::RgbaImage> {
let mut state = self.borrow_mut();
state.renderer.render_to_image(scene)
}

fn schedule_frame(&self) {
self.0.schedule_frame();
}
Expand Down
6 changes: 6 additions & 0 deletions crates/gpui_linux/src/linux/x11/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1744,6 +1744,12 @@ impl PlatformWindow for X11Window {
}
}

#[cfg(feature = "test-support")]
fn render_to_image(&self, scene: &Scene) -> anyhow::Result<image::RgbaImage> {
let mut inner = self.0.state.borrow_mut();
inner.renderer.render_to_image(scene)
Comment on lines +1749 to +1750

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor pending recovery redraws before screenshot readback

If a screenshot is captured after device recovery but before the forced next-frame refresh, WgpuRenderer::recover() has already cleared the atlas and reset device_lost(), while the window's rendered scene still contains old texture IDs. The normal frame path uses force_render_after_recovery to rebuild that scene, but this override bypasses the pending refresh, so a glyph or sprite batch panics in WgpuAtlasStorage::index instead of returning an error. Reject or defer readback until the scene has been rebuilt; the Wayland override needs the same protection.

Useful? React with 👍 / 👎.

}

fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
let inner = self.0.state.borrow();
inner.renderer.sprite_atlas().clone()
Expand Down
2 changes: 1 addition & 1 deletion crates/gpui_platform/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
5 changes: 5 additions & 0 deletions crates/gpui_wgpu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down
196 changes: 192 additions & 4 deletions crates/gpui_wgpu/src/wgpu_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<image::RgbaImage> {
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);
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -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<ScaledPixels> {
Bounds::new(
point(ScaledPixels(x), ScaledPixels(y)),
size(ScaledPixels(width), ScaledPixels(height)),
)
}

fn insert_quad(scene: &mut Scene, bounds: Bounds<ScaledPixels>, 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<u8> {
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<u8> {
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() {
Expand Down