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:
1 parent
16faa9aa2c
commit
eae9f3eed0
3 files changed
+102
-3
No files matched your search
@@ -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"]
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user