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