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    fn render_frame(
504        &mut self,
505        texture: &wgpu::Texture,
506        view: &wgpu::TextureView,
507        width: u32,
508        height: u32,
509    ) -> Result<(), WgpuRendererError> {
510        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
511            return Err(WgpuRendererError::Wgpu(
512                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
513                    .to_string(),
514            ));
515        };
516        let packet = self
517            .frontend
518            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
519            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
520        let mut returns = RenderReturns::default();
521        let result = gpu_renderer.render(
522            texture,
523            view,
524            width,
525            height,
526            packet,
527            self.surface_epoch,
528            &mut returns,
529        );
530        self.frontend.apply_returns(returns);
531        result.map_err(WgpuRendererError::Wgpu)
532    }
533
534    /// Render the current scene into an RGBA pixel buffer for robot tests.
535    ///
536    /// Uses the renderer's configured root scale.
537    pub fn capture_frame(
538        &mut self,
539        width: u32,
540        height: u32,
541    ) -> Result<CapturedFrame, WgpuRendererError> {
542        self.capture_frame_with_scale(width, height, self.frontend.root_scale)
543    }
544
545    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
546    pub fn capture_frame_with_scale(
547        &mut self,
548        width: u32,
549        height: u32,
550        root_scale: f32,
551    ) -> Result<CapturedFrame, WgpuRendererError> {
552        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
553            return Err(WgpuRendererError::Wgpu(
554                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
555                    .to_string(),
556            ));
557        };
558        let packet = self
559            .frontend
560            .build_frame_packet_with_scale(
561                width,
562                height,
563                root_scale,
564                self.renderer_epoch,
565                self.surface_epoch,
566            )
567            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
568        let mut returns = RenderReturns::default();
569        let result = gpu_renderer.render_to_rgba_pixels(
570            width,
571            height,
572            packet,
573            self.surface_epoch,
574            &mut returns,
575        );
576        self.frontend.apply_returns(returns);
577        let pixels = result.map_err(WgpuRendererError::Wgpu)?;
578        Ok(CapturedFrame {
579            width,
580            height,
581            pixels,
582        })
583    }
584
585    /// Threaded mode: whether the depth-one slot has room for a packet.
586    /// The Android loop checks this BEFORE `shell.update()` so
587    /// backpressure lands before the expensive update/lowering work.
588    /// Always `true` on the sync path, which has no slot to fill.
589    #[cfg(not(target_arch = "wasm32"))]
590    pub fn has_frame_credit(&self) -> bool {
591        match &self.backend {
592            PresentBackend::Threaded(handle) => handle.has_credit(),
593            PresentBackend::Sync(_) | PresentBackend::None => true,
594        }
595    }
596
597    /// Threaded mode: lower the current scene into a packet and hand it to
598    /// the present runtime. Credit is checked FIRST — a `NoCredit` return
599    /// means no packet was built at all (`frame_sequence` does not
600    /// advance). Returns `NoCredit` (with an error log) when the renderer
601    /// is not in threaded mode.
602    #[cfg(not(target_arch = "wasm32"))]
603    pub fn publish_frame(&mut self, width: u32, height: u32) -> PublishOutcome {
604        let PresentBackend::Threaded(handle) = &mut self.backend else {
605            log::error!("publish_frame called without a threaded present runtime");
606            return PublishOutcome::NoCredit;
607        };
608        if !handle.has_credit() {
609            return PublishOutcome::NoCredit;
610        }
611        let Some(packet) = self.frontend.build_frame_packet(
612            width,
613            height,
614            self.renderer_epoch,
615            self.surface_epoch,
616        ) else {
617            return PublishOutcome::NoGraph;
618        };
619        match handle.publish(packet) {
620            Ok(()) => PublishOutcome::Published,
621            Err(packet) => {
622                let mut returns = RenderReturns::default();
623                let _ = GpuRenderer::cancel_packet(
624                    *packet,
625                    CancelReason::SurfaceUnavailable,
626                    &mut returns,
627                );
628                self.frontend.apply_returns(returns);
629                log::error!("present runtime unavailable; frame recovered, not published");
630                PublishOutcome::NoCredit
631            }
632        }
633    }
634
635    /// Threaded mode: fold every pending `RenderReturns` back into
636    /// producer state and free the publish credit. Returns how many were
637    /// drained. No-op outside threaded mode.
638    #[cfg(not(target_arch = "wasm32"))]
639    pub fn drain_present_returns(&mut self) -> usize {
640        self.drain_present_returns_with(&mut |_, _, _| {})
641    }
642
643    /// [`drain_present_returns`][Self::drain_present_returns], reporting
644    /// each drained frame's id, outcome and present-thread timings — the
645    /// Android loop feeds its frame telemetry from this.
646    #[cfg(not(target_arch = "wasm32"))]
647    pub fn drain_present_returns_with(
648        &mut self,
649        on_return: &mut dyn FnMut(u64, PresentOutcome, PresentTimings),
650    ) -> usize {
651        let mut drained = 0;
652        loop {
653            let returns = {
654                let PresentBackend::Threaded(handle) = &mut self.backend else {
655                    break;
656                };
657                match handle.try_drain() {
658                    Some(returns) => returns,
659                    None => break,
660                }
661            };
662            drained += 1;
663            let frame_id = returns.frame_id;
664            let outcome = returns.outcome;
665            let timings = returns.timings;
666            self.frontend.apply_returns(returns);
667            on_return(frame_id, outcome, timings);
668        }
669        drained
670    }
671
672    /// Threaded mode: install a (re)created surface on the present thread
673    /// and wait for the acknowledgement. The caller must have bumped the
674    /// surface epoch first (`note_surface_reconfigured`
675    /// [Self::note_surface_reconfigured]) when the message invalidates
676    /// in-flight packets; the message carries the current epoch.
677    #[cfg(not(target_arch = "wasm32"))]
678    pub fn present_replace_surface(
679        &mut self,
680        surface: wgpu::Surface<'static>,
681        config: wgpu::SurfaceConfiguration,
682    ) -> bool {
683        let surface_epoch = self.surface_epoch;
684        let Some(handle) = self.present_handle_mut() else {
685            log::error!("present_replace_surface called without a threaded present runtime");
686            return false;
687        };
688        handle.send_control_and_wait(
689            move |ack| PresentControl::ReplaceSurface {
690                surface,
691                config,
692                surface_epoch,
693                ack,
694            },
695            "replace surface",
696        )
697    }
698
699    /// Threaded mode: reconfigure the present thread's surface (resize)
700    /// and wait for the acknowledgement. Same epoch contract as
701    /// [`present_replace_surface`][Self::present_replace_surface].
702    #[cfg(not(target_arch = "wasm32"))]
703    pub fn present_reconfigure(&mut self, config: wgpu::SurfaceConfiguration) -> bool {
704        let surface_epoch = self.surface_epoch;
705        let Some(handle) = self.present_handle_mut() else {
706            log::error!("present_reconfigure called without a threaded present runtime");
707            return false;
708        };
709        handle.send_control_and_wait(
710            move |ack| PresentControl::Reconfigure {
711                config,
712                surface_epoch,
713                ack,
714            },
715            "reconfigure surface",
716        )
717    }
718
719    /// Threaded mode: drop the present thread's surface (the window died;
720    /// the renderer survives for the next one) and wait for the
721    /// acknowledgement. Bump the epoch first so in-flight packets cancel.
722    #[cfg(not(target_arch = "wasm32"))]
723    pub fn present_drop_surface(&mut self) -> bool {
724        let Some(handle) = self.present_handle_mut() else {
725            log::error!("present_drop_surface called without a threaded present runtime");
726            return false;
727        };
728        handle.send_control_and_wait(|ack| PresentControl::DropSurface { ack }, "drop surface")
729    }
730
731    /// Threaded mode: drain outstanding returns, stop the present thread
732    /// and join it. The renderer returns to the uninitialized state.
733    #[cfg(not(target_arch = "wasm32"))]
734    pub fn shutdown_present_runtime(&mut self) {
735        if matches!(self.backend, PresentBackend::Threaded(_)) {
736            self.retire_live_backend();
737        }
738    }
739
740    #[cfg(not(target_arch = "wasm32"))]
741    #[doc(hidden)]
742    pub fn present_attach_offscreen_for_tests(&mut self, width: u32, height: u32) -> bool {
743        let Some(handle) = self.present_handle_mut() else {
744            return false;
745        };
746        handle.send_control_and_wait(
747            move |ack| PresentControl::AttachOffscreenTargetForTests { width, height, ack },
748            "attach offscreen target",
749        )
750    }
751
752    #[cfg(not(target_arch = "wasm32"))]
753    #[doc(hidden)]
754    pub fn send_attach_offscreen_unacked_for_tests(
755        &mut self,
756        width: u32,
757        height: u32,
758    ) -> Option<std::sync::mpsc::Receiver<()>> {
759        let handle = self.present_handle_mut()?;
760        handle.send_control_unacked(move |ack| PresentControl::AttachOffscreenTargetForTests {
761            width,
762            height,
763            ack,
764        })
765    }
766
767    #[cfg(not(target_arch = "wasm32"))]
768    #[doc(hidden)]
769    pub fn send_reconfigure_unacked_for_tests(
770        &mut self,
771        config: wgpu::SurfaceConfiguration,
772    ) -> Option<std::sync::mpsc::Receiver<()>> {
773        let surface_epoch = self.surface_epoch;
774        let handle = self.present_handle_mut()?;
775        handle.send_control_unacked(move |ack| PresentControl::Reconfigure {
776            config,
777            surface_epoch,
778            ack,
779        })
780    }
781
782    #[cfg(not(target_arch = "wasm32"))]
783    #[doc(hidden)]
784    pub fn send_drop_surface_unacked_for_tests(&mut self) -> Option<std::sync::mpsc::Receiver<()>> {
785        let handle = self.present_handle_mut()?;
786        handle.send_control_unacked(|ack| PresentControl::DropSurface { ack })
787    }
788
789    /// The producer's monotone packet sequence: the `frame_id` stamped on
790    /// the most recently lowered packet. After a `Published` outcome this
791    /// is the published frame's id (the Android loop keys its telemetry on
792    /// it); it also proves a `NoCredit` publish never lowered a frame.
793    pub fn last_published_frame_id(&self) -> u64 {
794        self.frontend.frame_sequence
795    }
796
797    #[cfg(not(target_arch = "wasm32"))]
798    #[doc(hidden)]
799    pub fn present_status_snapshot_for_tests(&self) -> Option<(bool, u64, u64)> {
800        match &self.backend {
801            PresentBackend::Threaded(handle) => {
802                let status = handle.status();
803                Some((
804                    status
805                        .needs_frame_warmup
806                        .load(std::sync::atomic::Ordering::Relaxed),
807                    status
808                        .presented_frames
809                        .load(std::sync::atomic::Ordering::Relaxed),
810                    status
811                        .placeholder_frames
812                        .load(std::sync::atomic::Ordering::Relaxed),
813                ))
814            }
815            _ => None,
816        }
817    }
818
819    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
820        match &self.backend {
821            PresentBackend::Sync(gpu_renderer) => gpu_renderer.last_frame_stats(),
822            #[cfg(not(target_arch = "wasm32"))]
823            PresentBackend::Threaded(handle) => *handle
824                .status()
825                .last_frame_stats
826                .lock()
827                .unwrap_or_else(|poisoned| poisoned.into_inner()),
828            PresentBackend::None => None,
829        }
830    }
831
832    /// GPU milliseconds by pass label, aggregated since the last `[GPU-PASS]`
833    /// print. Empty unless `CRANPOSE_GPU_PASS_TIMING` armed pass timing on a
834    /// device with [`wgpu::Features::TIMESTAMP_QUERY`].
835    pub fn gpu_pass_timings(&self) -> GpuPassTimingReport {
836        self.sync_gpu_renderer()
837            .map(GpuRenderer::gpu_pass_timings)
838            .unwrap_or_default()
839    }
840
841    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
842        let mut stats = self
843            .sync_gpu_renderer()
844            .map(GpuRenderer::debug_cpu_allocation_stats)
845            .unwrap_or_default();
846        stats.scene_graph_node_count = self
847            .frontend
848            .scene
849            .graph
850            .as_ref()
851            .map(RenderGraph::node_count)
852            .unwrap_or(0);
853        stats.scene_graph_heap_bytes = self
854            .frontend
855            .scene
856            .graph
857            .as_ref()
858            .map(RenderGraph::heap_bytes)
859            .unwrap_or(0);
860        stats.scene_hits_len = self.frontend.scene.hits.len();
861        stats.scene_hits_cap = self.frontend.scene.hits.capacity();
862        stats.scene_node_index_len = self.frontend.scene.node_index.len();
863        stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
864        stats
865    }
866
867    /// Return the WGPU device when GPU resources are initialized.
868    /// Sync backend only (desktop/web reconfigure paths); the threaded
869    /// runtime owns its device on the present thread.
870    pub fn try_device(&self) -> Option<&wgpu::Device> {
871        self.sync_gpu_renderer().map(|r| &*r.device)
872    }
873
874    #[doc(hidden)]
875    pub fn try_queue_for_tests(&self) -> Option<&wgpu::Queue> {
876        self.sync_gpu_renderer().map(|r| &*r.queue)
877    }
878
879    #[doc(hidden)]
880    pub fn device_error_count_for_tests(&self) -> u64 {
881        self.sync_gpu_renderer()
882            .map(GpuRenderer::device_error_count)
883            .unwrap_or(0)
884    }
885
886    #[doc(hidden)]
887    pub fn build_frame_packet_for_tests(
888        &mut self,
889        width: u32,
890        height: u32,
891    ) -> Option<HeldFramePacket> {
892        self.frontend
893            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
894            .map(HeldFramePacket)
895    }
896
897    #[doc(hidden)]
898    pub fn render_held_packet_for_tests(
899        &mut self,
900        texture: &wgpu::Texture,
901        view: &wgpu::TextureView,
902        width: u32,
903        height: u32,
904        packet: HeldFramePacket,
905    ) -> Result<PresentOutcome, WgpuRendererError> {
906        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
907            return Err(WgpuRendererError::Wgpu(
908                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
909                    .to_string(),
910            ));
911        };
912        let mut returns = RenderReturns::default();
913        let result = gpu_renderer.render(
914            texture,
915            view,
916            width,
917            height,
918            packet.0,
919            self.surface_epoch,
920            &mut returns,
921        );
922        let outcome = returns.outcome;
923        self.frontend.apply_returns(returns);
924        result.map_err(WgpuRendererError::Wgpu)?;
925        Ok(outcome)
926    }
927}
928
929#[doc(hidden)]
930pub struct HeldFramePacket(frame_packet::FramePacket);
931
932#[cfg(not(target_arch = "wasm32"))]
933#[doc(hidden)]
934pub struct InlinePresentRuntime {
935    state: PresentState,
936    msg_rx: std::sync::mpsc::Receiver<PresentMsg>,
937    shutdown_seen: bool,
938}
939
940#[cfg(not(target_arch = "wasm32"))]
941impl InlinePresentRuntime {
942    pub fn pump(&mut self) -> bool {
943        if self.shutdown_seen {
944            return false;
945        }
946        while let Ok(msg) = self.msg_rx.try_recv() {
947            if !self.state.run_once(msg) {
948                self.shutdown_seen = true;
949                return false;
950            }
951        }
952        self.state.consume_waiting();
953        true
954    }
955
956    pub fn has_waiting_packet(&self) -> bool {
957        self.state.has_waiting_packet()
958    }
959
960    pub fn step_one_message(&mut self) -> bool {
961        if self.shutdown_seen {
962            return false;
963        }
964        match self.msg_rx.try_recv() {
965            Ok(msg) => {
966                if !self.state.run_once(msg) {
967                    self.shutdown_seen = true;
968                }
969                true
970            }
971            Err(_) => false,
972        }
973    }
974
975    pub fn consume_waiting(&mut self) {
976        self.state.consume_waiting();
977    }
978}
979
980impl Default for WgpuRenderer {
981    fn default() -> Self {
982        Self::new(&[])
983    }
984}
985
986impl Renderer for WgpuRenderer {
987    type Scene = Scene;
988    type Error = WgpuRendererError;
989
990    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
991        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
992            self.frontend.text_fonts.clone(),
993            8192,
994        ));
995        self.frontend.app_context = Some(app_context.downgrade());
996    }
997
998    fn scene(&self) -> &Self::Scene {
999        &self.frontend.scene
1000    }
1001
1002    fn scene_mut(&mut self) -> &mut Self::Scene {
1003        &mut self.frontend.scene
1004    }
1005
1006    fn rebuild_scene(
1007        &mut self,
1008        layout_tree: &LayoutTree,
1009        _viewport: Size,
1010    ) -> Result<(), Self::Error> {
1011        self.frontend.scene.clear();
1012        self.frontend.clear_fps_overlay();
1013        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
1014        Ok(())
1015    }
1016
1017    fn rebuild_scene_from_applier(
1018        &mut self,
1019        applier: &mut MemoryApplier,
1020        root: NodeId,
1021        _viewport: Size,
1022    ) -> Result<(), Self::Error> {
1023        self.frontend.scene.clear();
1024        self.frontend.clear_fps_overlay();
1025        pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
1026        Ok(())
1027    }
1028
1029    fn update_scene_from_applier(
1030        &mut self,
1031        applier: &mut MemoryApplier,
1032        root: NodeId,
1033        viewport: Size,
1034        dirty_nodes: &[NodeId],
1035    ) -> Result<(), Self::Error> {
1036        if dirty_nodes.is_empty() {
1037            return self.rebuild_scene_from_applier(applier, root, viewport);
1038        }
1039        self.update_scene(applier, root, dirty_nodes, true);
1040        Ok(())
1041    }
1042
1043    fn update_visual_scene_from_applier(
1044        &mut self,
1045        applier: &mut MemoryApplier,
1046        root: NodeId,
1047        viewport: Size,
1048        dirty_nodes: &[NodeId],
1049    ) -> Result<(), Self::Error> {
1050        if dirty_nodes.is_empty() {
1051            return self.rebuild_scene_from_applier(applier, root, viewport);
1052        }
1053        self.update_scene(applier, root, dirty_nodes, false);
1054        Ok(())
1055    }
1056
1057    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1058        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1059        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
1060        if self.frontend.dev_overlay_graph.is_some()
1061            && self
1062                .frontend
1063                .dev_overlay_cache
1064                .as_ref()
1065                .is_some_and(|cache| {
1066                    cache.text == key.text
1067                        && cache.viewport_width_bits == key.viewport_width_bits
1068                        && cache.viewport_height_bits == key.viewport_height_bits
1069                })
1070        {
1071            return;
1072        }
1073        self.frontend.fps_overlay_graph = Some(
1074            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
1075                text,
1076                viewport,
1077                DEV_OVERLAY_NODE_ID,
1078            ),
1079        );
1080        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
1081            text: key.text,
1082            viewport_width_bits: key.viewport_width_bits,
1083            viewport_height_bits: key.viewport_height_bits,
1084        });
1085        self.frontend.refresh_dev_overlay();
1086    }
1087
1088    fn set_inspector_overlay(&mut self, graph: Option<cranpose_render_common::graph::RenderGraph>) {
1089        self.frontend.inspector_overlay_graph = graph;
1090        self.frontend.refresh_dev_overlay();
1091    }
1092
1093    fn needs_frame_warmup(&self) -> bool {
1094        match &self.backend {
1095            PresentBackend::Sync(gpu_renderer) => gpu_renderer.needs_frame_warmup(),
1096            #[cfg(not(target_arch = "wasm32"))]
1097            PresentBackend::Threaded(handle) => handle
1098                .status()
1099                .needs_frame_warmup
1100                .load(std::sync::atomic::Ordering::Relaxed),
1101            PresentBackend::None => false,
1102        }
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use std::cell::Cell;
1109
1110    use cranpose_render_common::graph::RenderNode;
1111    use cranpose_ui_graphics::GraphicsLayer;
1112
1113    use super::*;
1114    use crate::pipeline::TextLayoutResolver;
1115
1116    static TEST_FONT: &[u8] =
1117        cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
1118
1119    #[path = "inspector_overlay.rs"]
1120    mod inspector_overlay;
1121
1122    #[test]
1123    fn dev_overlay_is_recorded_outside_app_graph() {
1124        let mut renderer = WgpuRenderer::new(&[]);
1125        renderer.draw_dev_overlay(
1126            "240 FPS | avg 4.0ms | p95 4.5ms",
1127            Size {
1128                width: 800.0,
1129                height: 600.0,
1130            },
1131        );
1132
1133        assert!(
1134            renderer
1135                .frontend
1136                .scene
1137                .graph
1138                .as_ref()
1139                .is_none_or(|graph| graph.root.children.iter().all(|child| {
1140                    !matches!(
1141                        child,
1142                        RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
1143                    )
1144                })),
1145            "dev overlay must not be mixed into the app scene graph"
1146        );
1147
1148        let graph = renderer
1149            .frontend
1150            .dev_overlay_graph
1151            .as_ref()
1152            .expect("overlay graph");
1153        let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
1154            panic!("dev overlay should be the final top-level layer");
1155        };
1156
1157        assert_eq!(overlay.node_id, Some(NodeId::MAX));
1158        assert_eq!(
1159            overlay.graphics_layer.compositing_strategy,
1160            GraphicsLayer::default().compositing_strategy,
1161            "dev overlay should not allocate an offscreen surface"
1162        );
1163    }
1164
1165    struct CountingTextMeasurer {
1166        inner: SoftwareTextMeasurer,
1167        layout_calls: Rc<Cell<usize>>,
1168    }
1169
1170    impl CountingTextMeasurer {
1171        fn new(layout_calls: Rc<Cell<usize>>) -> Self {
1172            Self {
1173                inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
1174                layout_calls,
1175            }
1176        }
1177    }
1178
1179    impl TextMeasurer for CountingTextMeasurer {
1180        fn measure(
1181            &self,
1182            text: &cranpose_ui::text::AnnotatedString,
1183            style: &cranpose_ui::text::TextStyle,
1184        ) -> cranpose_ui::TextMetrics {
1185            self.inner.measure(text, style)
1186        }
1187
1188        fn get_offset_for_position(
1189            &self,
1190            text: &cranpose_ui::text::AnnotatedString,
1191            style: &cranpose_ui::text::TextStyle,
1192            x: f32,
1193            y: f32,
1194        ) -> usize {
1195            self.inner.get_offset_for_position(text, style, x, y)
1196        }
1197
1198        fn get_cursor_x_for_offset(
1199            &self,
1200            text: &cranpose_ui::text::AnnotatedString,
1201            style: &cranpose_ui::text::TextStyle,
1202            offset: usize,
1203        ) -> f32 {
1204            self.inner.get_cursor_x_for_offset(text, style, offset)
1205        }
1206
1207        fn layout(
1208            &self,
1209            text: &cranpose_ui::text::AnnotatedString,
1210            style: &cranpose_ui::text::TextStyle,
1211        ) -> cranpose_ui::text_layout_result::TextLayoutResult {
1212            self.layout_calls.set(self.layout_calls.get() + 1);
1213            self.inner.layout(text, style)
1214        }
1215    }
1216
1217    #[test]
1218    fn headless_text_measurer_uses_software_text_font() {
1219        let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
1220        let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
1221        let style = cranpose_ui::text::TextStyle::default();
1222
1223        let metrics = measurer.measure(&text, &style);
1224        let layout = measurer.layout(&text, &style);
1225
1226        assert!(metrics.width > 0.0);
1227        assert!(metrics.height > 0.0);
1228        assert_eq!(layout.lines.len(), metrics.line_count);
1229    }
1230
1231    #[test]
1232    fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
1233        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1234        let app_context = cranpose_ui::AppContext::new();
1235        renderer.attach_app_context_services(&app_context);
1236
1237        let metrics = app_context.enter(|| {
1238            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
1239            let style = cranpose_ui::text::TextStyle {
1240                span_style: cranpose_ui::text::SpanStyle {
1241                    font_size: cranpose_ui::text::TextUnit::Sp(14.0),
1242                    ..Default::default()
1243                },
1244                paragraph_style: cranpose_ui::text::ParagraphStyle {
1245                    platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
1246                        include_font_padding: None,
1247                        shaping: Some(cranpose_ui::text::TextShaping::Basic),
1248                    }),
1249                    ..Default::default()
1250                },
1251            };
1252            cranpose_ui::text::measure_text(&text, &style)
1253        });
1254
1255        assert!(
1256            metrics.width > 0.0,
1257            "software text service should measure text"
1258        );
1259        assert_eq!(
1260            renderer.frontend.text_state.text_cache_len(),
1261            0,
1262            "WGPU must not keep a renderer-side shaping cache for measurement"
1263        );
1264    }
1265
1266    #[test]
1267    fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
1268        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1269        let app_context = cranpose_ui::AppContext::new();
1270        renderer.attach_app_context_services(&app_context);
1271
1272        let prepared = app_context.enter(|| {
1273            let text = cranpose_ui::text::AnnotatedString::from(
1274                (0..48)
1275                    .map(|line| format!("// markdown code line {line:02}"))
1276                    .collect::<Vec<_>>()
1277                    .join("\n"),
1278            );
1279            let style = cranpose_ui::text::TextStyle::default();
1280            cranpose_ui::text::prepare_text_layout(
1281                &text,
1282                &style,
1283                cranpose_ui::text::TextLayoutOptions::default(),
1284                Some(952.0),
1285            )
1286        });
1287
1288        assert_eq!(prepared.metrics.line_count, 48);
1289        assert!(
1290            prepared.metrics.line_height > 18.0,
1291            "renderer-attached text service must not use fallback monospaced line height: {:?}",
1292            prepared.metrics
1293        );
1294        assert!(
1295            prepared.metrics.height > 900.0,
1296            "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
1297            prepared.metrics
1298        );
1299    }
1300
1301    #[test]
1302    fn render_text_layout_routes_through_attached_app_context_service() {
1303        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1304        let app_context = cranpose_ui::AppContext::new();
1305        renderer.attach_app_context_services(&app_context);
1306        let layout_calls = Rc::new(Cell::new(0));
1307        app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
1308
1309        app_context.enter(|| {
1310            let text = cranpose_ui::text::AnnotatedString::from("render text");
1311            let style = cranpose_ui::text::TextStyle::default();
1312            let layout = renderer.frontend.text_state.layout_text(&text, &style);
1313            assert!(layout.width > 0.0);
1314        });
1315
1316        assert_eq!(layout_calls.get(), 1);
1317    }
1318}