← Extensions to GPUI

Experimental zero-copy GPU interop. Show frames from your own GPU engine in xgpui windows, with no copies.

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

PlatformEngineShared memorySynchronisation
macOSwgpu or raw MetalIOSurfaceMTLSharedEvent
Linuxwgpu (Vulkan)opaque-fd external memorytimeline semaphore
Windowswgpu (D3D12), composited by D3D11shared NT-handle texturesshared 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

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

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:

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

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.

MethodPurpose
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() -> SurfaceStatsCounters 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

ItemMeaning
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::Bgra8SrgbBGRA8, straight alpha, sRGB-encoded. Looks the same in every window.
SurfaceFormat::Rgba16FloatLinearP3RGBA16F, 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.

MethodPurpose
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() -> &EngineSurfaceThe 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() -> u32How 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

ItemMeaning
SurfaceStatsSlots 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::WouldBlockEvery slot is in use; retry after the next presented frame.
AcquireError::InvalidatedThe surface was invalidated; create a new one.
Unsupported(String)The platform or configuration cannot support a surface.

Features and dependencies

Feature or targetAdds
alwaysxgpui, anyhow
macOSxgpui-apple, objc2-metal, objc2-io-surface
wgpu on Linuxxgpui-wgpu with external-memory, ash
wgpu on Windowsxgpui-windows, windows (D3D11, D3D12, DXGI)
wgpu on macOSwgpu

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.