Skip to main content

cranpose_render_wgpu/
lib.rs

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