Skip to main content

frust_gpu/
lifecycle.rs

1//! Surface lifecycle state machine.
2//!
3//! Android destroys and recreates the GPU surface on rotation and
4//! backgrounding — `wgpu` surfaces raise `ERROR_SURFACE_LOST_KHR` and panic on
5//! `configure`-after-resume if this is handled ad hoc. Frust therefore makes
6//! surface state a first-class machine, driven by the platform shells'
7//! `surfaceCreated`/`surfaceChanged`/`surfaceDestroyed` callbacks (Android) and
8//! `resumed`/`Resized`/`suspended` events (desktop).
9//!
10//! This module holds only the *decisions* and the two raw-pointer surface
11//! constructors; the renderer that owns the swapchain texture and drives them
12//! lives a layer above, so everything here is host-testable with no GPU and no
13//! window server in the loop.
14//!
15//! # States
16//!
17//! ```text
18//!            on_surface_created                 acquire == Lost
19//!   NoSurface ───────────────▶ SurfaceReady ──────────────────▶ SurfaceLost
20//!       ▲   on_surface_destroyed  │  ▲  on_surface_changed          │
21//!       └───────────────────────┘  └── (resize, stays Ready)       │
22//!       ▲                                on_surface_destroyed       │
23//!       └──────────────────────────────────────────────────────────┘
24//!                              on_surface_created (recreate)
25//! ```
26//!
27//! # Invariants the driving renderer must uphold
28//!
29//! - No `SurfaceTexture` outlives a surface transition: a render call acquires
30//!   and presents within a single call and stores nothing across one.
31//! - `Surface::configure` runs only from [`SurfacePhase::SurfaceReady`] (on
32//!   entry via `on_surface_created`/`on_surface_changed`, or on an `Outdated`
33//!   acquire).
34//! - Frame requests in [`SurfacePhase::NoSurface`]/[`SurfacePhase::SurfaceLost`]
35//!   are dropped — the render call reports [`FrameOutcome::Skipped`], never
36//!   panicking and never queueing.
37//!
38//! The pure decision logic ([`next_phase`], [`SurfacePhase::can_render`],
39//! [`decide_acquire`], [`next_invalid_streak`]) is separated from the `wgpu`
40//! calls so it is unit-testable without a GPU.
41
42use core::ffi::c_void;
43use core::ptr::NonNull;
44
45use anyhow::{Result, anyhow};
46
47/// Which lifecycle state the surface is in.
48///
49/// Rendering only happens in [`SurfacePhase::SurfaceReady`]; the other two
50/// phases mean there is no usable swapchain and frames are skipped.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum SurfacePhase {
53    /// No surface has been created yet, or the surface was explicitly destroyed.
54    NoSurface,
55    /// A configured surface is available and frames can be rendered.
56    SurfaceReady,
57    /// The surface was lost mid-render (e.g. `ERROR_SURFACE_LOST_KHR`); it has
58    /// been dropped and the shell must recreate it via `on_surface_created`.
59    SurfaceLost,
60}
61
62impl SurfacePhase {
63    /// Whether a frame may be rendered in this phase.
64    ///
65    /// Only [`SurfacePhase::SurfaceReady`] can render; the machine drops frames
66    /// in every other phase rather than queueing them.
67    pub fn can_render(self) -> bool {
68        matches!(self, SurfacePhase::SurfaceReady)
69    }
70}
71
72/// A platform lifecycle event that drives a phase transition.
73///
74/// Kept separate from the `wgpu` side so the transition table is pure and
75/// unit-testable ([`next_phase`]).
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum SurfaceEvent {
78    /// `surfaceCreated` (Android) / `resumed` (desktop): a surface is available.
79    Created,
80    /// `surfaceDestroyed` (Android) / `suspended` (desktop): the surface is gone.
81    Destroyed,
82    /// The swapchain reported `Lost` on acquire, mid-render.
83    Lost,
84}
85
86/// Pure phase-transition table.
87///
88/// Transitions are total by design: `Created` always lands in `SurfaceReady`
89/// (creating or recreating), `Destroyed` always in `NoSurface`, and `Lost`
90/// always in `SurfaceLost`. `Lost` is only ever emitted from `SurfaceReady`
91/// (it originates in the render call), so no illegal edge is reachable in
92/// practice.
93pub fn next_phase(_current: SurfacePhase, event: SurfaceEvent) -> SurfacePhase {
94    match event {
95        SurfaceEvent::Created => SurfacePhase::SurfaceReady,
96        SurfaceEvent::Destroyed => SurfacePhase::NoSurface,
97        SurfaceEvent::Lost => SurfacePhase::SurfaceLost,
98    }
99}
100
101/// `wgpu`-independent classification of a swapchain-acquire attempt.
102///
103/// The renderer maps `wgpu::SurfaceError` onto this so the acquire policy
104/// ([`decide_acquire`]) stays pure and testable.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum AcquireStatus {
107    /// `Success`/`Suboptimal`: a texture is available to present.
108    Usable,
109    /// `Outdated`: the swapchain is stale and must be reconfigured.
110    Outdated,
111    /// `Lost`: the surface is gone; drop it and move to `SurfaceLost`.
112    Lost,
113    /// `Timeout`/`Occluded`: transient; skip this frame.
114    Transient,
115    /// `Validation`: the swapchain raised a validation error on acquire (observed
116    /// under the Android emulator's SwiftShader driver as a one-off hiccup).
117    /// Treated as recoverable — reconfigure the surface and retry — rather than
118    /// a hard failure that would wedge the app, since the uncaptured-error
119    /// handler the device is created with already prevents the process-killing
120    /// panic wgpu would otherwise raise.
121    Invalid,
122}
123
124/// What the renderer should do after an acquire attempt.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum AcquireAction {
127    /// Present the acquired texture; report [`FrameOutcome::Rendered`].
128    Present,
129    /// Reconfigure the surface and ask the shell to redraw
130    /// ([`FrameOutcome::Redraw`]) — an `Outdated` acquire must still request a
131    /// redraw after reconfiguring, not silently reconfigure and stall.
132    Reconfigure,
133    /// Drop the surface, transition to `SurfaceLost`, report
134    /// [`FrameOutcome::SurfaceLost`] so the shell can recreate it.
135    Lose,
136    /// Skip this frame; report [`FrameOutcome::Skipped`].
137    Skip,
138}
139
140/// Cap on consecutive `Invalid`-acquire reconfigure retries before the machine
141/// gives up and transitions to [`SurfacePhase::SurfaceLost`].
142///
143/// Mirrors `frust-shell-ios::ffi_support::MAX_RECREATE_ATTEMPTS`'s style and
144/// rationale, applied one step earlier in the lifecycle: without a cap, a
145/// persistently-`Invalid` swapchain would retry a reconfigure every frame,
146/// forever. At the cap the existing per-shell `SurfaceLost` recovery paths take
147/// over instead — desktop recreates on the next resize/redraw, iOS's own
148/// `recover_surface` (capped separately), Android on the next `surfaceChanged`
149/// — so no shell code needs to change for this to be honoured.
150pub const MAX_INVALID_RECONFIGURES: u8 = 3;
151
152/// Pure acquire-error policy.
153///
154/// `consecutive_invalid` is the number of `Invalid` acquires already retried
155/// (via `Reconfigure`) since the last successful acquire; only the `Invalid`
156/// arm consults it. Host-testable: no `wgpu` state is touched here, only the
157/// counter the renderer tracks and resets on a successful acquire.
158pub fn decide_acquire(status: AcquireStatus, consecutive_invalid: u8) -> AcquireAction {
159    match status {
160        AcquireStatus::Usable => AcquireAction::Present,
161        AcquireStatus::Outdated => AcquireAction::Reconfigure,
162        AcquireStatus::Lost => AcquireAction::Lose,
163        AcquireStatus::Transient => AcquireAction::Skip,
164        // A validation error on acquire is treated like `Outdated`: rebuild the
165        // swapchain and ask for a redraw. Recovering (rather than failing)
166        // keeps a transient driver hiccup from permanently freezing the surface.
167        // But only up to `MAX_INVALID_RECONFIGURES` consecutive attempts — past
168        // that this is no longer a one-off hiccup, and retrying forever would
169        // spin the render loop on a permanently-broken swapchain. At the cap,
170        // give up and drop to `SurfaceLost` like a genuine `Lost` acquire, so
171        // the shell's existing recovery paths take over.
172        AcquireStatus::Invalid => {
173            if consecutive_invalid < MAX_INVALID_RECONFIGURES {
174                AcquireAction::Reconfigure
175            } else {
176                AcquireAction::Lose
177            }
178        }
179    }
180}
181
182/// Given the just-observed acquire `status` and the [`AcquireAction`]
183/// [`decide_acquire`] chose for it, returns the next consecutive-`Invalid`
184/// streak count the renderer should store.
185///
186/// Pure and host-testable, split out of the render call so the counter's
187/// reset-on-success / increment-on-retry / reset-on-give-up discipline is
188/// unit-tested without a GPU:
189/// - a successful acquire (`Usable`) always resets the streak to zero — the
190///   next `Invalid`, if any, starts a fresh episode with a full retry budget;
191/// - a retried `Invalid` (`Reconfigure`) increments the streak;
192/// - a given-up `Invalid` (`Lose`, i.e. the cap was hit) resets to zero — the
193///   giving-up transition itself ends the episode;
194/// - every other status/action pairing leaves the streak untouched.
195pub fn next_invalid_streak(status: AcquireStatus, action: AcquireAction, current: u8) -> u8 {
196    match (status, action) {
197        (AcquireStatus::Usable, _) => 0,
198        (AcquireStatus::Invalid, AcquireAction::Reconfigure) => current.saturating_add(1),
199        (AcquireStatus::Invalid, AcquireAction::Lose) => 0,
200        _ => current,
201    }
202}
203
204/// The outcome of a single render call.
205///
206/// Lets the shell react without knowing the internal state: request another
207/// redraw when the surface was reconfigured, recreate the surface when it was
208/// lost, or do nothing when a frame was drawn or skipped.
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub enum FrameOutcome {
211    /// A frame was encoded and presented to the swapchain.
212    Rendered,
213    /// No renderable surface (`NoSurface`/`SurfaceLost`) or a transient acquire
214    /// failure: nothing was drawn and nothing was queued.
215    Skipped,
216    /// The surface was stale and has been reconfigured; the shell should request
217    /// another redraw to draw against the fresh configuration.
218    Redraw,
219    /// The surface was lost and has been dropped; the machine is now in
220    /// [`SurfacePhase::SurfaceLost`] and the shell must recreate the surface via
221    /// `on_surface_created`.
222    SurfaceLost,
223}
224
225/// The outcome of a single encode call — the first phase of the two-phase
226/// render seam (encode + present).
227///
228/// A shell that times encode and present separately branches on this to decide
229/// whether presenting is worthwhile: an [`EncodeOutcome::Skipped`] frame laid
230/// no pixels into the intermediate target (no renderable surface), so there is
231/// nothing to present.
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub enum EncodeOutcome {
234    /// The scene was encoded into the intermediate target; the [`FrameOutcome`]
235    /// now comes from the matching present call.
236    Encoded,
237    /// No renderable surface (`NoSurface`/`SurfaceLost`): nothing was encoded
238    /// and nothing was queued.
239    Skipped,
240}
241
242/// The outcome of a single acquire call — the first half of the two-phase
243/// present seam (acquire + submit), itself the second phase of the render
244/// pipeline after encode.
245///
246/// A shell that times the swapchain **acquire** (the blocking vsync/present
247/// wait) separately from the **submit** (blit + queue-submit + present) branches
248/// on this to decide whether submitting is worthwhile. Only [`Self::Acquired`]
249/// carries a stashed swapchain texture for the submit call to draw into; every
250/// other variant is terminal and maps to a [`FrameOutcome`] with no submit.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum AcquireOutcome {
253    /// The swapchain texture was acquired and stashed; the submit call draws
254    /// into it and presents it. Maps to [`FrameOutcome::Rendered`] once
255    /// submitted.
256    Acquired,
257    /// The surface was stale and has been reconfigured internally; no texture was
258    /// stashed. Maps to [`FrameOutcome::Redraw`] — the shell should request
259    /// another frame against the fresh configuration.
260    Reconfigured,
261    /// The surface was lost and has been dropped; the machine is now in
262    /// [`SurfacePhase::SurfaceLost`]. Maps to [`FrameOutcome::SurfaceLost`] — the
263    /// shell must recreate the surface via `on_surface_created`.
264    Lost,
265    /// No renderable surface (`NoSurface`/`SurfaceLost`) or a transient acquire
266    /// failure: nothing was stashed and nothing was queued. Maps to
267    /// [`FrameOutcome::Skipped`].
268    Skipped,
269}
270
271/// Builds a `wgpu::Surface` from a raw `ANativeWindow` pointer.
272///
273/// This is one of the framework's sanctioned `unsafe` boundaries: turning a
274/// caller-owned raw pointer into a GPU surface. It is deliberately isolated in
275/// one function so the safety contract lives in exactly one place. See also
276/// [`create_metal_surface`] (the iOS/macOS counterpart) and
277/// `frust-shell-android`'s `jni_glue` module.
278///
279/// # Safety
280///
281/// `window_ptr` must be a valid, non-null `ANativeWindow*` that the caller has
282/// **acquired** (e.g. via `ndk::NativeWindow` / `ANativeWindow_acquire`, which
283/// the Android shell owns) and that **outlives** the returned [`wgpu::Surface`]
284/// and every `SurfaceTexture` acquired from it. The caller must drop the
285/// surface (and any outstanding textures) before releasing the window, i.e.
286/// before returning from the `surfaceDestroyed` callback.
287///
288/// Compiled unconditionally (the raw-handle types are host-available) so a host
289/// `cargo check --target aarch64-linux-android` exercises it.
290pub unsafe fn create_android_surface(
291    instance: &wgpu::Instance,
292    window_ptr: *mut c_void,
293) -> Result<wgpu::Surface<'static>> {
294    use wgpu::rwh::{
295        AndroidDisplayHandle, AndroidNdkWindowHandle, RawDisplayHandle, RawWindowHandle,
296    };
297
298    let window =
299        NonNull::new(window_ptr).ok_or_else(|| anyhow!("frust-gpu: null ANativeWindow pointer"))?;
300    let target = wgpu::SurfaceTargetUnsafe::RawHandle {
301        raw_display_handle: Some(RawDisplayHandle::Android(AndroidDisplayHandle::new())),
302        raw_window_handle: RawWindowHandle::AndroidNdk(AndroidNdkWindowHandle::new(window)),
303    };
304
305    // SAFETY: upheld by this function's own safety contract — `window_ptr` is a
306    // valid, acquired `ANativeWindow*` that outlives the returned surface.
307    let surface = unsafe { instance.create_surface_unsafe(target) }
308        .map_err(|e| anyhow!("frust-gpu: failed to create Android surface: {e}"))?;
309    Ok(surface)
310}
311
312/// Builds a `wgpu::Surface` from a raw `CAMetalLayer*` pointer.
313///
314/// This is one of the framework's sanctioned `unsafe` boundaries (see
315/// [`create_android_surface`] for the sibling Android path): turning a
316/// caller-owned raw pointer into a GPU surface. It is deliberately isolated in
317/// one function so the safety contract lives in exactly one place.
318///
319/// # Safety
320///
321/// `layer_ptr` must be a valid, live `CAMetalLayer*` that the caller (the
322/// Swift shell) owns and that **outlives** the returned [`wgpu::Surface`] and
323/// every `SurfaceTexture` acquired from it — enforced by the
324/// `frust_destroy`-before-view-teardown ordering in the generated app. The
325/// caller must drop the surface (and any outstanding textures) before the
326/// layer/view is torn down.
327///
328/// Only compiled on Apple targets: `wgpu::SurfaceTargetUnsafe::CoreAnimationLayer`
329/// is itself Metal-feature-gated in `wgpu` (available whenever `target_vendor
330/// = "apple"`), so this function is gated the same way rather than being
331/// compiled unconditionally like [`create_android_surface`] (whose raw-handle
332/// types are host-available on every platform).
333#[cfg(any(target_os = "ios", target_os = "macos"))]
334pub unsafe fn create_metal_surface(
335    instance: &wgpu::Instance,
336    layer_ptr: *mut c_void,
337) -> Result<wgpu::Surface<'static>> {
338    if layer_ptr.is_null() {
339        return Err(anyhow!("frust-gpu: null CAMetalLayer pointer"));
340    }
341    let target = wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer_ptr);
342
343    // SAFETY: upheld by this function's own safety contract — `layer_ptr` is a
344    // valid, live `CAMetalLayer*` that outlives the returned surface.
345    let surface = unsafe { instance.create_surface_unsafe(target) }
346        .map_err(|e| anyhow!("frust-gpu: failed to create Metal surface: {e}"))?;
347    Ok(surface)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn created_always_lands_in_surface_ready() {
356        for phase in [
357            SurfacePhase::NoSurface,
358            SurfacePhase::SurfaceReady,
359            SurfacePhase::SurfaceLost,
360        ] {
361            assert_eq!(
362                next_phase(phase, SurfaceEvent::Created),
363                SurfacePhase::SurfaceReady,
364                "creating (or recreating) a surface must reach SurfaceReady from {phase:?}"
365            );
366        }
367    }
368
369    #[test]
370    fn destroyed_always_lands_in_no_surface() {
371        for phase in [
372            SurfacePhase::NoSurface,
373            SurfacePhase::SurfaceReady,
374            SurfacePhase::SurfaceLost,
375        ] {
376            assert_eq!(
377                next_phase(phase, SurfaceEvent::Destroyed),
378                SurfacePhase::NoSurface,
379                "destroying a surface must reach NoSurface from {phase:?}"
380            );
381        }
382    }
383
384    #[test]
385    fn lost_transitions_ready_to_surface_lost() {
386        assert_eq!(
387            next_phase(SurfacePhase::SurfaceReady, SurfaceEvent::Lost),
388            SurfacePhase::SurfaceLost
389        );
390    }
391
392    #[test]
393    fn only_surface_ready_can_render() {
394        assert!(SurfacePhase::SurfaceReady.can_render());
395        assert!(!SurfacePhase::NoSurface.can_render());
396        assert!(!SurfacePhase::SurfaceLost.can_render());
397    }
398
399    #[test]
400    fn acquire_policy_maps_each_status_to_its_action() {
401        assert_eq!(
402            decide_acquire(AcquireStatus::Usable, 0),
403            AcquireAction::Present
404        );
405        assert_eq!(
406            decide_acquire(AcquireStatus::Outdated, 0),
407            AcquireAction::Reconfigure
408        );
409        assert_eq!(decide_acquire(AcquireStatus::Lost, 0), AcquireAction::Lose);
410        assert_eq!(
411            decide_acquire(AcquireStatus::Transient, 0),
412            AcquireAction::Skip
413        );
414        // A validation error on acquire recovers by reconfiguring the surface,
415        // not by failing — a transient SwiftShader hiccup must not wedge the app.
416        assert_eq!(
417            decide_acquire(AcquireStatus::Invalid, 0),
418            AcquireAction::Reconfigure
419        );
420    }
421
422    #[test]
423    fn invalid_reconfigures_under_the_cap() {
424        // Every count below the cap still reconfigures — mirrors the iOS
425        // `MAX_RECREATE_ATTEMPTS` "under cap" behavior at the analogous phase.
426        for consecutive_invalid in 0..MAX_INVALID_RECONFIGURES {
427            assert_eq!(
428                decide_acquire(AcquireStatus::Invalid, consecutive_invalid),
429                AcquireAction::Reconfigure,
430                "expected Reconfigure at consecutive_invalid={consecutive_invalid}"
431            );
432        }
433    }
434
435    #[test]
436    fn invalid_gives_up_at_the_cap() {
437        assert_eq!(
438            decide_acquire(AcquireStatus::Invalid, MAX_INVALID_RECONFIGURES),
439            AcquireAction::Lose,
440            "at the cap the machine must give up and drop to SurfaceLost"
441        );
442        // Saturating past the cap stays given-up (never re-enters Reconfigure).
443        assert_eq!(
444            decide_acquire(AcquireStatus::Invalid, u8::MAX),
445            AcquireAction::Lose
446        );
447    }
448
449    #[test]
450    fn invalid_streak_resets_on_successful_acquire() {
451        assert_eq!(
452            next_invalid_streak(AcquireStatus::Usable, AcquireAction::Present, 2),
453            0
454        );
455        // Even a streak already at (or past) the cap resets on success.
456        assert_eq!(
457            next_invalid_streak(AcquireStatus::Usable, AcquireAction::Present, u8::MAX),
458            0
459        );
460    }
461
462    #[test]
463    fn invalid_streak_increments_while_reconfiguring() {
464        assert_eq!(
465            next_invalid_streak(AcquireStatus::Invalid, AcquireAction::Reconfigure, 0),
466            1
467        );
468        assert_eq!(
469            next_invalid_streak(
470                AcquireStatus::Invalid,
471                AcquireAction::Reconfigure,
472                MAX_INVALID_RECONFIGURES - 1
473            ),
474            MAX_INVALID_RECONFIGURES
475        );
476        // Saturates rather than overflowing.
477        assert_eq!(
478            next_invalid_streak(AcquireStatus::Invalid, AcquireAction::Reconfigure, u8::MAX),
479            u8::MAX
480        );
481    }
482
483    #[test]
484    fn invalid_streak_resets_on_giving_up() {
485        assert_eq!(
486            next_invalid_streak(
487                AcquireStatus::Invalid,
488                AcquireAction::Lose,
489                MAX_INVALID_RECONFIGURES
490            ),
491            0
492        );
493    }
494
495    #[test]
496    fn invalid_streak_untouched_by_unrelated_status_action_pairs() {
497        assert_eq!(
498            next_invalid_streak(AcquireStatus::Outdated, AcquireAction::Reconfigure, 2),
499            2
500        );
501        assert_eq!(
502            next_invalid_streak(AcquireStatus::Lost, AcquireAction::Lose, 2),
503            2
504        );
505        assert_eq!(
506            next_invalid_streak(AcquireStatus::Transient, AcquireAction::Skip, 2),
507            2
508        );
509    }
510
511    #[test]
512    fn non_invalid_statuses_are_unaffected_by_the_counter() {
513        // The counter only matters for `Invalid`; every other status ignores it.
514        for consecutive_invalid in [0, 1, MAX_INVALID_RECONFIGURES, u8::MAX] {
515            assert_eq!(
516                decide_acquire(AcquireStatus::Usable, consecutive_invalid),
517                AcquireAction::Present
518            );
519            assert_eq!(
520                decide_acquire(AcquireStatus::Outdated, consecutive_invalid),
521                AcquireAction::Reconfigure
522            );
523            assert_eq!(
524                decide_acquire(AcquireStatus::Lost, consecutive_invalid),
525                AcquireAction::Lose
526            );
527            assert_eq!(
528                decide_acquire(AcquireStatus::Transient, consecutive_invalid),
529                AcquireAction::Skip
530            );
531        }
532    }
533}