Skip to main content

cranpose_render_wgpu/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub(crate) use cranpose_render_common::debug_toggles;
4pub use debug_toggles::{
5    DebugToggle, debug_toggle, debug_toggle_os, set_debug_toggle, set_debug_toggle_os,
6};
7pub use offscreen::COMPOSITION_BYTES_PER_PIXEL;
8pub use render::presentable_root_usages;
9mod ablation;
10mod arc_trig_fill;
11mod capture_hash;
12mod collect;
13mod depth_gate;
14mod draw_pass;
15mod effect_renderer;
16mod fast_cores;
17mod frame;
18mod geometry;
19mod idle_pool;
20mod layer_cache;
21pub use fast_cores::pin_current_thread_to_fast_cores;
22mod fixed_pipeline;
23mod frame_graph;
24mod frame_packet;
25mod frontend;
26mod glass_split;
27/// The GLSL templates of the built-in shader modules, which `build.rs` wrote.
28#[cfg(all(target_arch = "wasm32", feature = "gl-templates"))]
29mod gl_templates {
30    include!(concat!(env!("OUT_DIR"), "/gl_templates.rs"));
31}
32#[cfg(test)]
33#[path = "tests/gles_lowering.rs"]
34mod gles_lowering;
35mod glyph_run;
36mod glyph_run_arena;
37pub(crate) mod gpu_stats;
38mod initial_present;
39mod lazy_resource;
40mod offscreen;
41mod opaque_prefix;
42mod output_conversion;
43pub(crate) mod pass_timing;
44mod pipeline;
45mod pipeline_compiler;
46#[cfg(not(target_arch = "wasm32"))]
47pub mod pipeline_disk_cache;
48mod pipeline_recorder;
49mod pipeline_records;
50#[cfg(not(target_arch = "wasm32"))]
51mod present_runtime;
52mod record_columns;
53mod render;
54mod rrect_shadow;
55mod run_geometry;
56mod run_store;
57mod scene;
58mod shader_cache;
59#[cfg(not(target_arch = "wasm32"))]
60mod shader_flatten;
61#[cfg(not(target_arch = "wasm32"))]
62mod shader_fold;
63mod shaders;
64mod shape_pipelines;
65mod shared_shader;
66#[cfg(test)]
67mod test_support;
68mod thread_state;
69
70use std::{rc::Rc, sync::Arc};
71
72use cranpose_core::{MemoryApplier, NodeId};
73use cranpose_render_common::{
74    RenderScene, Renderer,
75    graph::RenderGraph,
76    software_text_raster::{
77        SoftwareTextFontSet, SoftwareTextMeasurer, software_text_font_set_from_fonts_or_default,
78    },
79};
80use cranpose_ui::{LayoutTree, TextMeasurer};
81use cranpose_ui_graphics::{Rect, Size};
82pub use frame_packet::PresentTimings;
83
84/// Platform code the present thread runs around every present to a surface,
85/// for what only the platform can do with its swapchain, such as asking the
86/// display when earlier frames reached the screen.
87#[cfg(not(target_arch = "wasm32"))]
88pub trait PresentObserver: Send {
89    /// Runs just before `surface` presents a frame.
90    fn before_present(&mut self, surface: &wgpu::Surface<'static>);
91    /// Runs just after `surface` presented it.
92    fn after_present(&mut self, surface: &wgpu::Surface<'static>);
93
94    /// Runs before `surface` is configured anew, while its current swapchain
95    /// still stands. Does nothing unless the platform needs it to.
96    fn before_reconfigure(&mut self, _surface: &wgpu::Surface<'static>) {}
97}
98use frame_packet::RenderReturns;
99#[doc(hidden)]
100pub use frame_packet::{CancelReason, PresentOutcome};
101use frontend::{DevOverlayCache, RendererFrontend};
102pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
103pub use initial_present::{clear_to_background, clear_to_default_background};
104pub use pass_timing::{GpuPassTimingEntry, GpuPassTimingReport};
105use pipeline_compiler::PipelineCompilation;
106#[cfg(not(target_arch = "wasm32"))]
107use present_runtime::{
108    PresentControl, PresentHandle, PresentMsg, PresentRuntimeInit, PresentState,
109};
110use render::{GpuRenderer, GpuRendererInit};
111pub use render::{
112    frame_clear_color, frames_presented, pipelines_created, pipelines_created_off_frame,
113};
114pub use scene::{HitRegion, Scene};
115pub use shared_shader::{shader_stages_flattened, shader_stages_folded};
116
117/// The optional device features the renderer exploits when the adapter
118/// offers them: pipeline caching (see `pipeline_disk_cache`), the
119/// timestamp queries behind `CRANPOSE_GPU_PASS_TIMING`, and, on a GPU
120/// that shares the host's memory, vertex and uniform buffers the CPU
121/// writes directly, so a frame's uploads need no copy. Every platform's
122/// `request_device` passes this so a profiling toggle never needs a rebuilt
123/// binary; intersecting with the adapter's own features keeps the request
124/// valid on adapters without them.
125pub fn optional_device_features(adapter: &wgpu::Adapter) -> wgpu::Features {
126    let mut wanted = wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY;
127    if matches!(
128        adapter.get_info().device_type,
129        wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::Cpu
130    ) {
131        wanted |= wgpu::Features::MAPPABLE_PRIMARY_BUFFERS;
132    }
133    adapter.features() & wanted
134}
135
136#[doc(hidden)]
137pub fn offscreen_render_target_for_tests(
138    device: &wgpu::Device,
139    width: u32,
140    height: u32,
141    label: &str,
142) -> (wgpu::Texture, wgpu::TextureView) {
143    let texture = offscreen::create_2d_texture(
144        device,
145        wgpu::TextureFormat::Rgba8Unorm,
146        width,
147        height,
148        wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
149        Some(label),
150    );
151    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
152    (texture, view)
153}
154
155pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
156    [
157        [rect.x, rect.y],
158        [rect.x + rect.width, rect.y],
159        [rect.x, rect.y + rect.height],
160        [rect.x + rect.width, rect.y + rect.height],
161    ]
162}
163
164#[derive(Debug)]
165pub enum WgpuRendererError {
166    Layout(String),
167    Wgpu(String),
168}
169
170/// CPU-readable RGBA frame captured from the renderer output.
171#[derive(Debug, Clone)]
172pub struct CapturedFrame {
173    pub width: u32,
174    pub height: u32,
175    pub pixels: Vec<u8>,
176}
177
178#[doc(hidden)]
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct DebugCpuAllocationStats {
181    pub scene_graph_node_count: usize,
182    pub scene_graph_heap_bytes: usize,
183    pub scene_hits_len: usize,
184    pub scene_hits_cap: usize,
185    pub scene_node_index_len: usize,
186    pub scene_node_index_cap: usize,
187    pub text_renderer_pool_len: usize,
188    pub text_renderer_pool_cap: usize,
189    pub image_texture_cache_len: usize,
190    pub image_texture_cache_cap: usize,
191    pub run_arena_staging_bytes: usize,
192    pub run_store_bytes: usize,
193    pub run_store_runs: usize,
194    pub scratch_image_vertices_cap: usize,
195    pub scratch_image_indices_cap: usize,
196    pub scratch_image_cmds_cap: usize,
197    pub scratch_glyph_instances_cap: usize,
198    pub layer_cache_len: usize,
199    pub layer_cache_bytes: u64,
200}
201
202/// The device a renderer draws with and the surface format it presents.
203struct GpuTarget {
204    device: Arc<wgpu::Device>,
205    queue: Arc<wgpu::Queue>,
206    surface_format: wgpu::TextureFormat,
207    adapter_backend: wgpu::Backend,
208    adapter_downlevel: wgpu::DownlevelFlags,
209}
210
211pub(crate) struct TextSystemState {
212    measurer: SoftwareTextMeasurer,
213}
214
215impl TextSystemState {
216    fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
217        Self {
218            measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
219        }
220    }
221
222    pub(crate) fn text_cache_len(&self) -> usize {
223        0
224    }
225}
226
227impl pipeline::TextLayoutResolver for TextSystemState {
228    fn layout_text(
229        &mut self,
230        text: &cranpose_ui::text::AnnotatedString,
231        style: &cranpose_ui::text::TextStyle,
232    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
233        if cranpose_ui::has_current_app_context() {
234            cranpose_ui::text::layout_text(text, style)
235        } else {
236            self.measurer.layout(text, style)
237        }
238    }
239}
240
241#[derive(Clone)]
242pub struct WgpuTextSystem {
243    software_fonts: SoftwareTextFontSet,
244}
245
246impl WgpuTextSystem {
247    pub fn from_fonts(fonts: &[&'static [u8]]) -> Self {
248        Self {
249            software_fonts: software_text_font_set_from_fonts_or_default(fonts),
250        }
251    }
252
253    /// Adopt a font set an app already built — the path app-supplied families
254    /// take, where faces were parsed once at startup rather than from static
255    /// byte slices here.
256    pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
257        Self { software_fonts }
258    }
259
260    pub(crate) fn render_state(&self) -> TextSystemState {
261        TextSystemState::from_font_set(self.software_fonts.clone())
262    }
263
264    pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
265        self.software_fonts.clone()
266    }
267}
268
269/// Create an accurate WGPU text measurer for headless tests without launching a window.
270pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
271    headless_text_measurer_with_fonts(&[])
272}
273
274/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
275pub fn headless_text_measurer_with_fonts(fonts: &[&'static [u8]]) -> Rc<dyn TextMeasurer> {
276    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
277}
278
279enum PresentBackend {
280    None,
281    Sync(Box<GpuRenderer>),
282    #[cfg(not(target_arch = "wasm32"))]
283    Threaded(PresentHandle),
284}
285
286/// What [`WgpuRenderer::publish_frame`] did.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum PublishOutcome {
289    /// No scene graph exists; nothing to lower.
290    NoGraph,
291    /// The depth-one slot is occupied (or the renderer is not in threaded
292    /// mode): NO packet was built — backpressure lands before lowering.
293    NoCredit,
294    /// A packet was built and handed to the present runtime.
295    Published,
296}
297
298/// WGPU-based renderer for GPU-accelerated 2D rendering.
299///
300/// This renderer supports:
301/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
302/// - Gradients (solid, linear, radial)
303/// - GPU text rendering via retained raster image batches
304/// - Cross-platform support (Desktop, Web, Android)
305pub struct WgpuRenderer {
306    frontend: RendererFrontend,
307    backend: PresentBackend,
308    renderer_epoch: u64,
309    surface_epoch: u64,
310}
311
312impl WgpuRenderer {
313    /// Create a new WGPU renderer.
314    ///
315    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
316    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
317    ///
318    /// Call [`init_gpu`][Self::init_gpu] before rendering.
319    pub fn new(fonts: &[&'static [u8]]) -> Self {
320        Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
321    }
322
323    /// Create a renderer over an already-parsed font set.
324    ///
325    /// Measurement and rasterization both take clones of this one set, so an
326    /// app-supplied family resolves identically on both sides.
327    pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
328        Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
329    }
330
331    pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
332        Self {
333            frontend: RendererFrontend::new(
334                text_system.render_state(),
335                text_system.software_fonts(),
336            ),
337            backend: PresentBackend::None,
338            renderer_epoch: 0,
339            surface_epoch: 0,
340        }
341    }
342
343    fn sync_gpu_renderer(&self) -> Option<&GpuRenderer> {
344        match &self.backend {
345            PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_ref()),
346            _ => None,
347        }
348    }
349
350    #[cfg(not(target_arch = "wasm32"))]
351    fn present_handle_mut(&mut self) -> Option<&mut PresentHandle> {
352        match &mut self.backend {
353            PresentBackend::Threaded(handle) => Some(handle),
354            _ => None,
355        }
356    }
357
358    fn retire_live_backend(&mut self) {
359        #[allow(unused_mut)]
360        let mut backend = std::mem::replace(&mut self.backend, PresentBackend::None);
361        #[cfg(not(target_arch = "wasm32"))]
362        if let PresentBackend::Threaded(handle) = &mut backend {
363            while let Some(returns) = handle.try_drain() {
364                self.frontend.apply_returns(returns);
365            }
366            handle.shutdown();
367        }
368        drop(backend);
369    }
370
371    /// Initialize GPU resources with a WGPU device and queue.
372    ///
373    /// Replacing a live renderer (Android surface recreation, device loss)
374    /// bumps the renderer epoch, so a packet built against the previous
375    /// renderer is cancelled instead of drawn.
376    pub fn init_gpu(
377        &mut self,
378        device: Arc<wgpu::Device>,
379        queue: Arc<wgpu::Queue>,
380        surface_format: wgpu::TextureFormat,
381        adapter_backend: wgpu::Backend,
382        adapter_downlevel: wgpu::DownlevelFlags,
383    ) {
384        let target = GpuTarget {
385            device,
386            queue,
387            surface_format,
388            adapter_backend,
389            adapter_downlevel,
390        };
391        self.install_gpu(target, PipelineCompilation::Background);
392    }
393
394    /// [`init_gpu`][Self::init_gpu], compiling every pipeline where it is
395    /// first needed instead of on the background compiler: no frame draws
396    /// with a stand-in, which a reference frame in a test must not.
397    #[doc(hidden)]
398    pub fn init_gpu_compiling_inline_for_tests(
399        &mut self,
400        device: Arc<wgpu::Device>,
401        queue: Arc<wgpu::Queue>,
402        surface_format: wgpu::TextureFormat,
403        adapter_backend: wgpu::Backend,
404        adapter_downlevel: wgpu::DownlevelFlags,
405    ) {
406        let target = GpuTarget {
407            device,
408            queue,
409            surface_format,
410            adapter_backend,
411            adapter_downlevel,
412        };
413        self.install_gpu(target, PipelineCompilation::Inline);
414    }
415
416    fn install_gpu(&mut self, target: GpuTarget, pipeline_compilation: PipelineCompilation) {
417        let init = self.next_gpu_init(target, pipeline_compilation);
418        self.backend = PresentBackend::Sync(Box::new(GpuRenderer::new(init)));
419    }
420
421    /// Retires the live backend and bumps the renderer epoch, then describes
422    /// the renderer that replaces it on `target`.
423    fn next_gpu_init(
424        &mut self,
425        target: GpuTarget,
426        pipeline_compilation: PipelineCompilation,
427    ) -> GpuRendererInit {
428        self.retire_live_backend();
429        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
430        GpuRendererInit {
431            device: target.device,
432            queue: target.queue,
433            surface_format: target.surface_format,
434            adapter_backend: target.adapter_backend,
435            adapter_downlevel: target.adapter_downlevel,
436            text_fonts: self.frontend.text_fonts.clone(),
437            renderer_epoch: self.renderer_epoch,
438            pipeline_compilation,
439        }
440    }
441
442    /// [`init_gpu`][Self::init_gpu] for the threaded present runtime
443    /// (Android): the same epoch bump and planner replacement hygiene, but
444    /// instead of constructing a `GpuRenderer` here, everything it needs —
445    /// all owned, all `Send` — crosses to a spawned present thread that
446    /// constructs its own (its `Rc` caches are thread-confined). Frames
447    /// then flow through [`publish_frame`][Self::publish_frame] /
448    /// [`drain_present_returns`][Self::drain_present_returns] under the
449    /// depth-one credit protocol instead of [`render`][Self::render].
450    ///
451    /// * `waker` — wakes the producer's event loop after every returns
452    ///   send (the Android frame waker).
453    /// * `clock` — producer's monotonic nanosecond clock, so present-side
454    ///   [`PresentTimings`] share the producer telemetry's clock domain;
455    ///   `None` leaves timings at zero.
456    /// * `observer` — runs on the present thread around every present to the
457    ///   surface.
458    #[cfg(not(target_arch = "wasm32"))]
459    #[expect(clippy::too_many_arguments)]
460    pub fn init_gpu_threaded(
461        &mut self,
462        device: Arc<wgpu::Device>,
463        queue: Arc<wgpu::Queue>,
464        surface_format: wgpu::TextureFormat,
465        adapter_backend: wgpu::Backend,
466        adapter_downlevel: wgpu::DownlevelFlags,
467        waker: Arc<dyn Fn() + Send + Sync>,
468        clock: Option<Arc<dyn Fn() -> i64 + Send + Sync>>,
469        observer: Option<Box<dyn PresentObserver>>,
470    ) -> Result<(), WgpuRendererError> {
471        let target = GpuTarget {
472            device,
473            queue,
474            surface_format,
475            adapter_backend,
476            adapter_downlevel,
477        };
478        let init = PresentRuntimeInit {
479            gpu: self.next_gpu_init(target, PipelineCompilation::Background),
480            clock,
481            observer,
482        };
483        let handle = PresentHandle::spawn(init, waker).map_err(WgpuRendererError::Wgpu)?;
484        self.backend = PresentBackend::Threaded(handle);
485        Ok(())
486    }
487
488    #[cfg(not(target_arch = "wasm32"))]
489    #[doc(hidden)]
490    pub fn init_gpu_inline_for_tests(
491        &mut self,
492        device: Arc<wgpu::Device>,
493        queue: Arc<wgpu::Queue>,
494        surface_format: wgpu::TextureFormat,
495        adapter_backend: wgpu::Backend,
496        adapter_downlevel: wgpu::DownlevelFlags,
497    ) -> InlinePresentRuntime {
498        let target = GpuTarget {
499            device,
500            queue,
501            surface_format,
502            adapter_backend,
503            adapter_downlevel,
504        };
505        let init = PresentRuntimeInit {
506            gpu: self.next_gpu_init(target, PipelineCompilation::Background),
507            clock: None,
508            observer: None,
509        };
510        let (handle, state, msg_rx) = PresentHandle::new_inline(init, Arc::new(|| {}));
511        self.backend = PresentBackend::Threaded(handle);
512        InlinePresentRuntime {
513            state,
514            msg_rx,
515            shutdown_seen: false,
516        }
517    }
518
519    /// Record that the surface was reconfigured (resize, format change,
520    /// swapchain recreation): bumps the surface epoch stamped into every
521    /// subsequent packet, so a packet built against the previous
522    /// configuration is cancelled by the present stage instead of drawn.
523    pub fn note_surface_reconfigured(&mut self) {
524        self.surface_epoch = self.surface_epoch.wrapping_add(1);
525    }
526
527    /// Clears each frame to nothing instead of the framework's background,
528    /// for a window whose surface composites with the desktop behind it. A
529    /// renderer starts opaque; `false` puts the background back. Takes
530    /// effect from the next frame, on either present backend.
531    pub fn set_transparent_background(&mut self, transparent: bool) {
532        self.frontend.transparent_background = transparent;
533    }
534
535    pub fn root_scale(&self) -> f32 {
536        self.frontend.root_scale
537    }
538
539    /// Render the scene to a texture view.
540    ///
541    /// Producer first, present second: the frontend collects the frame into
542    /// a `frame_packet::FramePacket` (root and dev overlay alike), the GPU
543    /// renderer consumes it, and the present stage's returns fold back into
544    /// the frontend afterwards.
545    pub fn render(
546        &mut self,
547        texture: &wgpu::Texture,
548        view: &wgpu::TextureView,
549        width: u32,
550        height: u32,
551    ) -> Result<(), WgpuRendererError> {
552        self.render_frame(texture, view, width, height)
553    }
554
555    /// Renders the frame into a presentable image. When the image carries the
556    /// composition format and the capture usages, the scene renders into it
557    /// directly and no output conversion pass runs.
558    pub fn render_surface_texture(
559        &mut self,
560        texture: &wgpu::Texture,
561        view: &wgpu::TextureView,
562        width: u32,
563        height: u32,
564    ) -> Result<(), WgpuRendererError> {
565        self.render_frame(texture, view, width, height)
566    }
567
568    /// Presents a surface image this renderer drew with
569    /// [`render_surface_texture`](Self::render_surface_texture), on the queue
570    /// that recorded it.
571    ///
572    /// Before [`init_gpu`](Self::init_gpu) there is no queue, and the image is
573    /// released without being shown.
574    pub fn present(&self, frame: wgpu::SurfaceTexture) {
575        match self.sync_gpu_renderer() {
576            Some(gpu_renderer) => gpu_renderer.queue.present(frame),
577            None => log::debug!("surface image released: the renderer has no GPU queue"),
578        }
579    }
580
581    fn render_frame(
582        &mut self,
583        texture: &wgpu::Texture,
584        view: &wgpu::TextureView,
585        width: u32,
586        height: u32,
587    ) -> Result<(), WgpuRendererError> {
588        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
589            return Err(WgpuRendererError::Wgpu(
590                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
591                    .to_string(),
592            ));
593        };
594        let packet = self
595            .frontend
596            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
597            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
598        let mut returns = RenderReturns::default();
599        let result = gpu_renderer.render(
600            texture,
601            view,
602            width,
603            height,
604            packet,
605            self.surface_epoch,
606            &mut returns,
607        );
608        self.frontend.apply_returns(returns);
609        result.map_err(WgpuRendererError::Wgpu)
610    }
611
612    /// Render the current scene into an RGBA pixel buffer for robot tests.
613    ///
614    /// Uses the renderer's configured root scale.
615    pub fn capture_frame(
616        &mut self,
617        width: u32,
618        height: u32,
619    ) -> Result<CapturedFrame, WgpuRendererError> {
620        self.capture_frame_with_scale(width, height, self.frontend.root_scale)
621    }
622
623    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
624    /// Builds every pipeline the pipeline cache file records on the
625    /// background compiler, and returns whether they were all built before
626    /// `timeout`, or before `stop` said to stop. An app runs it after an
627    /// update, before its next launch, so that launch finds them compiled;
628    /// the renderer writes the file when it is dropped, and drops the builds
629    /// not started. It draws nothing, and needs a renderer initialized for
630    /// synchronous rendering.
631    #[cfg(not(target_arch = "wasm32"))]
632    pub fn prepare_recorded_pipelines(
633        &mut self,
634        timeout: std::time::Duration,
635        stop: &dyn Fn() -> bool,
636    ) -> bool {
637        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
638            return false;
639        };
640        let deadline = web_time::Instant::now() + timeout;
641        let mut compiling = gpu_renderer.prepare_recorded();
642        while compiling {
643            if web_time::Instant::now() >= deadline || stop() {
644                return false;
645            }
646            std::thread::sleep(std::time::Duration::from_millis(10));
647            compiling = gpu_renderer.compiling();
648        }
649        true
650    }
651
652    pub fn capture_frame_with_scale(
653        &mut self,
654        width: u32,
655        height: u32,
656        root_scale: f32,
657    ) -> Result<CapturedFrame, WgpuRendererError> {
658        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
659            return Err(WgpuRendererError::Wgpu(
660                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
661                    .to_string(),
662            ));
663        };
664        let packet = self
665            .frontend
666            .build_frame_packet_with_scale(
667                width,
668                height,
669                root_scale,
670                self.renderer_epoch,
671                self.surface_epoch,
672            )
673            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
674        let mut returns = RenderReturns::default();
675        let result = gpu_renderer.render_to_rgba_pixels(
676            width,
677            height,
678            packet,
679            self.surface_epoch,
680            &mut returns,
681        );
682        self.frontend.apply_returns(returns);
683        let pixels = result.map_err(WgpuRendererError::Wgpu)?;
684        Ok(CapturedFrame {
685            width,
686            height,
687            pixels,
688        })
689    }
690
691    /// Threaded mode: whether the depth-one slot has room for a packet.
692    /// The Android loop checks this BEFORE `shell.update()` so
693    /// backpressure lands before the expensive update/lowering work.
694    /// Always `true` on the sync path, which has no slot to fill.
695    #[cfg(not(target_arch = "wasm32"))]
696    pub fn has_frame_credit(&self) -> bool {
697        match &self.backend {
698            PresentBackend::Threaded(handle) => handle.has_credit(),
699            PresentBackend::Sync(_) | PresentBackend::None => true,
700        }
701    }
702
703    /// Threaded mode: whether the present thread has handed back a frame
704    /// that [`drain_present_returns`][Self::drain_present_returns] has not
705    /// folded back yet, so draining it would free a publish credit. A loop
706    /// out of credit can work on other things until this turns true.
707    /// Always `false` outside threaded mode.
708    #[cfg(not(target_arch = "wasm32"))]
709    pub fn frame_credit_returned(&self) -> bool {
710        match &self.backend {
711            PresentBackend::Threaded(handle) => handle.has_undrained_return(),
712            PresentBackend::Sync(_) | PresentBackend::None => false,
713        }
714    }
715
716    /// Threaded mode: lower the current scene into a packet and hand it to
717    /// the present runtime. Credit is checked FIRST — a `NoCredit` return
718    /// means no packet was built at all (`frame_sequence` does not
719    /// advance). Returns `NoCredit` (with an error log) when the renderer
720    /// is not in threaded mode.
721    #[cfg(not(target_arch = "wasm32"))]
722    pub fn publish_frame(&mut self, width: u32, height: u32) -> PublishOutcome {
723        let PresentBackend::Threaded(handle) = &mut self.backend else {
724            log::error!("publish_frame called without a threaded present runtime");
725            return PublishOutcome::NoCredit;
726        };
727        if !handle.has_credit() {
728            return PublishOutcome::NoCredit;
729        }
730        let Some(packet) = self.frontend.build_frame_packet(
731            width,
732            height,
733            self.renderer_epoch,
734            self.surface_epoch,
735        ) else {
736            return PublishOutcome::NoGraph;
737        };
738        match handle.publish(packet) {
739            Ok(()) => PublishOutcome::Published,
740            Err(packet) => {
741                let mut returns = RenderReturns::default();
742                let _ = GpuRenderer::cancel_packet(
743                    *packet,
744                    CancelReason::SurfaceUnavailable,
745                    &mut returns,
746                );
747                self.frontend.apply_returns(returns);
748                log::error!("present runtime unavailable; frame recovered, not published");
749                PublishOutcome::NoCredit
750            }
751        }
752    }
753
754    /// Threaded mode: fold every pending `RenderReturns` back into
755    /// producer state and free the publish credit. Returns how many were
756    /// drained. No-op outside threaded mode.
757    #[cfg(not(target_arch = "wasm32"))]
758    pub fn drain_present_returns(&mut self) -> usize {
759        self.drain_present_returns_with(&mut |_, _, _| {})
760    }
761
762    /// [`drain_present_returns`][Self::drain_present_returns], reporting
763    /// each drained frame's id, outcome and present-thread timings — the
764    /// Android loop feeds its frame telemetry from this.
765    #[cfg(not(target_arch = "wasm32"))]
766    pub fn drain_present_returns_with(
767        &mut self,
768        on_return: &mut dyn FnMut(u64, PresentOutcome, PresentTimings),
769    ) -> usize {
770        let mut drained = 0;
771        loop {
772            let returns = {
773                let PresentBackend::Threaded(handle) = &mut self.backend else {
774                    break;
775                };
776                match handle.try_drain() {
777                    Some(returns) => returns,
778                    None => break,
779                }
780            };
781            drained += 1;
782            let frame_id = returns.frame_id;
783            let outcome = returns.outcome;
784            let timings = returns.timings;
785            self.frontend.apply_returns(returns);
786            on_return(frame_id, outcome, timings);
787        }
788        drained
789    }
790
791    /// Threaded mode: install a (re)created surface on the present thread.
792    /// Nothing waits for the present thread to set it up: frames published
793    /// afterwards queue behind it, so the caller composes its next frame
794    /// while the surface is configured. The caller must have bumped the
795    /// surface epoch first (`note_surface_reconfigured`
796    /// [Self::note_surface_reconfigured]) when the message invalidates
797    /// in-flight packets; the message carries the current epoch. Returns
798    /// false when the present thread is gone.
799    #[cfg(not(target_arch = "wasm32"))]
800    pub fn present_replace_surface(
801        &mut self,
802        surface: wgpu::Surface<'static>,
803        config: wgpu::SurfaceConfiguration,
804    ) -> bool {
805        let surface_epoch = self.surface_epoch;
806        let Some(handle) = self.present_handle_mut() else {
807            log::error!("present_replace_surface called without a threaded present runtime");
808            return false;
809        };
810        let sent = handle.send_control(PresentControl::ReplaceSurface {
811            surface,
812            config,
813            surface_epoch,
814        });
815        if !sent {
816            log::error!("[present-runtime] replace surface: runtime is gone");
817        }
818        sent
819    }
820
821    /// Threaded mode: reconfigure the present thread's surface (resize)
822    /// and wait for the acknowledgement. Same epoch contract as
823    /// [`present_replace_surface`][Self::present_replace_surface].
824    #[cfg(not(target_arch = "wasm32"))]
825    pub fn present_reconfigure(&mut self, config: wgpu::SurfaceConfiguration) -> bool {
826        let surface_epoch = self.surface_epoch;
827        let Some(handle) = self.present_handle_mut() else {
828            log::error!("present_reconfigure called without a threaded present runtime");
829            return false;
830        };
831        handle.send_control_and_wait(
832            move |ack| PresentControl::Reconfigure {
833                config,
834                surface_epoch,
835                ack,
836            },
837            "reconfigure surface",
838        )
839    }
840
841    /// Threaded mode: drop the present thread's surface (the window died;
842    /// the renderer survives for the next one) and wait for the
843    /// acknowledgement. Bump the epoch first so in-flight packets cancel.
844    #[cfg(not(target_arch = "wasm32"))]
845    pub fn present_drop_surface(&mut self) -> bool {
846        let Some(handle) = self.present_handle_mut() else {
847            log::error!("present_drop_surface called without a threaded present runtime");
848            return false;
849        };
850        handle.send_control_and_wait(|ack| PresentControl::DropSurface { ack }, "drop surface")
851    }
852
853    /// Threaded mode: drain outstanding returns, stop the present thread
854    /// and join it. The renderer returns to the uninitialized state.
855    #[cfg(not(target_arch = "wasm32"))]
856    pub fn shutdown_present_runtime(&mut self) {
857        if matches!(self.backend, PresentBackend::Threaded(_)) {
858            self.retire_live_backend();
859        }
860    }
861
862    #[cfg(not(target_arch = "wasm32"))]
863    #[doc(hidden)]
864    pub fn present_attach_offscreen_for_tests(&mut self, width: u32, height: u32) -> bool {
865        let Some(handle) = self.present_handle_mut() else {
866            return false;
867        };
868        handle.send_control_and_wait(
869            move |ack| PresentControl::AttachOffscreenTargetForTests { width, height, ack },
870            "attach offscreen target",
871        )
872    }
873
874    #[cfg(not(target_arch = "wasm32"))]
875    #[doc(hidden)]
876    pub fn send_attach_offscreen_unacked_for_tests(
877        &mut self,
878        width: u32,
879        height: u32,
880    ) -> Option<std::sync::mpsc::Receiver<()>> {
881        let handle = self.present_handle_mut()?;
882        handle.send_control_unacked(move |ack| PresentControl::AttachOffscreenTargetForTests {
883            width,
884            height,
885            ack,
886        })
887    }
888
889    #[cfg(not(target_arch = "wasm32"))]
890    #[doc(hidden)]
891    pub fn send_reconfigure_unacked_for_tests(
892        &mut self,
893        config: wgpu::SurfaceConfiguration,
894    ) -> Option<std::sync::mpsc::Receiver<()>> {
895        let surface_epoch = self.surface_epoch;
896        let handle = self.present_handle_mut()?;
897        handle.send_control_unacked(move |ack| PresentControl::Reconfigure {
898            config,
899            surface_epoch,
900            ack,
901        })
902    }
903
904    #[cfg(not(target_arch = "wasm32"))]
905    #[doc(hidden)]
906    pub fn send_drop_surface_unacked_for_tests(&mut self) -> Option<std::sync::mpsc::Receiver<()>> {
907        let handle = self.present_handle_mut()?;
908        handle.send_control_unacked(|ack| PresentControl::DropSurface { ack })
909    }
910
911    /// The OS id of the thread that presents frames, once that thread has
912    /// started, on systems whose scheduler hints name threads by one.
913    /// `None` when frames present on the calling thread.
914    pub fn present_thread_id(&self) -> Option<i32> {
915        match &self.backend {
916            #[cfg(not(target_arch = "wasm32"))]
917            PresentBackend::Threaded(handle) => {
918                let id = handle
919                    .status()
920                    .thread_id
921                    .load(std::sync::atomic::Ordering::Relaxed);
922                (id != 0).then_some(id)
923            }
924            _ => None,
925        }
926    }
927
928    /// The producer's monotone packet sequence: the `frame_id` stamped on
929    /// the most recently lowered packet. After a `Published` outcome this
930    /// is the published frame's id (the Android loop keys its telemetry on
931    /// it); it also proves a `NoCredit` publish never lowered a frame.
932    pub fn last_published_frame_id(&self) -> u64 {
933        self.frontend.frame_sequence
934    }
935
936    #[cfg(not(target_arch = "wasm32"))]
937    #[doc(hidden)]
938    pub fn present_status_snapshot_for_tests(&self) -> Option<(bool, u64, u64)> {
939        match &self.backend {
940            PresentBackend::Threaded(handle) => {
941                let status = handle.status();
942                Some((
943                    status
944                        .needs_frame_warmup
945                        .load(std::sync::atomic::Ordering::Relaxed),
946                    status
947                        .presented_frames
948                        .load(std::sync::atomic::Ordering::Relaxed),
949                    status
950                        .placeholder_frames
951                        .load(std::sync::atomic::Ordering::Relaxed),
952                ))
953            }
954            _ => None,
955        }
956    }
957
958    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
959        match &self.backend {
960            PresentBackend::Sync(gpu_renderer) => gpu_renderer.last_frame_stats(),
961            #[cfg(not(target_arch = "wasm32"))]
962            PresentBackend::Threaded(handle) => *handle
963                .status()
964                .last_frame_stats
965                .lock()
966                .unwrap_or_else(std::sync::PoisonError::into_inner),
967            PresentBackend::None => None,
968        }
969    }
970
971    /// Whether the pipeline cache file held an earlier launch's first screen,
972    /// whose pipelines build ahead of this renderer's first frame; `false`
973    /// for a first launch or emptied caches, whose first frame builds its
974    /// pipelines, and for a renderer that does not draw on the calling
975    /// thread.
976    #[cfg(not(target_arch = "wasm32"))]
977    pub fn first_screen_recorded(&self) -> bool {
978        self.sync_gpu_renderer()
979            .is_some_and(GpuRenderer::first_screen_recorded)
980    }
981
982    /// The pipelines this renderer's first screen drew with, encoded for
983    /// [`Self::build_first_screen_pipelines`] in a later launch. `None`
984    /// until the first screen ends, two seconds after the first frame and
985    /// later by every wait for a pipeline, and for a renderer that does not
986    /// draw on the calling thread.
987    pub fn first_screen_pipelines(&self) -> Option<Vec<u8>> {
988        self.sync_gpu_renderer()
989            .and_then(GpuRenderer::first_screen_pipelines)
990    }
991
992    /// Builds now the pipelines one set in `records` names: sets
993    /// [`Self::first_screen_pipelines`] wrote, one after another, each at the
994    /// size its first frame drew. A web page calls this as its renderer
995    /// starts, so the browser compiles them while the page composes its
996    /// first screen. Of the sets drawn on this renderer's backend by a build
997    /// with the same layout of pipeline keys, the one drawn nearest to
998    /// `viewport` pixels builds: a phone's screen and a desktop window's
999    /// draw with different pipelines, and a frame that waits for pipelines
1000    /// it does not draw with comes later. A renderer that does not draw on
1001    /// the calling thread builds nothing.
1002    pub fn build_first_screen_pipelines(&mut self, records: &[u8], viewport: (u32, u32)) {
1003        if let PresentBackend::Sync(gpu_renderer) = &mut self.backend {
1004            gpu_renderer.build_first_screen(records, viewport);
1005        }
1006    }
1007
1008    /// GPU milliseconds by pass label, aggregated since the last `[GPU-PASS]`
1009    /// print. Empty unless `CRANPOSE_GPU_PASS_TIMING` armed pass timing on a
1010    /// device with [`wgpu::Features::TIMESTAMP_QUERY`].
1011    pub fn gpu_pass_timings(&self) -> GpuPassTimingReport {
1012        self.sync_gpu_renderer()
1013            .map(GpuRenderer::gpu_pass_timings)
1014            .unwrap_or_default()
1015    }
1016
1017    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
1018        let mut stats = self
1019            .sync_gpu_renderer()
1020            .map(GpuRenderer::debug_cpu_allocation_stats)
1021            .unwrap_or_default();
1022        stats.scene_graph_node_count = self
1023            .frontend
1024            .scene
1025            .graph
1026            .as_ref()
1027            .map_or(0, RenderGraph::node_count);
1028        stats.scene_graph_heap_bytes = self
1029            .frontend
1030            .scene
1031            .graph
1032            .as_ref()
1033            .map_or(0, RenderGraph::heap_bytes);
1034        stats.scene_hits_len = self.frontend.scene.hits.len();
1035        stats.scene_hits_cap = self.frontend.scene.hits.capacity();
1036        stats.scene_node_index_len = self.frontend.scene.node_index.len();
1037        stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
1038        stats
1039    }
1040
1041    /// Starts a frame that takes its pipelines from a pipeline discovery:
1042    /// the frame that discovers them, or the one that creates them again
1043    /// while the browser holds them compiled. Such a frame also builds the
1044    /// pipelines it expects a later frame to need, at its end, so the browser
1045    /// compiles them behind the frame's own or holds them with those.
1046    #[cfg(target_arch = "wasm32")]
1047    pub fn begin_discovery_frame(&self) {
1048        if let Some(gpu_renderer) = self.sync_gpu_renderer() {
1049            gpu_renderer.pipeline_compiler().begin_discovery_frame();
1050        }
1051    }
1052
1053    /// Drops what earlier frames drew and kept, once the pipelines the
1054    /// browser compiled in the background landed
1055    /// (`wgpu::Device::compile_pipelines_in_background`): those frames left
1056    /// out the draws that use them.
1057    #[cfg(target_arch = "wasm32")]
1058    pub fn background_pipelines_landed(&mut self) {
1059        if let PresentBackend::Sync(gpu_renderer) = &mut self.backend {
1060            gpu_renderer.background_pipelines_landed();
1061        }
1062    }
1063
1064    /// Return the WGPU device when GPU resources are initialized.
1065    /// Sync backend only (desktop/web reconfigure paths); the threaded
1066    /// runtime owns its device on the present thread.
1067    pub fn try_device(&self) -> Option<&wgpu::Device> {
1068        self.sync_gpu_renderer().map(|r| &*r.device)
1069    }
1070
1071    #[doc(hidden)]
1072    pub fn try_queue_for_tests(&self) -> Option<&wgpu::Queue> {
1073        self.sync_gpu_renderer().map(|r| &*r.queue)
1074    }
1075
1076    #[doc(hidden)]
1077    pub fn device_error_count_for_tests(&self) -> u64 {
1078        self.sync_gpu_renderer()
1079            .map_or(0, GpuRenderer::device_error_count)
1080    }
1081
1082    #[doc(hidden)]
1083    pub fn build_frame_packet_for_tests(
1084        &mut self,
1085        width: u32,
1086        height: u32,
1087    ) -> Option<HeldFramePacket> {
1088        self.frontend
1089            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
1090            .map(HeldFramePacket)
1091    }
1092
1093    /// Return a producer-built packet without presenting it. This exercises
1094    /// the same bounded scene-storage recycler used by normal present returns
1095    /// in offline producer benchmarks.
1096    ///
1097    /// ```
1098    /// use cranpose_render_wgpu::{HeldFramePacket, WgpuRenderer};
1099    ///
1100    /// fn return_without_presenting(renderer: &mut WgpuRenderer, packet: HeldFramePacket) {
1101    ///     renderer.return_held_packet_for_tests(packet);
1102    /// }
1103    /// ```
1104    #[doc(hidden)]
1105    pub fn return_held_packet_for_tests(&mut self, packet: HeldFramePacket) {
1106        let packet = packet.0;
1107        self.frontend.apply_returns(RenderReturns {
1108            scene: Some(frame_packet::FrameSceneStorage {
1109                root: packet.root,
1110                overlay: packet.overlay,
1111            }),
1112            frame_id: packet.frame_id,
1113            ..RenderReturns::default()
1114        });
1115    }
1116
1117    #[doc(hidden)]
1118    pub fn render_held_packet_for_tests(
1119        &mut self,
1120        texture: &wgpu::Texture,
1121        view: &wgpu::TextureView,
1122        width: u32,
1123        height: u32,
1124        packet: HeldFramePacket,
1125    ) -> Result<PresentOutcome, WgpuRendererError> {
1126        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
1127            return Err(WgpuRendererError::Wgpu(
1128                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
1129                    .to_string(),
1130            ));
1131        };
1132        let mut returns = RenderReturns::default();
1133        let result = gpu_renderer.render(
1134            texture,
1135            view,
1136            width,
1137            height,
1138            packet.0,
1139            self.surface_epoch,
1140            &mut returns,
1141        );
1142        let outcome = returns.outcome;
1143        self.frontend.apply_returns(returns);
1144        result.map_err(WgpuRendererError::Wgpu)?;
1145        Ok(outcome)
1146    }
1147}
1148
1149#[doc(hidden)]
1150pub struct HeldFramePacket(frame_packet::FramePacket);
1151
1152#[cfg(not(target_arch = "wasm32"))]
1153#[doc(hidden)]
1154pub struct InlinePresentRuntime {
1155    state: PresentState,
1156    msg_rx: std::sync::mpsc::Receiver<PresentMsg>,
1157    shutdown_seen: bool,
1158}
1159
1160#[cfg(not(target_arch = "wasm32"))]
1161impl InlinePresentRuntime {
1162    pub fn pump(&mut self) -> bool {
1163        if self.shutdown_seen {
1164            return false;
1165        }
1166        while let Ok(msg) = self.msg_rx.try_recv() {
1167            if !self.state.run_once(msg) {
1168                self.shutdown_seen = true;
1169                return false;
1170            }
1171        }
1172        self.state.consume_waiting();
1173        true
1174    }
1175
1176    pub fn has_waiting_packet(&self) -> bool {
1177        self.state.has_waiting_packet()
1178    }
1179
1180    pub fn step_one_message(&mut self) -> bool {
1181        if self.shutdown_seen {
1182            return false;
1183        }
1184        match self.msg_rx.try_recv() {
1185            Ok(msg) => {
1186                if !self.state.run_once(msg) {
1187                    self.shutdown_seen = true;
1188                }
1189                true
1190            }
1191            Err(_) => false,
1192        }
1193    }
1194
1195    pub fn consume_waiting(&mut self) {
1196        self.state.consume_waiting();
1197    }
1198}
1199
1200impl Default for WgpuRenderer {
1201    fn default() -> Self {
1202        Self::new(&[])
1203    }
1204}
1205
1206/// Gives `app_context` the measurer its text lays out with, over `fonts`.
1207fn install_text_measurer(app_context: &cranpose_ui::AppContext, fonts: &SoftwareTextFontSet) {
1208    app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(fonts.clone(), 8192));
1209}
1210
1211impl Renderer for WgpuRenderer {
1212    type Scene = Scene;
1213    type Error = WgpuRendererError;
1214
1215    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
1216        install_text_measurer(app_context, &self.frontend.text_fonts);
1217        self.frontend.app_context = Some(app_context.downgrade());
1218    }
1219
1220    #[cfg(feature = "installed-fonts")]
1221    fn apply_installed_fonts(&mut self, app_context: &cranpose_ui::AppContext) {
1222        let Some(fonts) = self.frontend.text_fonts.with_installed_faces() else {
1223            return;
1224        };
1225        let attached = self
1226            .frontend
1227            .app_context
1228            .as_ref()
1229            .and_then(std::rc::Weak::upgrade)
1230            .is_some_and(|attached| std::ptr::eq(&*attached, app_context));
1231        if attached {
1232            install_text_measurer(app_context, &fonts);
1233        }
1234        self.frontend.text_state = TextSystemState::from_font_set(fonts.clone());
1235        self.frontend.text_fonts = fonts;
1236    }
1237
1238    fn set_root_scale(&mut self, scale: f32) {
1239        self.frontend.root_scale = scale;
1240    }
1241
1242    fn scene(&self) -> &Self::Scene {
1243        &self.frontend.scene
1244    }
1245
1246    fn scene_mut(&mut self) -> &mut Self::Scene {
1247        &mut self.frontend.scene
1248    }
1249
1250    fn rebuild_scene(
1251        &mut self,
1252        layout_tree: &LayoutTree,
1253        _viewport: Size,
1254    ) -> Result<(), Self::Error> {
1255        self.frontend.scene.clear();
1256        self.frontend.clear_fps_overlay();
1257        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
1258        Ok(())
1259    }
1260
1261    fn rebuild_scene_from_applier(
1262        &mut self,
1263        applier: &mut MemoryApplier,
1264        root: NodeId,
1265        _viewport: Size,
1266    ) -> Result<(), Self::Error> {
1267        self.frontend.clear_fps_overlay();
1268        self.frontend.scene.rebuild_from_applier(applier, root);
1269        Ok(())
1270    }
1271
1272    fn update_scene_from_applier(
1273        &mut self,
1274        applier: &mut MemoryApplier,
1275        root: NodeId,
1276        _viewport: Size,
1277        updates: cranpose_render_common::SceneUpdates<'_>,
1278    ) -> Result<(), Self::Error> {
1279        if updates.is_empty() {
1280            self.frontend.clear_fps_overlay();
1281        }
1282        self.frontend
1283            .scene
1284            .update_from_applier(applier, root, updates, true);
1285        Ok(())
1286    }
1287
1288    fn update_visual_scene_from_applier(
1289        &mut self,
1290        applier: &mut MemoryApplier,
1291        root: NodeId,
1292        _viewport: Size,
1293        updates: cranpose_render_common::SceneUpdates<'_>,
1294    ) -> Result<(), Self::Error> {
1295        if updates.is_empty() {
1296            self.frontend.clear_fps_overlay();
1297        }
1298        self.frontend
1299            .scene
1300            .update_from_applier(applier, root, updates, false);
1301        Ok(())
1302    }
1303
1304    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1305        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1306        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
1307        if self.frontend.dev_overlay_graph.is_some()
1308            && self
1309                .frontend
1310                .dev_overlay_cache
1311                .as_ref()
1312                .is_some_and(|cache| {
1313                    cache.text == key.text
1314                        && cache.viewport_width_bits == key.viewport_width_bits
1315                        && cache.viewport_height_bits == key.viewport_height_bits
1316                })
1317        {
1318            return;
1319        }
1320        self.frontend.fps_overlay_graph = Some(
1321            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
1322                text,
1323                viewport,
1324                DEV_OVERLAY_NODE_ID,
1325            ),
1326        );
1327        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
1328            text: key.text,
1329            viewport_width_bits: key.viewport_width_bits,
1330            viewport_height_bits: key.viewport_height_bits,
1331        });
1332        self.frontend.refresh_dev_overlay();
1333    }
1334
1335    fn set_inspector_overlay(&mut self, graph: Option<cranpose_render_common::graph::RenderGraph>) {
1336        self.frontend.inspector_overlay_graph = graph;
1337        self.frontend.refresh_dev_overlay();
1338    }
1339
1340    fn needs_frame_warmup(&self) -> bool {
1341        match &self.backend {
1342            PresentBackend::Sync(gpu_renderer) => gpu_renderer.needs_frame_warmup(),
1343            #[cfg(not(target_arch = "wasm32"))]
1344            PresentBackend::Threaded(handle) => handle
1345                .status()
1346                .needs_frame_warmup
1347                .load(std::sync::atomic::Ordering::Relaxed),
1348            PresentBackend::None => false,
1349        }
1350    }
1351
1352    fn awaits_pipelines(&self) -> bool {
1353        match &self.backend {
1354            PresentBackend::Sync(gpu_renderer) => gpu_renderer.awaits_pipelines(),
1355            #[cfg(not(target_arch = "wasm32"))]
1356            PresentBackend::Threaded(handle) => {
1357                let status = handle.status();
1358                status
1359                    .drew_placeholder
1360                    .load(std::sync::atomic::Ordering::Relaxed)
1361                    && status
1362                        .landing
1363                        .get()
1364                        .is_some_and(|landing| landing.in_flight())
1365            }
1366            PresentBackend::None => false,
1367        }
1368    }
1369}
1370
1371#[cfg(test)]
1372#[path = "tests/wgpu_tests.rs"]
1373mod tests;