gpui_windows: Implement render_to_image for headless window capture (#63012)

# 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

<!-- TODO: drop in the headless Windows capture (and the macOS one
beside it) -->

---

Release Notes:

- N/A

Co-authored-by: Lukas Wirth <lukas@zed.dev>
This commit is contained in:
JKNandLukas Wirth authored and GitHub committed 2026-08-26 08:17:09 +00:00
1 parent 16faa9aa2c
commit eae9f3eed0
3 files changed
+102 -3

No files matched your search

+1 -1
View File
@@ -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"]
+93 -2
View File
@@ -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<image::RgbaImage> {
// 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<ID3D11Texture2D> = 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<DevicePixels>) -> Result<()> {
+8
View File
@@ -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<image::RgbaImage> {
self.state
.renderer
.borrow_mut()
.render_to_image(scene, self.state.background_appearance.get())
}
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
self.state.renderer.borrow().sprite_atlas()
}