Skip to main content

agg_gui_wgpu/
gpu.rs

1//! wgpu device + surface bundle for one OS window, and the surface-acquire
2//! recovery policy that goes with it.
3//!
4//! Every native shell needs the same thing: an instance, an adapter, a device
5//! and queue, a non-sRGB surface format, a clamped surface configuration, and a
6//! per-frame `get_current_texture` that recovers from a stale swapchain instead
7//! of painting nothing. That was hand-rolled twice (`demo-wgpu`'s
8//! `native_shell` and `demo-native`'s `gpu`), with the two copies disagreeing
9//! about the `Timeout` case; this module is the single implementation both now
10//! use.
11//!
12//! wasm shells configure their canvas surface through the browser and never
13//! block on an adapter request, so this module is native-only.
14
15use std::sync::Arc;
16
17/// How badly the caller needs `COPY_SRC` on the surface texture.
18///
19/// `COPY_SRC` is what lets [`crate::WgpuGfxCtx::read_screenshot`] blit the
20/// rendered surface into a staging buffer. Not every surface supports it, so
21/// the caller states whether a screenshot is optional or the point of the run.
22#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
23#[non_exhaustive]
24pub enum CopySrc {
25    /// Never request it — the app has no read-back path.
26    #[default]
27    Never,
28    /// Request it when the surface supports it; carry on quietly if not.
29    /// The read-back path degrades to returning no pixels.
30    IfSupported,
31    /// Fail surface creation when the surface cannot provide it — for a
32    /// headless capture run, where a missing screenshot is the whole failure.
33    Required,
34}
35
36/// Everything [`Gpu::new`] needs beyond the window handle.
37///
38/// Build one with [`GpuConfig::new`] (or [`Default`]) and the `with_*`
39/// setters rather than a struct literal — the struct is `#[non_exhaustive]`
40/// so new knobs can be added without a breaking release:
41///
42/// ```no_run
43/// use agg_gui_wgpu::{CopySrc, GpuConfig};
44/// let cfg = GpuConfig::new("my-app").with_copy_src(CopySrc::IfSupported);
45/// ```
46#[derive(Clone, Copy, Debug)]
47#[non_exhaustive]
48pub struct GpuConfig {
49    /// `wgpu::DeviceDescriptor` label — shows up in backend validation
50    /// messages and GPU captures, so each shell names its own.
51    pub label: &'static str,
52    /// Surface read-back requirement, see [`CopySrc`].
53    pub copy_src: CopySrc,
54    /// Present mode for the swap chain. `AutoVsync` for normal windows.
55    pub present_mode: wgpu::PresentMode,
56    /// Device features requested **when the adapter offers them** — the set is
57    /// masked against `adapter.features()` before `request_device`, so an
58    /// adapter that lacks one still yields a device (the app degrades instead
59    /// of failing to start). For an app renderer that can use, e.g.,
60    /// `FLOAT32_BLENDABLE` when present and fall back when not.
61    pub optional_features: wgpu::Features,
62}
63
64impl Default for GpuConfig {
65    fn default() -> Self {
66        Self {
67            label: "agg-gui-wgpu",
68            copy_src: CopySrc::Never,
69            present_mode: wgpu::PresentMode::AutoVsync,
70            optional_features: wgpu::Features::empty(),
71        }
72    }
73}
74
75impl GpuConfig {
76    /// Default configuration under a shell-specific device label.
77    pub fn new(label: &'static str) -> Self {
78        Self {
79            label,
80            ..Self::default()
81        }
82    }
83
84    pub fn with_copy_src(mut self, copy_src: CopySrc) -> Self {
85        self.copy_src = copy_src;
86        self
87    }
88
89    /// Override the swap-chain present mode (default `AutoVsync`).
90    pub fn with_present_mode(mut self, present_mode: wgpu::PresentMode) -> Self {
91        self.present_mode = present_mode;
92        self
93    }
94
95    /// Request these device features when — and only when — the adapter
96    /// offers them. See [`GpuConfig::optional_features`].
97    pub fn with_optional_features(mut self, features: wgpu::Features) -> Self {
98        self.optional_features = features;
99        self
100    }
101}
102
103/// Why [`Gpu::new`] could not produce a usable surface.
104#[derive(Debug)]
105#[non_exhaustive]
106pub enum GpuInitError {
107    CreateSurface(wgpu::CreateSurfaceError),
108    RequestAdapter,
109    RequestDevice,
110    /// [`CopySrc::Required`] was asked for and the surface does not offer it.
111    CopySrcUnsupported,
112    /// The surface reported no supported texture formats — a torn-down or
113    /// otherwise unusable surface.
114    NoSurfaceFormats,
115    /// The surface reported no supported composite alpha modes.
116    NoAlphaModes,
117}
118
119impl std::fmt::Display for GpuInitError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::CreateSurface(e) => write!(f, "create wgpu surface: {e}"),
123            Self::RequestAdapter => write!(f, "no suitable wgpu adapter"),
124            Self::RequestDevice => write!(f, "could not request a wgpu device"),
125            Self::CopySrcUnsupported => {
126                write!(f, "surface does not support COPY_SRC read-back")
127            }
128            Self::NoSurfaceFormats => write!(f, "surface reports no supported texture formats"),
129            Self::NoAlphaModes => write!(f, "surface reports no supported alpha modes"),
130        }
131    }
132}
133
134impl std::error::Error for GpuInitError {}
135
136/// Pick the surface format to configure the swap chain with.
137///
138/// A non-sRGB format is preferred so the renderer's linear-space colour maths
139/// isn't gamma-corrected a second time by the surface; when the surface only
140/// offers sRGB formats we take its own first preference. Pure so the choice is
141/// testable without a live surface.
142fn pick_surface_format(
143    formats: &[wgpu::TextureFormat],
144) -> Result<wgpu::TextureFormat, GpuInitError> {
145    formats
146        .iter()
147        .copied()
148        .find(|f| !f.is_srgb())
149        .or_else(|| formats.first().copied())
150        .ok_or(GpuInitError::NoSurfaceFormats)
151}
152
153/// Pick the composite alpha mode — the surface's first preference.
154fn pick_alpha_mode(
155    modes: &[wgpu::CompositeAlphaMode],
156) -> Result<wgpu::CompositeAlphaMode, GpuInitError> {
157    modes.first().copied().ok_or(GpuInitError::NoAlphaModes)
158}
159
160/// Pick the present mode to configure the swap chain with.
161///
162/// `wgpu` resolves the `Auto*` modes itself against whatever the surface
163/// supports, so they are always safe to request. An explicit mode that the
164/// surface does not list (`Mailbox` on a driver that lacks it, `Immediate`
165/// under a compositor that forces vsync) is a validation error, so it falls
166/// back to `Fifo` — the one mode the spec guarantees every surface supports.
167///
168/// Pure so the fallback is testable without a live surface.
169pub fn pick_present_mode(
170    supported: &[wgpu::PresentMode],
171    requested: wgpu::PresentMode,
172) -> wgpu::PresentMode {
173    use wgpu::PresentMode as P;
174    match requested {
175        P::AutoVsync | P::AutoNoVsync => requested,
176        explicit if supported.contains(&explicit) => explicit,
177        _ => P::Fifo,
178    }
179}
180
181/// Clamp a surface configuration size to `[1, max_dim]` on both axes.
182///
183/// `max_dim` is the device's `max_texture_dimension_2d`. Applied on every
184/// `Surface::configure` so a stray oversized request — an over-large window, or
185/// a corrupted restored window size that slipped through — degrades to the GPU
186/// limit instead of panicking inside wgpu validation.
187pub fn clamp_surface_size(w: u32, h: u32, max_dim: u32) -> (u32, u32) {
188    let max_dim = max_dim.max(1);
189    (w.clamp(1, max_dim), h.clamp(1, max_dim))
190}
191
192/// wgpu device + surface bundle for one OS window.
193pub struct Gpu {
194    device: Arc<wgpu::Device>,
195    queue: Arc<wgpu::Queue>,
196    surface: wgpu::Surface<'static>,
197    surface_format: wgpu::TextureFormat,
198    config: wgpu::SurfaceConfiguration,
199    device_lost: Arc<std::sync::atomic::AtomicBool>,
200}
201
202impl Gpu {
203    /// Create the surface, adapter, device and queue for `target` (typically an
204    /// `Arc<winit::window::Window>`) and configure the swap chain at
205    /// `size` physical pixels, clamped by [`clamp_surface_size`].
206    ///
207    /// A non-sRGB surface format is preferred so the renderer's colour maths —
208    /// which writes linear-space values — isn't gamma-corrected twice by the
209    /// surface.
210    pub fn new(
211        target: impl Into<wgpu::SurfaceTarget<'static>>,
212        size: (u32, u32),
213        config: GpuConfig,
214    ) -> Result<Self, GpuInitError> {
215        let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
216        instance_desc.backends = wgpu::Backends::PRIMARY;
217        let instance = wgpu::Instance::new(instance_desc);
218        let surface = instance
219            .create_surface(target)
220            .map_err(GpuInitError::CreateSurface)?;
221        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
222            power_preference: wgpu::PowerPreference::HighPerformance,
223            compatible_surface: Some(&surface),
224            force_fallback_adapter: false,
225        }))
226        .map_err(|_| GpuInitError::RequestAdapter)?;
227
228        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
229            label: Some(config.label),
230            // Optional features are masked against what the adapter actually
231            // offers, so asking for an absent one degrades instead of failing
232            // `request_device`.
233            required_features: config.optional_features & adapter.features(),
234            required_limits: wgpu::Limits::default(),
235            memory_hints: wgpu::MemoryHints::Performance,
236            experimental_features: wgpu::ExperimentalFeatures::default(),
237            trace: wgpu::Trace::Off,
238        }))
239        .map_err(|_| GpuInitError::RequestDevice)?;
240
241        let caps = surface.get_capabilities(&adapter);
242        let surface_format = pick_surface_format(&caps.formats)?;
243        let alpha_mode = pick_alpha_mode(&caps.alpha_modes)?;
244
245        let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
246        let has_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
247        match config.copy_src {
248            CopySrc::Never => {}
249            CopySrc::IfSupported => {
250                if has_copy_src {
251                    usage |= wgpu::TextureUsages::COPY_SRC;
252                }
253            }
254            CopySrc::Required => {
255                if !has_copy_src {
256                    return Err(GpuInitError::CopySrcUnsupported);
257                }
258                usage |= wgpu::TextureUsages::COPY_SRC;
259            }
260        }
261
262        // Device loss (TDR, driver update, GPU reset, RDP session change) is
263        // reported out-of-band: nothing in the per-frame API returns an error,
264        // so a shell that does not watch this flag silently renders nothing
265        // forever. `Destroyed` is our own teardown, not a fault.
266        let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
267        {
268            let flag = Arc::clone(&device_lost);
269            device.set_device_lost_callback(move |reason, _message| {
270                if reason != wgpu::DeviceLostReason::Destroyed {
271                    flag.store(true, std::sync::atomic::Ordering::Relaxed);
272                }
273            });
274        }
275
276        let (cfg_w, cfg_h) =
277            clamp_surface_size(size.0, size.1, device.limits().max_texture_dimension_2d);
278        let surface_config = wgpu::SurfaceConfiguration {
279            usage,
280            format: surface_format,
281            width: cfg_w,
282            height: cfg_h,
283            present_mode: pick_present_mode(&caps.present_modes, config.present_mode),
284            desired_maximum_frame_latency: 2,
285            alpha_mode,
286            view_formats: vec![],
287        };
288        surface.configure(&device, &surface_config);
289
290        Ok(Self {
291            device: Arc::new(device),
292            queue: Arc::new(queue),
293            surface,
294            surface_format,
295            config: surface_config,
296            device_lost,
297        })
298    }
299
300    /// Has this device been lost since it was created?
301    ///
302    /// Set from wgpu's device-lost callback (TDR / driver reset / GPU removal
303    /// / RDP session change); our own `Device::destroy` is not counted. A
304    /// lost device cannot be revived — every resource created from it is dead
305    /// too — so the only recovery is to build a fresh [`Gpu`] for the same
306    /// window, rebuild the renderer on the new device, and drop any GPU
307    /// resources the app cached. Shells should poll this once per frame.
308    ///
309    /// The C# port polls the same flag at the top of a frame and rebuilds from
310    /// it in `PlatformWin32/win32/WebGpuControl.cs::TryRecoverDevice`
311    /// (agg-sharp), which is the closest thing to a reference implementation of
312    /// the recovery this flag is meant to drive.
313    pub fn device_lost(&self) -> bool {
314        self.device_lost.load(std::sync::atomic::Ordering::Relaxed)
315    }
316
317    pub fn device(&self) -> &Arc<wgpu::Device> {
318        &self.device
319    }
320
321    pub fn queue(&self) -> &Arc<wgpu::Queue> {
322        &self.queue
323    }
324
325    pub fn surface(&self) -> &wgpu::Surface<'static> {
326        &self.surface
327    }
328
329    pub fn surface_format(&self) -> wgpu::TextureFormat {
330        self.surface_format
331    }
332
333    /// The live swap-chain configuration — `width` / `height` are the clamped
334    /// physical pixel size the shell should hand to layout and `WgpuGfxCtx`.
335    pub fn config(&self) -> &wgpu::SurfaceConfiguration {
336        &self.config
337    }
338
339    /// Reconfigure the swap chain for a new physical size. A zero-sized
340    /// (minimized) window is ignored — there is no presentable surface then,
341    /// and wgpu rejects a zero extent.
342    pub fn resize(&mut self, w: u32, h: u32) {
343        if w == 0 || h == 0 {
344            return;
345        }
346        let (w, h) = clamp_surface_size(w, h, self.device.limits().max_texture_dimension_2d);
347        self.config.width = w;
348        self.config.height = h;
349        self.surface.configure(&self.device, &self.config);
350    }
351
352    /// Acquire the next surface texture, recovering from a stale swapchain by
353    /// reconfiguring and retrying once.
354    ///
355    /// Returns `None` when the frame must be skipped. `request_redraw` is
356    /// invoked for the skip cases that can still recover on their own, so a
357    /// reactive event loop (`ControlFlow::Wait`) wakes up to try again instead
358    /// of idling forever; pass a closure that calls `Window::request_redraw`.
359    pub fn acquire_frame(&self, request_redraw: impl Fn()) -> Option<wgpu::SurfaceTexture> {
360        use wgpu::CurrentSurfaceTexture as T;
361        let first = self.surface.get_current_texture();
362        match surface_acquire_action(&first) {
363            SurfaceAcquire::Present => match first {
364                T::Success(f) | T::Suboptimal(f) => Some(f),
365                _ => None,
366            },
367            SurfaceAcquire::Skip => None,
368            SurfaceAcquire::SkipAndRetry => {
369                request_redraw();
370                None
371            }
372            SurfaceAcquire::Reconfigure => {
373                // `configure` takes `&self`; the config already carries the
374                // current (nonzero, `resize`-clamped) size, so we just re-bind.
375                self.surface.configure(&self.device, &self.config);
376                match self.surface.get_current_texture() {
377                    T::Success(f) | T::Suboptimal(f) => Some(f),
378                    _ => {
379                        // Still nothing after the reconfigure: come back next
380                        // frame rather than sitting in `ControlFlow::Wait`.
381                        request_redraw();
382                        None
383                    }
384                }
385            }
386        }
387    }
388}
389
390/// How to handle the result of `Surface::get_current_texture`.
391#[derive(Clone, Copy, PartialEq, Eq, Debug)]
392#[non_exhaustive]
393pub enum SurfaceAcquire {
394    /// Texture is usable — render into it.
395    Present,
396    /// The swapchain is stale or gone (`Outdated`/`Lost`): reconfigure the
397    /// surface and try once more THIS frame.
398    Reconfigure,
399    /// Transient (`Timeout`): skip the frame, but ask for another one.
400    SkipAndRetry,
401    /// Skip the frame with no follow-up (`Occluded`/`Validation`): the window
402    /// is not visible / the app must fix the validation error, and a
403    /// self-requested redraw would just burn the CPU.
404    Skip,
405}
406
407/// Decide how to handle a surface-acquire status. A pure function so the
408/// recovery policy is unit-testable without a live GPU surface (the
409/// no-payload variants are constructible in tests).
410///
411/// `Outdated`/`Lost` fire right after a window resize reconfigures the
412/// swapchain, and after a GPU driver reset (TDR), a display-mode change, or an
413/// RDP reconnect. Treating them as a plain skip leaves a reactive shell
414/// (`ControlFlow::Wait` whenever `wants_draw()` is false) frozen or black until
415/// some unrelated event requests another redraw — the resize-black-screen
416/// regression. wgpu documents both as "reconfigure the surface and try again".
417pub fn surface_acquire_action(status: &wgpu::CurrentSurfaceTexture) -> SurfaceAcquire {
418    use wgpu::CurrentSurfaceTexture as T;
419    match status {
420        T::Success(_) | T::Suboptimal(_) => SurfaceAcquire::Present,
421        T::Outdated | T::Lost => SurfaceAcquire::Reconfigure,
422        T::Timeout => SurfaceAcquire::SkipAndRetry,
423        T::Occluded | T::Validation => SurfaceAcquire::Skip,
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::{
430        clamp_surface_size, pick_alpha_mode, pick_present_mode, pick_surface_format,
431        surface_acquire_action, GpuInitError, SurfaceAcquire,
432    };
433    use wgpu::CurrentSurfaceTexture as T;
434
435    #[test]
436    fn stale_swapchain_reconfigures_instead_of_skipping() {
437        // The resize-black-screen regression, and the frozen-window case after
438        // a driver reset (TDR) / display-mode change / RDP reconnect: both must
439        // drive a reconfigure-and-retry, NOT a silent skip.
440        assert_eq!(
441            surface_acquire_action(&T::Outdated),
442            SurfaceAcquire::Reconfigure
443        );
444        assert_eq!(
445            surface_acquire_action(&T::Lost),
446            SurfaceAcquire::Reconfigure
447        );
448    }
449
450    #[test]
451    fn timeout_skips_the_frame_but_asks_for_another() {
452        // Timeout is transient: the next acquire usually succeeds, so a
453        // reactive loop has to be woken back up or it waits forever.
454        assert_eq!(
455            surface_acquire_action(&T::Timeout),
456            SurfaceAcquire::SkipAndRetry
457        );
458    }
459
460    #[test]
461    fn occluded_and_validation_skip_without_self_requested_redraw() {
462        assert_eq!(surface_acquire_action(&T::Occluded), SurfaceAcquire::Skip);
463        assert_eq!(surface_acquire_action(&T::Validation), SurfaceAcquire::Skip);
464    }
465
466    #[test]
467    fn empty_capability_lists_are_an_error_not_a_panic() {
468        // A surface that reports no formats / no alpha modes is a broken or
469        // torn-down surface (headless RDP session, adapter lost mid-init).
470        // Indexing `[0]` there took the whole app down; callers get an error.
471        assert!(matches!(
472            pick_surface_format(&[]),
473            Err(GpuInitError::NoSurfaceFormats)
474        ));
475        assert!(matches!(
476            pick_alpha_mode(&[]),
477            Err(GpuInitError::NoAlphaModes)
478        ));
479    }
480
481    #[test]
482    fn non_srgb_format_is_preferred_and_first_is_the_fallback() {
483        use wgpu::TextureFormat as F;
484        // The renderer writes linear-space colour, so an sRGB surface would
485        // gamma-correct it twice — prefer any non-sRGB format on offer.
486        assert_eq!(
487            pick_surface_format(&[F::Bgra8UnormSrgb, F::Bgra8Unorm]).unwrap(),
488            F::Bgra8Unorm
489        );
490        // All-sRGB surface: fall back to the surface's own preference (first).
491        assert_eq!(
492            pick_surface_format(&[F::Bgra8UnormSrgb, F::Rgba8UnormSrgb]).unwrap(),
493            F::Bgra8UnormSrgb
494        );
495    }
496
497    #[test]
498    fn alpha_mode_takes_the_surface_preference() {
499        use wgpu::CompositeAlphaMode as A;
500        assert_eq!(
501            pick_alpha_mode(&[A::Opaque, A::PreMultiplied]).unwrap(),
502            A::Opaque
503        );
504    }
505
506    #[test]
507    fn unsupported_present_mode_falls_back_to_fifo() {
508        use wgpu::PresentMode as P;
509        // Only Fifo on offer (the guaranteed-everywhere mode): an explicit
510        // Mailbox/Immediate request would be a validation error.
511        assert_eq!(pick_present_mode(&[P::Fifo], P::Mailbox), P::Fifo);
512        assert_eq!(pick_present_mode(&[P::Fifo], P::Immediate), P::Fifo);
513        // Supported explicit modes pass through.
514        assert_eq!(
515            pick_present_mode(&[P::Fifo, P::Mailbox], P::Mailbox),
516            P::Mailbox
517        );
518        // wgpu resolves the Auto modes itself, so they are never rewritten —
519        // they do not appear in `caps.present_modes`.
520        assert_eq!(pick_present_mode(&[P::Fifo], P::AutoVsync), P::AutoVsync);
521        assert_eq!(
522            pick_present_mode(&[P::Fifo], P::AutoNoVsync),
523            P::AutoNoVsync
524        );
525        // A surface that reports nothing at all still yields a legal mode.
526        assert_eq!(pick_present_mode(&[], P::Immediate), P::Fifo);
527    }
528
529    #[test]
530    fn optional_features_default_empty_and_build() {
531        let cfg = super::GpuConfig::new("t");
532        assert_eq!(cfg.optional_features, wgpu::Features::empty());
533        let cfg = cfg.with_optional_features(wgpu::Features::FLOAT32_BLENDABLE);
534        assert_eq!(cfg.optional_features, wgpu::Features::FLOAT32_BLENDABLE);
535        // The request mask: an adapter without the feature yields an empty
536        // request rather than a failed `request_device`.
537        assert_eq!(
538            cfg.optional_features & wgpu::Features::empty(),
539            wgpu::Features::empty()
540        );
541    }
542
543    #[test]
544    fn surface_size_clamped_to_max_dim() {
545        assert_eq!(clamp_surface_size(10224, 5925, 8192), (8192, 5925));
546        assert_eq!(clamp_surface_size(0, 0, 8192), (1, 1));
547        assert_eq!(clamp_surface_size(1280, 720, 8192), (1280, 720));
548        // Degenerate zero limit still yields a valid 1x1.
549        assert_eq!(clamp_surface_size(100, 100, 0), (1, 1));
550    }
551}