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}