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}