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;
36use core::ops::Range;
37
38use anyhow::{Result, anyhow};
39
40use std::sync::OnceLock;
41use std::time::{Duration, Instant};
42
43use kurbo::Affine;
44
45use crate::context::{EngineSurface, RenderPath};
46use crate::overlay::{
47    CutServiceability, OverlayCut, OverlayCutOutcome, SegmentedOutcome, SegmentedPresents,
48};
49use frust_gpu::lifecycle::{
50    AcquireAction, AcquireOutcome, AcquireStatus, EncodeOutcome, FrameOutcome, SurfaceEvent,
51    SurfacePhase, decide_acquire, next_invalid_streak, next_phase,
52};
53use frust_gpu::{DetachedSurface, RenderContext, SurfaceAlphaRequest};
54
55/// The live GPU resources of a [`SurfacePhase::SurfaceReady`] surface.
56///
57/// The tier backend is created per surface (it is device-bound) and, with the
58/// `RenderSurface`, is dropped on every transition out of `SurfaceReady`,
59/// upholding the "no `SurfaceTexture`/surface outlives a transition" invariant.
60struct ReadySurface {
61    surface: EngineSurface,
62    /// The tier-specific renderer that produces this frame's pixels — on the
63    /// engine arms it targets the acquired swapchain texture in `submit`.
64    backend: TierBackend,
65    /// The device's persisted `wgpu::PipelineCache` (handed to the engine's
66    /// pipeline build) paired with the adapter fingerprint
67    /// its data is framed under, so [`SurfaceRenderer::pipeline_cache_data`] can
68    /// hand back a validatable blob. `None` on adapters without
69    /// `PIPELINE_CACHE` (Metal/DX12) — see
70    /// [`frust_gpu::RenderContext::create_pipeline_cache`].
71    pipeline_cache: Option<(wgpu::PipelineCache, String)>,
72    /// Whether this surface's first frame has yet to meet the engine's
73    /// pipeline warm-up through [`SurfaceRenderer`]'s [`FirstFrameWarmUp`]
74    /// strategy. Set by every install — each one builds a fresh engine and
75    /// starts a fresh warm-up — and cleared by the first encode of that frame.
76    warm_up_pending: bool,
77}
78
79/// How the first frame after a surface install meets the pipeline warm-up the
80/// install started, chosen by [`FIRST_FRAME_WARM_UP_VAR`].
81///
82/// The install queues every warm-up variant on the engine's background worker
83/// and returns at once, so the first frame usually arrives while that worker
84/// is still compiling. Whatever the strategy, nothing is compiled twice and no
85/// frame is encoded or submitted for the warm-up's sake: the strategies differ
86/// only in how much of the list must exist before the first frame records, and
87/// on which thread the rest is built.
88///
89/// [`Self::Steal`] is the default because it is the fastest first frame by a
90/// wide margin: the other two make the first frame pay for the whole list,
91/// and building it on two threads ([`Self::Finish`]) measured no faster than
92/// on the worker alone ([`Self::Await`]) — every build goes through the one
93/// driver pipeline cache. On a Pixel 4a (Adreno 618) the first frame's
94/// pipeline wait was about 15 ms stealing against 75 ms for either of the
95/// other two with the driver cache seeded, and about 180 ms against 410 ms
96/// without one. The other two remain for a host whose first frame is followed
97/// at once by frames that need the whole list.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) enum FirstFrameWarmUp {
100    /// Settle nothing up front: the frame requests the variants it draws,
101    /// stealing a queued one and waiting for one in flight, and the worker
102    /// builds the rest after it.
103    Steal,
104    /// Wait — up to [`FIRST_FRAME_WARM_UP_TIMEOUT`] — for the worker to build
105    /// the whole list, compiling nothing on the render thread.
106    Await,
107    /// Request the whole list on the render thread
108    /// (`frust_engine::EngineRenderer::finish_warm_up`): queued variants are
109    /// built here while the worker builds others, in-flight ones are awaited.
110    Finish,
111}
112
113/// Selects the [`FirstFrameWarmUp`] strategy: `steal`, `await` or `finish`
114/// (any case), from the compile-time `--define` or the runtime environment —
115/// a non-empty runtime value wins. Unset, empty or unrecognised means
116/// [`FirstFrameWarmUp::DEFAULT`].
117pub(crate) const FIRST_FRAME_WARM_UP_VAR: &str = "FRUST_FIRST_FRAME_WARM_UP";
118
119/// The longest [`FirstFrameWarmUp::Await`] holds the first frame back. Far
120/// above any healthy device's whole warm-up; a driver that has not finished by
121/// then lets the frame steal what it still needs instead of stalling it.
122pub(crate) const FIRST_FRAME_WARM_UP_TIMEOUT: Duration = Duration::from_secs(1);
123
124impl FirstFrameWarmUp {
125    /// The strategy a build without [`FIRST_FRAME_WARM_UP_VAR`] runs.
126    pub(crate) const DEFAULT: Self = Self::Steal;
127
128    /// The strategy the process configuration selects.
129    pub(crate) fn from_config() -> Self {
130        Self::parse(
131            option_env!("FRUST_FIRST_FRAME_WARM_UP"),
132            std::env::var(FIRST_FRAME_WARM_UP_VAR).ok().as_deref(),
133        )
134    }
135
136    /// The pure decision [`Self::from_config`] makes from the compile-time and
137    /// runtime values.
138    pub(crate) fn parse(compile_time: Option<&str>, runtime: Option<&str>) -> Self {
139        let chosen = runtime
140            .filter(|value| !value.is_empty())
141            .or(compile_time.filter(|value| !value.is_empty()));
142        match chosen {
143            None => Self::DEFAULT,
144            Some(value) if value.eq_ignore_ascii_case("steal") => Self::Steal,
145            Some(value) if value.eq_ignore_ascii_case("await") => Self::Await,
146            Some(value) if value.eq_ignore_ascii_case("finish") => Self::Finish,
147            Some(value) => {
148                log::warn!(
149                    "frust-render: unknown {FIRST_FRAME_WARM_UP_VAR}={value:?} (expected steal, \
150                     await or finish); using {}",
151                    Self::DEFAULT.name()
152                );
153                Self::DEFAULT
154            }
155        }
156    }
157
158    /// The value [`FIRST_FRAME_WARM_UP_VAR`] names this strategy by.
159    pub(crate) const fn name(self) -> &'static str {
160        match self {
161            Self::Steal => "steal",
162            Self::Await => "await",
163            Self::Finish => "finish",
164        }
165    }
166}
167
168/// Applies `strategy` to `engine` before the first frame after an install
169/// records, once per surface: `pending` is that surface's
170/// [`ReadySurface::warm_up_pending`], cleared here. Encodes and submits
171/// nothing. The strategy is logged once per process, with what it left.
172fn settle_first_frame_warm_up(
173    pending: &mut bool,
174    strategy: FirstFrameWarmUp,
175    engine: &mut frust_engine::EngineRenderer,
176    device: &wgpu::Device,
177) {
178    if !std::mem::take(pending) {
179        return;
180    }
181    let done = match strategy {
182        FirstFrameWarmUp::Steal => engine.warm_up_done(),
183        FirstFrameWarmUp::Await => engine.wait_warm_up(FIRST_FRAME_WARM_UP_TIMEOUT),
184        FirstFrameWarmUp::Finish => {
185            engine.finish_warm_up(device);
186            engine.warm_up_done()
187        }
188    };
189    static STRATEGY_LOGGED: OnceLock<()> = OnceLock::new();
190    STRATEGY_LOGGED.get_or_init(|| {
191        log::info!(
192            "frust-render first-frame warm-up: strategy={} (warm-up done before the first \
193             frame: {done}, {} pipelines compiled)",
194            strategy.name(),
195            engine.compiled_pipelines()
196        );
197    });
198}
199
200/// The per-surface renderer.
201///
202/// The engine variant records into the acquired swapchain view (or, on the
203/// un-premultiplying arm, into the surface's intermediate) in
204/// [`SurfaceRenderer::submit`]. A one-variant enum — the experimental
205/// `vello_cpu`-backed `Cpu` variant and its `cpu-tier` feature were retired
206/// alongside the vello-classic renderer's own removal, and the cargo feature
207/// that once gated this one went with the choice it described.
208// The renderer is boxed: the whole `ReadySurface` already lives behind a
209// `Box` (`SurfaceState::Ready`), so nothing on the hot path pays for its
210// size.
211enum TierBackend {
212    /// The frust-owned engine path: a
213    /// `frust_scene::Scene` compiled into sparse strips and recorded into the
214    /// frame's own `wgpu::CommandEncoder` by
215    /// [`frust_engine::EngineRenderer`], which never submits — the encoder is
216    /// created and submitted in [`SurfaceRenderer::submit`].
217    Engine {
218        engine: Box<frust_engine::EngineRenderer>,
219        /// How many of this surface's frames the engine has refused so far
220        /// (a scheduler escalation, an unserveable frame, a capacity ceiling).
221        ///
222        /// Counted rather than logged per frame: a refusal that reproduces
223        /// every frame would otherwise flood the log with one identical line
224        /// per vsync. The count feeds [`frust_gpu::context::decide_log_action`] —
225        /// the same latch the uncaptured-`wgpu`-error handler uses — so the
226        /// first few refusals are reported in full, the latch is announced
227        /// once, and the running total keeps surfacing on the periodic debug
228        /// bump afterwards.
229        refused_frames: u32,
230        /// The frame's fragment-shader pre-pass: every
231        /// `frust_scene::Command::ShaderQuad` this surface draws, rendered
232        /// into a pooled offscreen target and registered with `engine` as a
233        /// scene texture before the frame that samples it is compiled.
234        ///
235        /// Per surface, beside the renderer whose registry it writes and
236        /// seeded from the same persisted `wgpu::PipelineCache`: its targets
237        /// and compiled user programs are device-bound, so they die with the
238        /// surface exactly as the engine's own resources do.
239        shader_quads: Box<frust_engine::ShaderQuadPass>,
240        /// This surface's GPU timestamp ring — real per-pass GPU time, rather
241        /// than the CPU wall-clock spans around `encode`/`submit`.
242        ///
243        /// Created for every engine surface and *inert* unless the device was
244        /// created with `wgpu::Features::TIMESTAMP_QUERY`, which is the only
245        /// thing that decides whether a frame line reports `gpu_q=1`. Inert
246        /// costs one empty `Vec` and a branch per pass — no query set, no
247        /// buffers, no map — so there is nothing to feature-gate at this
248        /// level.
249        timestamps: frust_gpu::diag::TimestampRing,
250    },
251}
252
253/// One frame's real GPU time, split by the spans `frust-engine` names
254/// ([`frust_engine::EngineSpan`]).
255///
256/// The value [`SurfaceRenderer::gpu_pass_timings`] hands a shell so it can put
257/// GPU cost on the frame line beside the CPU spans it measured itself. Plain
258/// `Duration`s — no `wgpu` type crosses this crate's boundary
259/// (`docs/CODE_STANDARDS.md`'s wgpu-leak anti-pattern).
260///
261/// [`Self::total`] is the sum of the four, which is the frame's *attributed*
262/// GPU pass time: the queue and driver gaps between passes belong to no pass
263/// and are deliberately not folded in.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub struct GpuPassTimings {
266    /// Work recorded ahead of the frame's own passes — the glyph-atlas replay.
267    pub prepass: Duration,
268    /// The frame's own surface passes: clear, opaque strips, alpha strips and
269    /// the hole punch.
270    pub main: Duration,
271    /// Off-screen layer pages and filter passes.
272    pub composite: Duration,
273    /// The present-side conversion pass a straight-alpha swapchain needs.
274    pub blit: Duration,
275}
276
277impl GpuPassTimings {
278    /// The sum of every span.
279    #[must_use]
280    pub fn total(&self) -> Duration {
281        self.prepass
282            .saturating_add(self.main)
283            .saturating_add(self.composite)
284            .saturating_add(self.blit)
285    }
286}
287
288/// The surface half of the lifecycle machine, parallel to [`SurfacePhase`].
289///
290/// `Ready` is boxed: the live GPU resources dwarf the empty variants, and
291/// boxing keeps the common `NoSurface`/`Lost` states cheap to move.
292enum SurfaceState {
293    NoSurface,
294    Ready(Box<ReadySurface>),
295    Lost,
296}
297
298/// A frame that has been rendered and queue-submitted but **not yet
299/// presented** — the deferred half of [`SurfaceRenderer::submit_deferred`].
300///
301/// Opaque by design: it wraps a `wgpu::SurfaceTexture` (and the
302/// `wgpu::Queue` that presents it) behind private fields with no accessor,
303/// exactly like [`DetachedSurface`](crate::DetachedSurface), so no `wgpu` type
304/// is nameable outside this crate (`docs/CODE_STANDARDS.md`'s wgpu-leak
305/// anti-pattern). It is `Send` — a `wgpu::SurfaceTexture` owns its swapchain
306/// frame and borrows nothing, and a `wgpu::Queue` is a cheap `Send + Sync`
307/// handle — which is the whole point: a shell can hand it from the render
308/// thread to the thread that must issue the present.
309///
310/// **Why deferring the present is a contract, not a micro-optimisation.** On
311/// iOS, a `CAMetalLayer` with `presentsWithTransaction = true` requires
312/// `[drawable present]` to run on the thread committing the `CATransaction`
313/// that carries the sibling views' geometry. wgpu-hal's Metal present already
314/// implements exactly that shape when the layer has the flag set (submit a
315/// present command buffer, `waitUntilScheduled`, then `drawable.present()`) —
316/// it simply runs it on whichever thread calls [`Self::present`]. Under the
317/// render-thread split that thread commits no transaction, so the drawable is
318/// never handed to the compositor at all (measured: total loss of presentation).
319/// Handing this value to the UI thread and
320/// presenting *there* is what lets frust's surface land in the same transaction
321/// as the platform-view geometry while the split stays on.
322///
323/// Dropping one without presenting is safe and deliberate: the drawable returns
324/// to the layer's pool un-presented and that frame is simply skipped — the
325/// depth-1 latest-wins discipline (a newer frame supersedes an un-presented
326/// older one rather than blocking on it).
327pub struct DeferredPresent {
328    texture: wgpu::SurfaceTexture,
329    /// The queue the present is scheduled on. Carried because wgpu 30 moved
330    /// the present from `SurfaceTexture::present(self)` onto
331    /// `Queue::present(texture)`, and this handle outlives the `RenderContext`
332    /// borrow that produced it.
333    queue: wgpu::Queue,
334}
335
336impl std::fmt::Debug for DeferredPresent {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_struct("DeferredPresent").finish_non_exhaustive()
339    }
340}
341
342impl DeferredPresent {
343    /// Wraps an acquired, submitted, un-presented frame — the overlay spike's
344    /// way of handing its frame out beside the main one
345    /// ([`crate::overlay::OverlaySurface::take_deferred_present`]).
346    pub(crate) fn new(texture: wgpu::SurfaceTexture, queue: wgpu::Queue) -> Self {
347        Self { texture, queue }
348    }
349
350    /// Present the deferred frame **on the calling thread**.
351    ///
352    /// Consumes the handle, so a frame can never be presented twice. With
353    /// `presentsWithTransaction` set on the target `CAMetalLayer` this is the
354    /// call that must run on the transaction-committing (UI) thread; on every
355    /// other platform/configuration it is an ordinary present that happens to
356    /// have been moved off the submit site.
357    pub fn present(self) {
358        self.queue.present(self.texture);
359    }
360}
361
362/// Per-surface renderer and lifecycle state machine.
363///
364/// Holds the current surface state plus the cross-call stashes one frame
365/// needs. Constructed empty with [`SurfaceRenderer::new`]; the shell brings it
366/// online with [`on_surface_created`](Self::on_surface_created).
367pub struct SurfaceRenderer {
368    state: SurfaceState,
369    /// Consecutive `AcquireStatus::Invalid` acquires retried via `Reconfigure`
370    /// since the last successful acquire or surface (re)install — see
371    /// `frust_gpu::lifecycle`'s `decide_acquire`/`MAX_INVALID_RECONFIGURES`.
372    /// Reset on a successful acquire, on giving up (transitioning to
373    /// `SurfaceLost`), and on installing a fresh surface.
374    consecutive_invalid: u8,
375    /// Persisted, framed `wgpu::PipelineCache` blob (see
376    /// [`frust_gpu::pipeline_cache`]) a prior run wrote and the shell restored from
377    /// disk via [`set_initial_pipeline_cache_data`](Self::set_initial_pipeline_cache_data),
378    /// consumed at the next surface install to seed the engine's shader-pipeline
379    /// compilation. `None` is a cold start. Only meaningful on adapters with
380    /// `PIPELINE_CACHE` (any Vulkan adapter); validated and discarded elsewhere.
381    initial_cache_data: Option<Vec<u8>>,
382    /// Sub-step durations of the first surface install, until a shell takes them.
383    install_timings: Option<InstallTimings>,
384    /// Whether the first install has been measured (later installs are not).
385    install_timings_seen: bool,
386    /// The swapchain texture [`Self::acquire`] acquired and stashed for
387    /// [`Self::submit`] to render into and present (the acquire/submit span split).
388    /// `None` outside an in-flight `acquire`→`submit` pair — set only on an
389    /// [`AcquireOutcome::Acquired`] result, taken by the next [`Self::submit`].
390    /// A `wgpu::SurfaceTexture` is owned (it does not borrow the surface), so
391    /// stashing it here between the two clock-separated calls keeps the wgpu type
392    /// confined to `frust-render` while letting a shell time each span with its
393    /// own clock (timing stays shell-owned — see `frust-shell-common::perf`).
394    pending_present: Option<wgpu::SurfaceTexture>,
395    /// The `base_color` [`Self::encode`] was called with, stashed since the
396    /// render that consumes `base_color` runs in [`Self::submit`] (it targets
397    /// the acquired swapchain texture, which does not exist until
398    /// [`Self::acquire`]), so the color must survive the gap between `encode`
399    /// and `submit`. `None` outside an in-flight engine frame.
400    pending_base_color: Option<peniko::Color>,
401    /// The engine tier's owned copy of the frame's scene — its counterpart to
402    /// [`Self::scene`], and reused across frames for the same reason.
403    ///
404    /// `frust_engine::EngineRenderer::encode` needs the GPU resources only
405    /// [`Self::submit`] has (the acquired swapchain view), so the borrowed
406    /// `&Scene` [`Self::encode`] is handed has to survive the gap between the
407    /// two calls. `clone_from` reuses this buffer's capacity, so a
408    /// steady-state frame allocates nothing.
409    ///
410    /// **Measurement note.** The same span caveat applies: on this tier
411    /// `encode_us` holds a memcpy and nothing else — the whole scene compile
412    /// (strip building, paint encoding) plus command recording lands in
413    /// `submit_us`. Compare whole frames across tiers, never `encode_us`
414    /// against `encode_us`.
415    engine_scene: frust_scene::Scene,
416    /// The first rendered frame's submit decomposition, until the shell takes
417    /// it (see [`Self::take_first_submit_breakdown`]).
418    #[cfg(feature = "perf-trace")]
419    first_submit: Option<FirstSubmitBreakdown>,
420    /// Whether a first rendered frame has been measured already.
421    #[cfg(feature = "perf-trace")]
422    first_submit_seen: bool,
423    /// What [`Self::take_retained_ledger`] last handed out: the engine's
424    /// retained generation, or `Some(None)` for the empty ledger of a renderer
425    /// with no engine. `None` until the first take and again at every surface
426    /// episode's start or end, so the next take always publishes.
427    retained_published: Option<Option<u64>>,
428    /// The paint serial the shell named for the next scene
429    /// ([`Self::note_scene_serial`]); `0` until one is noted.
430    scene_serial: u64,
431    /// The scene [`Self::encode`] took last, until a `submit` commits or
432    /// drops it: its paint serial, and whether it records a retained page.
433    /// `None` outside an in-flight frame.
434    pending_serial: Option<(u64, bool)>,
435    /// The paint serial of the last scene a `submit` committed — the
436    /// [`ResidentPages::frame`] every published ledger carries. A refused or
437    /// skipped frame does not advance it; it outlives surface episodes.
438    committed_serial: u64,
439    /// Whether a scene recording a retained page was committed since the
440    /// ledger was last handed out: the UI waits for a ledger covering that
441    /// scene before drawing the page, so the next
442    /// [`Self::take_retained_ledger`] publishes even when the store itself did
443    /// not change (a page re-recorded at the same extent).
444    recording_committed: bool,
445    /// The device scale (physical / logical) last handed to
446    /// [`Self::note_device_scale`], kept as raw bits so a change is an exact
447    /// comparison. Outlives surface episodes: a recreate at the same scale is
448    /// not a scale change (the fresh engine's store is empty anyway).
449    device_scale: Option<u64>,
450    /// Whether [`Self::encode`] hands the overlay spike's range to an overlay
451    /// and keeps only the commands before the cut for this surface (see
452    /// [`Self::set_overlay_split`]). Never set unless the spike is on, so off
453    /// it the frame is the scene, copied whole, exactly as before.
454    overlay_split: bool,
455    /// The whole scene a segmented encode ([`Self::encode_segmented`] with at
456    /// least one cut) was handed, kept for [`Self::submit_segmented`] to cut
457    /// each overlay's range from — `engine_scene` holds only the main
458    /// segment then. Reused across frames like `engine_scene`.
459    segment_scene: frust_scene::Scene,
460    /// Whether `segment_scene` holds the in-flight frame's scene: set by a
461    /// segmented encode, cleared by every other encode and by the submit that
462    /// consumes it.
463    segment_pending: bool,
464    /// The segmented encode's classification of each cut, in cut order
465    /// ([`crate::overlay::cut_is_servable`]): which ranges the main segment
466    /// excluded and the overlays draw, and which it kept. Valid exactly while
467    /// `segment_pending` is; reused across frames.
468    segment_service: Vec<CutServiceability>,
469    /// How each surface's first frame meets the engine's pipeline warm-up,
470    /// read from the process configuration once, here.
471    first_frame_warm_up: FirstFrameWarmUp,
472    /// The driver pipeline cache of the last surface episode, paired with its
473    /// adapter fingerprint, kept from that episode's end until the next one
474    /// begins so [`Self::pipeline_cache_data`] can still be read once the
475    /// surface is gone — the moment a backgrounding app persists it.
476    retired_pipeline_cache: Option<(wgpu::PipelineCache, String)>,
477}
478
479/// One retained page the renderer holds resident: the key it was recorded
480/// under (`Command::PushRetained`'s `key`), its rasterized extent in physical
481/// pixels and the scale it was rasterized at.
482///
483/// Both are the page *as rasterized*: the bracket's rectangle mapped through
484/// its transform with the presentation scale applied, so `scale` is the device
485/// scale times the bracket's own `scale` (see `frust_engine::cache::retained`).
486/// A page is reusable by a `DrawRetained` with the same linear transform at any
487/// alpha and any translation, and by nothing else.
488#[derive(Debug, Clone, Copy, PartialEq)]
489pub struct ResidentPage {
490    /// The raw retained key.
491    pub key: u64,
492    /// The page's extent in physical pixels.
493    pub extent_px: (u32, u32),
494    /// The device scale the page was rasterized at.
495    pub scale: f32,
496}
497
498/// The retained pages a [`SurfaceRenderer`] holds resident after its last
499/// frame — the renderer's side of the resident-key ledger.
500///
501/// Plain data, so this crate needs no `frust-core` edge: a shell converts it
502/// into `frust_core::RetainedLedger` (`RetainedLedger::from_entries`) and hands
503/// it to the UI tree for the next paint, one frame stale by design.
504#[derive(Debug, Clone, Default, PartialEq)]
505pub struct ResidentPages {
506    /// Resident pages, in no particular order.
507    pub pages: Vec<ResidentPage>,
508    /// The paint serial ([`SurfaceRenderer::note_scene_serial`]) of the last
509    /// scene whose retained-store commit the renderer completed before this
510    /// was taken — what lets the UI tell a page holding its latest recording
511    /// from one holding an older recording under the same key. A refused or
512    /// failed frame does not advance it; the empty ledger a store clear,
513    /// surface destroy or scale change leaves carries the last committed
514    /// serial (`0` before any).
515    pub frame: u64,
516}
517
518/// `engine`'s retained store as the plain data a shell publishes, stamped with
519/// `frame`, the serial of the last committed scene — the one conversion
520/// [`SurfaceRenderer::retained_ledger`] makes, kept free so it is testable
521/// against a real engine with no surface in the loop.
522fn resident_pages(engine: &frust_engine::EngineRenderer, frame: u64) -> ResidentPages {
523    ResidentPages {
524        pages: engine
525            .retained_ledger()
526            .map(|entry| ResidentPage {
527                key: entry.key,
528                extent_px: entry.extent_px,
529                scale: entry.scale,
530            })
531            .collect(),
532        frame,
533    }
534}
535
536/// Whether `scene` records a retained page (opens a `PushRetained` bracket).
537fn scene_records_retained(scene: &frust_scene::Scene) -> bool {
538    scene
539        .commands()
540        .iter()
541        .any(|command| matches!(command, frust_scene::Command::PushRetained { .. }))
542}
543
544/// Where the first rendered frame's `submit` time went.
545///
546/// The three parts partition the span: `encode` excludes `pipeline_wait`, and
547/// `queue` is `Queue::submit` plus `Queue::present` (the present is absent
548/// when the caller presents a deferred frame itself).
549#[cfg(feature = "perf-trace")]
550#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
551pub struct FirstSubmitBreakdown {
552    /// Time inside the pipeline cache resolving not-yet-held variants.
553    pub pipeline_wait: std::time::Duration,
554    /// The engine's encode, less `pipeline_wait`.
555    pub encode: std::time::Duration,
556    /// `Queue::submit` plus the present.
557    pub queue: std::time::Duration,
558}
559
560/// The first-frame timing probe `submit_impl` threads through its arms. A
561/// zero-sized no-op without `perf-trace`.
562#[cfg(feature = "perf-trace")]
563struct SubmitProbe {
564    armed: bool,
565    encode_start: Option<std::time::Instant>,
566    queue_start: Option<std::time::Instant>,
567    breakdown: FirstSubmitBreakdown,
568}
569
570#[cfg(feature = "perf-trace")]
571impl SubmitProbe {
572    fn new(armed: bool) -> Self {
573        Self {
574            armed,
575            encode_start: None,
576            queue_start: None,
577            breakdown: FirstSubmitBreakdown::default(),
578        }
579    }
580
581    fn encode_begin(&mut self, engine: &mut frust_engine::EngineRenderer) {
582        if self.armed {
583            // Discard whatever earlier work (the install's warm-up) accrued.
584            let _ = engine.take_pipeline_wait();
585            self.encode_start = Some(std::time::Instant::now());
586        }
587    }
588
589    fn encode_end(&mut self, engine: &mut frust_engine::EngineRenderer) {
590        if let Some(start) = self.encode_start.take() {
591            let total = start.elapsed();
592            self.breakdown.pipeline_wait = engine.take_pipeline_wait();
593            self.breakdown.encode = total.saturating_sub(self.breakdown.pipeline_wait);
594        }
595    }
596
597    fn queue_begin(&mut self) {
598        if self.armed {
599            self.queue_start = Some(std::time::Instant::now());
600        }
601    }
602
603    fn queue_end(&mut self) {
604        if let Some(start) = self.queue_start.take() {
605            self.breakdown.queue = start.elapsed();
606        }
607    }
608}
609
610#[cfg(not(feature = "perf-trace"))]
611struct SubmitProbe;
612
613#[cfg(not(feature = "perf-trace"))]
614impl SubmitProbe {
615    fn new(_armed: bool) -> Self {
616        Self
617    }
618    fn encode_begin(&mut self, _engine: &mut frust_engine::EngineRenderer) {}
619    fn encode_end(&mut self, _engine: &mut frust_engine::EngineRenderer) {}
620    fn queue_begin(&mut self) {}
621    fn queue_end(&mut self) {}
622}
623
624impl Default for SurfaceRenderer {
625    fn default() -> Self {
626        Self::new()
627    }
628}
629
630/// Where the first surface install spent its time, from the
631/// `on_surface_created_*` entry to `install_surface`'s exit. Durations, not
632/// offsets. `device_create` is a part of `engine_surface` (reported on its own);
633/// the other fields are disjoint, so
634/// `engine_surface + cache_seed + caps_probe + engine.* + rest` is the whole
635/// install.
636#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
637pub struct InstallTimings {
638    /// Surface creation, device creation, capability gate and swapchain configure.
639    pub engine_surface: Duration,
640    /// The `ensure_device` call inside `engine_surface`.
641    pub device_create: Duration,
642    /// `create_pipeline_cache` (the driver-cache seed; ~0 off Vulkan).
643    pub cache_seed: Duration,
644    /// `TierCaps::probe`.
645    pub caps_probe: Duration,
646    /// `EngineRenderer::new`'s own split.
647    pub engine: frust_engine::renderer::ConstructionTimings,
648    /// The rest of `install_surface` (timestamp ring, shader quad pass, state adoption).
649    pub rest: Duration,
650}
651
652impl SurfaceRenderer {
653    /// Creates an empty renderer in [`SurfacePhase::NoSurface`].
654    ///
655    /// No GPU work happens here; the surface is created later by the shell via
656    /// [`on_surface_created`](Self::on_surface_created) once a window/surface
657    /// exists (deferred window creation on desktop, `surfaceCreated` on Android).
658    pub fn new() -> Self {
659        Self {
660            state: SurfaceState::NoSurface,
661            consecutive_invalid: 0,
662            initial_cache_data: None,
663            install_timings: None,
664            install_timings_seen: false,
665            pending_present: None,
666            pending_base_color: None,
667            engine_scene: frust_scene::Scene::default(),
668            #[cfg(feature = "perf-trace")]
669            first_submit: None,
670            #[cfg(feature = "perf-trace")]
671            first_submit_seen: false,
672            retained_published: None,
673            scene_serial: 0,
674            pending_serial: None,
675            committed_serial: 0,
676            recording_committed: false,
677            device_scale: None,
678            overlay_split: false,
679            segment_scene: frust_scene::Scene::default(),
680            segment_pending: false,
681            segment_service: Vec::new(),
682            first_frame_warm_up: FirstFrameWarmUp::from_config(),
683            retired_pipeline_cache: None,
684        }
685    }
686
687    /// Takes the first rendered frame's submit decomposition, once.
688    ///
689    /// `Some` from the first [`FrameOutcome::Rendered`] submit until taken;
690    /// `None` before it, after it was taken, and on every later frame.
691    #[cfg(feature = "perf-trace")]
692    pub fn take_first_submit_breakdown(&mut self) -> Option<FirstSubmitBreakdown> {
693        self.first_submit.take()
694    }
695
696    /// Drops the in-flight frame's cross-call stash: the engine arm's
697    /// `base_color` and the encoded scene's serial.
698    ///
699    /// It describes one frame of one surface, so any event that ends a
700    /// surface's life (`on_surface_destroyed`) or replaces it
701    /// (`install_surface`) must drop it rather than let the next `submit`
702    /// consume a stale colour. Idempotent.
703    fn clear_pending_frame(&mut self) {
704        self.pending_base_color = None;
705        self.pending_serial = None;
706        self.segment_pending = false;
707    }
708
709    /// Adopt `state` and reset everything else scoped to ONE surface: the
710    /// `Invalid`-reconfigure streak and the in-flight frame's stash.
711    ///
712    /// The one place a surface episode begins or ends. `install_surface`
713    /// enters through it with the freshly built [`SurfaceState::Ready`];
714    /// `on_surface_destroyed` leaves through it with
715    /// [`SurfaceState::NoSurface`]; and [`Self::acquire`]'s give-up arm leaves
716    /// through it with [`SurfaceState::Lost`] — so a reset can never be
717    /// written into one of those paths and forgotten in another. Everything
718    /// reset here describes the surface being replaced or torn down: the
719    /// streak belonged to it, and the stash describes one of its frames.
720    /// Nothing else may assign `self.state`.
721    ///
722    /// The retained ledger last published is reset with them: a new episode's
723    /// engine starts with an empty store and an ended one has none, so the
724    /// next [`Self::take_retained_ledger`] publishes (the empty ledger, either
725    /// way) whatever generation the old engine had reached.
726    ///
727    /// An episode that ends hands its driver pipeline cache to
728    /// [`Self::retired_pipeline_cache`]; the rest of the episode is dropped when
729    /// this method's block for the old surface ends, which joins the engine's
730    /// warm-up worker, so everything it built is in the cache by the time the
731    /// method returns. That is safe because nothing reads the retired cache
732    /// before then. An episode that begins drops whatever was retired there.
733    fn adopt_surface_state(&mut self, state: SurfaceState) {
734        let previous = std::mem::replace(&mut self.state, SurfaceState::NoSurface);
735        if let SurfaceState::Ready(ready) = previous {
736            let ReadySurface { pipeline_cache, .. } = *ready;
737            if pipeline_cache.is_some() {
738                self.retired_pipeline_cache = pipeline_cache;
739            }
740        }
741        if matches!(state, SurfaceState::Ready(_)) {
742            self.retired_pipeline_cache = None;
743        }
744        self.state = state;
745        self.consecutive_invalid = 0;
746        self.clear_pending_frame();
747        self.retained_published = None;
748    }
749
750    /// Restores the persisted pipeline-cache blob a prior run produced via
751    /// [`pipeline_cache_data`](Self::pipeline_cache_data), to seed the engine's
752    /// shader-pipeline compilation at the next surface install and cut warm-start
753    /// shader/pipeline compilation to near zero on Vulkan.
754    ///
755    /// Builder-style (a setter rather than a new `on_surface_created*`
756    /// parameter) so the three surface-creation entry points — and every shell
757    /// call site — keep their signatures; the shell (persistence lands in tasks
758    /// 13/14) calls this once after [`SurfaceRenderer::new`] and before the
759    /// first `on_surface_created*`. Has no effect in practice on adapters
760    /// without `PIPELINE_CACHE` (Metal/DX12): the blob is validated against
761    /// the live adapter at install time and discarded on any mismatch. Passing
762    /// `None` clears any restored blob (a cold start).
763    ///
764    /// **Caller contract (provenance):** `data` must be bytes this renderer
765    /// itself previously produced via
766    /// [`pipeline_cache_data`](Self::pipeline_cache_data) and the shell
767    /// persisted verbatim — never bytes from any other source. The framing
768    /// check downstream (`frust_gpu::pipeline_cache::unframe`) proves the
769    /// magic tag and the adapter fingerprint, which is provenance by
770    /// convention rather than integrity: a deliberately forged blob passing
771    /// that frame reaches `wgpu`'s `unsafe` pipeline-cache seam, whose
772    /// contract makes foreign data undefined behaviour. This method stays
773    /// safe because the obligation is a data-handling rule for the shell's
774    /// persistence layer, not something a signature can enforce.
775    pub fn set_initial_pipeline_cache_data(&mut self, data: Option<Vec<u8>>) {
776        self.initial_cache_data = data;
777    }
778
779    /// The current pipeline-cache data to persist, framed with the adapter
780    /// fingerprint so a later launch can validate it before reuse (see
781    /// [`frust_gpu::pipeline_cache`]).
782    ///
783    /// Read from the live surface's cache, or — between a surface's destroy
784    /// and the next install — from the cache that surface left behind, which
785    /// then holds everything its engine built. `None` when there is neither —
786    /// nothing installed yet, or an adapter without `PIPELINE_CACHE`
787    /// (Metal/DX12) — or when the driver has produced nothing to hand back
788    /// yet. The shell writes the returned bytes to disk and feeds them back via
789    /// [`set_initial_pipeline_cache_data`](Self::set_initial_pipeline_cache_data)
790    /// on the next launch.
791    pub fn pipeline_cache_data(&self) -> Option<Vec<u8>> {
792        let (cache, key) = match &self.state {
793            SurfaceState::Ready(ready) => ready.pipeline_cache.as_ref()?,
794            SurfaceState::NoSurface | SurfaceState::Lost => self.retired_pipeline_cache.as_ref()?,
795        };
796        let data = cache.get_data()?;
797        Some(frust_gpu::pipeline_cache::frame(key, &data))
798    }
799
800    /// How many pipelines the engine's warm-up worker has finished building,
801    /// or `None` outside [`SurfacePhase::SurfaceReady`].
802    ///
803    /// Diagnostic: a shell logs it beside the persisted pipeline-cache size so
804    /// a small blob can be read against how much of the warm-up had landed.
805    pub fn pipeline_warm_up_finished(&self) -> Option<u64> {
806        let SurfaceState::Ready(ready) = &self.state else {
807            return None;
808        };
809        let TierBackend::Engine { engine, .. } = &ready.backend;
810        Some(engine.warm_up_finished())
811    }
812
813    /// Whether the engine's warm-up has built every pipeline on its list, by
814    /// whichever thread built it, or `None` outside
815    /// [`SurfacePhase::SurfaceReady`].
816    ///
817    /// A shell reads it to time a [`Self::pipeline_cache_data`] persist: the
818    /// blob read once this is `true` holds the whole warm-up list.
819    pub fn pipeline_warm_up_done(&self) -> Option<bool> {
820        let SurfaceState::Ready(ready) = &self.state else {
821            return None;
822        };
823        let TierBackend::Engine { engine, .. } = &ready.backend;
824        Some(engine.warm_up_done())
825    }
826
827    /// How many render pipelines the engine has compiled so far, or `None`
828    /// outside [`SurfacePhase::SurfaceReady`]. Diagnostic, beside the
829    /// persisted pipeline-cache size.
830    pub fn compiled_pipelines(&self) -> Option<u64> {
831        let SurfaceState::Ready(ready) = &self.state else {
832            return None;
833        };
834        let TierBackend::Engine { engine, .. } = &ready.backend;
835        Some(engine.compiled_pipelines())
836    }
837
838    /// The retained pages the renderer holds resident after its last frame (see
839    /// [`ResidentPages`]): the engine's retained-page store, read as plain
840    /// data. Empty outside [`SurfacePhase::SurfaceReady`] — with no surface there
841    /// is no engine and no page. Never blocks, and allocates nothing while the
842    /// store is empty.
843    pub fn retained_ledger(&self) -> ResidentPages {
844        let SurfaceState::Ready(ready) = &self.state else {
845            return ResidentPages {
846                pages: Vec::new(),
847                frame: self.committed_serial,
848            };
849        };
850        let TierBackend::Engine { engine, .. } = &ready.backend;
851        resident_pages(engine, self.committed_serial)
852    }
853
854    /// [`Self::retained_ledger`], but only when it changed since the last call
855    /// — a page added, evicted or re-rendered at another extent, the store
856    /// cleared, or the surface created, recreated or destroyed (each of which
857    /// starts the next call over, so a fresh or torn-down surface always
858    /// publishes its empty ledger) — or when a committed scene recorded a page
859    /// while the store holds any: the UI draws a page only once a ledger
860    /// covers the scene that recorded it, and a page re-recorded at the same
861    /// extent changes nothing else. `None` when neither happened.
862    ///
863    /// What a shell calls after each frame's submit to publish the ledger
864    /// without re-publishing an unchanged one every frame.
865    pub fn take_retained_ledger(&mut self) -> Option<ResidentPages> {
866        let (generation, holds_pages) = match &self.state {
867            SurfaceState::Ready(ready) => {
868                let TierBackend::Engine { engine, .. } = &ready.backend;
869                (
870                    Some(engine.retained_generation()),
871                    engine.retained_ledger().next().is_some(),
872                )
873            }
874            SurfaceState::NoSurface | SurfaceState::Lost => (None, false),
875        };
876        let recorded = std::mem::take(&mut self.recording_committed) && holds_pages;
877        if self.retained_published == Some(generation) && !recorded {
878            return None;
879        }
880        self.retained_published = Some(generation);
881        Some(self.retained_ledger())
882    }
883
884    /// Name the paint serial (`frust_core::PaintOutcome::paint_serial`) of the
885    /// scene the next [`Self::encode`] takes. A shell calls it before every
886    /// encode, on whichever thread renders, with the serial that travelled
887    /// with the scene; once that scene's frame is committed, every ledger this
888    /// renderer publishes carries the serial as its [`ResidentPages::frame`].
889    /// A shell that never calls it publishes ledgers stamped `0`.
890    pub fn note_scene_serial(&mut self, serial: u64) {
891        self.scene_serial = serial;
892    }
893
894    /// Whether a frame was refused since the last call because a
895    /// `DrawRetained` named a page the engine did not hold (the engine then
896    /// empties its store), clearing the flag.
897    ///
898    /// The refused frame presented nothing, so a shell seeing `true` owes the
899    /// widget tree one more paint, against the emptied ledger it publishes
900    /// alongside — otherwise an idle screen would keep the frame before the
901    /// refused one until something else asked for a paint.
902    pub fn take_retained_refusal(&mut self) -> bool {
903        match &mut self.state {
904            SurfaceState::Ready(ready) => {
905                let TierBackend::Engine { engine, .. } = &mut ready.backend;
906                engine.take_retained_refusal()
907            }
908            SurfaceState::NoSurface | SurfaceState::Lost => false,
909        }
910    }
911
912    /// Drop every retained page the engine holds — what a device-scale change
913    /// calls for, since every body is about to be laid out again. The next
914    /// [`Self::take_retained_ledger`] reports the empty store. A no-op with no
915    /// surface.
916    pub fn clear_retained(&mut self) {
917        if let SurfaceState::Ready(ready) = &mut self.state {
918            let TierBackend::Engine { engine, .. } = &mut ready.backend;
919            engine.clear_retained();
920        }
921    }
922
923    /// Note the device scale (physical / logical, already sanitized by the
924    /// shell) the coming frames are painted at — the one place a shell reports
925    /// a scale change to the retained store.
926    ///
927    /// When `scale` differs from the last noted one (or none was noted yet),
928    /// every resident page was rasterized for a scale no frame will name
929    /// again, so the store is cleared ([`Self::clear_retained`]) and the next
930    /// [`Self::take_retained_ledger`] publishes the empty ledger even when the
931    /// store was empty already — the UI thread then paints every retained
932    /// body again instead of naming pages that are gone. The same scale again
933    /// is a no-op, so a shell may call this at every surface create and
934    /// resize. Returns whether the scale changed.
935    pub fn note_device_scale(&mut self, scale: f64) -> bool {
936        let bits = scale.to_bits();
937        if self.device_scale == Some(bits) {
938            return false;
939        }
940        self.device_scale = Some(bits);
941        self.clear_retained();
942        self.retained_published = None;
943        true
944    }
945
946    /// Whether the **live** surface actually resolved to a translucent
947    /// (alpha-compositing) mode — the truth a shell's Mode B paint contract
948    /// must key off, replacing the `SurfaceAlphaRequest` it *asked* for.
949    ///
950    /// [`SurfaceAlphaRequest::TranslucentPreferred`] is a *preference*: the
951    /// configure step resolves it against the platform's advertised alpha modes
952    /// and silently degrades to an opaque swapchain (with a `log::warn!`) when
953    /// none is available. A shell that clears its base color to `TRANSPARENT`
954    /// and lets `platform_view` slots punch their rects on the strength of the
955    /// request alone then presents black rectangles on that opaque swapchain.
956    /// Reading this after every successful `on_surface_created*`/
957    /// [`on_surface_installed`](Self::on_surface_installed) — and re-reading it
958    /// after every recreate — is what makes a fallback degrade to the Mode A
959    /// contract (opaque base, no punch) instead.
960    ///
961    /// `false` outside [`SurfacePhase::SurfaceReady`]: with no live surface
962    /// there is nothing translucent to composite against, and `false` is the
963    /// safe (Mode A) default — never punch a hole you can't prove is a window.
964    ///
965    /// Deliberately **not** named `is_surface_translucent`: `RenderRoot` (which
966    /// sits on the other side of this same call chain) already owns a method by
967    /// that name for the *pushed* flag, and a shell threads this value straight
968    /// into it. The signature is `wgpu`-free, like every other value crossing
969    /// this crate's boundary (`docs/CODE_STANDARDS.md`'s wgpu-leak
970    /// anti-pattern).
971    pub fn surface_resolved_translucent(&self) -> bool {
972        match &self.state {
973            SurfaceState::Ready(ready) => ready.surface.resolved_translucent(),
974            SurfaceState::NoSurface | SurfaceState::Lost => false,
975        }
976    }
977
978    /// The most recent frame's real GPU time per pass, or `None` when this
979    /// surface produces no such measurement.
980    ///
981    /// `None` — the `gpu_q=0` case a shell reports — for every reason there is:
982    /// no live surface, a device created without
983    /// `wgpu::Features::TIMESTAMP_QUERY` (which a build that did not compile
984    /// `perf-trace` in never asks for), and the first few frames of a surface,
985    /// before the ring's first readback has landed.
986    ///
987    /// The reading lags the calling frame: a frame's queries are mapped
988    /// without ever blocking the frame path, so what comes back is a recent
989    /// frame's GPU cost rather than the one being recorded. In steady state one
990    /// fresh reading lands per frame, so the series is complete and offset,
991    /// not sparse — see [`frust_gpu::diag::TimestampRing`].
992    pub fn gpu_pass_timings(&self) -> Option<GpuPassTimings> {
993        let SurfaceState::Ready(ready) = &self.state else {
994            return None;
995        };
996        let TierBackend::Engine { timestamps, .. } = &ready.backend;
997        let reading = timestamps.latest()?;
998        let span = |which: frust_engine::EngineSpan| reading.span(which.index());
999        Some(GpuPassTimings {
1000            prepass: span(frust_engine::EngineSpan::Prepass),
1001            main: span(frust_engine::EngineSpan::Main),
1002            composite: span(frust_engine::EngineSpan::Composite),
1003            blit: span(frust_engine::EngineSpan::Blit),
1004        })
1005    }
1006
1007    /// Arms (or disarms) the overlay spike's split on this surface: while
1008    /// armed, [`Self::encode`] keeps only the commands before the scene's
1009    /// [`spike_cut`](crate::overlay::spike_cut) for this surface's frame,
1010    /// leaving the commands from the cut on to the host's
1011    /// [`OverlaySurface`](crate::overlay::OverlaySurface), which draws them
1012    /// from the same scene. A scene with no cut is drawn whole.
1013    ///
1014    /// A host arms it exactly while it has a live overlay to hand the range
1015    /// to — armed with none, those commands would be drawn nowhere. Refused
1016    /// (a no-op answering `false`) unless the spike is switched on
1017    /// ([`crate::overlay::OVERLAY_SPIKE_ENV_VAR`]), so no production path can
1018    /// arm it; answers whether the split is now armed.
1019    ///
1020    /// The surface keeps its single-target shape either way: the cut changes
1021    /// which commands this surface's one frame draws, never where it draws.
1022    /// The bracket rule a cut obeys is
1023    /// [`frust_engine::renderer::project_command_range`]'s.
1024    pub fn set_overlay_split(&mut self, armed: bool) -> bool {
1025        self.overlay_split = armed && crate::overlay::overlay_spike_enabled();
1026        self.overlay_split
1027    }
1028
1029    /// The live surface's persisted driver cache, for an overlay built beside
1030    /// it to compile its pipelines through (see
1031    /// [`OverlaySurface::new`](crate::overlay::OverlaySurface::new)); `None`
1032    /// without a live surface or on an adapter without `PIPELINE_CACHE`.
1033    pub(crate) fn shared_pipeline_cache(&self) -> Option<wgpu::PipelineCache> {
1034        match &self.state {
1035            SurfaceState::Ready(ready) => ready
1036                .pipeline_cache
1037                .as_ref()
1038                .map(|(cache, _)| cache.clone()),
1039            SurfaceState::NoSurface | SurfaceState::Lost => None,
1040        }
1041    }
1042
1043    /// The current lifecycle phase.
1044    pub fn phase(&self) -> SurfacePhase {
1045        match self.state {
1046            SurfaceState::NoSurface => SurfacePhase::NoSurface,
1047            SurfaceState::Ready(_) => SurfacePhase::SurfaceReady,
1048            SurfaceState::Lost => SurfacePhase::SurfaceLost,
1049        }
1050    }
1051
1052    /// Brings the surface online (`surfaceCreated`/`resumed`): creates the
1053    /// swapchain and a device-bound renderer, transitioning to
1054    /// [`SurfacePhase::SurfaceReady`].
1055    ///
1056    /// Valid from any phase — calling it in `SurfaceLost` is how the shell
1057    /// recovers, and calling it in `SurfaceReady` replaces the surface (the old
1058    /// one is dropped first). `window` is any raw window handle the shell owns
1059    /// (`wgpu::SurfaceTarget`); no `winit` dependency is imposed here.
1060    ///
1061    /// Presentation uses vsync (`PresentMode::AutoVsync`), matching the
1062    /// vsync-driven frame pacing the platform shells provide.
1063    pub async fn on_surface_created(
1064        &mut self,
1065        ctx: &mut RenderContext,
1066        window: impl Into<wgpu::SurfaceTarget<'static>>,
1067        width: u32,
1068        height: u32,
1069        alpha: SurfaceAlphaRequest,
1070    ) -> Result<()> {
1071        let started = Instant::now();
1072        let surface = crate::context::create_engine_surface_from_target(
1073            ctx,
1074            window,
1075            width.max(1),
1076            height.max(1),
1077            wgpu::PresentMode::AutoVsync,
1078            alpha,
1079        )
1080        .await
1081        .map_err(|e| anyhow!("frust-render: failed to create render surface: {e}"))?;
1082        self.install_surface(ctx, surface, started)
1083    }
1084
1085    /// Brings the surface online from a [`DetachedSurface`] that was created on
1086    /// the windowing/main thread via
1087    /// [`RenderContext::surface_factory`](crate::RenderContext::surface_factory),
1088    /// transitioning to [`SurfacePhase::SurfaceReady`] (the
1089    /// render-thread split).
1090    ///
1091    /// The desktop counterpart of [`on_surface_created`](Self::on_surface_created)
1092    /// for the split: `on_surface_created` reads the window handle *and*
1093    /// configures on one thread, but winit only yields that handle on the main
1094    /// thread — so the split creates the surface there
1095    /// ([`SurfaceFactory::create_detached_surface`](crate::SurfaceFactory::create_detached_surface))
1096    /// and hands the `Send` surface here, where the render thread that owns this
1097    /// renderer/context does the device + swapchain + blitter work. Presentation
1098    /// uses vsync (`PresentMode::AutoVsync`), matching `on_surface_created`.
1099    ///
1100    /// Valid from any phase (recreation after `SurfaceLost`/resume replaces the
1101    /// old surface — dropped first).
1102    pub async fn on_surface_installed(
1103        &mut self,
1104        ctx: &mut RenderContext,
1105        surface: DetachedSurface,
1106        width: u32,
1107        height: u32,
1108        alpha: SurfaceAlphaRequest,
1109    ) -> Result<()> {
1110        let started = Instant::now();
1111        let surface = crate::context::create_engine_surface(
1112            ctx,
1113            surface.into_surface(),
1114            width.max(1),
1115            height.max(1),
1116            wgpu::PresentMode::AutoVsync,
1117            alpha,
1118        )
1119        .await
1120        .map_err(|e| anyhow!("frust-render: failed to configure detached surface: {e}"))?;
1121        self.install_surface(ctx, surface, started)
1122    }
1123
1124    /// Brings the surface online from a raw `ANativeWindow` pointer (Android
1125    /// `surfaceCreated`), transitioning to [`SurfacePhase::SurfaceReady`].
1126    ///
1127    /// Compiled unconditionally so a host `cargo check --target
1128    /// aarch64-linux-android` covers it. This is one of the framework's
1129    /// sanctioned unsafe entry points (see also
1130    /// [`on_surface_created_from_metal_layer`](Self::on_surface_created_from_metal_layer));
1131    /// the raw-pointer handling is isolated in
1132    /// [`frust_gpu::lifecycle::create_android_surface`].
1133    ///
1134    /// # Safety
1135    ///
1136    /// `window_ptr` must be a valid, acquired `ANativeWindow*` that outlives the
1137    /// surface (and all its `SurfaceTexture`s). See
1138    /// [`frust_gpu::lifecycle::create_android_surface`] for the full contract.
1139    pub async unsafe fn on_surface_created_from_android_window(
1140        &mut self,
1141        ctx: &mut RenderContext,
1142        window_ptr: *mut c_void,
1143        width: u32,
1144        height: u32,
1145        alpha: SurfaceAlphaRequest,
1146    ) -> Result<()> {
1147        let started = Instant::now();
1148        // SAFETY: forwarded to the caller's `on_surface_created_from_android_window`
1149        // contract — `window_ptr` is a valid, acquired ANativeWindow* outliving
1150        // the surface.
1151        let raw =
1152            unsafe { frust_gpu::lifecycle::create_android_surface(ctx.instance(), window_ptr) }?;
1153        let surface = crate::context::create_engine_surface(
1154            ctx,
1155            raw,
1156            width.max(1),
1157            height.max(1),
1158            wgpu::PresentMode::AutoVsync,
1159            alpha,
1160        )
1161        .await
1162        .map_err(|e| anyhow!("frust-render: failed to configure Android surface: {e}"))?;
1163        self.install_surface(ctx, surface, started)
1164    }
1165
1166    /// Brings the surface online from a raw `CAMetalLayer*` pointer (iOS/macOS
1167    /// Swift shell surface creation), transitioning to
1168    /// [`SurfacePhase::SurfaceReady`].
1169    ///
1170    /// Only compiled on Apple targets (mirrors [`frust_gpu::lifecycle::create_metal_surface`]'s
1171    /// gating): the raw-pointer handling is isolated there, one of the
1172    /// framework's sanctioned unsafe boundaries alongside
1173    /// [`on_surface_created_from_android_window`](Self::on_surface_created_from_android_window).
1174    ///
1175    /// Presentation uses `Fifo` — the only present mode guaranteed on
1176    /// iOS/Metal (vsync-equivalent, matching the desktop/Android
1177    /// `AutoVsync` paths in spirit).
1178    ///
1179    /// # Safety
1180    ///
1181    /// `layer_ptr` must be a valid, live `CAMetalLayer*` that outlives the
1182    /// surface (and all its `SurfaceTexture`s). See
1183    /// [`frust_gpu::lifecycle::create_metal_surface`] for the full contract.
1184    #[cfg(any(target_os = "ios", target_os = "macos"))]
1185    pub async unsafe fn on_surface_created_from_metal_layer(
1186        &mut self,
1187        ctx: &mut RenderContext,
1188        layer_ptr: *mut c_void,
1189        width: u32,
1190        height: u32,
1191        alpha: SurfaceAlphaRequest,
1192    ) -> Result<()> {
1193        let started = Instant::now();
1194        // SAFETY: forwarded to the caller's `on_surface_created_from_metal_layer`
1195        // contract — `layer_ptr` is a valid, live CAMetalLayer* outliving the
1196        // surface.
1197        let raw = unsafe { frust_gpu::lifecycle::create_metal_surface(ctx.instance(), layer_ptr) }?;
1198        let surface = crate::context::create_engine_surface(
1199            ctx,
1200            raw,
1201            width.max(1),
1202            height.max(1),
1203            wgpu::PresentMode::Fifo,
1204            alpha,
1205        )
1206        .await
1207        .map_err(|e| anyhow!("frust-render: failed to configure Metal surface: {e}"))?;
1208        self.install_surface(ctx, surface, started)
1209    }
1210
1211    /// Wraps a freshly created `RenderSurface` in a device-bound renderer and
1212    /// installs it as the live surface. Shared by the safe and Android paths.
1213    fn install_surface(
1214        &mut self,
1215        ctx: &RenderContext,
1216        surface: EngineSurface,
1217        started: Instant,
1218    ) -> Result<()> {
1219        let engine_surface = started.elapsed();
1220        let device_create = surface.device_create;
1221        let install_started = Instant::now();
1222        // Seed the engine's shader-pipeline compilation from a persisted
1223        // `wgpu::PipelineCache` when the adapter supports it (Vulkan/Android) and
1224        // the shell restored a validated blob via
1225        // `set_initial_pipeline_cache_data`. Returns `None` on adapters without
1226        // `PIPELINE_CACHE` (Metal/DX12). Created before the backend below so
1227        // it can be handed to `EngineRenderer::new`.
1228        // SAFETY: `initial_cache_data` arrives only via
1229        // `set_initial_pipeline_cache_data`, whose documented caller contract
1230        // requires bytes this renderer itself produced through
1231        // `pipeline_cache_data` and the shell persisted verbatim — the
1232        // provenance `create_pipeline_cache`'s `# Safety` section requires.
1233        // This call site cannot itself prove that history; it relies on that
1234        // documented contract plus `unframe`'s magic-tag/adapter-fingerprint
1235        // check, which rejects every accidental mismatch (integrity against a
1236        // deliberate forgery is out of scope — see the setter's doc). `None`
1237        // is a cold start.
1238        let cache_started = Instant::now();
1239        let pipeline_cache =
1240            unsafe { ctx.create_pipeline_cache(self.initial_cache_data.as_deref()) };
1241        let cache_seed = cache_started.elapsed();
1242
1243        // Build the backend. There is exactly one renderer and no tier to
1244        // dispatch on: `context::create_engine_surface` has already refused an
1245        // adapter the engine cannot drive, before any surface is configured.
1246        let (backend, caps_probe, engine_timings) = {
1247            let device = &ctx.device_handle().device;
1248            // Built for the format this surface's FRAMES will target,
1249            // because the engine warms its strip pipelines for exactly one
1250            // of them: the swapchain's own on the direct arm, the engine's
1251            // off-screen format on the un-premultiplying one, whose frames
1252            // land in a surface-owned intermediate instead
1253            // ([`engine_target_format`]).
1254            //
1255            // A resize keeps that format (`EngineSurface::resize`
1256            // rewrites only the dimensions), so `on_surface_changed`
1257            // resizes the renderer in place; a FORMAT change arrives as a
1258            // fresh surface and lands back here, building a renderer
1259            // warmed for the new format — off the frame path, and seeded
1260            // from the same persisted `wgpu::PipelineCache` blob, so a
1261            // re-warm after a format change is a driver-cache hit rather
1262            // than a cold compile.
1263            let probe_started = Instant::now();
1264            let caps = frust_gpu::TierCaps::probe(&ctx.device_handle().adapter);
1265            let caps_probe = probe_started.elapsed();
1266            let engine_format = engine_target_format(&surface.path, surface.config().format);
1267            let engine = frust_engine::EngineRenderer::new(
1268                device,
1269                &caps,
1270                engine_format,
1271                pipeline_cache.as_ref(),
1272            )
1273            .map_err(|e| anyhow!("frust-render: failed to create engine renderer: {e}"))?;
1274            let engine_timings = engine.construction_timings();
1275            // One line per process naming the renderer, next to the
1276            // `render-path`/`aa-mode` lines, so a capture proves which
1277            // renderer produced the frames it is timing.
1278            //
1279            // FIXED EVIDENCE MARKER: the `frust-render tier=engine (...)`
1280            // text below is byte-for-byte load-bearing — every device gate
1281            // greps logcat/console for it and `benchmarks/RESULTS.md`
1282            // quotes it verbatim. It outlived the retirement of the tier
1283            // *selection* deliberately (there is nothing left to select,
1284            // but the receipts still have to match), so do not reword,
1285            // re-case or re-punctuate it, and emit it unconditionally.
1286            static ENGINE_LOGGED: OnceLock<()> = OnceLock::new();
1287            ENGINE_LOGGED.get_or_init(|| {
1288                log::info!(
1289                    "frust-render tier=engine (frust-engine strip pipeline, format={:?} into \
1290                     a {:?} swapchain, {}x{}, adapter `{}`)",
1291                    engine_format,
1292                    surface.config().format,
1293                    surface.config().width,
1294                    surface.config().height,
1295                    caps.adapter_name
1296                );
1297            });
1298            // Built from the LIVE device rather than from `caps`: the
1299            // adapter offering `TIMESTAMP_QUERY` is not the same statement
1300            // as the device having been created with it, and creating a
1301            // query set the device never enabled is a validation error
1302            // rather than a missing measurement. The ring asks the device
1303            // itself and goes inert when the answer is no.
1304            let timestamps = frust_gpu::diag::TimestampRing::new(
1305                device,
1306                &ctx.device_handle().queue,
1307                frust_engine::EngineSpan::COUNT,
1308                frust_engine::diag::TIMESTAMP_RING_LABEL,
1309            );
1310            let backend = TierBackend::Engine {
1311                engine: Box::new(engine),
1312                refused_frames: 0,
1313                shader_quads: Box::new(frust_engine::ShaderQuadPass::new(pipeline_cache.clone())),
1314                timestamps,
1315            };
1316            (backend, caps_probe, engine_timings)
1317        };
1318        debug_assert_eq!(
1319            next_phase(self.phase(), SurfaceEvent::Created),
1320            SurfacePhase::SurfaceReady,
1321            "Created must reach SurfaceReady"
1322        );
1323        // Pair the live pipeline cache with the adapter fingerprint its data is
1324        // framed under, so `pipeline_cache_data()` can hand back a validatable
1325        // blob without re-reading the adapter. `None` when the adapter lacks
1326        // `PIPELINE_CACHE`.
1327        let pipeline_cache = pipeline_cache.map(|cache| (cache, ctx.adapter_cache_key()));
1328        // Dropping the previous `SurfaceState` here tears down any prior surface
1329        // before the new one goes live: no surface outlives a
1330        // transition.
1331        //
1332        // Through `adopt_surface_state` for the resets that come with it: a
1333        // freshly (re)installed surface starts a new `Invalid`-reconfigure
1334        // episode (any prior streak belonged to the surface just replaced),
1335        // and the in-flight frame's stashes describe a frame of that same
1336        // replaced surface (the composite one holding handles on textures
1337        // from the cache that died with it), so carrying either into the new
1338        // surface's first `submit` would paint the old frame's pages and
1339        // colour onto it.
1340        self.adopt_surface_state(SurfaceState::Ready(Box::new(ReadySurface {
1341            surface,
1342            backend,
1343            pipeline_cache,
1344            warm_up_pending: true,
1345        })));
1346        if !self.install_timings_seen {
1347            self.install_timings_seen = true;
1348            let measured = cache_seed
1349                + caps_probe
1350                + engine_timings.shader_modules
1351                + engine_timings.warm_up_start
1352                + engine_timings.rest;
1353            self.install_timings = Some(InstallTimings {
1354                engine_surface,
1355                device_create,
1356                cache_seed,
1357                caps_probe,
1358                engine: engine_timings,
1359                rest: install_started.elapsed().saturating_sub(measured),
1360            });
1361        }
1362        Ok(())
1363    }
1364
1365    /// The first surface install's sub-step durations, once (`None` afterwards,
1366    /// and before any install). Startup evidence only.
1367    pub fn take_install_timings(&mut self) -> Option<InstallTimings> {
1368        self.install_timings.take()
1369    }
1370
1371    /// Resizes the swapchain (`surfaceChanged`/`Resized`).
1372    ///
1373    /// Only acts in [`SurfacePhase::SurfaceReady`]; a resize with no surface is
1374    /// dropped. The renderer (and its compiled pipelines) is preserved — only
1375    /// the surface config and its sized attachments are recreated. Zero
1376    /// dimensions are ignored (a minimized window keeps its last valid size).
1377    pub fn on_surface_changed(&mut self, ctx: &RenderContext, width: u32, height: u32) {
1378        if width == 0 || height == 0 {
1379            return;
1380        }
1381        if let SurfaceState::Ready(ready) = &mut self.state {
1382            ready
1383                .surface
1384                .resize(&ctx.device_handle().device, width, height);
1385            match &mut ready.backend {
1386                TierBackend::Engine { engine, .. } => {
1387                    // The engine's own extent-sized resources (its intermediate
1388                    // pool's parked entries, and the depth attachment it would
1389                    // own if the surface did not) are re-established here,
1390                    // off the frame path. The surface's own depth attachment
1391                    // was recreated by `EngineSurface::resize` above, in
1392                    // the same step that reconfigured the swapchain.
1393                    //
1394                    // The pipelines survive: only a FORMAT change would need a
1395                    // renderer warmed for a different target, and a resize
1396                    // never changes one — `EngineSurface::resize` rewrites
1397                    // the dimensions alone. Asserted rather than assumed, since a
1398                    // silent divergence would compile a fresh pipeline on the
1399                    // frame path for every frame that followed. Compared
1400                    // against the format the ARM implies, not the swapchain's
1401                    // own, so the un-premultiplying arm (whose frames target
1402                    // the engine's off-screen format) is not reported as drift
1403                    // on every resize.
1404                    let expected =
1405                        engine_target_format(&ready.surface.path, ready.surface.config().format);
1406                    if engine.format() != expected {
1407                        log::warn!(
1408                            "frust-render: engine renderer warmed for {:?} but this surface's \
1409                             frames now target {:?} — a format change must arrive as a fresh \
1410                             surface, not a resize",
1411                            engine.format(),
1412                            expected
1413                        );
1414                    }
1415                    engine.resize(&ctx.device_handle().device, width, height);
1416                }
1417            }
1418        }
1419    }
1420
1421    /// Tears the surface down (`surfaceDestroyed`/`suspended`), transitioning to
1422    /// [`SurfacePhase::NoSurface`].
1423    ///
1424    /// Drops the `RenderSurface` (and its renderer) so no surface or texture
1425    /// outlives the platform's underlying window. Idempotent.
1426    pub fn on_surface_destroyed(&mut self) {
1427        debug_assert_eq!(
1428            next_phase(self.phase(), SurfaceEvent::Destroyed),
1429            SurfacePhase::NoSurface,
1430            "Destroyed must reach NoSurface"
1431        );
1432        // Release the in-flight frame's stash while the surface that produced
1433        // it is still alive: it describes a frame of the surface that is
1434        // dying, and a stale `base_color` is wrong to hand the next `submit`.
1435        // Idempotent — a cleared stash clears again to nothing, which is what
1436        // the repeated-`Destroyed` contract needs.
1437        self.clear_pending_frame();
1438        if let SurfaceState::Ready(ready) = &mut self.state {
1439            match &mut ready.backend {
1440                // Nothing to release ahead of time: every texture this
1441                // backend holds (resource textures, the intermediate pool, its
1442                // own depth attachment) dies with the renderer below, and the
1443                // surface's own depth attachment — plus, on the
1444                // un-premultiplying arm, its intermediate — dies with the
1445                // surface beside it.
1446                TierBackend::Engine { .. } => {}
1447            }
1448        }
1449        // The same door `install_surface` enters by, which repeats the
1450        // (idempotent) stash reset above and also clears the
1451        // `Invalid`-reconfigure streak — behaviour-neutral here, since that
1452        // streak belonged to the surface just torn down and `install_surface`
1453        // resets it again.
1454        self.adopt_surface_state(SurfaceState::NoSurface);
1455    }
1456
1457    /// Encodes `scene` and presents it, clearing to `base_color`; returns the
1458    /// [`FrameOutcome`] so the shell can react.
1459    ///
1460    /// This is a thin convenience wrapper over the two-phase seam
1461    /// [`Self::encode`] + [`Self::present`]: it encodes, and — unless the frame
1462    /// was skipped for want of a renderable surface — presents. A caller that
1463    /// wants to attribute GPU encode cost separately from the swapchain-acquire
1464    /// (vsync) wait — the render-thread-split decision hinges on that split —
1465    /// calls the two entry points directly and times each with
1466    /// its own clock (timing stays shell-owned; this crate reads no clock — see
1467    /// `frust-shell-common::perf`'s layering note).
1468    ///
1469    /// In [`SurfacePhase::NoSurface`]/[`SurfacePhase::SurfaceLost`] the frame is
1470    /// dropped ([`FrameOutcome::Skipped`]) — never panicking, never queueing.
1471    /// On an `Outdated` acquire the surface is reconfigured and
1472    /// [`FrameOutcome::Redraw`] asks the shell to try again; on `Lost` the
1473    /// surface is dropped, the machine moves to [`SurfacePhase::SurfaceLost`],
1474    /// and [`FrameOutcome::SurfaceLost`] tells the shell to recreate it.
1475    ///
1476    /// Nothing accumulates across calls: each frame re-encodes from `scene`.
1477    pub fn render(
1478        &mut self,
1479        ctx: &RenderContext,
1480        scene: &frust_scene::Scene,
1481        base_color: peniko::Color,
1482    ) -> Result<FrameOutcome> {
1483        match self.encode(ctx, scene, base_color)? {
1484            EncodeOutcome::Skipped => Ok(FrameOutcome::Skipped),
1485            EncodeOutcome::Encoded => self.present(ctx),
1486        }
1487    }
1488
1489    /// Phase 1 of the frame — the **encode** span: take the frame's scene. It
1490    /// does **not** touch the swapchain, so a caller timing this call in
1491    /// isolation measures encode cost with no vsync wait folded in. This copies
1492    /// the display list and stashes `base_color`; the GPU render moves to
1493    /// [`Self::submit`] (it needs the acquired swapchain texture), so
1494    /// `encode_us` is a memcpy and nothing else.
1495    ///
1496    /// Returns [`EncodeOutcome::Skipped`] (no work done, nothing queued) in any
1497    /// phase but [`SurfacePhase::SurfaceReady`]; otherwise
1498    /// [`EncodeOutcome::Encoded`], after which [`Self::present`] finishes the
1499    /// frame. Nothing accumulates across frames.
1500    // `ctx` is unused on this tier — all of its GPU work happens in `submit`
1501    // — but the parameter stays in the public signature so a shell's call
1502    // site does not have to special-case it.
1503    #[allow(unused_variables)]
1504    pub fn encode(
1505        &mut self,
1506        ctx: &RenderContext,
1507        scene: &frust_scene::Scene,
1508        base_color: peniko::Color,
1509    ) -> Result<EncodeOutcome> {
1510        // Frames are dropped in every phase but SurfaceReady.
1511        if !self.phase().can_render() {
1512            return Ok(EncodeOutcome::Skipped);
1513        }
1514        // Disjoint field borrows: the live surface and the engine arms' colour
1515        // stash; `consecutive_invalid` belongs to `present`.
1516        let Self {
1517            state,
1518            pending_base_color,
1519            engine_scene,
1520            ..
1521        } = self;
1522        let SurfaceState::Ready(ready) = state else {
1523            // Unreachable: `can_render()` above guaranteed SurfaceReady.
1524            return Ok(EncodeOutcome::Skipped);
1525        };
1526        // Reborrow through the `Box` once so `ready.backend` and `ready.surface`
1527        // are disjoint field borrows of a plain `&mut ReadySurface` — the tier
1528        // `match` below mutates `backend` while reading `surface`, which the
1529        // borrow checker only allows on a single deref.
1530        let ready: &mut ReadySurface = ready;
1531
1532        // Encode this frame's pixels. The engine renders in `submit`, which
1533        // is why its surface is configured on one of the two engine paths
1534        // (`context::choose_engine_render_path`): straight into the acquired
1535        // swapchain view, or into a surface-owned intermediate the same
1536        // `submit` un-premultiplies from. `RenderPath` has no other variant to
1537        // wire wrong — `submit_impl`'s own exhaustive match over `&surface.path`
1538        // is where a future third arm must be handled, as a compile error
1539        // rather than a runtime check here.
1540        // The frame's whole CPU-side encode on this arm: copy the display list
1541        // somewhere that outlives the borrow, since `EngineRenderer::encode`
1542        // compiles it itself in `submit`. `clone_from` reuses the buffer's
1543        // capacity, so a steady-state frame allocates nothing — see the
1544        // field's measurement note.
1545        //
1546        // The overlay spike's split is the one exception, and only while a
1547        // host armed it (`set_overlay_split`, refused with the spike off):
1548        // the copy is the commands before the cut alone, cut by the same
1549        // projection the overlay draws the rest through.
1550        match self
1551            .overlay_split
1552            .then(|| crate::overlay::spike_cut(scene))
1553            .flatten()
1554        {
1555            Some(cut) => {
1556                let TierBackend::Engine { engine, .. } = &ready.backend;
1557                frust_engine::renderer::project_command_range(
1558                    scene,
1559                    0..cut,
1560                    frust_engine::renderer::RangeOptions {
1561                        keep_draw_retained: engine.retained_layers(),
1562                    },
1563                    engine_scene,
1564                );
1565            }
1566            None => engine_scene.clone_from(scene),
1567        }
1568        // Carried to `submit`, where the render that consumes it runs.
1569        *pending_base_color = Some(base_color);
1570        // The scene's serial, committed by the `submit` that renders it.
1571        self.pending_serial = Some((self.scene_serial, scene_records_retained(scene)));
1572        // A whole-frame encode: no overlay draws any of it.
1573        self.segment_pending = false;
1574
1575        Ok(EncodeOutcome::Encoded)
1576    }
1577
1578    /// [`Self::encode`] for a **segmented** frame (render-model Phase 4,
1579    /// decision D10): the overlays above hosted native views draw the command
1580    /// ranges `cuts` of `scene` — the differ's
1581    /// `ViewCommand::Overlay.command_range`s, ascending and disjoint — and
1582    /// this surface draws everything else.
1583    ///
1584    /// **The main segment** is the complement of the cuts
1585    /// ([`crate::overlay::main_segment_ranges`]): `0..first.start`, every gap
1586    /// between two cuts, and whatever follows the last one, each gap projected
1587    /// on its own through [`frust_engine::renderer::project_command_ranges`]
1588    /// so its clips and layers are closed and reopened around the cuts (the
1589    /// bracket rule of [`frust_engine::renderer::project_command_range`]).
1590    /// The differ's cuts tile the tail from the first cut whenever every
1591    /// visible slot after it has content above it, so the main segment is
1592    /// usually `0..first.start`; a slot with nothing above it leaves a gap
1593    /// (its hole-punch, say) that the main surface keeps drawing.
1594    ///
1595    /// **Only servable cuts are taken** ([`crate::overlay::cut_is_servable`]):
1596    /// a cut whose range holds content an overlay cannot draw — a retained
1597    /// page's composite or recording, a retained bracket open across the cut,
1598    /// an external texture, a backdrop blur — stays in the main segment,
1599    /// drawn exactly as an uncut frame draws it, and its overlay records an
1600    /// empty range. [`Self::submit_segmented`] reports the classification per
1601    /// cut ([`OverlayCutOutcome::serviceability`]).
1602    ///
1603    /// The whole scene is copied once more, for [`Self::submit_segmented`] to
1604    /// cut each overlay's range from — `ctx` stays unused, as in `encode`.
1605    /// With no cut this is exactly [`Self::encode`] (the whole frame, copied
1606    /// whole), and so is a frame the overlay spike's split
1607    /// ([`Self::set_overlay_split`]) is armed on: explicit cuts replace the
1608    /// spike's fixed one here, never combine with it.
1609    ///
1610    /// Follow with [`Self::acquire`] and [`Self::submit_segmented`] (or
1611    /// [`Self::submit_segmented_deferred`]) with an [`OverlayCut`] per cut, in
1612    /// the same order. A plain [`Self::submit`] after a segmented encode draws
1613    /// the main segment alone.
1614    pub fn encode_segmented(
1615        &mut self,
1616        ctx: &RenderContext,
1617        scene: &frust_scene::Scene,
1618        base_color: peniko::Color,
1619        cuts: &[Range<usize>],
1620    ) -> Result<EncodeOutcome> {
1621        if cuts.is_empty() {
1622            return self.encode(ctx, scene, base_color);
1623        }
1624        if !self.phase().can_render() {
1625            return Ok(EncodeOutcome::Skipped);
1626        }
1627        let Self {
1628            state,
1629            pending_base_color,
1630            engine_scene,
1631            segment_scene,
1632            segment_service,
1633            ..
1634        } = self;
1635        let SurfaceState::Ready(ready) = state else {
1636            return Ok(EncodeOutcome::Skipped);
1637        };
1638        let TierBackend::Engine { engine, .. } = &ready.backend;
1639        segment_service.clear();
1640        segment_service.extend(
1641            cuts.iter()
1642                .map(|cut| crate::overlay::cut_is_servable(scene.commands(), cut.clone())),
1643        );
1644        // A kept cut's served range is empty, so it removes nothing from the
1645        // main segment.
1646        let served: Vec<Range<usize>> = cuts
1647            .iter()
1648            .zip(segment_service.iter())
1649            .map(|(cut, service)| service.served_range(cut))
1650            .collect();
1651        crate::overlay::project_main_segment(
1652            scene,
1653            &served,
1654            frust_engine::renderer::RangeOptions {
1655                keep_draw_retained: engine.retained_layers(),
1656            },
1657            engine_scene,
1658        );
1659        segment_scene.clone_from(scene);
1660        *pending_base_color = Some(base_color);
1661        self.pending_serial = Some((self.scene_serial, scene_records_retained(scene)));
1662        self.segment_pending = true;
1663        Ok(EncodeOutcome::Encoded)
1664    }
1665
1666    /// Phase 2 of the frame — the **present** span: a thin wrapper over the
1667    /// two-phase [`Self::acquire`] + [`Self::submit`] seam, kept for callers
1668    /// (and the [`Self::render`] convenience wrapper) that time present as one
1669    /// span. It acquires the swapchain texture (the blocking vsync/present
1670    /// wait) and, on success, renders/submits/presents it.
1671    ///
1672    /// A caller wanting the finer **acquire** (blocking vsync wait) vs
1673    /// **submit** (GPU render + queue-submit + present) attribution — to
1674    /// separate GPU saturation from render cost — calls
1675    /// [`Self::acquire`] and [`Self::submit`] directly, timing each with its own
1676    /// clock (timing stays shell-owned; this crate reads no clock — see
1677    /// `frust-shell-common::perf`'s layering note). `present`'s combined span
1678    /// equals `acquire` + `submit` by construction.
1679    ///
1680    /// Assumes [`Self::encode`] has already copied this frame's display list.
1681    /// In any phase but [`SurfacePhase::SurfaceReady`] the call is a
1682    /// no-op returning [`FrameOutcome::Skipped`]. On an `Outdated`
1683    /// acquire the surface is reconfigured and [`FrameOutcome::Redraw`] asks the
1684    /// shell to try again; on `Lost` the surface is dropped, the machine moves
1685    /// to [`SurfacePhase::SurfaceLost`], and [`FrameOutcome::SurfaceLost`] tells
1686    /// the shell to recreate it.
1687    pub fn present(&mut self, ctx: &RenderContext) -> Result<FrameOutcome> {
1688        match self.acquire(ctx)? {
1689            AcquireOutcome::Acquired => self.submit(ctx),
1690            AcquireOutcome::Reconfigured => Ok(FrameOutcome::Redraw),
1691            AcquireOutcome::Lost => Ok(FrameOutcome::SurfaceLost),
1692            AcquireOutcome::Skipped => Ok(FrameOutcome::Skipped),
1693        }
1694    }
1695
1696    /// Phase 2a of the frame — the **acquire** sub-span: acquire the swapchain
1697    /// texture (the blocking vsync/present wait, per the surface's present mode),
1698    /// classify the result, and — on a usable acquire — stash the
1699    /// texture for [`Self::submit`] to blit into. Timing this call in isolation
1700    /// attributes the blocking present/vsync wait separately from [`Self::submit`]'s
1701    /// blit/queue-submit work — the split needed to separate GPU saturation
1702    /// from blit cost.
1703    ///
1704    /// Returns [`AcquireOutcome::Acquired`] when a texture was stashed (the
1705    /// caller must follow with [`Self::submit`]); otherwise a terminal outcome —
1706    /// [`AcquireOutcome::Reconfigured`] (`Outdated` acquire, surface reconfigured),
1707    /// [`AcquireOutcome::Lost`] (surface dropped, now `SurfaceLost`), or
1708    /// [`AcquireOutcome::Skipped`] (no renderable surface or a transient failure).
1709    /// In any phase but [`SurfacePhase::SurfaceReady`] it is a no-op returning
1710    /// [`AcquireOutcome::Skipped`].
1711    pub fn acquire(&mut self, ctx: &RenderContext) -> Result<AcquireOutcome> {
1712        // Frames are dropped in every phase but SurfaceReady.
1713        if !self.phase().can_render() {
1714            return Ok(AcquireOutcome::Skipped);
1715        }
1716        // Disjoint field borrows: the live surface, the consecutive-Invalid
1717        // counter, and the stash slot (the reusable scene belongs to `encode`).
1718        let Self {
1719            state,
1720            consecutive_invalid,
1721            pending_present,
1722            ..
1723        } = self;
1724        let SurfaceState::Ready(ready) = state else {
1725            // Unreachable: `can_render()` above guaranteed SurfaceReady.
1726            return Ok(AcquireOutcome::Skipped);
1727        };
1728        let ready: &mut ReadySurface = ready;
1729
1730        use wgpu::CurrentSurfaceTexture as Cst;
1731        let acquired = ready.surface.gpu.surface().get_current_texture();
1732        let status = match &acquired {
1733            Cst::Success(_) | Cst::Suboptimal(_) => AcquireStatus::Usable,
1734            Cst::Outdated => AcquireStatus::Outdated,
1735            Cst::Lost => AcquireStatus::Lost,
1736            Cst::Timeout | Cst::Occluded => AcquireStatus::Transient,
1737            Cst::Validation => AcquireStatus::Invalid,
1738        };
1739
1740        let action = decide_acquire(status, *consecutive_invalid);
1741        // Reset-on-success / increment-on-retry / reset-on-give-up (the same
1742        // discipline mirrored from iOS's `recreate_failures`) — pure and
1743        // unit-tested in `next_invalid_streak` itself.
1744        if status == AcquireStatus::Invalid && action == AcquireAction::Lose {
1745            // The cap was hit rather than a genuine `Lost` acquire: log once so
1746            // the giving-up transition is visible before the streak resets.
1747            log::warn!(
1748                "frust-render: giving up on Invalid-acquire reconfigure after \
1749                 {consecutive_invalid} consecutive attempts; surface lost"
1750            );
1751        }
1752        *consecutive_invalid = next_invalid_streak(status, action, *consecutive_invalid);
1753
1754        let outcome = match action {
1755            AcquireAction::Present => {
1756                let surface_texture = match acquired {
1757                    Cst::Success(t) | Cst::Suboptimal(t) => t,
1758                    // `decide_acquire(Usable, _) == Present`, and only Success/
1759                    // Suboptimal classify as Usable — so this is unreachable.
1760                    // Report rather than panic to honour the no-panic invariant.
1761                    _ => {
1762                        return Err(anyhow!("frust-render: acquire classification desync"));
1763                    }
1764                };
1765                // Stash the acquired texture for `submit`; the blocking vsync wait
1766                // ended above, so timing stops here for the acquire sub-span.
1767                *pending_present = Some(surface_texture);
1768                AcquireOutcome::Acquired
1769            }
1770            AcquireAction::Reconfigure => {
1771                ready.surface.reconfigure(&ctx.device_handle().device);
1772                AcquireOutcome::Reconfigured
1773            }
1774            AcquireAction::Lose => {
1775                debug_assert_eq!(
1776                    next_phase(SurfacePhase::SurfaceReady, SurfaceEvent::Lost),
1777                    SurfacePhase::SurfaceLost,
1778                    "Lost must reach SurfaceLost from SurfaceReady"
1779                );
1780                AcquireOutcome::Lost
1781            }
1782            AcquireAction::Skip => AcquireOutcome::Skipped,
1783        };
1784        if matches!(outcome, AcquireOutcome::Lost) {
1785            // Drop the surface and its outstanding resources before returning
1786            // so the shell can recreate cleanly — through the same door
1787            // `install_surface` and `on_surface_destroyed` use, once the field
1788            // borrows above have ended. That also drops the in-flight frame's
1789            // stash: no `submit` will ever consume it now, and a stale
1790            // `base_color` describes a surface that no longer exists. The
1791            // streak was already reset above (`next_invalid_streak`,
1792            // reset-on-give-up); the door zeroing it again is a no-op. The
1793            // shell's own recovery path (recreate on
1794            // resize/redraw/surfaceChanged) then starts a fresh episode.
1795            self.adopt_surface_state(SurfaceState::Lost);
1796        }
1797        Ok(outcome)
1798    }
1799
1800    /// Phase 2b of the frame — the **submit** sub-span: turn the swapchain
1801    /// texture [`Self::acquire`] stashed into a presented frame, then present it.
1802    /// Timing this call in isolation attributes the submit work separately from
1803    /// [`Self::acquire`]'s blocking vsync wait.
1804    ///
1805    /// What the submit span contains: the whole GPU render runs HERE,
1806    /// targeting the acquired swapchain texture (which does not exist until
1807    /// [`Self::acquire`]) or the surface's intermediate, then present. So the
1808    /// frame's GPU cost lands in `submit_us`; `encode_us` is a memcpy.
1809    ///
1810    /// **v3 wire mapping.** The `acquire_us`/`submit_us` field names and the
1811    /// wire format are unchanged (`perf.rs` is untouched). The swapchain
1812    /// **acquire** (the blocking vsync wait) happens in [`Self::acquire`] and
1813    /// is recorded in `acquire_us`, so acquire precedes the GPU render, which
1814    /// lives entirely in this span.
1815    ///
1816    /// Must follow an [`AcquireOutcome::Acquired`] result from [`Self::acquire`]
1817    /// on the same frame — it consumes the stashed texture. With nothing stashed
1818    /// (no prior `Acquired`, or the surface vanished between the two calls) it is
1819    /// a no-op returning [`FrameOutcome::Skipped`]; otherwise
1820    /// [`FrameOutcome::Rendered`].
1821    ///
1822    /// A caller that must issue the present itself, on another thread, calls
1823    /// [`Self::submit_deferred`] instead.
1824    pub fn submit(&mut self, ctx: &RenderContext) -> Result<FrameOutcome> {
1825        #[cfg(feature = "perf-trace")]
1826        let measuring = !self.first_submit_seen;
1827        let SubmitParts {
1828            outcome,
1829            texture: presentable,
1830            ..
1831        } = self.submit_impl(ctx, &mut [])?;
1832        if let Some(surface_texture) = presentable {
1833            #[cfg(feature = "perf-trace")]
1834            let present_start =
1835                (measuring && self.first_submit.is_some()).then(std::time::Instant::now);
1836            ctx.device_handle().queue.present(surface_texture);
1837            #[cfg(feature = "perf-trace")]
1838            if let (Some(start), Some(breakdown)) = (present_start, self.first_submit.as_mut()) {
1839                breakdown.queue = breakdown.queue.saturating_add(start.elapsed());
1840            }
1841        }
1842        Ok(outcome)
1843    }
1844
1845    /// [`Self::submit`] with the final present step handed back to the caller
1846    /// instead of issued here: identical GPU work (render + queue-submit), but
1847    /// the acquired swapchain frame is returned as an opaque, `Send`
1848    /// [`DeferredPresent`] the caller presents on a thread of its choosing.
1849    ///
1850    /// The iOS `presentsWithTransaction` contract is why this exists — see
1851    /// [`DeferredPresent`]'s docs for the full mechanism. Everything else is
1852    /// unchanged: same outcomes, same stash discipline, and
1853    /// `Some(DeferredPresent)` accompanies exactly the
1854    /// [`FrameOutcome::Rendered`] case (every other outcome carries `None`).
1855    /// Dropping the returned handle instead of presenting it skips that frame's
1856    /// present without error.
1857    pub fn submit_deferred(
1858        &mut self,
1859        ctx: &RenderContext,
1860    ) -> Result<(FrameOutcome, Option<DeferredPresent>)> {
1861        let SubmitParts {
1862            outcome,
1863            texture: presentable,
1864            ..
1865        } = self.submit_impl(ctx, &mut [])?;
1866        Ok((
1867            outcome,
1868            // The queue is read INSIDE the map, never before it: `ctx`'s device
1869            // is created lazily by surface creation, and reading it eagerly
1870            // would panic on the no-surface path that returns `None` here.
1871            presentable.map(|texture| DeferredPresent {
1872                texture,
1873                queue: ctx.device_handle().queue.clone(),
1874            }),
1875        ))
1876    }
1877
1878    /// [`Self::submit`] for a frame [`Self::encode_segmented`] cut: the main
1879    /// segment AND every overlay's range recorded into **one** encoder — the
1880    /// external passes, the main segment's passes, then each [`OverlayCut`]'s
1881    /// [`OverlaySurface::record`](crate::overlay::OverlaySurface::record) in
1882    /// the order given — and **one** `Queue::submit`, so the GPU runs every
1883    /// target's work in order and no overlay waits on a submit of its own.
1884    /// Then the presents, on the calling thread: the main surface first, then
1885    /// each overlay that rendered, in cut order (bottom to top).
1886    ///
1887    /// The engine records and this crate submits once per frame, per the
1888    /// frame-recording invariant (its atlas-growth and glyph-replay
1889    /// exceptions aside) — the overlays' engines included. Each target
1890    /// still compiles its own segment: a compiled frame is specific to its
1891    /// target's size and root, so a segmented frame costs one compile per
1892    /// target (the main segment's plus one per overlay).
1893    ///
1894    /// The overlays share nothing with the main surface but its device and
1895    /// queue: their engines keep retention off, which is why a cut holding
1896    /// retained content (or an external texture, or a backdrop blur) was kept
1897    /// on the main surface by the encode — its overlay records an empty range
1898    /// and draws nothing, see [`crate::overlay::cut_is_servable`] — and each
1899    /// schedules its own budget.
1900    ///
1901    /// When the main frame does not render (nothing acquired, a surface gone,
1902    /// or an engine refusal) no overlay records either: every
1903    /// [`OverlayCutOutcome`] is [`OverlayFrame::Skipped`](crate::overlay::OverlayFrame::Skipped),
1904    /// nothing is submitted, and every overlay keeps its previous frame on
1905    /// screen. An overlay the engine refuses on its own is reported in its
1906    /// outcome and presents nothing, while the main frame and the other
1907    /// overlays still land. After a plain [`Self::encode`] the overlays are
1908    /// skipped the same way — there is no cut scene to draw them from.
1909    pub fn submit_segmented(
1910        &mut self,
1911        ctx: &RenderContext,
1912        overlays: &mut [OverlayCut<'_>],
1913    ) -> Result<SegmentedOutcome> {
1914        let parts = self.submit_impl(ctx, overlays)?;
1915        if let Some(texture) = parts.texture {
1916            ctx.device_handle().queue.present(texture);
1917        }
1918        for (cut, outcome) in overlays.iter_mut().zip(&parts.overlays) {
1919            if matches!(
1920                outcome.result,
1921                Ok(crate::overlay::OverlayFrame::Rendered(_))
1922            ) {
1923                cut.overlay.present();
1924            }
1925        }
1926        Ok(SegmentedOutcome {
1927            frame: parts.outcome,
1928            overlays: parts.overlays,
1929            encoders: parts.encoders,
1930            submits: parts.submits,
1931        })
1932    }
1933
1934    /// [`Self::submit_segmented`] with every present handed back instead of
1935    /// issued: identical GPU work, and the main frame plus each rendered
1936    /// overlay's frame returned as a [`SegmentedPresents`] for a host that
1937    /// lands them together itself — on Apple, inside the one `CATransaction`
1938    /// that also carries the native views' geometry
1939    /// ([`SegmentedPresents::present_all`] on the transaction's thread; see
1940    /// [`DeferredPresent`] for why the thread matters).
1941    pub fn submit_segmented_deferred(
1942        &mut self,
1943        ctx: &RenderContext,
1944        overlays: &mut [OverlayCut<'_>],
1945    ) -> Result<(SegmentedOutcome, SegmentedPresents)> {
1946        let parts = self.submit_impl(ctx, overlays)?;
1947        let mut presents = SegmentedPresents {
1948            main: parts.texture.map(|texture| DeferredPresent {
1949                texture,
1950                queue: ctx.device_handle().queue.clone(),
1951            }),
1952            overlays: Vec::new(),
1953        };
1954        for (cut, outcome) in overlays.iter_mut().zip(&parts.overlays) {
1955            if matches!(
1956                outcome.result,
1957                Ok(crate::overlay::OverlayFrame::Rendered(_))
1958            ) && let Some(present) = cut.overlay.take_deferred_present()
1959            {
1960                presents.overlays.push((cut.overlay_id, present));
1961            }
1962        }
1963        Ok((
1964            SegmentedOutcome {
1965                frame: parts.outcome,
1966                overlays: parts.overlays,
1967                encoders: parts.encoders,
1968                submits: parts.submits,
1969            },
1970            presents,
1971        ))
1972    }
1973
1974    /// Shared body of [`Self::submit`]/[`Self::submit_deferred`] and the
1975    /// segmented pair: everything up to (but not including) `Queue::present`,
1976    /// handing the acquired frame back so each wrapper decides where the
1977    /// present happens. `cuts` is empty on the single-target path, which then
1978    /// records and submits exactly what it always did.
1979    fn submit_impl(
1980        &mut self,
1981        ctx: &RenderContext,
1982        cuts: &mut [OverlayCut<'_>],
1983    ) -> Result<SubmitParts> {
1984        // Whether this frame's encode left a cut scene for the overlays;
1985        // consumed by this submit whatever it does.
1986        let segmented = std::mem::take(&mut self.segment_pending);
1987        // Nothing acquired this frame (no prior `Acquired`): nothing to present.
1988        let Some(surface_texture) = self.pending_present.take() else {
1989            return Ok(SubmitParts::skipped(
1990                cuts,
1991                frame_service(segmented, &self.segment_service),
1992            ));
1993        };
1994        // The stashed texture is owned, but the encoded frame (the engine's
1995        // copied scene) lives on `self` — if the surface vanished between
1996        // `acquire` and `submit`, drop the texture and skip rather than
1997        // present a stale frame.
1998        // Only the first rendered frame is measured; a refused or skipped one
1999        // leaves the probe armed for the next.
2000        #[cfg(feature = "perf-trace")]
2001        let mut probe = SubmitProbe::new(!self.first_submit_seen);
2002        #[cfg(not(feature = "perf-trace"))]
2003        let mut probe = SubmitProbe::new(false);
2004        let Self {
2005            state,
2006            pending_base_color,
2007            engine_scene,
2008            segment_scene,
2009            segment_service,
2010            first_frame_warm_up,
2011            ..
2012        } = self;
2013        let service = frame_service(segmented, segment_service);
2014        let SurfaceState::Ready(ready) = state else {
2015            return Ok(SubmitParts::skipped(cuts, service));
2016        };
2017        // The overlays' outcomes and the frame's encoder/submit counts.
2018        let overlays;
2019        let mut encoders = 0_u32;
2020        let mut submits = 0_u32;
2021        let ReadySurface {
2022            surface,
2023            backend,
2024            warm_up_pending,
2025            ..
2026        } = &mut **ready;
2027        let device_handle = ctx.device_handle();
2028        let swapchain_view = surface_texture
2029            .texture
2030            .create_view(&wgpu::TextureViewDescriptor::default());
2031        // Read before the arm is borrowed mutably below (the direct arm's
2032        // backdrop root is created lazily, inside the match).
2033        let surface_format = surface.config().format;
2034        let (surface_width, surface_height) = (surface.config().width, surface.config().height);
2035        let engine_format = engine_target_format(&surface.path, surface_format);
2036
2037        match &mut surface.path {
2038            // The engine tier's whole frame: one encoder, one
2039            // `EngineRenderer::encode` into the acquired swapchain view and
2040            // the surface's own depth attachment, one submit, then the
2041            // engine's end-of-frame maintenance. The renderer records into the
2042            // encoder and never submits, so every pass of the frame lands in
2043            // this one command buffer, in order, against the texture about to
2044            // be presented.
2045            //
2046            // A frame whose scene carries a backdrop blur is the one exception
2047            // to "straight into the swapchain": it reads a region of its own
2048            // target back, which a `RENDER_ATTACHMENT`-only swapchain cannot
2049            // be read from, so it renders into the surface's backdrop root
2050            // (same format, same extent, so the same pipelines and depth
2051            // attachment) and one copy pass presents it — the frame's only
2052            // extra pass, paid on exactly the frames that blur
2053            // (`context::frame_renders_via_intermediate`).
2054            RenderPath::EngineDirect { depth, backdrop } => {
2055                let TierBackend::Engine {
2056                    engine,
2057                    refused_frames,
2058                    shader_quads,
2059                    timestamps,
2060                } = backend;
2061                let root = crate::context::frame_renders_via_intermediate(
2062                    crate::context::RenderPathKind::EngineDirect,
2063                    crate::context::scene_carries_backdrop(engine_scene),
2064                )
2065                .then(|| {
2066                    backdrop.prepare(
2067                        &device_handle.device,
2068                        surface_format,
2069                        surface_width,
2070                        surface_height,
2071                    )
2072                });
2073                let engine_view = root.map_or(&swapchain_view, |(view, _)| view);
2074                // Set in `encode`; a well-formed frame always encoded first —
2075                // the same fallback every other arm takes.
2076                let base_color = pending_base_color.take().unwrap_or(peniko::Color::BLACK);
2077                let mut encoder =
2078                    device_handle
2079                        .device
2080                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2081                            label: Some("frust-render engine"),
2082                        });
2083                encoders += 1;
2084                // Whatever the process registered ahead of the scene:
2085                // caller-supplied passes recorded into this SAME encoder and
2086                // binding their own targets into the engine's external-texture
2087                // registry, plus the unbind of any id whose pass was
2088                // unregistered since the last frame (see
2089                // `crate::external_pass`). Ahead of the shader quads for the
2090                // same two orderings they need, and a no-op — one lock, no
2091                // encoder work — on the process that registered none.
2092                crate::external_pass::run_external_passes(
2093                    &device_handle.device,
2094                    &device_handle.queue,
2095                    &mut encoder,
2096                    engine,
2097                );
2098                // The frame's shader quads, rendered into this SAME encoder
2099                // ahead of the scene's own passes and registered with the
2100                // engine before it compiles: a quad's target must already be
2101                // bound when the display list naming it is walked, and must be
2102                // written before the pass that samples it runs. One encoder
2103                // gives both orderings for free.
2104                shader_quads.prepare(
2105                    &device_handle.device,
2106                    &device_handle.queue,
2107                    &mut encoder,
2108                    engine_scene,
2109                    Affine::IDENTITY,
2110                    engine,
2111                );
2112                // Opens this frame's ring slot and harvests whatever an earlier
2113                // frame left mapped in it — the one place a reading becomes
2114                // visible to `gpu_pass_timings`. A no-op on an inert ring.
2115                timestamps.begin_frame(&device_handle.device);
2116                probe.encode_begin(engine);
2117                settle_first_frame_warm_up(
2118                    warm_up_pending,
2119                    *first_frame_warm_up,
2120                    engine,
2121                    &device_handle.device,
2122                );
2123                let result = engine.encode_traced(
2124                    &device_handle.device,
2125                    &device_handle.queue,
2126                    &mut encoder,
2127                    engine_scene,
2128                    frust_engine::EngineTarget {
2129                        view: engine_view,
2130                        format: surface_format,
2131                        width: surface_width,
2132                        height: surface_height,
2133                        // The surface's own attachment, recreated with the
2134                        // swapchain (`context::RenderPath::EngineDirect`).
2135                        // Nothing else writes it, so it is not pre-cleared:
2136                        // the frame's first depth-using pass clears it.
2137                        depth: Some(depth.view()),
2138                        // The engine's strip pipelines write premultiplied
2139                        // alpha, which is what this surface's swapchain
2140                        // expects — an opaque one ignores alpha, and a
2141                        // premultiplied-expecting translucent one takes it
2142                        // as-is. A swapchain whose compositor genuinely
2143                        // stores STRAIGHT alpha is the other arm below
2144                        // (`context::choose_engine_render_path`); a Metal
2145                        // `PostMultiplied` swapchain (iOS included) lands
2146                        // HERE despite its name, because Metal's own
2147                        // compositor reads that mode premultiplied regardless
2148                        // (`context::compositor_expects_premultiplied`).
2149                        output: frust_engine::OutputAlpha::Premultiplied,
2150                    },
2151                    base_color,
2152                    // The engine arm renders at the surface's own resolution.
2153                    Affine::IDENTITY,
2154                    frust_engine::FrameTimestamps::new(timestamps),
2155                );
2156                probe.encode_end(engine);
2157                match result {
2158                    Ok(()) => {
2159                        // The backdrop root's copy into the swapchain, in the
2160                        // frame's own encoder after every engine pass, charged
2161                        // to `blit` like the straight-alpha arm's conversion.
2162                        if let Some((view, present)) = root {
2163                            present.record(
2164                                &device_handle.device,
2165                                &device_handle.queue,
2166                                &mut encoder,
2167                                view,
2168                                &swapchain_view,
2169                                (surface_width, surface_height),
2170                                frust_engine::FrameTimestamps::new(timestamps)
2171                                    .writes(frust_engine::EngineSpan::Blit),
2172                            );
2173                        }
2174                        // A segmented frame's overlays, into this SAME encoder
2175                        // after every pass of the main segment: one submit
2176                        // carries every target's work, in order. Empty on the
2177                        // single-target path.
2178                        overlays =
2179                            record_overlays(&mut encoder, segmented, segment_scene, cuts, service);
2180                        // `resolve` records the query resolves of EARLIER,
2181                        // completed frames' slots into this encoder; this
2182                        // frame's own queries are resolved by a later frame,
2183                        // once its GPU work has finished. The encoder must be
2184                        // submitted, or `abandon_frame` called if it is
2185                        // dropped, so no claimed slot is left half-resolved.
2186                        timestamps.resolve(&mut encoder);
2187                        probe.queue_begin();
2188                        device_handle.queue.submit([encoder.finish()]);
2189                        submits += 1;
2190                        probe.queue_end();
2191                        timestamps.end_frame();
2192                        engine.end_frame(&device_handle.queue);
2193                        crate::overlay::end_cuts(cuts, true);
2194                    }
2195                    Err(error) => {
2196                        // A refused frame left the encoder exactly as it was
2197                        // found, so there is nothing to submit and the
2198                        // acquired texture holds no frame — presenting it
2199                        // would show undefined content. The frame is dropped
2200                        // instead (the texture goes with this `None`), and the
2201                        // refusal is counted rather than logged per vsync.
2202                        // The ring's slot is abandoned for the same reason
2203                        // nothing is presented: no pass ran, so there is no
2204                        // measurement to map.
2205                        timestamps.abandon_frame();
2206                        *refused_frames = refused_frames.saturating_add(1);
2207                        log_engine_refusal(*refused_frames, &error);
2208                        engine.end_frame(&device_handle.queue);
2209                        return Ok(SubmitParts {
2210                            encoders,
2211                            ..SubmitParts::skipped(cuts, service)
2212                        });
2213                    }
2214                }
2215            }
2216            // The engine tier's straight-alpha arm: the same one-encoder frame
2217            // as above with one pass appended — `engine.encode` into the
2218            // surface's own intermediate, then the un-premultiplying fragment
2219            // pass from that intermediate into the acquired swapchain texture,
2220            // recorded into the SAME encoder so the conversion can never
2221            // execute against a frame that was not submitted with it. One
2222            // submit, then the engine's end-of-frame maintenance.
2223            //
2224            // The deferred-present contract is untouched: this is still
2225            // `submit_impl`, so a caller that hands the frame back
2226            // un-presented (`Self::submit_deferred`) gets a fully converted
2227            // swapchain texture to present inside its own transaction. (iOS
2228            // itself never reaches this arm — its sole translucent mode is a
2229            // Metal `PostMultiplied` swapchain, which
2230            // `context::choose_engine_render_path` now routes onto
2231            // `EngineDirect` above instead; this arm serves a genuinely
2232            // straight-alpha, non-Metal `PostMultiplied` compositor.)
2233            RenderPath::EngineDirectUnpremultiply {
2234                depth,
2235                intermediate_view,
2236                present,
2237            } => {
2238                let TierBackend::Engine {
2239                    engine,
2240                    refused_frames,
2241                    shader_quads,
2242                    timestamps,
2243                } = backend;
2244                let base_color = pending_base_color.take().unwrap_or(peniko::Color::BLACK);
2245                let mut encoder =
2246                    device_handle
2247                        .device
2248                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2249                            label: Some("frust-render engine unpremultiply"),
2250                        });
2251                encoders += 1;
2252                // Whatever the process registered ahead of the scene:
2253                // caller-supplied passes recorded into this SAME encoder and
2254                // binding their own targets into the engine's external-texture
2255                // registry, plus the unbind of any id whose pass was
2256                // unregistered since the last frame (see
2257                // `crate::external_pass`). Ahead of the shader quads for the
2258                // same two orderings they need, and a no-op — one lock, no
2259                // encoder work — on the process that registered none.
2260                crate::external_pass::run_external_passes(
2261                    &device_handle.device,
2262                    &device_handle.queue,
2263                    &mut encoder,
2264                    engine,
2265                );
2266                // The same pre-pass the direct arm records, for the same two
2267                // orderings — the conversion pass appended after the frame
2268                // changes neither of them.
2269                shader_quads.prepare(
2270                    &device_handle.device,
2271                    &device_handle.queue,
2272                    &mut encoder,
2273                    engine_scene,
2274                    Affine::IDENTITY,
2275                    engine,
2276                );
2277                timestamps.begin_frame(&device_handle.device);
2278                probe.encode_begin(engine);
2279                settle_first_frame_warm_up(
2280                    warm_up_pending,
2281                    *first_frame_warm_up,
2282                    engine,
2283                    &device_handle.device,
2284                );
2285                let result = engine.encode_traced(
2286                    &device_handle.device,
2287                    &device_handle.queue,
2288                    &mut encoder,
2289                    engine_scene,
2290                    frust_engine::EngineTarget {
2291                        // The frame lands in the intermediate, never in the
2292                        // swapchain: what the swapchain receives is the
2293                        // conversion's output.
2294                        view: intermediate_view,
2295                        format: engine_format,
2296                        width: surface_width,
2297                        height: surface_height,
2298                        depth: Some(depth.view()),
2299                        // The INTERMEDIATE's own convention, which is
2300                        // premultiplied like every other engine target — the
2301                        // strip pipelines are fixed premultiplied, so this is
2302                        // what keeps the frame's base colour in the same space
2303                        // as everything drawn over it. The SURFACE's straight
2304                        // alpha is what selected this arm
2305                        // (`UnpremultiplyPass::selected_by`) and is served by
2306                        // the pass below, not by relabelling this target.
2307                        output: frust_engine::OutputAlpha::Premultiplied,
2308                    },
2309                    base_color,
2310                    Affine::IDENTITY,
2311                    frust_engine::FrameTimestamps::new(timestamps),
2312                );
2313                probe.encode_end(engine);
2314                match result {
2315                    Ok(()) => {
2316                        // The conversion pass itself: charged to `blit` with a
2317                        // fresh pair from the same ring `encode_traced` just
2318                        // recorded the frame's other spans into — `None` on an
2319                        // inert ring (no `perf-trace`, or the device never got
2320                        // `TIMESTAMP_QUERY`), exactly like every other pass.
2321                        present.record(
2322                            &device_handle.device,
2323                            &mut encoder,
2324                            intermediate_view,
2325                            &swapchain_view,
2326                            frust_engine::FrameTimestamps::new(timestamps)
2327                                .writes(frust_engine::EngineSpan::Blit),
2328                        );
2329                        // The overlays after the conversion, as on the direct
2330                        // arm: one encoder, one submit.
2331                        overlays =
2332                            record_overlays(&mut encoder, segmented, segment_scene, cuts, service);
2333                        timestamps.resolve(&mut encoder);
2334                        probe.queue_begin();
2335                        device_handle.queue.submit([encoder.finish()]);
2336                        submits += 1;
2337                        probe.queue_end();
2338                        timestamps.end_frame();
2339                        engine.end_frame(&device_handle.queue);
2340                        crate::overlay::end_cuts(cuts, true);
2341                    }
2342                    Err(error) => {
2343                        // Identical to the direct arm's refusal, and for the
2344                        // same reason — with one extra consequence worth
2345                        // stating: the conversion pass is NOT recorded either,
2346                        // so the intermediate's stale contents are never
2347                        // converted onto the acquired texture. Nothing is
2348                        // submitted, the frame is dropped, and the previously
2349                        // presented content persists.
2350                        timestamps.abandon_frame();
2351                        *refused_frames = refused_frames.saturating_add(1);
2352                        log_engine_refusal(*refused_frames, &error);
2353                        engine.end_frame(&device_handle.queue);
2354                        return Ok(SubmitParts {
2355                            encoders,
2356                            ..SubmitParts::skipped(cuts, service)
2357                        });
2358                    }
2359                }
2360            }
2361        }
2362        #[cfg(feature = "perf-trace")]
2363        if probe.armed && !self.first_submit_seen {
2364            self.first_submit_seen = true;
2365            self.first_submit = Some(probe.breakdown);
2366        }
2367        // The frame's retained-store commit completed: from here on the
2368        // ledger describes the store after this scene (see `committed_serial`).
2369        if let Some((serial, records)) = self.pending_serial.take() {
2370            self.committed_serial = serial;
2371            self.recording_committed |= records;
2372        }
2373        Ok(SubmitParts {
2374            outcome: FrameOutcome::Rendered,
2375            texture: Some(surface_texture),
2376            overlays,
2377            encoders,
2378            submits,
2379        })
2380    }
2381}
2382
2383/// What [`SurfaceRenderer::submit_impl`] hands its wrappers: the main frame's
2384/// outcome and un-presented texture, the overlays' outcomes, and the frame's
2385/// encoder and submit counts ([`SegmentedOutcome`]'s).
2386struct SubmitParts {
2387    outcome: FrameOutcome,
2388    texture: Option<wgpu::SurfaceTexture>,
2389    overlays: Vec<OverlayCutOutcome>,
2390    encoders: u32,
2391    submits: u32,
2392}
2393
2394impl SubmitParts {
2395    /// A frame that did not render: nothing to present, every overlay
2396    /// skipped (carrying the frame's classification of its cut, `service`),
2397    /// nothing submitted.
2398    fn skipped(cuts: &[OverlayCut<'_>], service: &[CutServiceability]) -> Self {
2399        Self {
2400            outcome: FrameOutcome::Skipped,
2401            texture: None,
2402            overlays: crate::overlay::skipped_cuts(cuts, service),
2403            encoders: 0,
2404            submits: 0,
2405        }
2406    }
2407}
2408
2409/// The overlay half of a segmented frame's encoder: every cut's served range
2410/// of the frame's whole scene (`service`, the encode's classification), when
2411/// the frame's encode was segmented; every cut skipped otherwise (a plain
2412/// `encode` left no whole scene to cut).
2413fn record_overlays(
2414    encoder: &mut wgpu::CommandEncoder,
2415    segmented: bool,
2416    scene: &frust_scene::Scene,
2417    cuts: &mut [OverlayCut<'_>],
2418    service: &[CutServiceability],
2419) -> Vec<OverlayCutOutcome> {
2420    if segmented {
2421        crate::overlay::record_cuts(encoder, scene, cuts, service)
2422    } else {
2423        crate::overlay::skipped_cuts(cuts, service)
2424    }
2425}
2426
2427/// The in-flight frame's cut classification: the segmented encode's, or none
2428/// after a plain encode.
2429fn frame_service(segmented: bool, service: &[CutServiceability]) -> &[CutServiceability] {
2430    if segmented { service } else { &[] }
2431}
2432
2433/// The target format a surface's [`frust_engine::EngineRenderer`] must be
2434/// warmed for, given the arm it was configured on and the format its swapchain
2435/// reports.
2436///
2437/// The engine warms its strip pipelines for exactly one colour format, and a
2438/// pipeline whose colour target disagrees with its attachment is a validation
2439/// error rather than a mis-render. On [`RenderPath::EngineDirect`] the frame's
2440/// attachment IS the swapchain, so that is the format; on
2441/// [`RenderPath::EngineDirectUnpremultiply`] the frame's attachment is the
2442/// intermediate instead, whose format is the engine's own off-screen one
2443/// (`context::create_engine_intermediate` creates it with exactly this), and
2444/// only the conversion pass speaks the swapchain's format.
2445///
2446/// One answer for both the install site and the resize-time drift check
2447/// below, so the two cannot disagree about which format this surface's
2448/// renderer was built for.
2449fn engine_target_format(
2450    path: &RenderPath,
2451    surface_format: wgpu::TextureFormat,
2452) -> wgpu::TextureFormat {
2453    match path {
2454        RenderPath::EngineDirectUnpremultiply { .. } => {
2455            frust_engine::gpu::pipelines::INTERMEDIATE_FORMAT
2456        }
2457        RenderPath::EngineDirect { .. } => surface_format,
2458    }
2459}
2460
2461/// Reports the `count`-th frame this surface's engine renderer refused, under
2462/// [`frust_gpu::context::decide_log_action`]'s latch.
2463///
2464/// A refusal is a per-frame event on a path that can reproduce every vsync — a
2465/// scheduler escalation on a layer shape the engine does not serve, or a
2466/// capacity ceiling a busy frame keeps hitting — so logging each one would
2467/// bury the log without adding information after the first few. The latch is
2468/// the one the uncaptured-`wgpu`-error handler already uses: the first few
2469/// refusals are logged in full, the latch is announced once naming the running
2470/// total, and the periodic debug bump keeps the counter visible afterwards.
2471/// Every line carries the count, so a capture read later says how many frames
2472/// were lost, not merely that some were.
2473fn log_engine_refusal(count: u32, error: &frust_engine::EngineError) {
2474    match frust_gpu::context::decide_log_action(count) {
2475        frust_gpu::context::LogAction::Log => {
2476            log::warn!(
2477                "frust-render: engine refused frame {count} on this surface — {error}; the \
2478                 frame is dropped rather than presented"
2479            );
2480        }
2481        frust_gpu::context::LogAction::SuppressionNotice => {
2482            log::warn!(
2483                "frust-render: further engine frame refusals suppressed (total so far: {count})"
2484            );
2485        }
2486        frust_gpu::context::LogAction::Silent { debug_bump } => {
2487            if debug_bump {
2488                log::debug!(
2489                    "frust-render: engine frame refusals now {count} on this surface (still \
2490                     suppressed)"
2491                );
2492            }
2493        }
2494    }
2495}
2496
2497#[cfg(test)]
2498mod tests {
2499    use super::*;
2500
2501    #[test]
2502    fn the_first_frame_warm_up_strategy_parses_from_either_source() {
2503        use FirstFrameWarmUp::{Await, Finish, Steal};
2504        assert_eq!(
2505            FirstFrameWarmUp::parse(None, None),
2506            FirstFrameWarmUp::DEFAULT
2507        );
2508        assert_eq!(FirstFrameWarmUp::parse(Some("steal"), None), Steal);
2509        assert_eq!(FirstFrameWarmUp::parse(None, Some("AWAIT")), Await);
2510        assert_eq!(
2511            FirstFrameWarmUp::parse(Some("await"), Some("Finish")),
2512            Finish
2513        );
2514        // An empty runtime value falls through to the compile-time one.
2515        assert_eq!(FirstFrameWarmUp::parse(Some("steal"), Some("")), Steal);
2516        assert_eq!(
2517            FirstFrameWarmUp::parse(Some(""), Some("")),
2518            FirstFrameWarmUp::DEFAULT
2519        );
2520        assert_eq!(
2521            FirstFrameWarmUp::parse(None, Some("eager")),
2522            FirstFrameWarmUp::DEFAULT,
2523            "an unknown value keeps the default rather than guessing"
2524        );
2525        for strategy in [Steal, Await, Finish] {
2526            assert_eq!(
2527                FirstFrameWarmUp::parse(None, Some(strategy.name())),
2528                strategy
2529            );
2530        }
2531    }
2532
2533    /// Serializes every test in this binary that creates a GPU device, the
2534    /// same guard the workspace's other GPU suites take: the NVIDIA Vulkan
2535    /// driver serializes `vkDestroyDevice` against other Vulkan work on a
2536    /// process-global mutex, and two tests tearing devices down at once
2537    /// have deadlocked inside it. Poison is ignored deliberately — one
2538    /// test's failure must not cascade into its siblings.
2539    static RENDER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2540
2541    /// A device on the environment's adapter, with the limits production asks
2542    /// for, and that adapter's capabilities.
2543    fn test_device() -> (wgpu::Device, wgpu::Queue, frust_gpu::TierCaps) {
2544        pollster::block_on(async {
2545            let instance = wgpu::Instance::new(
2546                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
2547            );
2548            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
2549            // the GPU on a multi-adapter host instead of the run silently
2550            // landing on whichever one enumerates first.
2551            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
2552                .await
2553                .expect("no compatible GPU adapter");
2554            println!("frust-render engine arm adapter: {:?}", adapter.get_info());
2555            let caps = frust_gpu::TierCaps::probe(&adapter);
2556            // The limits production asks for, not the defaults, so the target
2557            // this test builds is sized against the same ceiling
2558            // `context::extent_within_limits` refuses on.
2559            let limits = crate::context::effective_limits(
2560                adapter.limits(),
2561                crate::context::is_ios_simulator(),
2562            );
2563            let (device, queue) = adapter
2564                .request_device(&wgpu::DeviceDescriptor {
2565                    label: Some("frust-render engine arm test"),
2566                    required_features: wgpu::Features::empty(),
2567                    required_limits: limits,
2568                    ..Default::default()
2569                })
2570                .await
2571                .expect("failed to create device");
2572            (device, queue, caps)
2573        })
2574    }
2575
2576    #[test]
2577    fn starts_with_no_surface() {
2578        let renderer = SurfaceRenderer::new();
2579        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2580    }
2581
2582    /// One surface episode ending drops everything scoped to it: the
2583    /// `Invalid`-reconfigure streak and the stashed base colour, which
2584    /// described a frame of the surface being replaced.
2585    ///
2586    /// Asserted through `adopt_surface_state` itself — the single door
2587    /// `install_surface` and `on_surface_destroyed` both go through — rather
2588    /// than by re-typing their reset statements here. A previous version of
2589    /// this test did the latter and would have stayed green if either caller
2590    /// stopped resetting anything at all.
2591    #[test]
2592    fn adopting_a_surface_state_drops_the_previous_frames_stashes() {
2593        let mut renderer = SurfaceRenderer::new();
2594        renderer.pending_base_color = Some(peniko::Color::WHITE);
2595        renderer.consecutive_invalid = 3;
2596
2597        renderer.adopt_surface_state(SurfaceState::NoSurface);
2598
2599        assert!(renderer.pending_base_color.is_none());
2600        assert_eq!(renderer.consecutive_invalid, 0);
2601        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2602        // Idempotent, as the repeated-`Destroyed` contract needs.
2603        renderer.adopt_surface_state(SurfaceState::NoSurface);
2604        assert!(renderer.pending_base_color.is_none());
2605        assert_eq!(renderer.consecutive_invalid, 0);
2606    }
2607
2608    /// The give-up arm of `acquire` leaves through the same door: adopting
2609    /// [`SurfaceState::Lost`] drops the in-flight stash, so a surface that
2610    /// went away mid-frame cannot hand its base colour to the next one.
2611    #[test]
2612    fn adopting_the_lost_state_drops_the_dying_surfaces_stashes() {
2613        let mut renderer = SurfaceRenderer::new();
2614        renderer.pending_base_color = Some(peniko::Color::WHITE);
2615
2616        renderer.adopt_surface_state(SurfaceState::Lost);
2617
2618        assert_eq!(renderer.phase(), SurfacePhase::SurfaceLost);
2619        assert!(
2620            renderer.pending_base_color.is_none(),
2621            "a lost surface's base colour must not outlive it"
2622        );
2623    }
2624
2625    #[test]
2626    fn resolved_translucent_is_false_without_a_live_surface() {
2627        // The Mode A default: with no surface installed
2628        // there is nothing proven translucent, so a shell reading this before
2629        // its first install keeps the opaque paint contract rather than
2630        // punching holes it can't back.
2631        let renderer = SurfaceRenderer::new();
2632        assert!(!renderer.surface_resolved_translucent());
2633    }
2634
2635    #[test]
2636    fn deferred_present_is_send() {
2637        // The whole point of `DeferredPresent` is crossing a thread boundary
2638        // (render thread → the thread committing the CATransaction), so pin the
2639        // auto-trait: losing it would break `frust-shell-ios`'s present-sync
2640        // handoff at a distance, in a crate that can't see this type's fields.
2641        fn assert_send<T: Send>() {}
2642        assert_send::<DeferredPresent>();
2643    }
2644
2645    #[test]
2646    fn submit_deferred_yields_no_frame_without_a_surface() {
2647        // No GPU needed: with nothing acquired (no surface at all) the deferred
2648        // submit skips exactly like `submit`, and hands back no present handle —
2649        // `Some(..)` accompanies only `Rendered`.
2650        let ctx = RenderContext::new();
2651        let mut renderer = SurfaceRenderer::new();
2652
2653        let (outcome, deferred) = renderer
2654            .submit_deferred(&ctx)
2655            .expect("submit_deferred in NoSurface must not error");
2656        assert_eq!(outcome, FrameOutcome::Skipped);
2657        assert!(deferred.is_none());
2658    }
2659
2660    #[test]
2661    fn render_is_skipped_without_a_surface() {
2662        // No GPU needed: a `NoSurface` renderer short-circuits before any wgpu
2663        // work. `RenderContext::new()` only builds a wgpu `Instance` (no device).
2664        let ctx = RenderContext::new();
2665        let mut renderer = SurfaceRenderer::new();
2666        let scene = frust_scene::Scene::new();
2667
2668        let outcome = renderer
2669            .render(&ctx, &scene, peniko::Color::WHITE)
2670            .expect("render in NoSurface must not error");
2671        assert_eq!(outcome, FrameOutcome::Skipped);
2672    }
2673
2674    /// Destroy is idempotent, reaches `NoSurface`, and drops the in-flight
2675    /// stash: a stale `base_color` describes the dead surface's frame, not
2676    /// the next one's.
2677    #[test]
2678    fn destroy_is_idempotent_and_resets_to_no_surface() {
2679        let mut renderer = SurfaceRenderer::new();
2680        renderer.pending_base_color = Some(peniko::Color::WHITE);
2681
2682        renderer.on_surface_destroyed();
2683        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2684        assert!(
2685            renderer.pending_base_color.is_none(),
2686            "the dying surface's base colour must not reach the next submit"
2687        );
2688
2689        renderer.on_surface_destroyed();
2690        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2691        assert!(renderer.pending_base_color.is_none());
2692    }
2693
2694    #[test]
2695    fn resize_without_surface_is_a_no_op() {
2696        let ctx = RenderContext::new();
2697        let mut renderer = SurfaceRenderer::new();
2698        renderer.on_surface_changed(&ctx, 800, 600);
2699        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2700    }
2701
2702    #[test]
2703    fn pipeline_cache_data_is_none_without_a_surface() {
2704        // No GPU needed: with no installed surface there is no live cache to
2705        // read back, regardless of whether a blob was restored.
2706        let mut renderer = SurfaceRenderer::new();
2707        assert_eq!(renderer.pipeline_cache_data(), None);
2708        renderer.set_initial_pipeline_cache_data(Some(vec![1, 2, 3, 4]));
2709        assert_eq!(renderer.pipeline_cache_data(), None);
2710    }
2711
2712    #[test]
2713    fn set_initial_pipeline_cache_data_none_clears_the_blob() {
2714        // Setter is total and side-effect-free without a surface; clearing to
2715        // `None` (a cold start) is a no-op on the observable `NoSurface` state.
2716        let mut renderer = SurfaceRenderer::new();
2717        renderer.set_initial_pipeline_cache_data(Some(vec![9, 9, 9]));
2718        renderer.set_initial_pipeline_cache_data(None);
2719        assert_eq!(renderer.phase(), SurfacePhase::NoSurface);
2720        assert_eq!(renderer.pipeline_cache_data(), None);
2721    }
2722
2723    /// The engine arm's real-device acceptance, headless.
2724    ///
2725    /// A windowed run is the arm's own acceptance and cannot happen on a box
2726    /// with no display server, so this drives the exact `EngineTarget` the
2727    /// engine arm of [`SurfaceRenderer::submit`] builds — the surface's
2728    /// reported format at `RENDER_ATTACHMENT`, the surface-owned `Depth24Plus`
2729    /// attachment, premultiplied output, the identity root — against an
2730    /// offscreen target of the same shape, under a `wgpu` validation error
2731    /// scope. What it proves is what the windowed run would: this seam's
2732    /// target construction is accepted by a real device and produces the
2733    /// frame's pixels. What it cannot prove is the swapchain half (acquire,
2734    /// present, alpha-mode compositing), which stays owed to a display.
2735    ///
2736    /// Run over BOTH surface formats, since which one a swapchain reports
2737    /// first is the platform's business and the engine warms its pipelines for
2738    /// exactly the one it is handed.
2739    #[test]
2740    #[ignore = "requires a GPU; run locally with `cargo test -p frust-render -- --ignored`"]
2741    fn engine_arm_records_a_frame_into_its_target_without_validation_errors() {
2742        let _serialized = RENDER_LOCK
2743            .lock()
2744            .unwrap_or_else(std::sync::PoisonError::into_inner);
2745
2746        const SIZE: u32 = 64;
2747
2748        let (device, queue, caps) = test_device();
2749
2750        let mut scene = frust_scene::Scene::new();
2751        {
2752            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
2753            builder.fill_rect(
2754                kurbo::Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64),
2755                peniko::Brush::Solid(peniko::color::palette::css::RED),
2756            );
2757        }
2758
2759        for format in frust_gpu::SURFACE_FORMATS {
2760            let target = frust_gpu::HeadlessTarget::new(&device, SIZE, SIZE, format);
2761            let depth = frust_engine::DepthTexture::new(&device, SIZE, SIZE);
2762            let mut engine = frust_engine::EngineRenderer::new(&device, &caps, format, None)
2763                .expect("failed to create the engine renderer");
2764
2765            let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
2766            let mut encoder =
2767                device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
2768            engine
2769                .encode(
2770                    &device,
2771                    &queue,
2772                    &mut encoder,
2773                    &scene,
2774                    frust_engine::EngineTarget {
2775                        view: target.view(),
2776                        format,
2777                        width: SIZE,
2778                        height: SIZE,
2779                        depth: Some(depth.view()),
2780                        output: frust_engine::OutputAlpha::Premultiplied,
2781                    },
2782                    peniko::Color::BLACK,
2783                    Affine::IDENTITY,
2784                )
2785                .expect("the engine refused a plain opaque frame");
2786            queue.submit([encoder.finish()]);
2787            engine.end_frame(&queue);
2788
2789            // Pop the scope by polling the device, the shape every GPU suite
2790            // in this workspace uses: the pop resolves only once the queue has
2791            // been pumped.
2792            let error = {
2793                use std::task::{Context, Poll, Waker};
2794                let waker = Waker::noop();
2795                let mut cx = Context::from_waker(waker);
2796                let mut pop = std::pin::pin!(scope.pop());
2797                loop {
2798                    match pop.as_mut().poll(&mut cx) {
2799                        Poll::Ready(error) => break error,
2800                        Poll::Pending => {
2801                            let _ = device.poll(wgpu::PollType::wait_indefinitely());
2802                        }
2803                    }
2804                }
2805            };
2806            assert!(
2807                error.is_none(),
2808                "the engine arm's {format:?} target raised a validation error: {error:?}"
2809            );
2810
2811            let pixels = target.read_back(&device, &queue);
2812            let index = (((SIZE / 2) * SIZE + (SIZE / 2)) * 4) as usize;
2813            let centre: [u8; 4] = pixels[index..index + 4]
2814                .try_into()
2815                .expect("a read-back row holds four bytes per pixel");
2816            // Channel order differs between the two formats, so the assertion
2817            // that holds for both is "opaque, and not the black it was cleared
2818            // to" — the red rect reached the target.
2819            assert_eq!(centre[3], 255, "{format:?}: the frame is not opaque");
2820            assert!(
2821                centre[0] != 0 || centre[1] != 0 || centre[2] != 0,
2822                "{format:?}: the target still holds its clear colour ({centre:?})"
2823            );
2824        }
2825    }
2826
2827    /// The ledger a shell publishes, without a surface: empty, handed out once
2828    /// by `take_retained_ledger` and not again until something changes — and a
2829    /// surface episode starting or ending is such a change, so a fresh or
2830    /// torn-down surface always publishes its empty ledger.
2831    #[test]
2832    fn the_retained_ledger_is_published_once_per_change_without_a_surface() {
2833        let mut renderer = SurfaceRenderer::new();
2834        assert_eq!(renderer.retained_ledger(), ResidentPages::default());
2835
2836        assert_eq!(
2837            renderer.take_retained_ledger(),
2838            Some(ResidentPages::default()),
2839            "the first take always publishes"
2840        );
2841        assert_eq!(
2842            renderer.take_retained_ledger(),
2843            None,
2844            "nothing changed since"
2845        );
2846
2847        renderer.adopt_surface_state(SurfaceState::NoSurface);
2848        assert_eq!(
2849            renderer.take_retained_ledger(),
2850            Some(ResidentPages::default()),
2851            "an episode ending publishes the empty ledger again"
2852        );
2853        assert!(!renderer.take_retained_refusal());
2854        // Nothing to clear, and nothing to fail.
2855        renderer.clear_retained();
2856        assert_eq!(renderer.take_retained_ledger(), None);
2857
2858        renderer.on_surface_destroyed();
2859        assert_eq!(
2860            renderer.take_retained_ledger(),
2861            Some(ResidentPages::default()),
2862            "a surface destroy publishes the empty ledger, once"
2863        );
2864        assert_eq!(renderer.take_retained_ledger(), None);
2865    }
2866
2867    /// The serial a shell names is committed only by a frame that rendered: a
2868    /// frame skipped for want of a surface leaves the ledger's serial where it
2869    /// was, an episode ending drops the in-flight one, and the empty ledger a
2870    /// destroy publishes still carries the last committed serial.
2871    #[test]
2872    fn only_a_rendered_frame_commits_its_scene_serial() {
2873        let mut renderer = SurfaceRenderer::new();
2874        let ctx = RenderContext::new();
2875        renderer.note_scene_serial(9);
2876        let outcome = renderer
2877            .encode(&ctx, &frust_scene::Scene::new(), peniko::Color::BLACK)
2878            .expect("no surface is not an error");
2879        assert_eq!(outcome, EncodeOutcome::Skipped);
2880        assert_eq!(renderer.pending_serial, None, "nothing was encoded");
2881        assert_eq!(renderer.take_retained_ledger().map(|l| l.frame), Some(0));
2882
2883        // As if scene 4 had been committed and scene 5 was in flight.
2884        renderer.committed_serial = 4;
2885        renderer.pending_serial = Some((5, true));
2886        renderer.on_surface_destroyed();
2887        assert_eq!(renderer.pending_serial, None, "the in-flight scene is gone");
2888        assert_eq!(
2889            renderer.take_retained_ledger(),
2890            Some(ResidentPages {
2891                pages: Vec::new(),
2892                frame: 4,
2893            }),
2894            "the empty ledger names the last committed scene"
2895        );
2896    }
2897
2898    #[test]
2899    fn a_scene_records_a_retained_page_only_through_a_push_retained_bracket() {
2900        let rect = kurbo::Rect::new(0.0, 0.0, 4.0, 4.0);
2901        let mut scene = frust_scene::Scene::new();
2902        {
2903            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
2904            builder.draw_retained(3, rect, 1.0, 1.0);
2905        }
2906        assert!(!scene_records_retained(&scene), "a draw records nothing");
2907        {
2908            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
2909            builder.push_retained(3, rect, 1.0, 1.0);
2910            builder.pop_retained();
2911        }
2912        assert!(scene_records_retained(&scene));
2913    }
2914
2915    /// A device-scale change clears the store and owes an EMPTY publish (even
2916    /// over an already-empty store); the same scale again changes nothing.
2917    #[test]
2918    fn a_device_scale_change_clears_and_republishes_the_empty_ledger() {
2919        let mut renderer = SurfaceRenderer::new();
2920        assert_eq!(
2921            renderer.take_retained_ledger(),
2922            Some(ResidentPages::default())
2923        );
2924
2925        assert!(renderer.note_device_scale(2.0), "the first scale noted");
2926        assert_eq!(
2927            renderer.take_retained_ledger(),
2928            Some(ResidentPages::default()),
2929            "a scale change publishes the empty ledger"
2930        );
2931
2932        assert!(!renderer.note_device_scale(2.0), "same scale: a no-op");
2933        assert_eq!(renderer.take_retained_ledger(), None, "and owes nothing");
2934
2935        assert!(renderer.note_device_scale(3.0));
2936        assert_eq!(
2937            renderer.take_retained_ledger(),
2938            Some(ResidentPages::default())
2939        );
2940        assert_eq!(renderer.take_retained_ledger(), None);
2941
2942        // A surface episode ending is not a scale change: the scale survives
2943        // it, so a recreate at the same scale owes nothing beyond the
2944        // episode's own empty publish.
2945        renderer.on_surface_destroyed();
2946        assert_eq!(
2947            renderer.take_retained_ledger(),
2948            Some(ResidentPages::default())
2949        );
2950        assert!(!renderer.note_device_scale(3.0));
2951        assert_eq!(renderer.take_retained_ledger(), None);
2952    }
2953
2954    /// What `retained_ledger` reports for a live engine, on a real device: the
2955    /// key a `PushRetained` body was recorded under, at the extent and scale
2956    /// its rectangle covered.
2957    #[test]
2958    #[ignore = "requires a GPU; run locally with `cargo test -p frust-render -- --ignored`"]
2959    fn the_retained_ledger_reports_what_the_engine_holds() {
2960        let _serialized = RENDER_LOCK
2961            .lock()
2962            .unwrap_or_else(std::sync::PoisonError::into_inner);
2963
2964        const SIZE: u32 = 64;
2965        const KEY: u64 = 41;
2966        let (device, queue, caps) = test_device();
2967        let format = wgpu::TextureFormat::Rgba8Unorm;
2968        let target = frust_gpu::HeadlessTarget::new(&device, SIZE, SIZE, format);
2969        let mut engine = frust_engine::EngineRenderer::new(&device, &caps, format, None)
2970            .expect("failed to create the engine renderer");
2971
2972        let mut scene = frust_scene::Scene::new();
2973        {
2974            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
2975            builder.push_transform(Affine::translate((8.0, 8.0)) * Affine::scale(2.0));
2976            builder.push_retained(KEY, kurbo::Rect::new(0.0, 0.0, 16.0, 12.0), 0.5, 1.0);
2977            builder.fill_rect(
2978                kurbo::Rect::new(0.0, 0.0, 16.0, 12.0),
2979                peniko::Brush::Solid(peniko::color::palette::css::RED),
2980            );
2981            builder.pop_retained();
2982            builder.pop_transform();
2983        }
2984        assert!(resident_pages(&engine, 0).pages.is_empty());
2985
2986        let mut encoder =
2987            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
2988        engine
2989            .encode(
2990                &device,
2991                &queue,
2992                &mut encoder,
2993                &scene,
2994                frust_engine::EngineTarget {
2995                    view: target.view(),
2996                    format,
2997                    width: SIZE,
2998                    height: SIZE,
2999                    depth: None,
3000                    output: frust_engine::OutputAlpha::Premultiplied,
3001                },
3002                peniko::Color::BLACK,
3003                Affine::IDENTITY,
3004            )
3005            .expect("the engine refused a retained frame");
3006        queue.submit([encoder.finish()]);
3007        engine.end_frame(&queue);
3008
3009        let resident = resident_pages(&engine, 7);
3010        assert_eq!(
3011            resident.pages,
3012            vec![ResidentPage {
3013                key: KEY,
3014                extent_px: (32, 24),
3015                scale: 2.0,
3016            }],
3017            "the body's page is reported at its rasterized extent and scale"
3018        );
3019        assert_eq!(resident.frame, 7, "stamped with the committed serial");
3020    }
3021}