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