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