frust_gpu/surface.rs
1//! Swapchain surfaces: creation, cross-thread hand-off, alpha-mode policy and
2//! configuration.
3//!
4//! Three types cover the whole life of a swapchain, in the order a shell meets
5//! them:
6//!
7//! 1. [`SurfaceFactory`] — a cheap clone of the `wgpu::Instance` that creates a
8//! surface **on the thread the windowing backend requires** (the main/UI
9//! thread on winit/AppKit).
10//! 2. [`DetachedSurface`] — the created-but-unconfigured surface, opaque and
11//! `Send`, moved to whichever thread owns the renderer.
12//! 3. [`ConfiguredSurface`] — the surface plus the swapchain configuration it
13//! was brought up with, and the one derived answer everything downstream
14//! keys off: whether it *actually* came up translucent
15//! ([`ConfiguredSurface::resolved_translucent`]).
16//!
17//! Every decision this module makes — which composite alpha mode to resolve
18//! ([`resolve_alpha_mode`]), which swapchain format to pick
19//! ([`select_surface_format`]), what the configuration should be
20//! ([`surface_config`]) — is a pure function over plain values, so it is
21//! host-testable with neither a GPU nor a window server. Only
22//! [`ConfiguredSurface::configure`]/[`ConfiguredSurface::reconfigure`]/
23//! [`ConfiguredSurface::resize`] touch a live device.
24//!
25//! # Device seam
26//!
27//! This module speaks `wgpu` primitives directly — a `&wgpu::Instance` to
28//! create surfaces from, a `&wgpu::Device` to configure them against — rather
29//! than an owning context type. Anything that pools the instance/adapter/device
30//! sits above these entry points and passes its handles in, so nothing here
31//! needs to know how that pooling works.
32//!
33//! # Render-attachment only
34//!
35//! The swapchain is configured with `RENDER_ATTACHMENT` and nothing else: the
36//! engine draws the frame through an ordinary render pass, in whichever of
37//! [`SURFACE_FORMATS`] the platform reports first. There is no
38//! compute-storage-write path here, so no surface has to carry
39//! `STORAGE_BINDING` and no surface is pinned to a single format to satisfy a
40//! compute target.
41
42use anyhow::{Result, anyhow};
43
44/// Every swapchain format [`select_surface_format`] will configure a surface
45/// with — a membership set, not a preference order.
46///
47/// Both are 8-bit-per-channel unorm formats the engine's pipelines are built
48/// for, and the engine warms itself for whichever one the surface hands over,
49/// so neither is privileged. Which of the two a platform reporting both lands
50/// on is the *surface's* own preference order — see [`select_surface_format`].
51pub const SURFACE_FORMATS: [wgpu::TextureFormat; 2] = [
52 wgpu::TextureFormat::Rgba8Unorm,
53 wgpu::TextureFormat::Bgra8Unorm,
54];
55
56/// How many frames the swapchain may have in flight before `get_current_texture`
57/// blocks. Two is wgpu's own default and the value every frust surface has
58/// shipped with: one frame being presented while the next is recorded.
59const DESIRED_MAXIMUM_FRAME_LATENCY: u32 = 2;
60
61/// What a caller wants of the surface's alpha, expressed without naming a
62/// `wgpu` type — the public, platform-independent request a shell makes.
63///
64/// **This is a request, not an outcome.** A `TranslucentPreferred` surface on a
65/// platform that advertises no translucent composite mode silently resolves
66/// opaque; read [`ConfiguredSurface::resolved_translucent`] after configuration
67/// rather than keying a paint contract off the request, or a hole-punched view
68/// presents black rectangles on an opaque swapchain.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum SurfaceAlphaRequest {
71 /// An ordinary opaque window: `wgpu::CompositeAlphaMode::Auto`, whatever the
72 /// surface reports.
73 Opaque,
74 /// Prefer a translucent alpha-compositing mode, so content behind the
75 /// surface shows through where the frame's alpha is below 1 — the mode a
76 /// hole-punched native view underneath the frame needs.
77 TranslucentPreferred,
78}
79
80/// Resolves a caller's [`SurfaceAlphaRequest`] against the live surface's
81/// reported `alpha_modes`, choosing the actual `wgpu::CompositeAlphaMode` to
82/// configure with.
83///
84/// `Opaque` resolves to `Auto` without consulting the surface at all.
85/// `TranslucentPreferred` tries, in order, `Inherit` (Android's only reported
86/// translucent mode), `PostMultiplied` (iOS's translucent mode), then
87/// `PreMultiplied` — falling back to `Auto` with a `log::warn!` when none of the
88/// three is in `capabilities.alpha_modes` (translucency is simply unavailable
89/// on that surface).
90pub fn resolve_alpha_mode(
91 request: SurfaceAlphaRequest,
92 capabilities: &wgpu::SurfaceCapabilities,
93) -> wgpu::CompositeAlphaMode {
94 match request {
95 SurfaceAlphaRequest::Opaque => wgpu::CompositeAlphaMode::Auto,
96 SurfaceAlphaRequest::TranslucentPreferred => {
97 const PREFERRED: [wgpu::CompositeAlphaMode; 3] = [
98 wgpu::CompositeAlphaMode::Inherit,
99 wgpu::CompositeAlphaMode::PostMultiplied,
100 wgpu::CompositeAlphaMode::PreMultiplied,
101 ];
102 PREFERRED
103 .into_iter()
104 .find(|mode| capabilities.alpha_modes.contains(mode))
105 .unwrap_or_else(|| {
106 log::warn!(
107 "frust-gpu: translucency requested but unavailable \
108 (alpha_modes={:?}) — falling back to Auto (opaque)",
109 capabilities.alpha_modes
110 );
111 wgpu::CompositeAlphaMode::Auto
112 })
113 }
114 }
115}
116
117/// Whether a **resolved** `wgpu::CompositeAlphaMode` actually composites the
118/// surface's alpha against what is behind it — i.e. whether the surface really
119/// came up translucent, as opposed to what the caller *requested*.
120///
121/// This is the truth behind [`ConfiguredSurface::resolved_translucent`]:
122/// [`resolve_alpha_mode`] can silently degrade a
123/// [`SurfaceAlphaRequest::TranslucentPreferred`] to `Auto` when the platform
124/// advertises no translucent mode, and a shell that kept keying its paint
125/// contract off the *request* would then clear to transparent and punch its
126/// native-view slots against an OPAQUE swapchain — presenting black rectangles.
127///
128/// The three translucent modes are exactly [`resolve_alpha_mode`]'s preference
129/// list — `Inherit` (Android), `PostMultiplied` (iOS), `PreMultiplied`;
130/// `Opaque`/`Auto` ignore the surface's alpha entirely and are therefore *not*
131/// translucent (`Auto` is what every opaque caller and every fallback resolves
132/// to).
133pub fn alpha_mode_is_translucent(mode: wgpu::CompositeAlphaMode) -> bool {
134 matches!(
135 mode,
136 wgpu::CompositeAlphaMode::Inherit
137 | wgpu::CompositeAlphaMode::PostMultiplied
138 | wgpu::CompositeAlphaMode::PreMultiplied
139 )
140}
141
142/// Whether a swapchain configured with this composite alpha mode expects the
143/// pixels it is handed to be **premultiplied**.
144///
145/// The engine writes premultiplied alpha, so this predicate now reads as "no
146/// conversion needed": the two premultiplied-expecting modes are
147/// `PreMultiplied` and — the shipped Android translucent case — `Inherit`, and
148/// both take the engine's output unchanged. On Android the only reported
149/// translucent mode is `Inherit`, under which SurfaceFlinger blends a
150/// `TRANSLUCENT` SurfaceView premultiplied; a straight-alpha frame handed to it
151/// over-brightens every partial-alpha pixel by `1/a` (measured on device: a
152/// 50%-alpha `#FFF176` reaching SurfaceFlinger stored straight `#FFF176@128`
153/// instead of premultiplied `#807B3B@128`).
154///
155/// The cost has therefore moved to the *other* side. `PostMultiplied` — iOS's
156/// translucent mode, and the one mode this returns `false` for while still
157/// being translucent ([`alpha_mode_is_straight_translucent`]) — expects
158/// STRAIGHT alpha, so a translucent iOS surface is the case that needs an
159/// un-premultiply pass over the engine's output before present.
160/// `Opaque`/`Auto` ignore alpha entirely and need nothing either way.
161pub fn alpha_mode_needs_premultiply(mode: wgpu::CompositeAlphaMode) -> bool {
162 matches!(
163 mode,
164 wgpu::CompositeAlphaMode::PreMultiplied | wgpu::CompositeAlphaMode::Inherit
165 )
166}
167
168/// Whether a **resolved** alpha mode means "translucent, and the swapchain
169/// stores STRAIGHT alpha" — the combination the engine's premultiplied output
170/// cannot be presented into unconverted.
171///
172/// True for `PostMultiplied` alone: it is translucent
173/// ([`alpha_mode_is_translucent`]) and does not expect premultiplied pixels
174/// ([`alpha_mode_needs_premultiply`] is false), which is iOS's translucent
175/// mode. `Inherit`/`PreMultiplied` are translucent *and* premultiplied, so they
176/// take the frame as it is written; `Opaque`/`Auto` ignore alpha entirely.
177///
178/// Pure and mode-only, so the decision is host-testable without a surface; the
179/// live per-surface answer is
180/// [`ConfiguredSurface::straight_alpha_translucent`], which also respects a
181/// translucency the surface never actually got.
182pub fn alpha_mode_is_straight_translucent(mode: wgpu::CompositeAlphaMode) -> bool {
183 alpha_mode_is_translucent(mode) && !alpha_mode_needs_premultiply(mode)
184}
185
186/// Whether `backend`'s compositor actually composites `mode` premultiplied,
187/// **despite** [`alpha_mode_is_straight_translucent`] answering true for it —
188/// an upstream wgpu-hal truth bug specific to one backend/mode pair, not a
189/// property of the mode alone.
190///
191/// True for `wgpu::Backend::Metal` with `CompositeAlphaMode::PostMultiplied`
192/// alone. wgpu-hal's Metal adapter advertises `PostMultiplied`
193/// (`wgpu-hal-30.0.1/src/metal/adapter.rs:468-471`,
194/// `composite_alpha_modes: [Opaque, PostMultiplied]`) but its surface
195/// configuration implements the mode as nothing beyond
196/// `render_layer.setOpaque(false)` (`wgpu-hal-30.0.1/src/metal/surface.rs:269-273`)
197/// — it never asks Core Animation to treat the layer's content as straight
198/// alpha, and Core Animation has no such mode: a `CAMetalLayer` ONLY
199/// composites premultiplied (MoltenVK's own equivalent exposes
200/// `OPAQUE | PRE_MULTIPLIED` for the identical layer, never a straight
201/// option). So a Metal `PostMultiplied` swapchain reads back exactly like a
202/// premultiplied one — `display = C·a + BG·(1−a)` — even though the mode's
203/// name and wgpu's advertised contract say straight. Handing it the
204/// spec-correct straight-alpha conversion therefore double-corrects: the
205/// frame is un-premultiplied for a compositor that was going to
206/// premultiply-composite it anyway, over-brightening every partial-alpha
207/// pixel — device-visible only at fractional alpha (an indigo/navy wash,
208/// black frames near a translucent split). See `docs/LIMITATIONS.md`'s
209/// `engine-metal-postmultiplied-truth-bug`. Re-checked at the wgpu 30.0.1
210/// pin: unchanged. Upstream trunk PR gfx-rs/wgpu#9922 (merged 2026-08-18,
211/// unreleased as of 30.0.1) makes Metal advertise `PreMultiplied` instead, so
212/// this predicate retires at the pin bump that carries it.
213///
214/// Every other backend keeps the ordinary reading: a genuinely-straight
215/// `PostMultiplied` compositor exists on at least one other backend (e.g.
216/// Vulkan's own `POST_MULTIPLIED` composite-alpha flag), so this predicate is
217/// Metal-specific rather than blanket-disbelieving the mode everywhere.
218///
219/// A **platform** fact rather than a renderer policy, which is why it sits
220/// beside [`resolve_alpha_mode`] here: which render path a renderer picks in
221/// response is the renderer's own business (see
222/// `frust_render::context::choose_engine_render_path`, the one caller).
223///
224/// Pure and (backend, mode)-only, so the decision is host-testable without a
225/// live adapter.
226pub fn compositor_expects_premultiplied(
227 backend: wgpu::Backend,
228 mode: wgpu::CompositeAlphaMode,
229) -> bool {
230 backend == wgpu::Backend::Metal && mode == wgpu::CompositeAlphaMode::PostMultiplied
231}
232
233/// Picks the swapchain format to configure with: the first format **the
234/// surface reports** that is one of [`SURFACE_FORMATS`].
235///
236/// The preference order is the *surface's*, not this module's — the platform
237/// lists its formats best-first, and taking its first supported entry is the
238/// selection every shipped frust build has configured its swapchain with
239/// (device-gated on Android/Vulkan, iOS/macOS/Metal and Windows). The engine
240/// warms its strip pipelines for whichever of the two it is handed, so neither
241/// is privileged here; reordering this to prefer `Rgba8Unorm` would silently
242/// change the swapchain format on the platforms that report `Bgra8Unorm` first.
243///
244/// Errors when the surface supports neither, which no shipping platform does —
245/// the engine draws through a render pass into whichever of the two is
246/// available and has no third format to fall back on.
247pub fn select_surface_format(
248 capabilities: &wgpu::SurfaceCapabilities,
249) -> Result<wgpu::TextureFormat> {
250 capabilities
251 .formats
252 .iter()
253 .copied()
254 .find(|format| SURFACE_FORMATS.contains(format))
255 .ok_or_else(|| {
256 anyhow!(
257 "frust-gpu: no supported surface format (Rgba8Unorm/Bgra8Unorm) in {:?}",
258 capabilities.formats
259 )
260 })
261}
262
263/// Builds the `wgpu::SurfaceConfiguration` a surface is brought up with.
264///
265/// Split out of [`ConfiguredSurface::configure`] so the configuration a surface
266/// gets is decided by a pure function over plain values and can be asserted
267/// without a device: `RENDER_ATTACHMENT` usage only, the caller's format and
268/// alpha mode verbatim, no view formats, the fixed frame latency
269/// (`DESIRED_MAXIMUM_FRAME_LATENCY`, two frames in flight), and
270/// `SurfaceColorSpace::Auto`.
271///
272/// `Auto` is the deliberate choice for the colour space, not a placeholder.
273/// It is the only value guaranteed to be supported for every format a
274/// surface advertises, and `wgpu` defines it as "reproduce the historical
275/// behaviour": `Srgb` for every non-`Rgba16Float` format, which is exactly
276/// the pair [`select_surface_format`] can return (`Rgba8Unorm`/`Bgra8Unorm`).
277/// Naming `Srgb` explicitly would resolve identically today but would start
278/// failing validation the moment a driver in HDR mode stopped advertising it
279/// for the chosen format, and frust encodes no wide-gamut or HDR output —
280/// the engine writes sRGB-encoded values a non-`*Srgb` swapchain format
281/// carries through untouched. A wide-gamut/HDR surface is a deliberate
282/// feature, not a version-bump side effect.
283///
284/// `size` is `(width, height)` in physical pixels and must be non-zero on both
285/// axes; a zero-sized swapchain is a `wgpu` validation error, and the shell's
286/// own resize path is where a minimized/zero-extent window is filtered out.
287pub fn surface_config(
288 format: wgpu::TextureFormat,
289 alpha_mode: wgpu::CompositeAlphaMode,
290 size: (u32, u32),
291 present_mode: wgpu::PresentMode,
292) -> wgpu::SurfaceConfiguration {
293 let (width, height) = size;
294 wgpu::SurfaceConfiguration {
295 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
296 format,
297 color_space: wgpu::SurfaceColorSpace::Auto,
298 width,
299 height,
300 present_mode,
301 desired_maximum_frame_latency: DESIRED_MAXIMUM_FRAME_LATENCY,
302 alpha_mode,
303 view_formats: vec![],
304 }
305}
306
307/// A cheap, cloneable handle to a `wgpu::Instance` used to create a surface on
308/// a *different* thread than the one that owns the renderer.
309///
310/// # Why this exists
311///
312/// `wgpu` surface creation reads the platform window handle, which several
313/// windowing backends (notably winit on macOS/AppKit) only make available on
314/// the main/UI thread. A render-thread split therefore cannot create the
315/// surface where the renderer lives; instead the UI thread creates a
316/// [`DetachedSurface`] via this factory and hands it across to the render
317/// thread, which configures it there. A `wgpu::Instance` is `Send + Sync +
318/// Clone` (Arc-backed) and a `Surface` it produces stays compatible with any
319/// adapter/device the cloned instance requests, so the two threads share one
320/// instance with no `unsafe`.
321///
322/// The mobile shells do not need this: they receive a platform-created surface
323/// pointer and go through
324/// [`create_android_surface`](crate::lifecycle::create_android_surface) /
325/// `create_metal_surface` instead.
326#[derive(Clone)]
327pub struct SurfaceFactory {
328 instance: wgpu::Instance,
329}
330
331impl SurfaceFactory {
332 /// Clones `instance` into a factory that can be moved to the UI thread.
333 pub fn new(instance: &wgpu::Instance) -> Self {
334 Self {
335 instance: instance.clone(),
336 }
337 }
338
339 /// Create a [`DetachedSurface`] from a window handle **on the calling
340 /// thread** — call this on the thread the windowing backend requires (the
341 /// main/UI thread for winit). The returned surface is `Send` and may then be
342 /// moved to the render thread and configured there.
343 ///
344 /// This performs *only* the window-handle-dependent step (surface creation);
345 /// the device and the swapchain configuration are both built later, on the
346 /// configuring thread, so nothing here touches a GPU device.
347 pub fn create_detached_surface(
348 &self,
349 target: impl Into<wgpu::SurfaceTarget<'static>>,
350 ) -> Result<DetachedSurface> {
351 let surface = self
352 .instance
353 .create_surface(target)
354 .map_err(|e| anyhow!("frust-gpu: failed to create surface: {e}"))?;
355 Ok(DetachedSurface { surface })
356 }
357}
358
359/// A created-but-not-yet-configured `wgpu::Surface`, produced by
360/// [`SurfaceFactory::create_detached_surface`] on the windowing thread and
361/// configured on the render thread.
362///
363/// Opaque on purpose: a shell only ever moves this value across a thread
364/// boundary, so the wrapped `wgpu` type stays out of every signature above this
365/// crate. `Send`, which is the whole point.
366pub struct DetachedSurface {
367 surface: wgpu::Surface<'static>,
368}
369
370impl DetachedSurface {
371 /// Consume the wrapper, yielding the raw surface to configure.
372 ///
373 /// Hidden from the rendered docs rather than made private: the renderer
374 /// crate above this one is the intended (and only) caller, and no layer
375 /// above *it* may re-export this accessor — doing so would make the wrapped
376 /// `wgpu::Surface` nameable from shell and widget code, which is exactly
377 /// what the wrapper exists to prevent.
378 #[doc(hidden)]
379 pub fn into_surface(self) -> wgpu::Surface<'static> {
380 self.surface
381 }
382}
383
384/// A configured swapchain surface: the surface itself, the configuration it was
385/// brought up with, and whether it actually came up translucent.
386pub struct ConfiguredSurface {
387 surface: wgpu::Surface<'static>,
388 config: wgpu::SurfaceConfiguration,
389 /// Whether this surface **actually** came up translucent — the projection of
390 /// `config.alpha_mode` through [`alpha_mode_is_translucent`], computed once
391 /// at configure time (the mode never changes for a live surface; a resize
392 /// reconfigures with the same mode).
393 ///
394 /// Stored as a plain `bool` rather than re-derived from `config.alpha_mode`
395 /// at each read, so the value a shell observes crosses this crate's boundary
396 /// with no `wgpu` type in the signature.
397 resolved_translucent: bool,
398}
399
400impl ConfiguredSurface {
401 /// Configures `surface` against `device` and returns the wrapper.
402 ///
403 /// `format` is the format the caller resolved from what the *surface*
404 /// reports ([`select_surface_format`]) rather than one this layer imposes,
405 /// and `alpha_mode` the one [`resolve_alpha_mode`] resolved from the
406 /// surface's reported modes. `size` is `(width, height)` in physical pixels
407 /// and must be non-zero on both axes.
408 pub fn configure(
409 surface: wgpu::Surface<'static>,
410 device: &wgpu::Device,
411 format: wgpu::TextureFormat,
412 alpha_mode: wgpu::CompositeAlphaMode,
413 size: (u32, u32),
414 present_mode: wgpu::PresentMode,
415 ) -> Self {
416 let config = surface_config(format, alpha_mode, size, present_mode);
417 surface.configure(device, &config);
418 Self {
419 surface,
420 config,
421 resolved_translucent: alpha_mode_is_translucent(alpha_mode),
422 }
423 }
424
425 /// Re-applies the current configuration to the swapchain — the recovery step
426 /// an `Outdated` (or retried `Invalid`) acquire asks for, where the surface
427 /// is stale but its geometry has not changed.
428 pub fn reconfigure(&self, device: &wgpu::Device) {
429 self.surface.configure(device, &self.config);
430 }
431
432 /// Resizes the swapchain in place. `width`/`height` are physical pixels and
433 /// must both be non-zero; the shell's resize path filters a minimized or
434 /// zero-extent window out before reaching here.
435 pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
436 self.config.width = width;
437 self.config.height = height;
438 self.reconfigure(device);
439 }
440
441 /// The live surface, for acquiring a swapchain texture.
442 pub fn surface(&self) -> &wgpu::Surface<'static> {
443 &self.surface
444 }
445
446 /// The configuration this surface is currently up with.
447 pub fn config(&self) -> &wgpu::SurfaceConfiguration {
448 &self.config
449 }
450
451 /// The swapchain's `(width, height)` in physical pixels.
452 pub fn size(&self) -> (u32, u32) {
453 (self.config.width, self.config.height)
454 }
455
456 /// Whether this surface really came up translucent — the answer a shell's
457 /// paint contract keys off, never the original
458 /// [`SurfaceAlphaRequest`].
459 pub fn resolved_translucent(&self) -> bool {
460 self.resolved_translucent
461 }
462
463 /// Whether this surface really came up translucent with a swapchain that
464 /// stores STRAIGHT alpha — iOS's `PostMultiplied` translucent mode, the one
465 /// configuration whose present needs the engine's premultiplied output
466 /// converted back.
467 ///
468 /// Reads [`Self::resolved_translucent`] rather than the raw alpha mode, so a
469 /// surface that never got the translucency it asked for answers `false`.
470 pub fn straight_alpha_translucent(&self) -> bool {
471 self.resolved_translucent && alpha_mode_is_straight_translucent(self.config.alpha_mode)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 /// Builds a synthetic `wgpu::SurfaceCapabilities` reporting only the given
480 /// `alpha_modes` — the rest of the struct is irrelevant to
481 /// `resolve_alpha_mode`, which reads only that one field.
482 fn caps_with_alpha_modes(
483 alpha_modes: &[wgpu::CompositeAlphaMode],
484 ) -> wgpu::SurfaceCapabilities {
485 wgpu::SurfaceCapabilities {
486 alpha_modes: alpha_modes.to_vec(),
487 ..Default::default()
488 }
489 }
490
491 /// The same for the format list `select_surface_format` reads.
492 fn caps_with_formats(formats: &[wgpu::TextureFormat]) -> wgpu::SurfaceCapabilities {
493 wgpu::SurfaceCapabilities {
494 formats: formats.to_vec(),
495 ..Default::default()
496 }
497 }
498
499 #[test]
500 fn opaque_request_always_resolves_to_auto() {
501 // `Opaque` never consults `alpha_modes` — the same answer regardless of
502 // what the surface reports.
503 for modes in [
504 [wgpu::CompositeAlphaMode::Inherit].as_slice(),
505 &[
506 wgpu::CompositeAlphaMode::Opaque,
507 wgpu::CompositeAlphaMode::PostMultiplied,
508 ],
509 &[wgpu::CompositeAlphaMode::Opaque],
510 ] {
511 let caps = caps_with_alpha_modes(modes);
512 assert_eq!(
513 resolve_alpha_mode(SurfaceAlphaRequest::Opaque, &caps),
514 wgpu::CompositeAlphaMode::Auto
515 );
516 }
517 }
518
519 #[test]
520 fn translucent_preferred_picks_inherit_first() {
521 // Android's observed shape: `Inherit` is the only reported mode.
522 let caps = caps_with_alpha_modes(&[wgpu::CompositeAlphaMode::Inherit]);
523 assert_eq!(
524 resolve_alpha_mode(SurfaceAlphaRequest::TranslucentPreferred, &caps),
525 wgpu::CompositeAlphaMode::Inherit
526 );
527 }
528
529 #[test]
530 fn translucent_preferred_picks_post_multiplied_when_inherit_absent() {
531 // iOS's observed shape: `[Opaque, PostMultiplied]` — no `Inherit`.
532 let caps = caps_with_alpha_modes(&[
533 wgpu::CompositeAlphaMode::Opaque,
534 wgpu::CompositeAlphaMode::PostMultiplied,
535 ]);
536 assert_eq!(
537 resolve_alpha_mode(SurfaceAlphaRequest::TranslucentPreferred, &caps),
538 wgpu::CompositeAlphaMode::PostMultiplied
539 );
540 }
541
542 #[test]
543 fn translucent_preferred_falls_back_to_pre_multiplied_last() {
544 // Neither `Inherit` nor `PostMultiplied` present, but `PreMultiplied`
545 // is — the third preference in the resolution order.
546 let caps = caps_with_alpha_modes(&[
547 wgpu::CompositeAlphaMode::Opaque,
548 wgpu::CompositeAlphaMode::PreMultiplied,
549 ]);
550 assert_eq!(
551 resolve_alpha_mode(SurfaceAlphaRequest::TranslucentPreferred, &caps),
552 wgpu::CompositeAlphaMode::PreMultiplied
553 );
554 }
555
556 #[test]
557 fn translucent_preferred_falls_back_to_auto_when_none_available() {
558 // A surface reporting only `Opaque` (no `Inherit`/`PostMultiplied`/
559 // `PreMultiplied`) can't satisfy translucency — fall back to `Auto`
560 // rather than erroring.
561 let caps = caps_with_alpha_modes(&[wgpu::CompositeAlphaMode::Opaque]);
562 assert_eq!(
563 resolve_alpha_mode(SurfaceAlphaRequest::TranslucentPreferred, &caps),
564 wgpu::CompositeAlphaMode::Auto
565 );
566 }
567
568 #[test]
569 fn resolved_translucency_is_true_only_for_the_three_translucent_modes() {
570 // The projection a shell's paint contract keys off: exactly
571 // `resolve_alpha_mode`'s preference list.
572 for mode in [
573 wgpu::CompositeAlphaMode::Inherit,
574 wgpu::CompositeAlphaMode::PostMultiplied,
575 wgpu::CompositeAlphaMode::PreMultiplied,
576 ] {
577 assert!(alpha_mode_is_translucent(mode), "{mode:?}");
578 }
579 // `Auto` is what BOTH an opaque request and a failed translucent
580 // resolution land on — neither composites alpha.
581 for mode in [
582 wgpu::CompositeAlphaMode::Auto,
583 wgpu::CompositeAlphaMode::Opaque,
584 ] {
585 assert!(!alpha_mode_is_translucent(mode), "{mode:?}");
586 }
587 }
588
589 #[test]
590 fn premultiplied_expecting_modes_take_engine_output_unchanged() {
591 // The engine writes premultiplied alpha, so these two are the modes that
592 // need no conversion at all.
593 for mode in [
594 wgpu::CompositeAlphaMode::PreMultiplied,
595 wgpu::CompositeAlphaMode::Inherit,
596 ] {
597 assert!(alpha_mode_needs_premultiply(mode), "{mode:?}");
598 assert!(!alpha_mode_is_straight_translucent(mode), "{mode:?}");
599 }
600 // Alpha-ignoring modes never expect premultiplied pixels either.
601 for mode in [
602 wgpu::CompositeAlphaMode::Auto,
603 wgpu::CompositeAlphaMode::Opaque,
604 ] {
605 assert!(!alpha_mode_needs_premultiply(mode), "{mode:?}");
606 }
607 }
608
609 /// Over every mode `resolve_alpha_mode` can produce, `PostMultiplied` alone
610 /// is translucent AND straight-alpha — the one surface that has to convert
611 /// the engine's premultiplied output before present.
612 #[test]
613 fn only_post_multiplied_is_translucent_with_straight_alpha() {
614 assert!(alpha_mode_is_straight_translucent(
615 wgpu::CompositeAlphaMode::PostMultiplied
616 ));
617 assert!(!alpha_mode_needs_premultiply(
618 wgpu::CompositeAlphaMode::PostMultiplied
619 ));
620 // Opaque destinations are not translucent at all, so they are not the
621 // straight-translucent case however their alpha is stored.
622 for mode in [
623 wgpu::CompositeAlphaMode::Auto,
624 wgpu::CompositeAlphaMode::Opaque,
625 ] {
626 assert!(!alpha_mode_is_straight_translucent(mode), "{mode:?}");
627 }
628 }
629
630 #[test]
631 fn the_surfaces_own_order_decides_when_both_formats_are_reported() {
632 // The shipped selection: the platform lists its formats best-first and
633 // the first supported entry wins, whichever of the two that is. This is
634 // what every device-gated build configured its swapchain with; picking
635 // by THIS module's order instead would flip the format on platforms
636 // that report `Bgra8Unorm` first.
637 let bgra_first = caps_with_formats(&[
638 wgpu::TextureFormat::Bgra8Unorm,
639 wgpu::TextureFormat::Rgba8Unorm,
640 ]);
641 assert_eq!(
642 select_surface_format(&bgra_first).unwrap(),
643 wgpu::TextureFormat::Bgra8Unorm,
644 "the surface's own preference order decides, not this module's"
645 );
646 let rgba_first = caps_with_formats(&[
647 wgpu::TextureFormat::Rgba8Unorm,
648 wgpu::TextureFormat::Bgra8Unorm,
649 ]);
650 assert_eq!(
651 select_surface_format(&rgba_first).unwrap(),
652 wgpu::TextureFormat::Rgba8Unorm
653 );
654 }
655
656 #[test]
657 fn unsupported_formats_are_skipped_over_rather_than_taken() {
658 // The shape a Metal/Dx12 surface reports: an sRGB variant first, which
659 // is not one of ours, then the plain `Bgra8Unorm` that is.
660 let caps = caps_with_formats(&[
661 wgpu::TextureFormat::Bgra8UnormSrgb,
662 wgpu::TextureFormat::Bgra8Unorm,
663 ]);
664 assert_eq!(
665 select_surface_format(&caps).unwrap(),
666 wgpu::TextureFormat::Bgra8Unorm
667 );
668 }
669
670 #[test]
671 fn a_surface_supporting_neither_format_is_an_error() {
672 let caps = caps_with_formats(&[wgpu::TextureFormat::Rgba16Float]);
673 assert!(select_surface_format(&caps).is_err());
674 }
675
676 #[test]
677 fn config_asks_for_render_attachment_only() {
678 // The engine draws the frame through a render pass; nothing here writes
679 // the swapchain through a compute storage binding, so no surface has to
680 // carry `STORAGE_BINDING`.
681 let config = surface_config(
682 wgpu::TextureFormat::Bgra8Unorm,
683 wgpu::CompositeAlphaMode::Auto,
684 (800, 600),
685 wgpu::PresentMode::AutoVsync,
686 );
687 assert_eq!(config.usage, wgpu::TextureUsages::RENDER_ATTACHMENT);
688 }
689
690 #[test]
691 fn config_carries_the_callers_format_alpha_mode_and_size() {
692 // Both `SURFACE_FORMATS` entries configure verbatim: the format comes
693 // from what the surface reported, not from a fixed engine target format.
694 for format in SURFACE_FORMATS {
695 let config = surface_config(
696 format,
697 wgpu::CompositeAlphaMode::Inherit,
698 (1080, 2400),
699 wgpu::PresentMode::Fifo,
700 );
701 assert_eq!(config.format, format);
702 assert_eq!(config.alpha_mode, wgpu::CompositeAlphaMode::Inherit);
703 assert_eq!((config.width, config.height), (1080, 2400));
704 assert_eq!(config.present_mode, wgpu::PresentMode::Fifo);
705 assert!(config.view_formats.is_empty());
706 assert_eq!(
707 config.desired_maximum_frame_latency,
708 DESIRED_MAXIMUM_FRAME_LATENCY
709 );
710 // The colour space is a deliberate constant, not caller-supplied:
711 // `Auto` is supported for every advertised format and resolves to
712 // the sRGB behaviour every shipped frust build has presented in.
713 assert_eq!(config.color_space, wgpu::SurfaceColorSpace::Auto);
714 }
715 }
716
717 #[test]
718 fn forced_mismatch_translucent_request_resolves_not_translucent() {
719 // A surface whose advertised capabilities carry NO translucent mode,
720 // asked for translucency. The request is honoured as far as it can be
721 // (`Auto`), but the RESOLVED translucency — the value
722 // `SurfaceRenderer::surface_resolved_translucent` hands the shells — is
723 // `false`, so the shells keep the opaque (Mode A) paint contract: an
724 // opaque base colour and no `ClearRect` punch.
725 for modes in [
726 [wgpu::CompositeAlphaMode::Opaque].as_slice(),
727 &[wgpu::CompositeAlphaMode::Auto],
728 &[],
729 ] {
730 let caps = caps_with_alpha_modes(modes);
731 let resolved = resolve_alpha_mode(SurfaceAlphaRequest::TranslucentPreferred, &caps);
732 assert_eq!(resolved, wgpu::CompositeAlphaMode::Auto, "{modes:?}");
733 assert!(
734 !alpha_mode_is_translucent(resolved),
735 "a fallback-to-opaque surface must never report translucent ({modes:?})"
736 );
737 }
738 }
739
740 #[test]
741 fn happy_path_translucent_request_resolves_translucent() {
742 // The shipped configs stay unchanged: Android (`Inherit`-only) and iOS
743 // (`[Opaque, PostMultiplied]`) both resolve to a translucent mode, so
744 // the punch + transparent base keep running exactly as today.
745 for modes in [
746 [wgpu::CompositeAlphaMode::Inherit].as_slice(),
747 &[
748 wgpu::CompositeAlphaMode::Opaque,
749 wgpu::CompositeAlphaMode::PostMultiplied,
750 ],
751 ] {
752 let resolved = resolve_alpha_mode(
753 SurfaceAlphaRequest::TranslucentPreferred,
754 &caps_with_alpha_modes(modes),
755 );
756 assert!(alpha_mode_is_translucent(resolved), "{modes:?}");
757 }
758 // An opaque request never reports translucent, whatever the surface
759 // advertises.
760 assert!(!alpha_mode_is_translucent(resolve_alpha_mode(
761 SurfaceAlphaRequest::Opaque,
762 &caps_with_alpha_modes(&[wgpu::CompositeAlphaMode::Inherit]),
763 )));
764 }
765
766 /// Every composite alpha mode a surface can resolve to, so a claim about
767 /// the truth bug is made across the whole space rather than the modes it
768 /// was written for.
769 const EVERY_ALPHA_MODE: [wgpu::CompositeAlphaMode; 5] = [
770 wgpu::CompositeAlphaMode::Auto,
771 wgpu::CompositeAlphaMode::Opaque,
772 wgpu::CompositeAlphaMode::Inherit,
773 wgpu::CompositeAlphaMode::PreMultiplied,
774 wgpu::CompositeAlphaMode::PostMultiplied,
775 ];
776
777 #[test]
778 fn compositor_expects_premultiplied_is_metal_post_multiplied_only() {
779 // Direct guard on the predicate itself, across the whole (backend,
780 // mode) space: the upstream wgpu-hal truth bug is one backend/mode
781 // pair, and must never spread to another backend or another mode.
782 for backend in wgpu::Backend::ALL {
783 for mode in EVERY_ALPHA_MODE {
784 let expected = backend == wgpu::Backend::Metal
785 && mode == wgpu::CompositeAlphaMode::PostMultiplied;
786 assert_eq!(
787 compositor_expects_premultiplied(backend, mode),
788 expected,
789 "compositor_expects_premultiplied({backend:?}, {mode:?})"
790 );
791 }
792 }
793 }
794
795 #[test]
796 fn the_truth_bug_only_ever_contradicts_a_straight_translucent_mode() {
797 // The predicate exists to override `alpha_mode_is_straight_translucent`
798 // for one pair; anywhere it answers true, that predicate must have
799 // answered true too, or it would be silently redirecting a mode that
800 // never needed conversion in the first place.
801 for backend in wgpu::Backend::ALL {
802 for mode in EVERY_ALPHA_MODE {
803 if compositor_expects_premultiplied(backend, mode) {
804 assert!(
805 alpha_mode_is_straight_translucent(mode),
806 "{backend:?}/{mode:?} is contradicted without being straight-translucent"
807 );
808 }
809 }
810 }
811 }
812}