Skip to main content

frust_render/
renderer.rs

1//! [`SurfaceRenderer`]: the surface-lifecycle state machine that
2//! renders a [`frust_scene::Scene`] into one window's swapchain each frame.
3//!
4//! Wraps the pure lifecycle logic in [`frust_gpu::lifecycle`] around the
5//! actual `wgpu` resources: it starts in [`SurfacePhase::NoSurface`], becomes
6//! renderable on `on_surface_created`, resizes on `on_surface_changed`, tears
7//! down on `on_surface_destroyed`, and drops to [`SurfacePhase::SurfaceLost`]
8//! when the swapchain reports `Lost` mid-frame.
9//!
10//! The frame — there is one renderer and one path family — is:
11//! encode copies the frame's scene, then acquire → the scene's fragment-shader
12//! quads rendered into their own offscreen targets
13//! (`frust_engine::ShaderQuadPass`, a no-op for a scene drawing none) → one
14//! `EngineRenderer::encode` into the acquired swapchain view and its
15//! surface-owned depth attachment
16//! ([`RenderPath::EngineDirect`](crate::context::RenderPath::EngineDirect)) → submit → present →
17//! `end_frame`. Every one of those passes goes into a single encoder, which is
18//! what puts a shader quad's own pass ahead of the frame that samples it. No
19//! intermediate and no blit. A swapchain whose compositor
20//! genuinely reads it as STRAIGHT alpha takes the same shape with one pass
21//! appended and one indirection added: the frame is encoded into a
22//! surface-owned intermediate and un-premultiplied into the acquired view from
23//! there, in the same encoder
24//! ([`RenderPath::EngineDirectUnpremultiply`](crate::context::RenderPath::EngineDirectUnpremultiply)).
25//! iOS's sole
26//! translucent mode (`PostMultiplied`) does NOT take that arm: it is backed
27//! by Metal, whose compositor reads a `PostMultiplied` swapchain premultiplied
28//! regardless of the mode's name (an upstream wgpu-hal truth bug — see
29//! [`crate::context::choose_engine_render_path`]), so it stays on the
30//! `EngineDirect` arm above with no conversion pass at all.
31//!
32//! This crate remaps the v3 present spans on the engine arm — see
33//! [`SurfaceRenderer::submit`].
34
35use core::ffi::c_void;
36
37use anyhow::{Result, anyhow};
38
39use std::sync::OnceLock;
40use std::time::Duration;
41
42use kurbo::Affine;
43
44use crate::context::{EngineSurface, RenderPath};
45use frust_gpu::lifecycle::{
46    AcquireAction, AcquireOutcome, AcquireStatus, EncodeOutcome, FrameOutcome, SurfaceEvent,
47    SurfacePhase, decide_acquire, next_invalid_streak, next_phase,
48};
49use frust_gpu::{DetachedSurface, RenderContext, SurfaceAlphaRequest};
50
51/// The live GPU resources of a [`SurfacePhase::SurfaceReady`] surface.
52///
53/// The tier backend is created per surface (it is device-bound) and, with the
54/// `RenderSurface`, is dropped on every transition out of `SurfaceReady`,
55/// upholding the "no `SurfaceTexture`/surface outlives a transition" invariant.
56struct ReadySurface {
57    surface: EngineSurface,
58    /// The tier-specific renderer that produces this frame's pixels — on the
59    /// engine arms it targets the acquired swapchain texture in `submit`.
60    backend: TierBackend,
61    /// The device's persisted `wgpu::PipelineCache` (handed to the engine's
62    /// pipeline build) paired with the adapter fingerprint
63    /// its data is framed under, so [`SurfaceRenderer::pipeline_cache_data`] can
64    /// hand back a validatable blob. `None` on adapters without
65    /// `PIPELINE_CACHE` (Metal/DX12) — see
66    /// [`frust_gpu::RenderContext::create_pipeline_cache`].
67    pipeline_cache: Option<(wgpu::PipelineCache, String)>,
68}
69
70/// The per-surface renderer.
71///
72/// The engine variant records into the acquired swapchain view (or, on the
73/// un-premultiplying arm, into the surface's intermediate) in
74/// [`SurfaceRenderer::submit`]. A one-variant enum — the experimental
75/// `vello_cpu`-backed `Cpu` variant and its `cpu-tier` feature were retired
76/// alongside the vello-classic renderer's own removal, and the cargo feature
77/// that once gated this one went with the choice it described.
78// The renderer is boxed: the whole `ReadySurface` already lives behind a
79// `Box` (`SurfaceState::Ready`), so nothing on the hot path pays for its
80// size.
81enum TierBackend {
82    /// The frust-owned engine path: a
83    /// `frust_scene::Scene` compiled into sparse strips and recorded into the
84    /// frame's own `wgpu::CommandEncoder` by
85    /// [`frust_engine::EngineRenderer`], which never submits — the encoder is
86    /// created and submitted in [`SurfaceRenderer::submit`].
87    Engine {
88        engine: Box<frust_engine::EngineRenderer>,
89        /// How many of this surface's frames the engine has refused so far
90        /// (a scheduler escalation, an unserveable frame, a capacity ceiling).
91        ///
92        /// Counted rather than logged per frame: a refusal that reproduces
93        /// every frame would otherwise flood the log with one identical line
94        /// per vsync. The count feeds [`frust_gpu::context::decide_log_action`] —
95        /// the same latch the uncaptured-`wgpu`-error handler uses — so the
96        /// first few refusals are reported in full, the latch is announced
97        /// once, and the running total keeps surfacing on the periodic debug
98        /// bump afterwards.
99        refused_frames: u32,
100        /// The frame's fragment-shader pre-pass: every
101        /// `frust_scene::Command::ShaderQuad` this surface draws, rendered
102        /// into a pooled offscreen target and registered with `engine` as a
103        /// scene texture before the frame that samples it is compiled.
104        ///
105        /// Per surface, beside the renderer whose registry it writes and
106        /// seeded from the same persisted `wgpu::PipelineCache`: its targets
107        /// and compiled user programs are device-bound, so they die with the
108        /// surface exactly as the engine's own resources do.
109        shader_quads: Box<frust_engine::ShaderQuadPass>,
110        /// This surface's GPU timestamp ring — real per-pass GPU time, rather
111        /// than the CPU wall-clock spans around `encode`/`submit`.
112        ///
113        /// Created for every engine surface and *inert* unless the device was
114        /// created with `wgpu::Features::TIMESTAMP_QUERY`, which is the only
115        /// thing that decides whether a frame line reports `gpu_q=1`. Inert
116        /// costs one empty `Vec` and a branch per pass — no query set, no
117        /// buffers, no map — so there is nothing to feature-gate at this
118        /// level.
119        timestamps: frust_gpu::diag::TimestampRing,
120    },
121}
122
123/// One frame's real GPU time, split by the spans `frust-engine` names
124/// ([`frust_engine::EngineSpan`]).
125///
126/// The value [`SurfaceRenderer::gpu_pass_timings`] hands a shell so it can put
127/// GPU cost on the frame line beside the CPU spans it measured itself. Plain
128/// `Duration`s — no `wgpu` type crosses this crate's boundary
129/// (`docs/CODE_STANDARDS.md`'s wgpu-leak anti-pattern).
130///
131/// [`Self::total`] is the sum of the four, which is the frame's *attributed*
132/// GPU pass time: the queue and driver gaps between passes belong to no pass
133/// and are deliberately not folded in.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct GpuPassTimings {
136    /// Work recorded ahead of the frame's own passes — the glyph-atlas replay.
137    pub prepass: Duration,
138    /// The frame's own surface passes: clear, opaque strips, alpha strips and
139    /// the hole punch.
140    pub main: Duration,
141    /// Off-screen layer pages and filter passes.
142    pub composite: Duration,
143    /// The present-side conversion pass a straight-alpha swapchain needs.
144    pub blit: Duration,
145}
146
147impl GpuPassTimings {
148    /// The sum of every span.
149    #[must_use]
150    pub fn total(&self) -> Duration {
151        self.prepass
152            .saturating_add(self.main)
153            .saturating_add(self.composite)
154            .saturating_add(self.blit)
155    }
156}
157
158/// The surface half of the lifecycle machine, parallel to [`SurfacePhase`].
159///
160/// `Ready` is boxed: the live GPU resources dwarf the empty variants, and
161/// boxing keeps the common `NoSurface`/`Lost` states cheap to move.
162enum SurfaceState {
163    NoSurface,
164    Ready(Box<ReadySurface>),
165    Lost,
166}
167
168/// A frame that has been rendered and queue-submitted but **not yet
169/// presented** — the deferred half of [`SurfaceRenderer::submit_deferred`].
170///
171/// Opaque by design: it wraps a `wgpu::SurfaceTexture` (and the
172/// `wgpu::Queue` that presents it) behind private fields with no accessor,
173/// exactly like [`DetachedSurface`](crate::DetachedSurface), so no `wgpu` type
174/// is nameable outside this crate (`docs/CODE_STANDARDS.md`'s wgpu-leak
175/// anti-pattern). It is `Send` — a `wgpu::SurfaceTexture` owns its swapchain
176/// frame and borrows nothing, and a `wgpu::Queue` is a cheap `Send + Sync`
177/// handle — which is the whole point: a shell can hand it from the render
178/// thread to the thread that must issue the present.
179///
180/// **Why deferring the present is a contract, not a micro-optimisation.** On
181/// iOS, a `CAMetalLayer` with `presentsWithTransaction = true` requires
182/// `[drawable present]` to run on the thread committing the `CATransaction`
183/// that carries the sibling views' geometry. wgpu-hal's Metal present already
184/// implements exactly that shape when the layer has the flag set (submit a
185/// present command buffer, `waitUntilScheduled`, then `drawable.present()`) —
186/// it simply runs it on whichever thread calls [`Self::present`]. Under the
187/// render-thread split that thread commits no transaction, so the drawable is
188/// never handed to the compositor at all (measured: total loss of presentation).
189/// Handing this value to the UI thread and
190/// presenting *there* is what lets frust's surface land in the same transaction
191/// as the platform-view geometry while the split stays on.
192///
193/// Dropping one without presenting is safe and deliberate: the drawable returns
194/// to the layer's pool un-presented and that frame is simply skipped — the
195/// depth-1 latest-wins discipline (a newer frame supersedes an un-presented
196/// older one rather than blocking on it).
197pub struct DeferredPresent {
198    texture: wgpu::SurfaceTexture,
199    /// The queue the present is scheduled on. Carried because wgpu 30 moved
200    /// the present from `SurfaceTexture::present(self)` onto
201    /// `Queue::present(texture)`, and this handle outlives the `RenderContext`
202    /// borrow that produced it.
203    queue: wgpu::Queue,
204}
205
206impl std::fmt::Debug for DeferredPresent {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.debug_struct("DeferredPresent").finish_non_exhaustive()
209    }
210}
211
212impl DeferredPresent {
213    /// Present the deferred frame **on the calling thread**.
214    ///
215    /// Consumes the handle, so a frame can never be presented twice. With
216    /// `presentsWithTransaction` set on the target `CAMetalLayer` this is the
217    /// call that must run on the transaction-committing (UI) thread; on every
218    /// other platform/configuration it is an ordinary present that happens to
219    /// have been moved off the submit site.
220    pub fn present(self) {
221        self.queue.present(self.texture);
222    }
223}
224
225/// Per-surface renderer and lifecycle state machine.
226///
227/// Holds the current surface state plus the cross-call stashes one frame
228/// needs. Constructed empty with [`SurfaceRenderer::new`]; the shell brings it
229/// online with [`on_surface_created`](Self::on_surface_created).
230pub struct SurfaceRenderer {
231    state: SurfaceState,
232    /// Consecutive `AcquireStatus::Invalid` acquires retried via `Reconfigure`
233    /// since the last successful acquire or surface (re)install — see
234    /// `frust_gpu::lifecycle`'s `decide_acquire`/`MAX_INVALID_RECONFIGURES`.
235    /// Reset on a successful acquire, on giving up (transitioning to
236    /// `SurfaceLost`), and on installing a fresh surface.
237    consecutive_invalid: u8,
238    /// Persisted, framed `wgpu::PipelineCache` blob (see
239    /// [`frust_gpu::pipeline_cache`]) a prior run wrote and the shell restored from
240    /// disk via [`set_initial_pipeline_cache_data`](Self::set_initial_pipeline_cache_data),
241    /// consumed at the next surface install to seed the engine's shader-pipeline
242    /// compilation. `None` is a cold start. Only meaningful on adapters with
243    /// `PIPELINE_CACHE` (any Vulkan adapter); validated and discarded elsewhere.
244    initial_cache_data: Option<Vec<u8>>,
245    /// The swapchain texture [`Self::acquire`] acquired and stashed for
246    /// [`Self::submit`] to render into and present (the acquire/submit span split).
247    /// `None` outside an in-flight `acquire`→`submit` pair — set only on an
248    /// [`AcquireOutcome::Acquired`] result, taken by the next [`Self::submit`].
249    /// A `wgpu::SurfaceTexture` is owned (it does not borrow the surface), so
250    /// stashing it here between the two clock-separated calls keeps the wgpu type
251    /// confined to `frust-render` while letting a shell time each span with its
252    /// own clock (timing stays shell-owned — see `frust-shell-common::perf`).
253    pending_present: Option<wgpu::SurfaceTexture>,
254    /// The `base_color` [`Self::encode`] was called with, stashed since the
255    /// render that consumes `base_color` runs in [`Self::submit`] (it targets
256    /// the acquired swapchain texture, which does not exist until
257    /// [`Self::acquire`]), so the color must survive the gap between `encode`
258    /// and `submit`. `None` outside an in-flight engine frame.
259    pending_base_color: Option<peniko::Color>,
260    /// The engine tier's owned copy of the frame's scene — its counterpart to
261    /// [`Self::scene`], and reused across frames for the same reason.
262    ///
263    /// `frust_engine::EngineRenderer::encode` needs the GPU resources only
264    /// [`Self::submit`] has (the acquired swapchain view), so the borrowed
265    /// `&Scene` [`Self::encode`] is handed has to survive the gap between the
266    /// two calls. `clone_from` reuses this buffer's capacity, so a
267    /// steady-state frame allocates nothing.
268    ///
269    /// **Measurement note.** The same span caveat applies: on this tier
270    /// `encode_us` holds a memcpy and nothing else — the whole scene compile
271    /// (strip building, paint encoding) plus command recording lands in
272    /// `submit_us`. Compare whole frames across tiers, never `encode_us`
273    /// against `encode_us`.
274    engine_scene: frust_scene::Scene,
275}
276
277impl Default for SurfaceRenderer {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl SurfaceRenderer {
284    /// Creates an empty renderer in [`SurfacePhase::NoSurface`].
285    ///
286    /// No GPU work happens here; the surface is created later by the shell via
287    /// [`on_surface_created`](Self::on_surface_created) once a window/surface
288    /// exists (deferred window creation on desktop, `surfaceCreated` on Android).
289    pub fn new() -> Self {
290        Self {
291            state: SurfaceState::NoSurface,
292            consecutive_invalid: 0,
293            initial_cache_data: None,
294            pending_present: None,
295            pending_base_color: None,
296            engine_scene: frust_scene::Scene::default(),
297        }
298    }
299
300    /// Drops the in-flight frame's cross-call stash: the engine arm's
301    /// `base_color`.
302    ///
303    /// It describes one frame of one surface, so any event that ends a
304    /// surface's life (`on_surface_destroyed`) or replaces it
305    /// (`install_surface`) must drop it rather than let the next `submit`
306    /// consume a stale colour. Idempotent.
307    fn clear_pending_frame(&mut self) {
308        self.pending_base_color = None;
309    }
310
311    /// Adopt `state` and reset everything else scoped to ONE surface: the
312    /// `Invalid`-reconfigure streak and the in-flight frame's stash.
313    ///
314    /// The one place a surface episode begins or ends. `install_surface`
315    /// enters through it with the freshly built [`SurfaceState::Ready`];
316    /// `on_surface_destroyed` leaves through it with
317    /// [`SurfaceState::NoSurface`]; and [`Self::acquire`]'s give-up arm leaves
318    /// through it with [`SurfaceState::Lost`] — so a reset can never be
319    /// written into one of those paths and forgotten in another. Everything
320    /// reset here describes the surface being replaced or torn down: the
321    /// streak belonged to it, and the stash describes one of its frames.
322    /// Nothing else may assign `self.state`.
323    fn adopt_surface_state(&mut self, state: SurfaceState) {
324        self.state = state;
325        self.consecutive_invalid = 0;
326        self.clear_pending_frame();
327    }
328
329    /// Restores the persisted pipeline-cache blob a prior run produced via
330    /// [`pipeline_cache_data`](Self::pipeline_cache_data), to seed the engine's
331    /// shader-pipeline compilation at the next surface install and cut warm-start
332    /// shader/pipeline compilation to near zero on Vulkan.
333    ///
334    /// Builder-style (a setter rather than a new `on_surface_created*`
335    /// parameter) so the three surface-creation entry points — and every shell
336    /// call site — keep their signatures; the shell (persistence lands in tasks
337    /// 13/14) calls this once after [`SurfaceRenderer::new`] and before the
338    /// first `on_surface_created*`. Has no effect in practice on adapters
339    /// without `PIPELINE_CACHE` (Metal/DX12): the blob is validated against
340    /// the live adapter at install time and discarded on any mismatch. Passing
341    /// `None` clears any restored blob (a cold start).
342    ///
343    /// **Caller contract (provenance):** `data` must be bytes this renderer
344    /// itself previously produced via
345    /// [`pipeline_cache_data`](Self::pipeline_cache_data) and the shell
346    /// persisted verbatim — never bytes from any other source. The framing
347    /// check downstream (`frust_gpu::pipeline_cache::unframe`) proves the
348    /// magic tag and the adapter fingerprint, which is provenance by
349    /// convention rather than integrity: a deliberately forged blob passing
350    /// that frame reaches `wgpu`'s `unsafe` pipeline-cache seam, whose
351    /// contract makes foreign data undefined behaviour. This method stays
352    /// safe because the obligation is a data-handling rule for the shell's
353    /// persistence layer, not something a signature can enforce.
354    pub fn set_initial_pipeline_cache_data(&mut self, data: Option<Vec<u8>>) {
355        self.initial_cache_data = data;
356    }
357
358    /// The current pipeline-cache data to persist, framed with the adapter
359    /// fingerprint so a later launch can validate it before reuse (see
360    /// [`frust_gpu::pipeline_cache`]).
361    ///
362    /// `None` when there is no live cache to read — no surface installed, or an
363    /// adapter without `PIPELINE_CACHE` (Metal/DX12) — or when the driver has
364    /// produced nothing to hand back yet. The shell (tasks 13/14) writes the
365    /// returned bytes to disk and feeds them back via
366    /// [`set_initial_pipeline_cache_data`](Self::set_initial_pipeline_cache_data)
367    /// on the next launch.
368    pub fn pipeline_cache_data(&self) -> Option<Vec<u8>> {
369        let SurfaceState::Ready(ready) = &self.state else {
370            return None;
371        };
372        let (cache, key) = ready.pipeline_cache.as_ref()?;
373        let data = cache.get_data()?;
374        Some(frust_gpu::pipeline_cache::frame(key, &data))
375    }
376
377    /// Whether the **live** surface actually resolved to a translucent
378    /// (alpha-compositing) mode — the truth a shell's Mode B paint contract
379    /// must key off, replacing the `SurfaceAlphaRequest` it *asked* for.
380    ///
381    /// [`SurfaceAlphaRequest::TranslucentPreferred`] is a *preference*: the
382    /// configure step resolves it against the platform's advertised alpha modes
383    /// and silently degrades to an opaque swapchain (with a `log::warn!`) when
384    /// none is available. A shell that clears its base color to `TRANSPARENT`
385    /// and lets `platform_view` slots punch their rects on the strength of the
386    /// request alone then presents black rectangles on that opaque swapchain.
387    /// Reading this after every successful `on_surface_created*`/
388    /// [`on_surface_installed`](Self::on_surface_installed) — and re-reading it
389    /// after every recreate — is what makes a fallback degrade to the Mode A
390    /// contract (opaque base, no punch) instead.
391    ///
392    /// `false` outside [`SurfacePhase::SurfaceReady`]: with no live surface
393    /// there is nothing translucent to composite against, and `false` is the
394    /// safe (Mode A) default — never punch a hole you can't prove is a window.
395    ///
396    /// Deliberately **not** named `is_surface_translucent`: `RenderRoot` (which
397    /// sits on the other side of this same call chain) already owns a method by
398    /// that name for the *pushed* flag, and a shell threads this value straight
399    /// into it. The signature is `wgpu`-free, like every other value crossing
400    /// this crate's boundary (`docs/CODE_STANDARDS.md`'s wgpu-leak
401    /// anti-pattern).
402    pub fn surface_resolved_translucent(&self) -> bool {
403        match &self.state {
404            SurfaceState::Ready(ready) => ready.surface.resolved_translucent(),
405            SurfaceState::NoSurface | SurfaceState::Lost => false,
406        }
407    }
408
409    /// The most recent frame's real GPU time per pass, or `None` when this
410    /// surface produces no such measurement.
411    ///
412    /// `None` — the `gpu_q=0` case a shell reports — for every reason there is:
413    /// no live surface, a device created without
414    /// `wgpu::Features::TIMESTAMP_QUERY` (which a build that did not compile
415    /// `perf-trace` in never asks for), and the first few frames of a surface,
416    /// before the ring's first readback has landed.
417    ///
418    /// The reading lags the calling frame: a frame's queries are mapped
419    /// without ever blocking the frame path, so what comes back is a recent
420    /// frame's GPU cost rather than the one being recorded. In steady state one
421    /// fresh reading lands per frame, so the series is complete and offset,
422    /// not sparse — see [`frust_gpu::diag::TimestampRing`].
423    pub fn gpu_pass_timings(&self) -> Option<GpuPassTimings> {
424        let SurfaceState::Ready(ready) = &self.state else {
425            return None;
426        };
427        let TierBackend::Engine { timestamps, .. } = &ready.backend;
428        let reading = timestamps.latest()?;
429        let span = |which: frust_engine::EngineSpan| reading.span(which.index());
430        Some(GpuPassTimings {
431            prepass: span(frust_engine::EngineSpan::Prepass),
432            main: span(frust_engine::EngineSpan::Main),
433            composite: span(frust_engine::EngineSpan::Composite),
434            blit: span(frust_engine::EngineSpan::Blit),
435        })
436    }
437
438    /// The current lifecycle phase.
439    pub fn phase(&self) -> SurfacePhase {
440        match self.state {
441            SurfaceState::NoSurface => SurfacePhase::NoSurface,
442            SurfaceState::Ready(_) => SurfacePhase::SurfaceReady,
443            SurfaceState::Lost => SurfacePhase::SurfaceLost,
444        }
445    }
446
447    /// Brings the surface online (`surfaceCreated`/`resumed`): creates the
448    /// swapchain and a device-bound renderer, transitioning to
449    /// [`SurfacePhase::SurfaceReady`].
450    ///
451    /// Valid from any phase — calling it in `SurfaceLost` is how the shell
452    /// recovers, and calling it in `SurfaceReady` replaces the surface (the old
453    /// one is dropped first). `window` is any raw window handle the shell owns
454    /// (`wgpu::SurfaceTarget`); no `winit` dependency is imposed here.
455    ///
456    /// Presentation uses vsync (`PresentMode::AutoVsync`), matching the
457    /// vsync-driven frame pacing the platform shells provide.
458    pub async fn on_surface_created(
459        &mut self,
460        ctx: &mut RenderContext,
461        window: impl Into<wgpu::SurfaceTarget<'static>>,
462        width: u32,
463        height: u32,
464        alpha: SurfaceAlphaRequest,
465    ) -> Result<()> {
466        let surface = crate::context::create_engine_surface_from_target(
467            ctx,
468            window,
469            width.max(1),
470            height.max(1),
471            wgpu::PresentMode::AutoVsync,
472            alpha,
473        )
474        .await
475        .map_err(|e| anyhow!("frust-render: failed to create render surface: {e}"))?;
476        self.install_surface(ctx, surface)
477    }
478
479    /// Brings the surface online from a [`DetachedSurface`] that was created on
480    /// the windowing/main thread via
481    /// [`RenderContext::surface_factory`](crate::RenderContext::surface_factory),
482    /// transitioning to [`SurfacePhase::SurfaceReady`] (the
483    /// render-thread split).
484    ///
485    /// The desktop counterpart of [`on_surface_created`](Self::on_surface_created)
486    /// for the split: `on_surface_created` reads the window handle *and*
487    /// configures on one thread, but winit only yields that handle on the main
488    /// thread — so the split creates the surface there
489    /// ([`SurfaceFactory::create_detached_surface`](crate::SurfaceFactory::create_detached_surface))
490    /// and hands the `Send` surface here, where the render thread that owns this
491    /// renderer/context does the device + swapchain + blitter work. Presentation
492    /// uses vsync (`PresentMode::AutoVsync`), matching `on_surface_created`.
493    ///
494    /// Valid from any phase (recreation after `SurfaceLost`/resume replaces the
495    /// old surface — dropped first).
496    pub async fn on_surface_installed(
497        &mut self,
498        ctx: &mut RenderContext,
499        surface: DetachedSurface,
500        width: u32,
501        height: u32,
502        alpha: SurfaceAlphaRequest,
503    ) -> Result<()> {
504        let surface = crate::context::create_engine_surface(
505            ctx,
506            surface.into_surface(),
507            width.max(1),
508            height.max(1),
509            wgpu::PresentMode::AutoVsync,
510            alpha,
511        )
512        .await
513        .map_err(|e| anyhow!("frust-render: failed to configure detached surface: {e}"))?;
514        self.install_surface(ctx, surface)
515    }
516
517    /// Brings the surface online from a raw `ANativeWindow` pointer (Android
518    /// `surfaceCreated`), transitioning to [`SurfacePhase::SurfaceReady`].
519    ///
520    /// Compiled unconditionally so a host `cargo check --target
521    /// aarch64-linux-android` covers it. This is one of the framework's
522    /// sanctioned unsafe entry points (see also
523    /// [`on_surface_created_from_metal_layer`](Self::on_surface_created_from_metal_layer));
524    /// the raw-pointer handling is isolated in
525    /// [`frust_gpu::lifecycle::create_android_surface`].
526    ///
527    /// # Safety
528    ///
529    /// `window_ptr` must be a valid, acquired `ANativeWindow*` that outlives the
530    /// surface (and all its `SurfaceTexture`s). See
531    /// [`frust_gpu::lifecycle::create_android_surface`] for the full contract.
532    pub async unsafe fn on_surface_created_from_android_window(
533        &mut self,
534        ctx: &mut RenderContext,
535        window_ptr: *mut c_void,
536        width: u32,
537        height: u32,
538        alpha: SurfaceAlphaRequest,
539    ) -> Result<()> {
540        // SAFETY: forwarded to the caller's `on_surface_created_from_android_window`
541        // contract — `window_ptr` is a valid, acquired ANativeWindow* outliving
542        // the surface.
543        let raw =
544            unsafe { frust_gpu::lifecycle::create_android_surface(ctx.instance(), window_ptr) }?;
545        let surface = crate::context::create_engine_surface(
546            ctx,
547            raw,
548            width.max(1),
549            height.max(1),
550            wgpu::PresentMode::AutoVsync,
551            alpha,
552        )
553        .await
554        .map_err(|e| anyhow!("frust-render: failed to configure Android surface: {e}"))?;
555        self.install_surface(ctx, surface)
556    }
557
558    /// Brings the surface online from a raw `CAMetalLayer*` pointer (iOS/macOS
559    /// Swift shell surface creation), transitioning to
560    /// [`SurfacePhase::SurfaceReady`].
561    ///
562    /// Only compiled on Apple targets (mirrors [`frust_gpu::lifecycle::create_metal_surface`]'s
563    /// gating): the raw-pointer handling is isolated there, one of the
564    /// framework's sanctioned unsafe boundaries alongside
565    /// [`on_surface_created_from_android_window`](Self::on_surface_created_from_android_window).
566    ///
567    /// Presentation uses `Fifo` — the only present mode guaranteed on
568    /// iOS/Metal (vsync-equivalent, matching the desktop/Android
569    /// `AutoVsync` paths in spirit).
570    ///
571    /// # Safety
572    ///
573    /// `layer_ptr` must be a valid, live `CAMetalLayer*` that outlives the
574    /// surface (and all its `SurfaceTexture`s). See
575    /// [`frust_gpu::lifecycle::create_metal_surface`] for the full contract.
576    #[cfg(any(target_os = "ios", target_os = "macos"))]
577    pub async unsafe fn on_surface_created_from_metal_layer(
578        &mut self,
579        ctx: &mut RenderContext,
580        layer_ptr: *mut c_void,
581        width: u32,
582        height: u32,
583        alpha: SurfaceAlphaRequest,
584    ) -> Result<()> {
585        // SAFETY: forwarded to the caller's `on_surface_created_from_metal_layer`
586        // contract — `layer_ptr` is a valid, live CAMetalLayer* outliving the
587        // surface.
588        let raw = unsafe { frust_gpu::lifecycle::create_metal_surface(ctx.instance(), layer_ptr) }?;
589        let surface = crate::context::create_engine_surface(
590            ctx,
591            raw,
592            width.max(1),
593            height.max(1),
594            wgpu::PresentMode::Fifo,
595            alpha,
596        )
597        .await
598        .map_err(|e| anyhow!("frust-render: failed to configure Metal surface: {e}"))?;
599        self.install_surface(ctx, surface)
600    }
601
602    /// Wraps a freshly created `RenderSurface` in a device-bound renderer and
603    /// installs it as the live surface. Shared by the safe and Android paths.
604    fn install_surface(&mut self, ctx: &RenderContext, surface: EngineSurface) -> Result<()> {
605        // Seed the engine's shader-pipeline compilation from a persisted
606        // `wgpu::PipelineCache` when the adapter supports it (Vulkan/Android) and
607        // the shell restored a validated blob via
608        // `set_initial_pipeline_cache_data`. Returns `None` on adapters without
609        // `PIPELINE_CACHE` (Metal/DX12). Created before the backend below so
610        // it can be handed to `EngineRenderer::new`.
611        // SAFETY: `initial_cache_data` arrives only via
612        // `set_initial_pipeline_cache_data`, whose documented caller contract
613        // requires bytes this renderer itself produced through
614        // `pipeline_cache_data` and the shell persisted verbatim — the
615        // provenance `create_pipeline_cache`'s `# Safety` section requires.
616        // This call site cannot itself prove that history; it relies on that
617        // documented contract plus `unframe`'s magic-tag/adapter-fingerprint
618        // check, which rejects every accidental mismatch (integrity against a
619        // deliberate forgery is out of scope — see the setter's doc). `None`
620        // is a cold start.
621        let pipeline_cache =
622            unsafe { ctx.create_pipeline_cache(self.initial_cache_data.as_deref()) };
623
624        // Build the backend. There is exactly one renderer and no tier to
625        // dispatch on: `context::create_engine_surface` has already refused an
626        // adapter the engine cannot drive, before any surface is configured.
627        let backend = {
628            let device = &ctx.device_handle().device;
629            // Built for the format this surface's FRAMES will target,
630            // because the engine warms its strip pipelines for exactly one
631            // of them: the swapchain's own on the direct arm, the engine's
632            // off-screen format on the un-premultiplying one, whose frames
633            // land in a surface-owned intermediate instead
634            // ([`engine_target_format`]).
635            //
636            // A resize keeps that format (`EngineSurface::resize`
637            // rewrites only the dimensions), so `on_surface_changed`
638            // resizes the renderer in place; a FORMAT change arrives as a
639            // fresh surface and lands back here, building a renderer
640            // warmed for the new format — off the frame path, and seeded
641            // from the same persisted `wgpu::PipelineCache` blob, so a
642            // re-warm after a format change is a driver-cache hit rather
643            // than a cold compile.
644            let caps = frust_gpu::TierCaps::probe(&ctx.device_handle().adapter);
645            let engine_format = engine_target_format(&surface.path, surface.config().format);
646            let engine = frust_engine::EngineRenderer::new(
647                device,
648                &caps,
649                engine_format,
650                pipeline_cache.as_ref(),
651            )
652            .map_err(|e| anyhow!("frust-render: failed to create engine renderer: {e}"))?;
653            // One line per process naming the renderer, next to the
654            // `render-path`/`aa-mode` lines, so a capture proves which
655            // renderer produced the frames it is timing.
656            //
657            // FIXED EVIDENCE MARKER: the `frust-render tier=engine (...)`
658            // text below is byte-for-byte load-bearing — every device gate
659            // greps logcat/console for it and `benchmarks/RESULTS.md`
660            // quotes it verbatim. It outlived the retirement of the tier
661            // *selection* deliberately (there is nothing left to select,
662            // but the receipts still have to match), so do not reword,
663            // re-case or re-punctuate it, and emit it unconditionally.
664            static ENGINE_LOGGED: OnceLock<()> = OnceLock::new();
665            ENGINE_LOGGED.get_or_init(|| {
666                log::info!(
667                    "frust-render tier=engine (frust-engine strip pipeline, format={:?} into \
668                     a {:?} swapchain, {}x{}, adapter `{}`)",
669                    engine_format,
670                    surface.config().format,
671                    surface.config().width,
672                    surface.config().height,
673                    caps.adapter_name
674                );
675            });
676            // Built from the LIVE device rather than from `caps`: the
677            // adapter offering `TIMESTAMP_QUERY` is not the same statement
678            // as the device having been created with it, and creating a
679            // query set the device never enabled is a validation error
680            // rather than a missing measurement. The ring asks the device
681            // itself and goes inert when the answer is no.
682            let timestamps = frust_gpu::diag::TimestampRing::new(
683                device,
684                &ctx.device_handle().queue,
685                frust_engine::EngineSpan::COUNT,
686                frust_engine::diag::TIMESTAMP_RING_LABEL,
687            );
688            TierBackend::Engine {
689                engine: Box::new(engine),
690                refused_frames: 0,
691                shader_quads: Box::new(frust_engine::ShaderQuadPass::new(pipeline_cache.clone())),
692                timestamps,
693            }
694        };
695        debug_assert_eq!(
696            next_phase(self.phase(), SurfaceEvent::Created),
697            SurfacePhase::SurfaceReady,
698            "Created must reach SurfaceReady"
699        );
700        // Pair the live pipeline cache with the adapter fingerprint its data is
701        // framed under, so `pipeline_cache_data()` can hand back a validatable
702        // blob without re-reading the adapter. `None` when the adapter lacks
703        // `PIPELINE_CACHE`.
704        let pipeline_cache = pipeline_cache.map(|cache| (cache, ctx.adapter_cache_key()));
705        // Dropping the previous `SurfaceState` here tears down any prior surface
706        // before the new one goes live: no surface outlives a
707        // transition.
708        //
709        // Through `adopt_surface_state` for the resets that come with it: a
710        // freshly (re)installed surface starts a new `Invalid`-reconfigure
711        // episode (any prior streak belonged to the surface just replaced),
712        // and the in-flight frame's stashes describe a frame of that same
713        // replaced surface (the composite one holding handles on textures
714        // from the cache that died with it), so carrying either into the new
715        // surface's first `submit` would paint the old frame's pages and
716        // colour onto it.
717        self.adopt_surface_state(SurfaceState::Ready(Box::new(ReadySurface {
718            surface,
719            backend,
720            pipeline_cache,
721        })));
722        Ok(())
723    }
724
725    /// Resizes the swapchain (`surfaceChanged`/`Resized`).
726    ///
727    /// Only acts in [`SurfacePhase::SurfaceReady`]; a resize with no surface is
728    /// dropped. The renderer (and its compiled pipelines) is preserved — only
729    /// the surface config and its sized attachments are recreated. Zero
730    /// dimensions are ignored (a minimized window keeps its last valid size).
731    pub fn on_surface_changed(&mut self, ctx: &RenderContext, width: u32, height: u32) {
732        if width == 0 || height == 0 {
733            return;
734        }
735        if let SurfaceState::Ready(ready) = &mut self.state {
736            ready
737                .surface
738                .resize(&ctx.device_handle().device, width, height);
739            match &mut ready.backend {
740                TierBackend::Engine { engine, .. } => {
741                    // The engine's own extent-sized resources (its intermediate
742                    // pool's parked entries, and the depth attachment it would
743                    // own if the surface did not) are re-established here,
744                    // off the frame path. The surface's own depth attachment
745                    // was recreated by `EngineSurface::resize` above, in
746                    // the same step that reconfigured the swapchain.
747                    //
748                    // The pipelines survive: only a FORMAT change would need a
749                    // renderer warmed for a different target, and a resize
750                    // never changes one — `EngineSurface::resize` rewrites
751                    // the dimensions alone. Asserted rather than assumed, since a
752                    // silent divergence would compile a fresh pipeline on the
753                    // frame path for every frame that followed. Compared
754                    // against the format the ARM implies, not the swapchain's
755                    // own, so the un-premultiplying arm (whose frames target
756                    // the engine's off-screen format) is not reported as drift
757                    // on every resize.
758                    let expected =
759                        engine_target_format(&ready.surface.path, ready.surface.config().format);
760                    if engine.format() != expected {
761                        log::warn!(
762                            "frust-render: engine renderer warmed for {:?} but this surface's \
763                             frames now target {:?} — a format change must arrive as a fresh \
764                             surface, not a resize",
765                            engine.format(),
766                            expected
767                        );
768                    }
769                    engine.resize(&ctx.device_handle().device, width, height);
770                }
771            }
772        }
773    }
774
775    /// Tears the surface down (`surfaceDestroyed`/`suspended`), transitioning to
776    /// [`SurfacePhase::NoSurface`].
777    ///
778    /// Drops the `RenderSurface` (and its renderer) so no surface or texture
779    /// outlives the platform's underlying window. Idempotent.
780    pub fn on_surface_destroyed(&mut self) {
781        debug_assert_eq!(
782            next_phase(self.phase(), SurfaceEvent::Destroyed),
783            SurfacePhase::NoSurface,
784            "Destroyed must reach NoSurface"
785        );
786        // Release the in-flight frame's stash while the surface that produced
787        // it is still alive: it describes a frame of the surface that is
788        // dying, and a stale `base_color` is wrong to hand the next `submit`.
789        // Idempotent — a cleared stash clears again to nothing, which is what
790        // the repeated-`Destroyed` contract needs.
791        self.clear_pending_frame();
792        if let SurfaceState::Ready(ready) = &mut self.state {
793            match &mut ready.backend {
794                // Nothing to release ahead of time: every texture this
795                // backend holds (resource textures, the intermediate pool, its
796                // own depth attachment) dies with the renderer below, and the
797                // surface's own depth attachment — plus, on the
798                // un-premultiplying arm, its intermediate — dies with the
799                // surface beside it.
800                TierBackend::Engine { .. } => {}
801            }
802        }
803        // The same door `install_surface` enters by, which repeats the
804        // (idempotent) stash reset above and also clears the
805        // `Invalid`-reconfigure streak — behaviour-neutral here, since that
806        // streak belonged to the surface just torn down and `install_surface`
807        // resets it again.
808        self.adopt_surface_state(SurfaceState::NoSurface);
809    }
810
811    /// Encodes `scene` and presents it, clearing to `base_color`; returns the
812    /// [`FrameOutcome`] so the shell can react.
813    ///
814    /// This is a thin convenience wrapper over the two-phase seam
815    /// [`Self::encode`] + [`Self::present`]: it encodes, and — unless the frame
816    /// was skipped for want of a renderable surface — presents. A caller that
817    /// wants to attribute GPU encode cost separately from the swapchain-acquire
818    /// (vsync) wait — the render-thread-split decision hinges on that split —
819    /// calls the two entry points directly and times each with
820    /// its own clock (timing stays shell-owned; this crate reads no clock — see
821    /// `frust-shell-common::perf`'s layering note).
822    ///
823    /// In [`SurfacePhase::NoSurface`]/[`SurfacePhase::SurfaceLost`] the frame is
824    /// dropped ([`FrameOutcome::Skipped`]) — never panicking, never queueing.
825    /// On an `Outdated` acquire the surface is reconfigured and
826    /// [`FrameOutcome::Redraw`] asks the shell to try again; on `Lost` the
827    /// surface is dropped, the machine moves to [`SurfacePhase::SurfaceLost`],
828    /// and [`FrameOutcome::SurfaceLost`] tells the shell to recreate it.
829    ///
830    /// Nothing accumulates across calls: each frame re-encodes from `scene`.
831    pub fn render(
832        &mut self,
833        ctx: &RenderContext,
834        scene: &frust_scene::Scene,
835        base_color: peniko::Color,
836    ) -> Result<FrameOutcome> {
837        match self.encode(ctx, scene, base_color)? {
838            EncodeOutcome::Skipped => Ok(FrameOutcome::Skipped),
839            EncodeOutcome::Encoded => self.present(ctx),
840        }
841    }
842
843    /// Phase 1 of the frame — the **encode** span: take the frame's scene. It
844    /// does **not** touch the swapchain, so a caller timing this call in
845    /// isolation measures encode cost with no vsync wait folded in. This copies
846    /// the display list and stashes `base_color`; the GPU render moves to
847    /// [`Self::submit`] (it needs the acquired swapchain texture), so
848    /// `encode_us` is a memcpy and nothing else.
849    ///
850    /// Returns [`EncodeOutcome::Skipped`] (no work done, nothing queued) in any
851    /// phase but [`SurfacePhase::SurfaceReady`]; otherwise
852    /// [`EncodeOutcome::Encoded`], after which [`Self::present`] finishes the
853    /// frame. Nothing accumulates across frames.
854    // `ctx` is unused on this tier — all of its GPU work happens in `submit`
855    // — but the parameter stays in the public signature so a shell's call
856    // site does not have to special-case it.
857    #[allow(unused_variables)]
858    pub fn encode(
859        &mut self,
860        ctx: &RenderContext,
861        scene: &frust_scene::Scene,
862        base_color: peniko::Color,
863    ) -> Result<EncodeOutcome> {
864        // Frames are dropped in every phase but SurfaceReady.
865        if !self.phase().can_render() {
866            return Ok(EncodeOutcome::Skipped);
867        }
868        // Disjoint field borrows: the live surface and the engine arms' colour
869        // stash; `consecutive_invalid` belongs to `present`.
870        let Self {
871            state,
872            pending_base_color,
873            engine_scene,
874            ..
875        } = self;
876        let SurfaceState::Ready(ready) = state else {
877            // Unreachable: `can_render()` above guaranteed SurfaceReady.
878            return Ok(EncodeOutcome::Skipped);
879        };
880        // Reborrow through the `Box` once so `ready.backend` and `ready.surface`
881        // are disjoint field borrows of a plain `&mut ReadySurface` — the tier
882        // `match` below mutates `backend` while reading `surface`, which the
883        // borrow checker only allows on a single deref.
884        let ready: &mut ReadySurface = ready;
885
886        // Encode this frame's pixels. The engine renders in `submit`, which
887        // is why its surface is configured on one of the two engine paths
888        // (`context::choose_engine_render_path`): straight into the acquired
889        // swapchain view, or into a surface-owned intermediate the same
890        // `submit` un-premultiplies from. `RenderPath` has no other variant to
891        // wire wrong — `submit_impl`'s own exhaustive match over `&surface.path`
892        // is where a future third arm must be handled, as a compile error
893        // rather than a runtime check here.
894        // The frame's whole CPU-side encode on this arm: copy the display list
895        // somewhere that outlives the borrow, since `EngineRenderer::encode`
896        // compiles it itself in `submit`. `clone_from` reuses the buffer's
897        // capacity, so a steady-state frame allocates nothing — see the
898        // field's measurement note.
899        engine_scene.clone_from(scene);
900        // Carried to `submit`, where the render that consumes it runs.
901        *pending_base_color = Some(base_color);
902
903        Ok(EncodeOutcome::Encoded)
904    }
905
906    /// Phase 2 of the frame — the **present** span: a thin wrapper over the
907    /// two-phase [`Self::acquire`] + [`Self::submit`] seam, kept for callers
908    /// (and the [`Self::render`] convenience wrapper) that time present as one
909    /// span. It acquires the swapchain texture (the blocking vsync/present
910    /// wait) and, on success, renders/submits/presents it.
911    ///
912    /// A caller wanting the finer **acquire** (blocking vsync wait) vs
913    /// **submit** (GPU render + queue-submit + present) attribution — to
914    /// separate GPU saturation from render cost — calls
915    /// [`Self::acquire`] and [`Self::submit`] directly, timing each with its own
916    /// clock (timing stays shell-owned; this crate reads no clock — see
917    /// `frust-shell-common::perf`'s layering note). `present`'s combined span
918    /// equals `acquire` + `submit` by construction.
919    ///
920    /// Assumes [`Self::encode`] has already copied this frame's display list.
921    /// In any phase but [`SurfacePhase::SurfaceReady`] the call is a
922    /// no-op returning [`FrameOutcome::Skipped`]. On an `Outdated`
923    /// acquire the surface is reconfigured and [`FrameOutcome::Redraw`] asks the
924    /// shell to try again; on `Lost` the surface is dropped, the machine moves
925    /// to [`SurfacePhase::SurfaceLost`], and [`FrameOutcome::SurfaceLost`] tells
926    /// the shell to recreate it.
927    pub fn present(&mut self, ctx: &RenderContext) -> Result<FrameOutcome> {
928        match self.acquire(ctx)? {
929            AcquireOutcome::Acquired => self.submit(ctx),
930            AcquireOutcome::Reconfigured => Ok(FrameOutcome::Redraw),
931            AcquireOutcome::Lost => Ok(FrameOutcome::SurfaceLost),
932            AcquireOutcome::Skipped => Ok(FrameOutcome::Skipped),
933        }
934    }
935
936    /// Phase 2a of the frame — the **acquire** sub-span: acquire the swapchain
937    /// texture (the blocking vsync/present wait, per the surface's present mode),
938    /// classify the result, and — on a usable acquire — stash the
939    /// texture for [`Self::submit`] to blit into. Timing this call in isolation
940    /// attributes the blocking present/vsync wait separately from [`Self::submit`]'s
941    /// blit/queue-submit work — the split needed to separate GPU saturation
942    /// from blit cost.
943    ///
944    /// Returns [`AcquireOutcome::Acquired`] when a texture was stashed (the
945    /// caller must follow with [`Self::submit`]); otherwise a terminal outcome —
946    /// [`AcquireOutcome::Reconfigured`] (`Outdated` acquire, surface reconfigured),
947    /// [`AcquireOutcome::Lost`] (surface dropped, now `SurfaceLost`), or
948    /// [`AcquireOutcome::Skipped`] (no renderable surface or a transient failure).
949    /// In any phase but [`SurfacePhase::SurfaceReady`] it is a no-op returning
950    /// [`AcquireOutcome::Skipped`].
951    pub fn acquire(&mut self, ctx: &RenderContext) -> Result<AcquireOutcome> {
952        // Frames are dropped in every phase but SurfaceReady.
953        if !self.phase().can_render() {
954            return Ok(AcquireOutcome::Skipped);
955        }
956        // Disjoint field borrows: the live surface, the consecutive-Invalid
957        // counter, and the stash slot (the reusable scene belongs to `encode`).
958        let Self {
959            state,
960            consecutive_invalid,
961            pending_present,
962            ..
963        } = self;
964        let SurfaceState::Ready(ready) = state else {
965            // Unreachable: `can_render()` above guaranteed SurfaceReady.
966            return Ok(AcquireOutcome::Skipped);
967        };
968        let ready: &mut ReadySurface = ready;
969
970        use wgpu::CurrentSurfaceTexture as Cst;
971        let acquired = ready.surface.gpu.surface().get_current_texture();
972        let status = match &acquired {
973            Cst::Success(_) | Cst::Suboptimal(_) => AcquireStatus::Usable,
974            Cst::Outdated => AcquireStatus::Outdated,
975            Cst::Lost => AcquireStatus::Lost,
976            Cst::Timeout | Cst::Occluded => AcquireStatus::Transient,
977            Cst::Validation => AcquireStatus::Invalid,
978        };
979
980        let action = decide_acquire(status, *consecutive_invalid);
981        // Reset-on-success / increment-on-retry / reset-on-give-up (the same
982        // discipline mirrored from iOS's `recreate_failures`) — pure and
983        // unit-tested in `next_invalid_streak` itself.
984        if status == AcquireStatus::Invalid && action == AcquireAction::Lose {
985            // The cap was hit rather than a genuine `Lost` acquire: log once so
986            // the giving-up transition is visible before the streak resets.
987            log::warn!(
988                "frust-render: giving up on Invalid-acquire reconfigure after \
989                 {consecutive_invalid} consecutive attempts; surface lost"
990            );
991        }
992        *consecutive_invalid = next_invalid_streak(status, action, *consecutive_invalid);
993
994        let outcome = match action {
995            AcquireAction::Present => {
996                let surface_texture = match acquired {
997                    Cst::Success(t) | Cst::Suboptimal(t) => t,
998                    // `decide_acquire(Usable, _) == Present`, and only Success/
999                    // Suboptimal classify as Usable — so this is unreachable.
1000                    // Report rather than panic to honour the no-panic invariant.
1001                    _ => {
1002                        return Err(anyhow!("frust-render: acquire classification desync"));
1003                    }
1004                };
1005                // Stash the acquired texture for `submit`; the blocking vsync wait
1006                // ended above, so timing stops here for the acquire sub-span.
1007                *pending_present = Some(surface_texture);
1008                AcquireOutcome::Acquired
1009            }
1010            AcquireAction::Reconfigure => {
1011                ready.surface.reconfigure(&ctx.device_handle().device);
1012                AcquireOutcome::Reconfigured
1013            }
1014            AcquireAction::Lose => {
1015                debug_assert_eq!(
1016                    next_phase(SurfacePhase::SurfaceReady, SurfaceEvent::Lost),
1017                    SurfacePhase::SurfaceLost,
1018                    "Lost must reach SurfaceLost from SurfaceReady"
1019                );
1020                AcquireOutcome::Lost
1021            }
1022            AcquireAction::Skip => AcquireOutcome::Skipped,
1023        };
1024        if matches!(outcome, AcquireOutcome::Lost) {
1025            // Drop the surface and its outstanding resources before returning
1026            // so the shell can recreate cleanly — through the same door
1027            // `install_surface` and `on_surface_destroyed` use, once the field
1028            // borrows above have ended. That also drops the in-flight frame's
1029            // stash: no `submit` will ever consume it now, and a stale
1030            // `base_color` describes a surface that no longer exists. The
1031            // streak was already reset above (`next_invalid_streak`,
1032            // reset-on-give-up); the door zeroing it again is a no-op. The
1033            // shell's own recovery path (recreate on
1034            // resize/redraw/surfaceChanged) then starts a fresh episode.
1035            self.adopt_surface_state(SurfaceState::Lost);
1036        }
1037        Ok(outcome)
1038    }
1039
1040    /// Phase 2b of the frame — the **submit** sub-span: turn the swapchain
1041    /// texture [`Self::acquire`] stashed into a presented frame, then present it.
1042    /// Timing this call in isolation attributes the submit work separately from
1043    /// [`Self::acquire`]'s blocking vsync wait.
1044    ///
1045    /// What the submit span contains: the whole GPU render runs HERE,
1046    /// targeting the acquired swapchain texture (which does not exist until
1047    /// [`Self::acquire`]) or the surface's intermediate, then present. So the
1048    /// frame's GPU cost lands in `submit_us`; `encode_us` is a memcpy.
1049    ///
1050    /// **v3 wire mapping.** The `acquire_us`/`submit_us` field names and the
1051    /// wire format are unchanged (`perf.rs` is untouched). The swapchain
1052    /// **acquire** (the blocking vsync wait) happens in [`Self::acquire`] and
1053    /// is recorded in `acquire_us`, so acquire precedes the GPU render, which
1054    /// lives entirely in this span.
1055    ///
1056    /// Must follow an [`AcquireOutcome::Acquired`] result from [`Self::acquire`]
1057    /// on the same frame — it consumes the stashed texture. With nothing stashed
1058    /// (no prior `Acquired`, or the surface vanished between the two calls) it is
1059    /// a no-op returning [`FrameOutcome::Skipped`]; otherwise
1060    /// [`FrameOutcome::Rendered`].
1061    ///
1062    /// A caller that must issue the present itself, on another thread, calls
1063    /// [`Self::submit_deferred`] instead.
1064    pub fn submit(&mut self, ctx: &RenderContext) -> Result<FrameOutcome> {
1065        let (outcome, presentable) = self.submit_impl(ctx)?;
1066        if let Some(surface_texture) = presentable {
1067            ctx.device_handle().queue.present(surface_texture);
1068        }
1069        Ok(outcome)
1070    }
1071
1072    /// [`Self::submit`] with the final present step handed back to the caller
1073    /// instead of issued here: identical GPU work (render + queue-submit), but
1074    /// the acquired swapchain frame is returned as an opaque, `Send`
1075    /// [`DeferredPresent`] the caller presents on a thread of its choosing.
1076    ///
1077    /// The iOS `presentsWithTransaction` contract is why this exists — see
1078    /// [`DeferredPresent`]'s docs for the full mechanism. Everything else is
1079    /// unchanged: same outcomes, same stash discipline, and
1080    /// `Some(DeferredPresent)` accompanies exactly the
1081    /// [`FrameOutcome::Rendered`] case (every other outcome carries `None`).
1082    /// Dropping the returned handle instead of presenting it skips that frame's
1083    /// present without error.
1084    pub fn submit_deferred(
1085        &mut self,
1086        ctx: &RenderContext,
1087    ) -> Result<(FrameOutcome, Option<DeferredPresent>)> {
1088        let (outcome, presentable) = self.submit_impl(ctx)?;
1089        Ok((
1090            outcome,
1091            // The queue is read INSIDE the map, never before it: `ctx`'s device
1092            // is created lazily by surface creation, and reading it eagerly
1093            // would panic on the no-surface path that returns `None` here.
1094            presentable.map(|texture| DeferredPresent {
1095                texture,
1096                queue: ctx.device_handle().queue.clone(),
1097            }),
1098        ))
1099    }
1100
1101    /// Shared body of [`Self::submit`]/[`Self::submit_deferred`]: everything up
1102    /// to (but not including) `Queue::present`, handing the acquired frame
1103    /// back so each wrapper decides where the present happens.
1104    fn submit_impl(
1105        &mut self,
1106        ctx: &RenderContext,
1107    ) -> Result<(FrameOutcome, Option<wgpu::SurfaceTexture>)> {
1108        // Nothing acquired this frame (no prior `Acquired`): nothing to present.
1109        let Some(surface_texture) = self.pending_present.take() else {
1110            return Ok((FrameOutcome::Skipped, None));
1111        };
1112        // The stashed texture is owned, but the encoded frame (the engine's
1113        // copied scene) lives on `self` — if the surface vanished between
1114        // `acquire` and `submit`, drop the texture and skip rather than
1115        // present a stale frame.
1116        let Self {
1117            state,
1118            pending_base_color,
1119            engine_scene,
1120            ..
1121        } = self;
1122        let SurfaceState::Ready(ready) = state else {
1123            return Ok((FrameOutcome::Skipped, None));
1124        };
1125        let ReadySurface {
1126            surface, backend, ..
1127        } = &mut **ready;
1128        let device_handle = ctx.device_handle();
1129        let swapchain_view = surface_texture
1130            .texture
1131            .create_view(&wgpu::TextureViewDescriptor::default());
1132
1133        match &surface.path {
1134            // The engine tier's whole frame: one encoder, one
1135            // `EngineRenderer::encode` into the acquired swapchain view and
1136            // the surface's own depth attachment, one submit, then the
1137            // engine's end-of-frame maintenance. The renderer records into the
1138            // encoder and never submits, so every pass of the frame lands in
1139            // this one command buffer, in order, against the texture about to
1140            // be presented.
1141            RenderPath::EngineDirect { depth } => {
1142                let TierBackend::Engine {
1143                    engine,
1144                    refused_frames,
1145                    shader_quads,
1146                    timestamps,
1147                } = backend;
1148                // Set in `encode`; a well-formed frame always encoded first —
1149                // the same fallback every other arm takes.
1150                let base_color = pending_base_color.take().unwrap_or(peniko::Color::BLACK);
1151                let mut encoder =
1152                    device_handle
1153                        .device
1154                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1155                            label: Some("frust-render engine"),
1156                        });
1157                // Whatever the process registered ahead of the scene:
1158                // caller-supplied passes recorded into this SAME encoder and
1159                // binding their own targets into the engine's external-texture
1160                // registry, plus the unbind of any id whose pass was
1161                // unregistered since the last frame (see
1162                // `crate::external_pass`). Ahead of the shader quads for the
1163                // same two orderings they need, and a no-op — one lock, no
1164                // encoder work — on the process that registered none.
1165                crate::external_pass::run_external_passes(
1166                    &device_handle.device,
1167                    &device_handle.queue,
1168                    &mut encoder,
1169                    engine,
1170                );
1171                // The frame's shader quads, rendered into this SAME encoder
1172                // ahead of the scene's own passes and registered with the
1173                // engine before it compiles: a quad's target must already be
1174                // bound when the display list naming it is walked, and must be
1175                // written before the pass that samples it runs. One encoder
1176                // gives both orderings for free.
1177                shader_quads.prepare(
1178                    &device_handle.device,
1179                    &device_handle.queue,
1180                    &mut encoder,
1181                    engine_scene,
1182                    Affine::IDENTITY,
1183                    engine,
1184                );
1185                // Opens this frame's ring slot and harvests whatever an earlier
1186                // frame left mapped in it — the one place a reading becomes
1187                // visible to `gpu_pass_timings`. A no-op on an inert ring.
1188                timestamps.begin_frame(&device_handle.device);
1189                let result = engine.encode_traced(
1190                    &device_handle.device,
1191                    &device_handle.queue,
1192                    &mut encoder,
1193                    engine_scene,
1194                    frust_engine::EngineTarget {
1195                        view: &swapchain_view,
1196                        format: surface.config().format,
1197                        width: surface.config().width,
1198                        height: surface.config().height,
1199                        // The surface's own attachment, recreated with the
1200                        // swapchain (`context::RenderPath::EngineDirect`).
1201                        // Nothing else writes it, so it is not pre-cleared:
1202                        // the frame's first depth-using pass clears it.
1203                        depth: Some(depth.view()),
1204                        // The engine's strip pipelines write premultiplied
1205                        // alpha, which is what this surface's swapchain
1206                        // expects — an opaque one ignores alpha, and a
1207                        // premultiplied-expecting translucent one takes it
1208                        // as-is. A swapchain whose compositor genuinely
1209                        // stores STRAIGHT alpha is the other arm below
1210                        // (`context::choose_engine_render_path`); a Metal
1211                        // `PostMultiplied` swapchain (iOS included) lands
1212                        // HERE despite its name, because Metal's own
1213                        // compositor reads that mode premultiplied regardless
1214                        // (`context::compositor_expects_premultiplied`).
1215                        output: frust_engine::OutputAlpha::Premultiplied,
1216                    },
1217                    base_color,
1218                    // The engine arm renders at the surface's own resolution.
1219                    Affine::IDENTITY,
1220                    frust_engine::FrameTimestamps::new(timestamps),
1221                );
1222                match result {
1223                    Ok(()) => {
1224                        // Recorded into the frame's own encoder, after every
1225                        // pass that wrote a query and before the one submit —
1226                        // a resolve in a second command buffer would race the
1227                        // passes it reads.
1228                        timestamps.resolve(&mut encoder);
1229                        device_handle.queue.submit([encoder.finish()]);
1230                        timestamps.end_frame();
1231                        engine.end_frame(&device_handle.queue);
1232                    }
1233                    Err(error) => {
1234                        // A refused frame left the encoder exactly as it was
1235                        // found, so there is nothing to submit and the
1236                        // acquired texture holds no frame — presenting it
1237                        // would show undefined content. The frame is dropped
1238                        // instead (the texture goes with this `None`), and the
1239                        // refusal is counted rather than logged per vsync.
1240                        // The ring's slot is abandoned for the same reason
1241                        // nothing is presented: no pass ran, so there is no
1242                        // measurement to map.
1243                        timestamps.abandon_frame();
1244                        *refused_frames = refused_frames.saturating_add(1);
1245                        log_engine_refusal(*refused_frames, &error);
1246                        engine.end_frame(&device_handle.queue);
1247                        return Ok((FrameOutcome::Skipped, None));
1248                    }
1249                }
1250            }
1251            // The engine tier's straight-alpha arm: the same one-encoder frame
1252            // as above with one pass appended — `engine.encode` into the
1253            // surface's own intermediate, then the un-premultiplying fragment
1254            // pass from that intermediate into the acquired swapchain texture,
1255            // recorded into the SAME encoder so the conversion can never
1256            // execute against a frame that was not submitted with it. One
1257            // submit, then the engine's end-of-frame maintenance.
1258            //
1259            // The deferred-present contract is untouched: this is still
1260            // `submit_impl`, so a caller that hands the frame back
1261            // un-presented (`Self::submit_deferred`) gets a fully converted
1262            // swapchain texture to present inside its own transaction. (iOS
1263            // itself never reaches this arm — its sole translucent mode is a
1264            // Metal `PostMultiplied` swapchain, which
1265            // `context::choose_engine_render_path` now routes onto
1266            // `EngineDirect` above instead; this arm serves a genuinely
1267            // straight-alpha, non-Metal `PostMultiplied` compositor.)
1268            RenderPath::EngineDirectUnpremultiply {
1269                depth,
1270                intermediate_view,
1271                present,
1272            } => {
1273                let TierBackend::Engine {
1274                    engine,
1275                    refused_frames,
1276                    shader_quads,
1277                    timestamps,
1278                } = backend;
1279                let base_color = pending_base_color.take().unwrap_or(peniko::Color::BLACK);
1280                let mut encoder =
1281                    device_handle
1282                        .device
1283                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1284                            label: Some("frust-render engine unpremultiply"),
1285                        });
1286                // Whatever the process registered ahead of the scene:
1287                // caller-supplied passes recorded into this SAME encoder and
1288                // binding their own targets into the engine's external-texture
1289                // registry, plus the unbind of any id whose pass was
1290                // unregistered since the last frame (see
1291                // `crate::external_pass`). Ahead of the shader quads for the
1292                // same two orderings they need, and a no-op — one lock, no
1293                // encoder work — on the process that registered none.
1294                crate::external_pass::run_external_passes(
1295                    &device_handle.device,
1296                    &device_handle.queue,
1297                    &mut encoder,
1298                    engine,
1299                );
1300                // The same pre-pass the direct arm records, for the same two
1301                // orderings — the conversion pass appended after the frame
1302                // changes neither of them.
1303                shader_quads.prepare(
1304                    &device_handle.device,
1305                    &device_handle.queue,
1306                    &mut encoder,
1307                    engine_scene,
1308                    Affine::IDENTITY,
1309                    engine,
1310                );
1311                timestamps.begin_frame(&device_handle.device);
1312                let result = engine.encode_traced(
1313                    &device_handle.device,
1314                    &device_handle.queue,
1315                    &mut encoder,
1316                    engine_scene,
1317                    frust_engine::EngineTarget {
1318                        // The frame lands in the intermediate, never in the
1319                        // swapchain: what the swapchain receives is the
1320                        // conversion's output.
1321                        view: intermediate_view,
1322                        format: engine_target_format(&surface.path, surface.config().format),
1323                        width: surface.config().width,
1324                        height: surface.config().height,
1325                        depth: Some(depth.view()),
1326                        // The INTERMEDIATE's own convention, which is
1327                        // premultiplied like every other engine target — the
1328                        // strip pipelines are fixed premultiplied, so this is
1329                        // what keeps the frame's base colour in the same space
1330                        // as everything drawn over it. The SURFACE's straight
1331                        // alpha is what selected this arm
1332                        // (`UnpremultiplyPass::selected_by`) and is served by
1333                        // the pass below, not by relabelling this target.
1334                        output: frust_engine::OutputAlpha::Premultiplied,
1335                    },
1336                    base_color,
1337                    Affine::IDENTITY,
1338                    frust_engine::FrameTimestamps::new(timestamps),
1339                );
1340                match result {
1341                    Ok(()) => {
1342                        // The conversion pass itself: charged to `blit` with a
1343                        // fresh pair from the same ring `encode_traced` just
1344                        // recorded the frame's other spans into — `None` on an
1345                        // inert ring (no `perf-trace`, or the device never got
1346                        // `TIMESTAMP_QUERY`), exactly like every other pass.
1347                        present.record(
1348                            &device_handle.device,
1349                            &mut encoder,
1350                            intermediate_view,
1351                            &swapchain_view,
1352                            frust_engine::FrameTimestamps::new(timestamps)
1353                                .writes(frust_engine::EngineSpan::Blit),
1354                        );
1355                        timestamps.resolve(&mut encoder);
1356                        device_handle.queue.submit([encoder.finish()]);
1357                        timestamps.end_frame();
1358                        engine.end_frame(&device_handle.queue);
1359                    }
1360                    Err(error) => {
1361                        // Identical to the direct arm's refusal, and for the
1362                        // same reason — with one extra consequence worth
1363                        // stating: the conversion pass is NOT recorded either,
1364                        // so the intermediate's stale contents are never
1365                        // converted onto the acquired texture. Nothing is
1366                        // submitted, the frame is dropped, and the previously
1367                        // presented content persists.
1368                        timestamps.abandon_frame();
1369                        *refused_frames = refused_frames.saturating_add(1);
1370                        log_engine_refusal(*refused_frames, &error);
1371                        engine.end_frame(&device_handle.queue);
1372                        return Ok((FrameOutcome::Skipped, None));
1373                    }
1374                }
1375            }
1376        }
1377        Ok((FrameOutcome::Rendered, Some(surface_texture)))
1378    }
1379}
1380
1381/// The target format a surface's [`frust_engine::EngineRenderer`] must be
1382/// warmed for, given the arm it was configured on and the format its swapchain
1383/// reports.
1384///
1385/// The engine warms its strip pipelines for exactly one colour format, and a
1386/// pipeline whose colour target disagrees with its attachment is a validation
1387/// error rather than a mis-render. On [`RenderPath::EngineDirect`] the frame's
1388/// attachment IS the swapchain, so that is the format; on
1389/// [`RenderPath::EngineDirectUnpremultiply`] the frame's attachment is the
1390/// intermediate instead, whose format is the engine's own off-screen one
1391/// (`context::create_engine_intermediate` creates it with exactly this), and
1392/// only the conversion pass speaks the swapchain's format.
1393///
1394/// One answer for both the install site and the resize-time drift check
1395/// below, so the two cannot disagree about which format this surface's
1396/// renderer was built for.
1397fn engine_target_format(
1398    path: &RenderPath,
1399    surface_format: wgpu::TextureFormat,
1400) -> wgpu::TextureFormat {
1401    match path {
1402        RenderPath::EngineDirectUnpremultiply { .. } => {
1403            frust_engine::gpu::pipelines::INTERMEDIATE_FORMAT
1404        }
1405        RenderPath::EngineDirect { .. } => surface_format,
1406    }
1407}
1408
1409/// Reports the `count`-th frame this surface's engine renderer refused, under
1410/// [`frust_gpu::context::decide_log_action`]'s latch.
1411///
1412/// A refusal is a per-frame event on a path that can reproduce every vsync — a
1413/// scheduler escalation on a layer shape the engine does not serve, or a
1414/// capacity ceiling a busy frame keeps hitting — so logging each one would
1415/// bury the log without adding information after the first few. The latch is
1416/// the one the uncaptured-`wgpu`-error handler already uses: the first few
1417/// refusals are logged in full, the latch is announced once naming the running
1418/// total, and the periodic debug bump keeps the counter visible afterwards.
1419/// Every line carries the count, so a capture read later says how many frames
1420/// were lost, not merely that some were.
1421fn log_engine_refusal(count: u32, error: &frust_engine::EngineError) {
1422    match frust_gpu::context::decide_log_action(count) {
1423        frust_gpu::context::LogAction::Log => {
1424            log::warn!(
1425                "frust-render: engine refused frame {count} on this surface — {error}; the \
1426                 frame is dropped rather than presented"
1427            );
1428        }
1429        frust_gpu::context::LogAction::SuppressionNotice => {
1430            log::warn!(
1431                "frust-render: further engine frame refusals suppressed (total so far: {count})"
1432            );
1433        }
1434        frust_gpu::context::LogAction::Silent { debug_bump } => {
1435            if debug_bump {
1436                log::debug!(
1437                    "frust-render: engine frame refusals now {count} on this surface (still \
1438                     suppressed)"
1439                );
1440            }
1441        }
1442    }
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use super::*;
1448
1449    #[test]
1450    fn starts_with_no_surface() {
1451        let renderer = SurfaceRenderer::new();
1452        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1453    }
1454
1455    /// One surface episode ending drops everything scoped to it: the
1456    /// `Invalid`-reconfigure streak and the stashed base colour, which
1457    /// described a frame of the surface being replaced.
1458    ///
1459    /// Asserted through `adopt_surface_state` itself — the single door
1460    /// `install_surface` and `on_surface_destroyed` both go through — rather
1461    /// than by re-typing their reset statements here. A previous version of
1462    /// this test did the latter and would have stayed green if either caller
1463    /// stopped resetting anything at all.
1464    #[test]
1465    fn adopting_a_surface_state_drops_the_previous_frames_stashes() {
1466        let mut renderer = SurfaceRenderer::new();
1467        renderer.pending_base_color = Some(peniko::Color::WHITE);
1468        renderer.consecutive_invalid = 3;
1469
1470        renderer.adopt_surface_state(SurfaceState::NoSurface);
1471
1472        assert!(renderer.pending_base_color.is_none());
1473        assert_eq!(renderer.consecutive_invalid, 0);
1474        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1475        // Idempotent, as the repeated-`Destroyed` contract needs.
1476        renderer.adopt_surface_state(SurfaceState::NoSurface);
1477        assert!(renderer.pending_base_color.is_none());
1478        assert_eq!(renderer.consecutive_invalid, 0);
1479    }
1480
1481    /// The give-up arm of `acquire` leaves through the same door: adopting
1482    /// [`SurfaceState::Lost`] drops the in-flight stash, so a surface that
1483    /// went away mid-frame cannot hand its base colour to the next one.
1484    #[test]
1485    fn adopting_the_lost_state_drops_the_dying_surfaces_stashes() {
1486        let mut renderer = SurfaceRenderer::new();
1487        renderer.pending_base_color = Some(peniko::Color::WHITE);
1488
1489        renderer.adopt_surface_state(SurfaceState::Lost);
1490
1491        assert_eq!(renderer.phase(), SurfacePhase::SurfaceLost);
1492        assert!(
1493            renderer.pending_base_color.is_none(),
1494            "a lost surface's base colour must not outlive it"
1495        );
1496    }
1497
1498    #[test]
1499    fn resolved_translucent_is_false_without_a_live_surface() {
1500        // The Mode A default: with no surface installed
1501        // there is nothing proven translucent, so a shell reading this before
1502        // its first install keeps the opaque paint contract rather than
1503        // punching holes it can't back.
1504        let renderer = SurfaceRenderer::new();
1505        assert!(!renderer.surface_resolved_translucent());
1506    }
1507
1508    #[test]
1509    fn deferred_present_is_send() {
1510        // The whole point of `DeferredPresent` is crossing a thread boundary
1511        // (render thread → the thread committing the CATransaction), so pin the
1512        // auto-trait: losing it would break `frust-shell-ios`'s present-sync
1513        // handoff at a distance, in a crate that can't see this type's fields.
1514        fn assert_send<T: Send>() {}
1515        assert_send::<DeferredPresent>();
1516    }
1517
1518    #[test]
1519    fn submit_deferred_yields_no_frame_without_a_surface() {
1520        // No GPU needed: with nothing acquired (no surface at all) the deferred
1521        // submit skips exactly like `submit`, and hands back no present handle —
1522        // `Some(..)` accompanies only `Rendered`.
1523        let ctx = RenderContext::new();
1524        let mut renderer = SurfaceRenderer::new();
1525
1526        let (outcome, deferred) = renderer
1527            .submit_deferred(&ctx)
1528            .expect("submit_deferred in NoSurface must not error");
1529        assert_eq!(outcome, FrameOutcome::Skipped);
1530        assert!(deferred.is_none());
1531    }
1532
1533    #[test]
1534    fn render_is_skipped_without_a_surface() {
1535        // No GPU needed: a `NoSurface` renderer short-circuits before any wgpu
1536        // work. `RenderContext::new()` only builds a wgpu `Instance` (no device).
1537        let ctx = RenderContext::new();
1538        let mut renderer = SurfaceRenderer::new();
1539        let scene = frust_scene::Scene::new();
1540
1541        let outcome = renderer
1542            .render(&ctx, &scene, peniko::Color::WHITE)
1543            .expect("render in NoSurface must not error");
1544        assert_eq!(outcome, FrameOutcome::Skipped);
1545    }
1546
1547    /// Destroy is idempotent, reaches `NoSurface`, and drops the in-flight
1548    /// stash: a stale `base_color` describes the dead surface's frame, not
1549    /// the next one's.
1550    #[test]
1551    fn destroy_is_idempotent_and_resets_to_no_surface() {
1552        let mut renderer = SurfaceRenderer::new();
1553        renderer.pending_base_color = Some(peniko::Color::WHITE);
1554
1555        renderer.on_surface_destroyed();
1556        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1557        assert!(
1558            renderer.pending_base_color.is_none(),
1559            "the dying surface's base colour must not reach the next submit"
1560        );
1561
1562        renderer.on_surface_destroyed();
1563        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1564        assert!(renderer.pending_base_color.is_none());
1565    }
1566
1567    #[test]
1568    fn resize_without_surface_is_a_no_op() {
1569        let ctx = RenderContext::new();
1570        let mut renderer = SurfaceRenderer::new();
1571        renderer.on_surface_changed(&ctx, 800, 600);
1572        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1573    }
1574
1575    #[test]
1576    fn pipeline_cache_data_is_none_without_a_surface() {
1577        // No GPU needed: with no installed surface there is no live cache to
1578        // read back, regardless of whether a blob was restored.
1579        let mut renderer = SurfaceRenderer::new();
1580        assert_eq!(renderer.pipeline_cache_data(), None);
1581        renderer.set_initial_pipeline_cache_data(Some(vec![1, 2, 3, 4]));
1582        assert_eq!(renderer.pipeline_cache_data(), None);
1583    }
1584
1585    #[test]
1586    fn set_initial_pipeline_cache_data_none_clears_the_blob() {
1587        // Setter is total and side-effect-free without a surface; clearing to
1588        // `None` (a cold start) is a no-op on the observable `NoSurface` state.
1589        let mut renderer = SurfaceRenderer::new();
1590        renderer.set_initial_pipeline_cache_data(Some(vec![9, 9, 9]));
1591        renderer.set_initial_pipeline_cache_data(None);
1592        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
1593        assert_eq!(renderer.pipeline_cache_data(), None);
1594    }
1595
1596    /// The engine arm's real-device acceptance, headless.
1597    ///
1598    /// A windowed run is the arm's own acceptance and cannot happen on a box
1599    /// with no display server, so this drives the exact `EngineTarget` the
1600    /// engine arm of [`SurfaceRenderer::submit`] builds — the surface's
1601    /// reported format at `RENDER_ATTACHMENT`, the surface-owned `Depth24Plus`
1602    /// attachment, premultiplied output, the identity root — against an
1603    /// offscreen target of the same shape, under a `wgpu` validation error
1604    /// scope. What it proves is what the windowed run would: this seam's
1605    /// target construction is accepted by a real device and produces the
1606    /// frame's pixels. What it cannot prove is the swapchain half (acquire,
1607    /// present, alpha-mode compositing), which stays owed to a display.
1608    ///
1609    /// Run over BOTH surface formats, since which one a swapchain reports
1610    /// first is the platform's business and the engine warms its pipelines for
1611    /// exactly the one it is handed.
1612    #[test]
1613    #[ignore = "requires a GPU; run locally with `cargo test -p frust-render -- --ignored`"]
1614    fn engine_arm_records_a_frame_into_its_target_without_validation_errors() {
1615        /// Serializes every test in this binary that creates a GPU device, the
1616        /// same guard the workspace's other GPU suites take: the NVIDIA Vulkan
1617        /// driver serializes `vkDestroyDevice` against other Vulkan work on a
1618        /// process-global mutex, and two tests tearing devices down at once
1619        /// have deadlocked inside it. Poison is ignored deliberately — one
1620        /// test's failure must not cascade into its siblings.
1621        static RENDER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1622        let _serialized = RENDER_LOCK
1623            .lock()
1624            .unwrap_or_else(std::sync::PoisonError::into_inner);
1625
1626        const SIZE: u32 = 64;
1627
1628        let (device, queue, caps) = pollster::block_on(async {
1629            let instance = wgpu::Instance::new(
1630                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1631            );
1632            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
1633            // the GPU on a multi-adapter host instead of the run silently
1634            // landing on whichever one enumerates first.
1635            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
1636                .await
1637                .expect("no compatible GPU adapter");
1638            println!("frust-render engine arm adapter: {:?}", adapter.get_info());
1639            let caps = frust_gpu::TierCaps::probe(&adapter);
1640            // The limits production asks for, not the defaults, so the target
1641            // this test builds is sized against the same ceiling
1642            // `context::extent_within_limits` refuses on.
1643            let limits = crate::context::effective_limits(
1644                adapter.limits(),
1645                crate::context::is_ios_simulator(),
1646            );
1647            let (device, queue) = adapter
1648                .request_device(&wgpu::DeviceDescriptor {
1649                    label: Some("frust-render engine arm test"),
1650                    required_features: wgpu::Features::empty(),
1651                    required_limits: limits,
1652                    ..Default::default()
1653                })
1654                .await
1655                .expect("failed to create device");
1656            (device, queue, caps)
1657        });
1658
1659        let mut scene = frust_scene::Scene::new();
1660        {
1661            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
1662            builder.fill_rect(
1663                kurbo::Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64),
1664                peniko::Brush::Solid(peniko::color::palette::css::RED),
1665            );
1666        }
1667
1668        for format in frust_gpu::SURFACE_FORMATS {
1669            let target = frust_gpu::HeadlessTarget::new(&device, SIZE, SIZE, format);
1670            let depth = frust_engine::DepthTexture::new(&device, SIZE, SIZE);
1671            let mut engine = frust_engine::EngineRenderer::new(&device, &caps, format, None)
1672                .expect("failed to create the engine renderer");
1673
1674            let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
1675            let mut encoder =
1676                device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1677            engine
1678                .encode(
1679                    &device,
1680                    &queue,
1681                    &mut encoder,
1682                    &scene,
1683                    frust_engine::EngineTarget {
1684                        view: target.view(),
1685                        format,
1686                        width: SIZE,
1687                        height: SIZE,
1688                        depth: Some(depth.view()),
1689                        output: frust_engine::OutputAlpha::Premultiplied,
1690                    },
1691                    peniko::Color::BLACK,
1692                    Affine::IDENTITY,
1693                )
1694                .expect("the engine refused a plain opaque frame");
1695            queue.submit([encoder.finish()]);
1696            engine.end_frame(&queue);
1697
1698            // Pop the scope by polling the device, the shape every GPU suite
1699            // in this workspace uses: the pop resolves only once the queue has
1700            // been pumped.
1701            let error = {
1702                use std::task::{Context, Poll, Waker};
1703                let waker = Waker::noop();
1704                let mut cx = Context::from_waker(waker);
1705                let mut pop = std::pin::pin!(scope.pop());
1706                loop {
1707                    match pop.as_mut().poll(&mut cx) {
1708                        Poll::Ready(error) => break error,
1709                        Poll::Pending => {
1710                            let _ = device.poll(wgpu::PollType::wait_indefinitely());
1711                        }
1712                    }
1713                }
1714            };
1715            assert!(
1716                error.is_none(),
1717                "the engine arm's {format:?} target raised a validation error: {error:?}"
1718            );
1719
1720            let pixels = target.read_back(&device, &queue);
1721            let index = (((SIZE / 2) * SIZE + (SIZE / 2)) * 4) as usize;
1722            let centre: [u8; 4] = pixels[index..index + 4]
1723                .try_into()
1724                .expect("a read-back row holds four bytes per pixel");
1725            // Channel order differs between the two formats, so the assertion
1726            // that holds for both is "opaque, and not the black it was cleared
1727            // to" — the red rect reached the target.
1728            assert_eq!(centre[3], 255, "{format:?}: the frame is not opaque");
1729            assert!(
1730                centre[0] != 0 || centre[1] != 0 || centre[2] != 0,
1731                "{format:?}: the target still holds its clear colour ({centre:?})"
1732            );
1733        }
1734    }
1735}