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/// Pick the present mode to configure the swap chain with.
147///
148/// `wgpu` resolves the `Auto*` modes itself against whatever the surface
149/// supports, so they are always safe to request. An explicit mode that the
150/// surface does not list (`Mailbox` on a driver that lacks it, `Immediate`
151/// under a compositor that forces vsync) is a validation error, so it falls
152/// back to `Fifo` — the one mode the spec guarantees every surface supports.
153///
154/// Pure so the fallback is testable without a live surface.
155pub fn pick_present_mode(
156 supported: &[wgpu::PresentMode],
157 requested: wgpu::PresentMode,
158) -> wgpu::PresentMode {
159 use wgpu::PresentMode as P;
160 match requested {
161 P::AutoVsync | P::AutoNoVsync => requested,
162 explicit if supported.contains(&explicit) => explicit,
163 _ => P::Fifo,
164 }
165}
166
167/// Clamp a surface configuration size to `[1, max_dim]` on both axes.
168///
169/// `max_dim` is the device's `max_texture_dimension_2d`. Applied on every
170/// `Surface::configure` so a stray oversized request — an over-large window, or
171/// a corrupted restored window size that slipped through — degrades to the GPU
172/// limit instead of panicking inside wgpu validation.
173pub fn clamp_surface_size(w: u32, h: u32, max_dim: u32) -> (u32, u32) {
174 let max_dim = max_dim.max(1);
175 (w.clamp(1, max_dim), h.clamp(1, max_dim))
176}
177
178/// wgpu device + surface bundle for one OS window.
179pub struct Gpu {
180 device: Arc<wgpu::Device>,
181 queue: Arc<wgpu::Queue>,
182 surface: wgpu::Surface<'static>,
183 surface_format: wgpu::TextureFormat,
184 config: wgpu::SurfaceConfiguration,
185 device_lost: Arc<std::sync::atomic::AtomicBool>,
186}
187
188impl Gpu {
189 /// Create the surface, adapter, device and queue for `target` (typically an
190 /// `Arc<winit::window::Window>`) and configure the swap chain at
191 /// `size` physical pixels, clamped by [`clamp_surface_size`].
192 ///
193 /// A non-sRGB surface format is preferred so the renderer's colour maths —
194 /// which writes linear-space values — isn't gamma-corrected twice by the
195 /// surface.
196 pub fn new(
197 target: impl Into<wgpu::SurfaceTarget<'static>>,
198 size: (u32, u32),
199 config: GpuConfig,
200 ) -> Result<Self, GpuInitError> {
201 let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
202 instance_desc.backends = wgpu::Backends::PRIMARY;
203 let instance = wgpu::Instance::new(instance_desc);
204 let surface = instance
205 .create_surface(target)
206 .map_err(GpuInitError::CreateSurface)?;
207 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
208 power_preference: wgpu::PowerPreference::HighPerformance,
209 compatible_surface: Some(&surface),
210 force_fallback_adapter: false,
211 }))
212 .map_err(|_| GpuInitError::RequestAdapter)?;
213
214 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
215 label: Some(config.label),
216 required_features: wgpu::Features::empty(),
217 required_limits: wgpu::Limits::default(),
218 memory_hints: wgpu::MemoryHints::Performance,
219 experimental_features: wgpu::ExperimentalFeatures::default(),
220 trace: wgpu::Trace::Off,
221 }))
222 .map_err(|_| GpuInitError::RequestDevice)?;
223
224 let caps = surface.get_capabilities(&adapter);
225 let surface_format = pick_surface_format(&caps.formats)?;
226 let alpha_mode = pick_alpha_mode(&caps.alpha_modes)?;
227
228 let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
229 let has_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
230 match config.copy_src {
231 CopySrc::Never => {}
232 CopySrc::IfSupported => {
233 if has_copy_src {
234 usage |= wgpu::TextureUsages::COPY_SRC;
235 }
236 }
237 CopySrc::Required => {
238 if !has_copy_src {
239 return Err(GpuInitError::CopySrcUnsupported);
240 }
241 usage |= wgpu::TextureUsages::COPY_SRC;
242 }
243 }
244
245 // Device loss (TDR, driver update, GPU reset, RDP session change) is
246 // reported out-of-band: nothing in the per-frame API returns an error,
247 // so a shell that does not watch this flag silently renders nothing
248 // forever. `Destroyed` is our own teardown, not a fault.
249 let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
250 {
251 let flag = Arc::clone(&device_lost);
252 device.set_device_lost_callback(move |reason, _message| {
253 if reason != wgpu::DeviceLostReason::Destroyed {
254 flag.store(true, std::sync::atomic::Ordering::Relaxed);
255 }
256 });
257 }
258
259 let (cfg_w, cfg_h) =
260 clamp_surface_size(size.0, size.1, device.limits().max_texture_dimension_2d);
261 let surface_config = wgpu::SurfaceConfiguration {
262 usage,
263 format: surface_format,
264 width: cfg_w,
265 height: cfg_h,
266 present_mode: pick_present_mode(&caps.present_modes, config.present_mode),
267 desired_maximum_frame_latency: 2,
268 alpha_mode,
269 view_formats: vec![],
270 };
271 surface.configure(&device, &surface_config);
272
273 Ok(Self {
274 device: Arc::new(device),
275 queue: Arc::new(queue),
276 surface,
277 surface_format,
278 config: surface_config,
279 device_lost,
280 })
281 }
282
283 /// Has this device been lost since it was created?
284 ///
285 /// Set from wgpu's device-lost callback (TDR / driver reset / GPU removal
286 /// / RDP session change); our own `Device::destroy` is not counted. A
287 /// lost device cannot be revived — every resource created from it is dead
288 /// too — so the only recovery is to build a fresh [`Gpu`] for the same
289 /// window, rebuild the renderer on the new device, and drop any GPU
290 /// resources the app cached. Shells should poll this once per frame.
291 pub fn device_lost(&self) -> bool {
292 self.device_lost.load(std::sync::atomic::Ordering::Relaxed)
293 }
294
295 pub fn device(&self) -> &Arc<wgpu::Device> {
296 &self.device
297 }
298
299 pub fn queue(&self) -> &Arc<wgpu::Queue> {
300 &self.queue
301 }
302
303 pub fn surface(&self) -> &wgpu::Surface<'static> {
304 &self.surface
305 }
306
307 pub fn surface_format(&self) -> wgpu::TextureFormat {
308 self.surface_format
309 }
310
311 /// The live swap-chain configuration — `width` / `height` are the clamped
312 /// physical pixel size the shell should hand to layout and `WgpuGfxCtx`.
313 pub fn config(&self) -> &wgpu::SurfaceConfiguration {
314 &self.config
315 }
316
317 /// Reconfigure the swap chain for a new physical size. A zero-sized
318 /// (minimized) window is ignored — there is no presentable surface then,
319 /// and wgpu rejects a zero extent.
320 pub fn resize(&mut self, w: u32, h: u32) {
321 if w == 0 || h == 0 {
322 return;
323 }
324 let (w, h) = clamp_surface_size(w, h, self.device.limits().max_texture_dimension_2d);
325 self.config.width = w;
326 self.config.height = h;
327 self.surface.configure(&self.device, &self.config);
328 }
329
330 /// Acquire the next surface texture, recovering from a stale swapchain by
331 /// reconfiguring and retrying once.
332 ///
333 /// Returns `None` when the frame must be skipped. `request_redraw` is
334 /// invoked for the skip cases that can still recover on their own, so a
335 /// reactive event loop (`ControlFlow::Wait`) wakes up to try again instead
336 /// of idling forever; pass a closure that calls `Window::request_redraw`.
337 pub fn acquire_frame(&self, request_redraw: impl Fn()) -> Option<wgpu::SurfaceTexture> {
338 use wgpu::CurrentSurfaceTexture as T;
339 let first = self.surface.get_current_texture();
340 match surface_acquire_action(&first) {
341 SurfaceAcquire::Present => match first {
342 T::Success(f) | T::Suboptimal(f) => Some(f),
343 _ => None,
344 },
345 SurfaceAcquire::Skip => None,
346 SurfaceAcquire::SkipAndRetry => {
347 request_redraw();
348 None
349 }
350 SurfaceAcquire::Reconfigure => {
351 // `configure` takes `&self`; the config already carries the
352 // current (nonzero, `resize`-clamped) size, so we just re-bind.
353 self.surface.configure(&self.device, &self.config);
354 match self.surface.get_current_texture() {
355 T::Success(f) | T::Suboptimal(f) => Some(f),
356 _ => {
357 // Still nothing after the reconfigure: come back next
358 // frame rather than sitting in `ControlFlow::Wait`.
359 request_redraw();
360 None
361 }
362 }
363 }
364 }
365 }
366}
367
368/// How to handle the result of `Surface::get_current_texture`.
369#[derive(Clone, Copy, PartialEq, Eq, Debug)]
370#[non_exhaustive]
371pub enum SurfaceAcquire {
372 /// Texture is usable — render into it.
373 Present,
374 /// The swapchain is stale or gone (`Outdated`/`Lost`): reconfigure the
375 /// surface and try once more THIS frame.
376 Reconfigure,
377 /// Transient (`Timeout`): skip the frame, but ask for another one.
378 SkipAndRetry,
379 /// Skip the frame with no follow-up (`Occluded`/`Validation`): the window
380 /// is not visible / the app must fix the validation error, and a
381 /// self-requested redraw would just burn the CPU.
382 Skip,
383}
384
385/// Decide how to handle a surface-acquire status. A pure function so the
386/// recovery policy is unit-testable without a live GPU surface (the
387/// no-payload variants are constructible in tests).
388///
389/// `Outdated`/`Lost` fire right after a window resize reconfigures the
390/// swapchain, and after a GPU driver reset (TDR), a display-mode change, or an
391/// RDP reconnect. Treating them as a plain skip leaves a reactive shell
392/// (`ControlFlow::Wait` whenever `wants_draw()` is false) frozen or black until
393/// some unrelated event requests another redraw — the resize-black-screen
394/// regression. wgpu documents both as "reconfigure the surface and try again".
395pub fn surface_acquire_action(status: &wgpu::CurrentSurfaceTexture) -> SurfaceAcquire {
396 use wgpu::CurrentSurfaceTexture as T;
397 match status {
398 T::Success(_) | T::Suboptimal(_) => SurfaceAcquire::Present,
399 T::Outdated | T::Lost => SurfaceAcquire::Reconfigure,
400 T::Timeout => SurfaceAcquire::SkipAndRetry,
401 T::Occluded | T::Validation => SurfaceAcquire::Skip,
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::{
408 clamp_surface_size, pick_alpha_mode, pick_present_mode, pick_surface_format,
409 surface_acquire_action, GpuInitError, SurfaceAcquire,
410 };
411 use wgpu::CurrentSurfaceTexture as T;
412
413 #[test]
414 fn stale_swapchain_reconfigures_instead_of_skipping() {
415 // The resize-black-screen regression, and the frozen-window case after
416 // a driver reset (TDR) / display-mode change / RDP reconnect: both must
417 // drive a reconfigure-and-retry, NOT a silent skip.
418 assert_eq!(
419 surface_acquire_action(&T::Outdated),
420 SurfaceAcquire::Reconfigure
421 );
422 assert_eq!(
423 surface_acquire_action(&T::Lost),
424 SurfaceAcquire::Reconfigure
425 );
426 }
427
428 #[test]
429 fn timeout_skips_the_frame_but_asks_for_another() {
430 // Timeout is transient: the next acquire usually succeeds, so a
431 // reactive loop has to be woken back up or it waits forever.
432 assert_eq!(
433 surface_acquire_action(&T::Timeout),
434 SurfaceAcquire::SkipAndRetry
435 );
436 }
437
438 #[test]
439 fn occluded_and_validation_skip_without_self_requested_redraw() {
440 assert_eq!(surface_acquire_action(&T::Occluded), SurfaceAcquire::Skip);
441 assert_eq!(surface_acquire_action(&T::Validation), SurfaceAcquire::Skip);
442 }
443
444 #[test]
445 fn empty_capability_lists_are_an_error_not_a_panic() {
446 // A surface that reports no formats / no alpha modes is a broken or
447 // torn-down surface (headless RDP session, adapter lost mid-init).
448 // Indexing `[0]` there took the whole app down; callers get an error.
449 assert!(matches!(
450 pick_surface_format(&[]),
451 Err(GpuInitError::NoSurfaceFormats)
452 ));
453 assert!(matches!(
454 pick_alpha_mode(&[]),
455 Err(GpuInitError::NoAlphaModes)
456 ));
457 }
458
459 #[test]
460 fn non_srgb_format_is_preferred_and_first_is_the_fallback() {
461 use wgpu::TextureFormat as F;
462 // The renderer writes linear-space colour, so an sRGB surface would
463 // gamma-correct it twice — prefer any non-sRGB format on offer.
464 assert_eq!(
465 pick_surface_format(&[F::Bgra8UnormSrgb, F::Bgra8Unorm]).unwrap(),
466 F::Bgra8Unorm
467 );
468 // All-sRGB surface: fall back to the surface's own preference (first).
469 assert_eq!(
470 pick_surface_format(&[F::Bgra8UnormSrgb, F::Rgba8UnormSrgb]).unwrap(),
471 F::Bgra8UnormSrgb
472 );
473 }
474
475 #[test]
476 fn alpha_mode_takes_the_surface_preference() {
477 use wgpu::CompositeAlphaMode as A;
478 assert_eq!(
479 pick_alpha_mode(&[A::Opaque, A::PreMultiplied]).unwrap(),
480 A::Opaque
481 );
482 }
483
484 #[test]
485 fn unsupported_present_mode_falls_back_to_fifo() {
486 use wgpu::PresentMode as P;
487 // Only Fifo on offer (the guaranteed-everywhere mode): an explicit
488 // Mailbox/Immediate request would be a validation error.
489 assert_eq!(pick_present_mode(&[P::Fifo], P::Mailbox), P::Fifo);
490 assert_eq!(pick_present_mode(&[P::Fifo], P::Immediate), P::Fifo);
491 // Supported explicit modes pass through.
492 assert_eq!(
493 pick_present_mode(&[P::Fifo, P::Mailbox], P::Mailbox),
494 P::Mailbox
495 );
496 // wgpu resolves the Auto modes itself, so they are never rewritten —
497 // they do not appear in `caps.present_modes`.
498 assert_eq!(pick_present_mode(&[P::Fifo], P::AutoVsync), P::AutoVsync);
499 assert_eq!(
500 pick_present_mode(&[P::Fifo], P::AutoNoVsync),
501 P::AutoNoVsync
502 );
503 // A surface that reports nothing at all still yields a legal mode.
504 assert_eq!(pick_present_mode(&[], P::Immediate), P::Fifo);
505 }
506
507 #[test]
508 fn surface_size_clamped_to_max_dim() {
509 assert_eq!(clamp_surface_size(10224, 5925, 8192), (8192, 5925));
510 assert_eq!(clamp_surface_size(0, 0, 8192), (1, 1));
511 assert_eq!(clamp_surface_size(1280, 720, 8192), (1280, 720));
512 // Degenerate zero limit still yields a valid 1x1.
513 assert_eq!(clamp_surface_size(100, 100, 0), (1, 1));
514 }
515}