From eae9f3eed021d72a3908401c1fa92fc681fe3bb9 Mon Sep 17 00:00:00 2001 From: JKN Date: Wed, 26 Aug 2026 08:17:09 +0000 Subject: [PATCH] gpui_windows: Implement render_to_image for headless window capture (#63012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective `gpui_macos` implements `PlatformWindow::render_to_image` — the MetalRenderer samples an offscreen target, so a **hidden** window can be captured. `gpui_windows` does not implement it, so the method falls through to the trait default and returns `Err("render_to_image not implemented for this platform")`. That leaves Windows consumers (tests, headless screenshot harnesses) on `PrintWindow`-style workarounds, which require the window to actually be on screen: a DirectComposition swap chain that never presents has no frame to read, so you get a visible flash — or an off-screen parking trick — for every capture. This closes the macOS/Windows gap. Linux (`gpui_linux`) still returns the trait default; a Blade/wgpu readback is the obvious follow-up but is not in this PR. ## Solution The DirectX analogue of the Metal path: - `DirectXRenderer::draw`'s clear, scene upload and batch encoding are factored into a shared `render(scene, background_appearance)`. `draw` is now `render` + `present`; `render_to_image` is `render` + readback. Nothing about how a frame is produced is duplicated between them, so the two cannot drift. - `render_to_image` renders into the **existing** render target — created at window construction, so no window need ever be shown — then copies it into a `D3D11_USAGE_STAGING` texture, `Map`s it, and converts BGRA → RGBA. `RowPitch` padding is honoured per row; the copy loop carries a safety comment. - It refuses to run while `skip_draws` is set. That flag marks a pending device-lost recovery, where the atlas still holds tile references from the previous device — drawing before the forced re-render rebuilds them panics in `DirectXAtlasState::texture` (the case `WindowState::force_render_pending` documents). Returning an error beats panicking in a capture harness. - Gated on `cfg(any(test, feature = "test-support"))` to match the trait method. `gpui_platform`'s `test-support` now forwards to `gpui_windows/test-support`, the way it already does for `gpui_macos`. No behaviour change outside `test-support`/`cfg(test)` builds beyond the `render` factoring, which is a pure code move. ## Testing - **Windows, functional:** an earlier revision of this patch has been in production use since June in the `--screenshot` harness of a GPUI app I ship ([Ferail](https://github.com/jonx/Ferail)), capturing a hidden window (`show: false`) with no flash. BGRA→RGBA output was verified against the on-screen rendering. - **This exact revision** has been cross-checked from macOS only: `cargo clippy -p gpui_windows --no-default-features --features test-support --target x86_64-pc-windows-msvc -- -D warnings` is clean, both with and without `test-support` (clang-cl + `xwin`; `--no-default-features` skips only the `windows-manifest` embed-resource step, which is irrelevant here). I'm relying on CI for a native Windows build of this revision — happy to report back from a Windows host if that's a blocker for review. - **Not covered by an automated test.** The behaviour needs a live D3D11 device, so it can only be exercised by a Windows-hosted test. Happy to add one under `gpui_windows` if you'd like it — say the word and I'll push it rather than guess at the shape you want. - **Reviewers:** the interesting part is the staging-texture readback in `directx_renderer.rs`. Worth a second pair of eyes on the `skip_draws` guard and on premultiplied alpha (the swap chain is `DXGI_ALPHA_MODE_PREMULTIPLIED`, so non-opaque captures come back premultiplied — same as the macOS path, but it does mean semi-transparent regions are not straight-alpha). ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) — n/a, no UI surface - [ ] Tests cover the new/changed behavior — see Testing; needs a Windows host, happy to add - [x] Performance impact has been considered and is acceptable — the readback path is `test-support`-only; `draw` is unchanged apart from the `render` factoring ## Showcase --- Release Notes: - N/A Co-authored-by: Lukas Wirth --- crates/gpui_platform/Cargo.toml | 2 +- crates/gpui_windows/src/directx_renderer.rs | 95 ++++++++++++++++++++- crates/gpui_windows/src/window.rs | 8 ++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index a41cb17672..a502ff138d 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"] +test-support = ["gpui/test-support", "gpui_macos/test-support", "gpui_windows/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_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 5719c12f2d..8590407e61 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -337,11 +337,24 @@ impl DirectXRenderer { // and so likely do not have the textures anymore that are required for drawing return Ok(()); } + self.render(scene, background_appearance)?; + self.present() + } + + /// Clear the render target for `background_appearance` and encode every + /// primitive batch of `scene` into it, without presenting. Shared by + /// [`draw`](Self::draw) (which then presents) and + /// [`render_to_image`](Self::render_to_image) (which reads the target back + /// instead), so the two cannot drift. + fn render( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result<()> { self.pre_draw(&match background_appearance { WindowBackgroundAppearance::Opaque => [1.0f32; 4], _ => [0.0f32; 4], })?; - self.upload_scene_buffers(scene)?; let annotation = self @@ -388,7 +401,85 @@ impl DirectXRenderer { ) })?; } - self.present() + Ok(()) + } + + /// Render `scene` to an offscreen CPU image **without presenting** so + /// the window need never be shown or visible (the macOS headless path + /// goes through MetalRenderer; this is the Windows analogue). Draws into + /// the existing render target, copies it into a `D3D11_USAGE_STAGING` + /// texture, maps it, and converts BGRA to RGBA. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn render_to_image( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result { + // A pending device-lost recovery (`skip_draws`) leaves the atlas holding + // tile references from the previous device; drawing before the forced + // re-render rebuilds them panics in `DirectXAtlasState::texture`. + anyhow::ensure!( + !self.skip_draws, + "render_to_image unavailable while recovering from a lost device" + ); + self.render(scene, background_appearance)?; + + let devices = self.devices.as_ref().context("devices missing")?; + let device = &devices.device; + let context = &devices.device_context; + let resources = self.resources.as_ref().context("resources missing")?; + let render_target = resources + .render_target + .as_ref() + .context("render target missing")?; + + // A CPU-readable copy of the render target. + let mut desc = D3D11_TEXTURE2D_DESC::default(); + unsafe { render_target.GetDesc(&mut desc) }; + let width = desc.Width; + let height = desc.Height; + let staging_desc = D3D11_TEXTURE2D_DESC { + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + MipLevels: 1, + ArraySize: 1, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + ..desc + }; + let mut staging: Option = None; + unsafe { device.CreateTexture2D(&staging_desc, None, Some(&mut staging))? }; + let staging = staging.context("creating staging texture")?; + unsafe { context.CopyResource(&staging, render_target) }; + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { context.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))? }; + let row_bytes = (width as usize) * 4; + let mut pixels = vec![0u8; row_bytes * height as usize]; + // SAFETY: `Map` succeeded, so `pData` points at `RowPitch * height` + // readable bytes for as long as the mapping is held, and `RowPitch >= + // row_bytes` (it only ever adds trailing padding). `pixels` is sized + // `row_bytes * height`, so every copy stays in bounds on both sides, + // and the regions cannot overlap (`pixels` is a fresh allocation). + unsafe { + let src = mapped.pData as *const u8; + for row in 0..height as usize { + let s = src.add(row * mapped.RowPitch as usize); + let d = pixels.as_mut_ptr().add(row * row_bytes); + std::ptr::copy_nonoverlapping(s, d, row_bytes); + } + context.Unmap(&staging, 0); + } + // The render target is BGRA; image::RgbaImage expects RGBA. + for px in pixels.chunks_exact_mut(4) { + px.swap(0, 2); + } + image::RgbaImage::from_raw(width, height, pixels) + .context("Failed to build RgbaImage from staging readback") } pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index af240716c9..d81efacc8c 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -996,6 +996,14 @@ impl PlatformWindow for WindowsWindow { .log_err(); } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + self.state + .renderer + .borrow_mut() + .render_to_image(scene, self.state.background_appearance.get()) + } + fn sprite_atlas(&self) -> Arc { self.state.renderer.borrow().sprite_atlas() }