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}
57
58impl Default for GpuConfig {
59    fn default() -> Self {
60        Self {
61            label: "agg-gui-wgpu",
62            copy_src: CopySrc::Never,
63            present_mode: wgpu::PresentMode::AutoVsync,
64        }
65    }
66}
67
68impl GpuConfig {
69    /// Default configuration under a shell-specific device label.
70    pub fn new(label: &'static str) -> Self {
71        Self {
72            label,
73            ..Self::default()
74        }
75    }
76
77    pub fn with_copy_src(mut self, copy_src: CopySrc) -> Self {
78        self.copy_src = copy_src;
79        self
80    }
81
82    /// Override the swap-chain present mode (default `AutoVsync`).
83    pub fn with_present_mode(mut self, present_mode: wgpu::PresentMode) -> Self {
84        self.present_mode = present_mode;
85        self
86    }
87}
88
89/// Why [`Gpu::new`] could not produce a usable surface.
90#[derive(Debug)]
91#[non_exhaustive]
92pub enum GpuInitError {
93    CreateSurface(wgpu::CreateSurfaceError),
94    RequestAdapter,
95    RequestDevice,
96    /// [`CopySrc::Required`] was asked for and the surface does not offer it.
97    CopySrcUnsupported,
98    /// The surface reported no supported texture formats — a torn-down or
99    /// otherwise unusable surface.
100    NoSurfaceFormats,
101    /// The surface reported no supported composite alpha modes.
102    NoAlphaModes,
103}
104
105impl std::fmt::Display for GpuInitError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match self {
108            Self::CreateSurface(e) => write!(f, "create wgpu surface: {e}"),
109            Self::RequestAdapter => write!(f, "no suitable wgpu adapter"),
110            Self::RequestDevice => write!(f, "could not request a wgpu device"),
111            Self::CopySrcUnsupported => {
112                write!(f, "surface does not support COPY_SRC read-back")
113            }
114            Self::NoSurfaceFormats => write!(f, "surface reports no supported texture formats"),
115            Self::NoAlphaModes => write!(f, "surface reports no supported alpha modes"),
116        }
117    }
118}
119
120impl std::error::Error for GpuInitError {}
121
122/// Pick the surface format to configure the swap chain with.
123///
124/// A non-sRGB format is preferred so the renderer's linear-space colour maths
125/// isn't gamma-corrected a second time by the surface; when the surface only
126/// offers sRGB formats we take its own first preference. Pure so the choice is
127/// testable without a live surface.
128fn pick_surface_format(
129    formats: &[wgpu::TextureFormat],
130) -> Result<wgpu::TextureFormat, GpuInitError> {
131    formats
132        .iter()
133        .copied()
134        .find(|f| !f.is_srgb())
135        .or_else(|| formats.first().copied())
136        .ok_or(GpuInitError::NoSurfaceFormats)
137}
138
139/// Pick the composite alpha mode — the surface's first preference.
140fn pick_alpha_mode(
141    modes: &[wgpu::CompositeAlphaMode],
142) -> Result<wgpu::CompositeAlphaMode, GpuInitError> {
143    modes.first().copied().ok_or(GpuInitError::NoAlphaModes)
144}
145
146/// Clamp a surface configuration size to `[1, max_dim]` on both axes.
147///
148/// `max_dim` is the device's `max_texture_dimension_2d`. Applied on every
149/// `Surface::configure` so a stray oversized request — an over-large window, or
150/// a corrupted restored window size that slipped through — degrades to the GPU
151/// limit instead of panicking inside wgpu validation.
152pub fn clamp_surface_size(w: u32, h: u32, max_dim: u32) -> (u32, u32) {
153    let max_dim = max_dim.max(1);
154    (w.clamp(1, max_dim), h.clamp(1, max_dim))
155}
156
157/// wgpu device + surface bundle for one OS window.
158pub struct Gpu {
159    device: Arc<wgpu::Device>,
160    queue: Arc<wgpu::Queue>,
161    surface: wgpu::Surface<'static>,
162    surface_format: wgpu::TextureFormat,
163    config: wgpu::SurfaceConfiguration,
164}
165
166impl Gpu {
167    /// Create the surface, adapter, device and queue for `target` (typically an
168    /// `Arc<winit::window::Window>`) and configure the swap chain at
169    /// `size` physical pixels, clamped by [`clamp_surface_size`].
170    ///
171    /// A non-sRGB surface format is preferred so the renderer's colour maths —
172    /// which writes linear-space values — isn't gamma-corrected twice by the
173    /// surface.
174    pub fn new(
175        target: impl Into<wgpu::SurfaceTarget<'static>>,
176        size: (u32, u32),
177        config: GpuConfig,
178    ) -> Result<Self, GpuInitError> {
179        let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
180        instance_desc.backends = wgpu::Backends::PRIMARY;
181        let instance = wgpu::Instance::new(instance_desc);
182        let surface = instance
183            .create_surface(target)
184            .map_err(GpuInitError::CreateSurface)?;
185        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
186            power_preference: wgpu::PowerPreference::HighPerformance,
187            compatible_surface: Some(&surface),
188            force_fallback_adapter: false,
189        }))
190        .map_err(|_| GpuInitError::RequestAdapter)?;
191
192        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
193            label: Some(config.label),
194            required_features: wgpu::Features::empty(),
195            required_limits: wgpu::Limits::default(),
196            memory_hints: wgpu::MemoryHints::Performance,
197            experimental_features: wgpu::ExperimentalFeatures::default(),
198            trace: wgpu::Trace::Off,
199        }))
200        .map_err(|_| GpuInitError::RequestDevice)?;
201
202        let caps = surface.get_capabilities(&adapter);
203        let surface_format = pick_surface_format(&caps.formats)?;
204        let alpha_mode = pick_alpha_mode(&caps.alpha_modes)?;
205
206        let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
207        let has_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
208        match config.copy_src {
209            CopySrc::Never => {}
210            CopySrc::IfSupported => {
211                if has_copy_src {
212                    usage |= wgpu::TextureUsages::COPY_SRC;
213                }
214            }
215            CopySrc::Required => {
216                if !has_copy_src {
217                    return Err(GpuInitError::CopySrcUnsupported);
218                }
219                usage |= wgpu::TextureUsages::COPY_SRC;
220            }
221        }
222
223        let (cfg_w, cfg_h) =
224            clamp_surface_size(size.0, size.1, device.limits().max_texture_dimension_2d);
225        let surface_config = wgpu::SurfaceConfiguration {
226            usage,
227            format: surface_format,
228            width: cfg_w,
229            height: cfg_h,
230            present_mode: config.present_mode,
231            desired_maximum_frame_latency: 2,
232            alpha_mode,
233            view_formats: vec![],
234        };
235        surface.configure(&device, &surface_config);
236
237        Ok(Self {
238            device: Arc::new(device),
239            queue: Arc::new(queue),
240            surface,
241            surface_format,
242            config: surface_config,
243        })
244    }
245
246    pub fn device(&self) -> &Arc<wgpu::Device> {
247        &self.device
248    }
249
250    pub fn queue(&self) -> &Arc<wgpu::Queue> {
251        &self.queue
252    }
253
254    pub fn surface(&self) -> &wgpu::Surface<'static> {
255        &self.surface
256    }
257
258    pub fn surface_format(&self) -> wgpu::TextureFormat {
259        self.surface_format
260    }
261
262    /// The live swap-chain configuration — `width` / `height` are the clamped
263    /// physical pixel size the shell should hand to layout and `WgpuGfxCtx`.
264    pub fn config(&self) -> &wgpu::SurfaceConfiguration {
265        &self.config
266    }
267
268    /// Reconfigure the swap chain for a new physical size. A zero-sized
269    /// (minimized) window is ignored — there is no presentable surface then,
270    /// and wgpu rejects a zero extent.
271    pub fn resize(&mut self, w: u32, h: u32) {
272        if w == 0 || h == 0 {
273            return;
274        }
275        let (w, h) = clamp_surface_size(w, h, self.device.limits().max_texture_dimension_2d);
276        self.config.width = w;
277        self.config.height = h;
278        self.surface.configure(&self.device, &self.config);
279    }
280
281    /// Acquire the next surface texture, recovering from a stale swapchain by
282    /// reconfiguring and retrying once.
283    ///
284    /// Returns `None` when the frame must be skipped. `request_redraw` is
285    /// invoked for the skip cases that can still recover on their own, so a
286    /// reactive event loop (`ControlFlow::Wait`) wakes up to try again instead
287    /// of idling forever; pass a closure that calls `Window::request_redraw`.
288    pub fn acquire_frame(&self, request_redraw: impl Fn()) -> Option<wgpu::SurfaceTexture> {
289        use wgpu::CurrentSurfaceTexture as T;
290        let first = self.surface.get_current_texture();
291        match surface_acquire_action(&first) {
292            SurfaceAcquire::Present => match first {
293                T::Success(f) | T::Suboptimal(f) => Some(f),
294                _ => None,
295            },
296            SurfaceAcquire::Skip => None,
297            SurfaceAcquire::SkipAndRetry => {
298                request_redraw();
299                None
300            }
301            SurfaceAcquire::Reconfigure => {
302                // `configure` takes `&self`; the config already carries the
303                // current (nonzero, `resize`-clamped) size, so we just re-bind.
304                self.surface.configure(&self.device, &self.config);
305                match self.surface.get_current_texture() {
306                    T::Success(f) | T::Suboptimal(f) => Some(f),
307                    _ => {
308                        // Still nothing after the reconfigure: come back next
309                        // frame rather than sitting in `ControlFlow::Wait`.
310                        request_redraw();
311                        None
312                    }
313                }
314            }
315        }
316    }
317}
318
319/// How to handle the result of `Surface::get_current_texture`.
320#[derive(Clone, Copy, PartialEq, Eq, Debug)]
321#[non_exhaustive]
322pub enum SurfaceAcquire {
323    /// Texture is usable — render into it.
324    Present,
325    /// The swapchain is stale or gone (`Outdated`/`Lost`): reconfigure the
326    /// surface and try once more THIS frame.
327    Reconfigure,
328    /// Transient (`Timeout`): skip the frame, but ask for another one.
329    SkipAndRetry,
330    /// Skip the frame with no follow-up (`Occluded`/`Validation`): the window
331    /// is not visible / the app must fix the validation error, and a
332    /// self-requested redraw would just burn the CPU.
333    Skip,
334}
335
336/// Decide how to handle a surface-acquire status. A pure function so the
337/// recovery policy is unit-testable without a live GPU surface (the
338/// no-payload variants are constructible in tests).
339///
340/// `Outdated`/`Lost` fire right after a window resize reconfigures the
341/// swapchain, and after a GPU driver reset (TDR), a display-mode change, or an
342/// RDP reconnect. Treating them as a plain skip leaves a reactive shell
343/// (`ControlFlow::Wait` whenever `wants_draw()` is false) frozen or black until
344/// some unrelated event requests another redraw — the resize-black-screen
345/// regression. wgpu documents both as "reconfigure the surface and try again".
346pub fn surface_acquire_action(status: &wgpu::CurrentSurfaceTexture) -> SurfaceAcquire {
347    use wgpu::CurrentSurfaceTexture as T;
348    match status {
349        T::Success(_) | T::Suboptimal(_) => SurfaceAcquire::Present,
350        T::Outdated | T::Lost => SurfaceAcquire::Reconfigure,
351        T::Timeout => SurfaceAcquire::SkipAndRetry,
352        T::Occluded | T::Validation => SurfaceAcquire::Skip,
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::{
359        clamp_surface_size, pick_alpha_mode, pick_surface_format, surface_acquire_action,
360        GpuInitError, SurfaceAcquire,
361    };
362    use wgpu::CurrentSurfaceTexture as T;
363
364    #[test]
365    fn stale_swapchain_reconfigures_instead_of_skipping() {
366        // The resize-black-screen regression, and the frozen-window case after
367        // a driver reset (TDR) / display-mode change / RDP reconnect: both must
368        // drive a reconfigure-and-retry, NOT a silent skip.
369        assert_eq!(
370            surface_acquire_action(&T::Outdated),
371            SurfaceAcquire::Reconfigure
372        );
373        assert_eq!(
374            surface_acquire_action(&T::Lost),
375            SurfaceAcquire::Reconfigure
376        );
377    }
378
379    #[test]
380    fn timeout_skips_the_frame_but_asks_for_another() {
381        // Timeout is transient: the next acquire usually succeeds, so a
382        // reactive loop has to be woken back up or it waits forever.
383        assert_eq!(
384            surface_acquire_action(&T::Timeout),
385            SurfaceAcquire::SkipAndRetry
386        );
387    }
388
389    #[test]
390    fn occluded_and_validation_skip_without_self_requested_redraw() {
391        assert_eq!(surface_acquire_action(&T::Occluded), SurfaceAcquire::Skip);
392        assert_eq!(surface_acquire_action(&T::Validation), SurfaceAcquire::Skip);
393    }
394
395    #[test]
396    fn empty_capability_lists_are_an_error_not_a_panic() {
397        // A surface that reports no formats / no alpha modes is a broken or
398        // torn-down surface (headless RDP session, adapter lost mid-init).
399        // Indexing `[0]` there took the whole app down; callers get an error.
400        assert!(matches!(
401            pick_surface_format(&[]),
402            Err(GpuInitError::NoSurfaceFormats)
403        ));
404        assert!(matches!(
405            pick_alpha_mode(&[]),
406            Err(GpuInitError::NoAlphaModes)
407        ));
408    }
409
410    #[test]
411    fn non_srgb_format_is_preferred_and_first_is_the_fallback() {
412        use wgpu::TextureFormat as F;
413        // The renderer writes linear-space colour, so an sRGB surface would
414        // gamma-correct it twice — prefer any non-sRGB format on offer.
415        assert_eq!(
416            pick_surface_format(&[F::Bgra8UnormSrgb, F::Bgra8Unorm]).unwrap(),
417            F::Bgra8Unorm
418        );
419        // All-sRGB surface: fall back to the surface's own preference (first).
420        assert_eq!(
421            pick_surface_format(&[F::Bgra8UnormSrgb, F::Rgba8UnormSrgb]).unwrap(),
422            F::Bgra8UnormSrgb
423        );
424    }
425
426    #[test]
427    fn alpha_mode_takes_the_surface_preference() {
428        use wgpu::CompositeAlphaMode as A;
429        assert_eq!(
430            pick_alpha_mode(&[A::Opaque, A::PreMultiplied]).unwrap(),
431            A::Opaque
432        );
433    }
434
435    #[test]
436    fn surface_size_clamped_to_max_dim() {
437        assert_eq!(clamp_surface_size(10224, 5925, 8192), (8192, 5925));
438        assert_eq!(clamp_surface_size(0, 0, 8192), (1, 1));
439        assert_eq!(clamp_surface_size(1280, 720, 8192), (1280, 720));
440        // Degenerate zero limit still yields a valid 1x1.
441        assert_eq!(clamp_surface_size(100, 100, 0), (1, 1));
442    }
443}