GPU interop
xgpui-gpu-interop lets a GPU engine you own present frames into an xgpui
window, the way it would present to a swapchain. The engine keeps its own
device, queue and thread, and can render or run compute shaders as it likes.
xgpui composites the result like any other element: no copies, no extra
synchronisation code in your app, and wide-gamut colour where the window
supports it.
For questions and bug reports, open a GitHub issue. The crate page lists its keywords, categories and README.
Use it when xgpui-shader paints are not enough: long compute passes, image
pipelines, simulations or anything that should not run inside GPUI's frame.
The central type is EngineSurface: a small ring of textures (slots)
shared between your engine's GPU device and xgpui's compositor. Your engine
acquires a free slot, renders into it and presents it; xgpui shows the newest
presented slot wherever you paint the surface. The ring hands a slot back to
the engine only once no window is showing it any more.
Use it
Add it with the wgpu engine adapter:
cargo add xgpui-gpu-interop --features wgpu
Create the target on the engine's thread, render into an acquired slot and present it. Paint the surface from any element:
let mut target = WgpuSurfaceTarget::new(SurfaceDesc {
width: 640,
height: 400,
format: SurfaceFormat::Rgba16FloatLinearP3, // or Bgra8Srgb
slots: 3,
}, &device, &queue)?;
// Engine thread
let slot = target.surface().acquire()?; // Err(WouldBlock) when the ring is full
let texture = target.texture(&slot)?; // render or compute into it
target.present(slot, [encoder.finish()])?; // submits, then publishes the frame
// UI thread, in an element's paint phase
surface.paint(window, bounds, radii);
EngineSurface is cheap to clone, so hand a clone to the view and keep the
target on the engine thread. On Linux, create the engine's device with
WgpuSurfaceTarget::request_device so it can share memory with the compositor.
Create the engine's device on the same GPU as xgpui's compositor; on a machine
with more than one GPU, see known limits.
Platforms
| Platform | Engine | Shared memory | Synchronisation |
|---|---|---|---|
| macOS | wgpu or raw Metal | IOSurface | MTLSharedEvent |
| Linux | wgpu (Vulkan) | opaque-fd external memory | timeline semaphore |
| Windows | wgpu (D3D12), composited by D3D11 | shared NT-handle textures | shared fence |
If the compositor cannot use the surface (for example, the engine is on a
different GPU), the first frame fails with a typed Unsupported reason and
EngineSurface::unsupported() reports it.
Colour
Bgra8Srgbshows the same in every window.Rgba16FloatLinearP3shows in wide gamut in aWideGamutSdrwindow and as a mapped sRGB preview elsewhere.paintpicks per window;contentandpreview_contentpick explicitly. RGBA16F slots accept storage writes, so compute shaders can write them directly.
Colour is checked against a CPU reference on all three platforms. See colour workflows for how wide-gamut windows work. Linux shows the sRGB preview until native wide gamut lands (#146).
What it guarantees
- Published frames don't change. A slot is never rewritten while a window shows it, and returns to the ring once it no longer does. Use 3 slots plus one for each extra window showing the surface.
- A misbehaving engine can't freeze the UI. Until a frame is ready, the last ready frame stays on screen. A GPU hang is the exception: the driver's timeout handles it.
- Device loss is recoverable. Call
EngineSurface::invalidate()when the engine loses its device and create a new surface. When the compositor's device is replaced, slots are re-imported. - On Windows, drop the lost device before creating its replacement. D3D12
devices are singletons per adapter: while any object of a lost device is still
alive, a "new" device on that adapter is the lost one again, and wgpu falls back
to the software adapter, whose slots the compositor cannot open.
invalidate()releases the surface's own D3D12 objects; drop your device, queue, resources andWgpuSurfaceTargetyourself. After a TDR the hardware adapter can take a moment to return, so retry rather than accept a software adapter.
Heavy engines
SurfaceStats reports frames composited, frames never shown and the latency
from present to first composition. If the engine saturates the GPU, turn on
heavy mode so the UI keeps its frame rate:
target.set_heavy(Some(HeavyEngine::default()));
loop {
let chunks = target.suggested_chunks(); // split the frame into short submissions
render_frame_in(chunks, &mut target)?;
std::thread::sleep(target.pace()); // back off while the UI lags
}
With four 4K engines saturating an Intel Iris Xe, the UI went from 5–6 frames in 6 s to a median of 16.7 ms per frame.
Frame rate and unfocused windows
GPUI rate-limits animation frames, the frames requested with
window.request_animation_frame() or on_next_frame:
- Unfocused windows get about 30 fps by default
(
WindowOptions::inactive_frame_interval, 33.3 ms). A second window showing a surface is unfocused most of the time. - Low-power mode caps animation frames at 30 fps in every window, on macOS, Windows and Linux.
- Thermal pressure (serious or critical, reported on macOS only) caps animation frames in the active window at 60 fps.
A redraw requested with cx.notify() alone is not rate-limited. So
instead of requesting an animation frame every display frame, redraw when the
engine presents. EngineSurface::on_present runs on the engine's
thread, so forward it to the view:
let (tx, mut rx) = futures::channel::mpsc::unbounded::<()>();
surface.on_present(move || {
tx.unbounded_send(()).ok();
});
cx.spawn(async move |this, cx| {
while rx.next().await.is_some() {
if this.update(cx, |_, cx| cx.notify()).is_err() {
break; // the view is gone
}
}
})
.detach();
New engine frames then appear at the engine's own rate in any window, and the
UI does no work between frames. To keep animation frames at full rate in an
unfocused window, open it with inactive_frame_interval: None.
Low-power mode still applies.
Known limits
- Tested hardware: Apple M-series; Intel Iris Xe (Fedora 44 and Windows 11); Intel Arc 140T (Windows 11). AMD and NVIDIA GPUs are untested.
- One GPU at a time. The engine must run on the compositor's GPU, and
nothing yet helps an app find it. On a machine with an integrated and a discrete
GPU, an engine on the other one is refused with
Unsupported(#253). - Engines: wgpu on all three platforms, and raw Metal on macOS. Raw Vulkan and D3D12 engines have no adapter yet.
- Colour: Linux shows wide content as the sRGB preview until #146. Windows wide colour is verified only through an 8-bit readback.
- First frame: on a compositor that cannot wait on the GPU, frames are shown once complete, so the first can take up to about 100 ms.
- Engine priority: Linux runs the engine queue at low priority where supported; Windows cannot lower it through wgpu.
API reference
Rustdoc for released versions is on docs.rs ↗; the tables below match this checkout. Every item is re-exported from the crate root. For full rustdoc, run cargo doc --open -p xgpui-gpu-interop --features wgpu.
EngineSurface
A ring of shareable slots presented into xgpui. Cheap to clone; the engine side and the view each hold one.
| Method | Purpose |
|---|---|
acquire() -> Result<SurfaceSlot, AcquireError> | A writable slot. Frees slots whose GPU sampling has finished before giving up with WouldBlock. |
present(slot, SyncPoint) -> Result<()> | Publish a written slot. Frames with a submitted signal are shown at once with a GPU-side wait; others once their signal is observed reached. Adapters call this for you. |
paint(window, bounds, corner_radii) | Paint the latest frame, scaled and clipped to a rounded rectangle; picks wide or preview colour for the window. |
content() / preview_content() | The latest frame as CustomContent for a wide-gamut window, or as an sRGB preview for any other window. |
on_present(callback) | Called after every present and when a waiting frame becomes ready. Typically wakes the UI. |
stats() -> SurfaceStats | Counters for diagnostics and pacing. |
invalidate() / is_valid() | Mark the surface dead after engine device loss. The last frame keeps showing; create a new surface on a new device. |
unsupported() -> Option<String> | Why the compositor could not use the surface (for example, a different GPU). It then offers no frames. |
desc() / transport() | The surface's fixed description, and how frames reach the compositor (always Transport::ZeroCopy). |
SurfaceSlot: an acquired slot. index() is stable for the surface's lifetime (useful as a cache key); desc() repeats the surface description; on macOS iosurface() exposes the slot's IOSurface for raw Metal engines. Dropping a slot without presenting returns it to the ring.
SurfaceDesc and SurfaceFormat
| Item | Meaning |
|---|---|
SurfaceDesc { width, height, format, slots } | Fixed slot properties. slots is 2–8: use 3, plus one for each extra window showing the surface. validate() checks sizes and the 512 MiB per-surface budget. |
SurfaceFormat::Bgra8Srgb | BGRA8, straight alpha, sRGB-encoded. Looks the same in every window. |
SurfaceFormat::Rgba16FloatLinearP3 | RGBA16F, premultiplied, linear Display P3. Wide gamut in a WideGamutSdr window (macOS, Windows), sRGB preview elsewhere and on Linux. Allows storage (compute) writes. |
bytes_per_pixel(), supports_storage(), color_mode(wide) | Format helpers. |
WgpuSurfaceTarget (feature wgpu)
The adapter for wgpu engines on all three platforms. It creates the shared slots on the engine's device, hands out wgpu textures and presents with a signal the compositor can wait on.
| Method | Purpose |
|---|---|
new(desc, &device, &queue) | Create the slot ring on the engine's device. Check required_features() when requesting the device. |
request_device(&adapter) (Linux) | Create a Vulkan device with the external-memory extensions and, where supported, a low-priority queue (engine_queue_low_priority()). |
surface() -> &EngineSurface | The surface to clone into the view. |
texture(&slot) -> wgpu::Texture, format() | The slot as a wgpu texture to render or compute into. |
present(slot, command_buffers) | Submit the frame's work with the slot's signal, then publish it. |
set_gpu_wait(bool), gpu_wait(), gpu_sync() | Turn off GPU-side waits (observe-only: frames show once complete). |
set_heavy(Option<HeavyEngine>), heavy() | Heavy-engine mode, and its current status. |
pace() -> Duration, suggested_chunks() -> u32 | How long to wait before the next frame, and how many submissions to split it into, while the UI lags. |
SyncPoint
The signal a presented frame waits on. With WgpuSurfaceTarget you never build one. On macOS, raw Metal engines use SyncPoint::new(event, value) (shown once reached) or the unsafe SyncPoint::submitted(event, value) (GPU-side wait; the signal must already be committed). On Linux and Windows, SyncPoint::completed() marks work that is already finished.
Stats, heavy mode and errors
| Item | Meaning |
|---|---|
SurfaceStats | Slots free / writing / published; totals presents, would_block, composited, unshown; waiting frames; composite_latency_ms from present to first composition. |
HeavyEngine { max_hz, min_hz, lag_budget_ms, chunk_ms, max_chunks } | Defaults 60 Hz, 0.25 Hz, 33 ms, 4 ms, 64. Pace down when compositor latency exceeds the budget; split frames into submissions of about chunk_ms. |
HeavyStatus { config, rate_hz, frame_ms, suggested_chunks } | What heavy mode is currently doing. |
AcquireError::WouldBlock | Every slot is in use; retry after the next presented frame. |
AcquireError::Invalidated | The surface was invalidated; create a new one. |
Unsupported(String) | The platform or configuration cannot support a surface. |
Features and dependencies
| Feature or target | Adds |
|---|---|
| always | xgpui, anyhow |
| macOS | xgpui-apple, objc2-metal, objc2-io-surface |
wgpu on Linux | xgpui-wgpu with external-memory, ash |
wgpu on Windows | xgpui-windows, windows (D3D11, D3D12, DXGI) |
wgpu on macOS | wgpu |
Enabling wgpu on Linux turns on xgpui-wgpu/external-memory for the whole app through Cargo feature unification; device creation falls back if the extensions are missing.
Try it
Run from the repository root:
cargo run --release -p xgpui-ext-lab --bin interop-boids
cargo run -p xgpui-ext-lab --bin gpu-canvas-workbench
cargo run -p xgpui-ext-lab --bin gpu-interop-lab -- --threaded
Interop Boids shows why the crate exists: wgpu's own boids example, ported almost unchanged, runs on its own device and thread inside an xgpui window. Each step is a compute pass on persistent particle buffers followed by a render pass, which a shader paint can't do. A toggle moves the engine onto the UI thread for contrast: at 100,000 boids the UI keeps 60 fps on the engine's own thread and drops to 18 fps on the UI thread. It also has Pause/Play, boid counts, a speed slider and live engine and UI timings.
The GPU Canvas Workbench is a small editor on a compute engine: edits are
checked against a CPU reference, with a second window, minimize, resize,
pan/zoom, wide versus sRGB comparison (--compare) and simulated device loss.
--check runs it as an automated test. The GPU interop lab stresses
surfaces: --stress-surfaces N --stress-size WxH --work N [--heavy].
Run the tests with:
cargo test -p xgpui-gpu-interop --all-features
Hosted CI compiles and lints the crate and both labs on macOS, Linux and Windows. It has no usable GPU, so surfaces run only on real hardware; the one exception is the Windows headless D3D11 retirement test, which runs on WARP.
The design is in #197, with transport evidence per machine in #198 and device-loss runs (including a real Windows TDR) in #119.
GPUI public API changes: additions and compatibility
Seven additive hooks in gpui-wgpu, gpui-windows and gpui-apple let the
extension adopt shared textures and wait on engine signals. Nothing in gpui
itself changes and no application needs to migrate. The hooks ship in xgpui-wgpu 2.3.0, xgpui-windows 2.2.0 and xgpui-apple 4.1.0. See
the backend hooks.