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
6#![deny(unsafe_code)]
7
8mod effect_renderer;
9mod frame_graph;
10pub(crate) mod gpu_stats;
11mod layer_events;
12mod layer_surface_cache;
13mod normalized_scene;
14mod offscreen;
15mod pipeline;
16mod render;
17mod scene;
18mod shader_cache;
19mod shaders;
20mod surface_executor;
21mod surface_plan;
22mod surface_requirements;
23#[cfg(test)]
24mod test_support;
25
26pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
27pub use scene::{ClickAction, HitRegion, Scene};
28
29use cranpose_core::{MemoryApplier, NodeId};
30use cranpose_render_common::{
31    graph::RenderGraph,
32    software_text_raster::{
33        software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
34    },
35    RenderScene, Renderer,
36};
37use cranpose_ui::{LayoutTree, TextMeasurer};
38use cranpose_ui_graphics::{Rect, Size};
39use render::GpuRenderer;
40use std::rc::{Rc, Weak};
41use std::sync::Arc;
42
43/// Convert an axis-aligned rectangle to four corner positions (TL, TR, BL, BR).
44pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
45    [
46        [rect.x, rect.y],
47        [rect.x + rect.width, rect.y],
48        [rect.x, rect.y + rect.height],
49        [rect.x + rect.width, rect.y + rect.height],
50    ]
51}
52
53#[derive(Debug)]
54pub enum WgpuRendererError {
55    Layout(String),
56    Wgpu(String),
57}
58
59/// CPU-readable RGBA frame captured from the renderer output.
60#[derive(Debug, Clone)]
61pub struct CapturedFrame {
62    pub width: u32,
63    pub height: u32,
64    pub pixels: Vec<u8>,
65}
66
67#[doc(hidden)]
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct DebugCpuAllocationStats {
70    pub scene_graph_node_count: usize,
71    pub scene_graph_heap_bytes: usize,
72    pub scene_hits_len: usize,
73    pub scene_hits_cap: usize,
74    pub scene_node_index_len: usize,
75    pub scene_node_index_cap: usize,
76    pub text_renderer_pool_len: usize,
77    pub text_renderer_pool_cap: usize,
78    pub swash_image_cache_len: usize,
79    pub swash_image_cache_cap: usize,
80    pub swash_outline_cache_len: usize,
81    pub swash_outline_cache_cap: usize,
82    pub image_texture_cache_len: usize,
83    pub image_texture_cache_cap: usize,
84    pub scratch_shape_data_cap: usize,
85    pub scratch_gradients_cap: usize,
86    pub scratch_vertices_cap: usize,
87    pub scratch_indices_cap: usize,
88    pub scratch_image_vertices_cap: usize,
89    pub scratch_image_indices_cap: usize,
90    pub scratch_image_cmds_cap: usize,
91    pub scratch_segment_items_cap: usize,
92    pub scratch_effect_ranges_cap: usize,
93    pub scratch_layer_events_cap: usize,
94    pub staged_upload_bytes_cap: usize,
95    pub staged_upload_copies_cap: usize,
96    pub layer_surface_cache_len: usize,
97    pub layer_surface_cache_cap: usize,
98    pub layer_surface_cache_identity_len: usize,
99    pub layer_surface_cache_identity_cap: usize,
100    pub layer_surface_rect_cache_len: usize,
101    pub layer_surface_rect_cache_cap: usize,
102    pub layer_surface_requirements_cache_len: usize,
103    pub layer_surface_requirements_cache_cap: usize,
104    pub layer_cache_seen_this_frame_len: usize,
105    pub layer_cache_seen_this_frame_cap: usize,
106}
107
108pub(crate) struct TextSystemState {
109    measurer: SoftwareTextMeasurer,
110}
111
112impl TextSystemState {
113    fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
114        Self {
115            measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
116        }
117    }
118
119    pub(crate) fn text_cache_len(&self) -> usize {
120        0
121    }
122}
123
124impl pipeline::TextLayoutResolver for TextSystemState {
125    fn layout_text(
126        &mut self,
127        text: &cranpose_ui::text::AnnotatedString,
128        style: &cranpose_ui::text::TextStyle,
129    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
130        if cranpose_ui::has_current_app_context() {
131            cranpose_ui::text::layout_text(text, style)
132        } else {
133            self.measurer.layout(text, style)
134        }
135    }
136}
137
138#[derive(Clone)]
139pub struct WgpuTextSystem {
140    software_fonts: SoftwareTextFontSet,
141}
142
143impl WgpuTextSystem {
144    pub fn from_fonts(fonts: &[&[u8]]) -> Self {
145        Self {
146            software_fonts: software_text_font_set_from_fonts_or_default(fonts),
147        }
148    }
149
150    fn render_state(&self) -> TextSystemState {
151        TextSystemState::from_font_set(self.software_fonts.clone())
152    }
153
154    fn software_fonts(&self) -> SoftwareTextFontSet {
155        self.software_fonts.clone()
156    }
157}
158
159/// Create an accurate WGPU text measurer for headless tests without launching a window.
160pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
161    headless_text_measurer_with_fonts(&[])
162}
163
164/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
165pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
166    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
167}
168
169/// WGPU-based renderer for GPU-accelerated 2D rendering.
170///
171/// This renderer supports:
172/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
173/// - Gradients (solid, linear, radial)
174/// - GPU text rendering via retained raster image batches
175/// - Cross-platform support (Desktop, Web, Android)
176pub struct WgpuRenderer {
177    scene: Scene,
178    gpu_renderer: Option<GpuRenderer>,
179    text_state: TextSystemState,
180    text_fonts: SoftwareTextFontSet,
181    app_context: Option<Weak<cranpose_ui::AppContext>>,
182    /// Root scale factor for text rendering (use for density scaling)
183    root_scale: f32,
184    dev_overlay_cache: Option<DevOverlayCache>,
185    dev_overlay_graph: Option<RenderGraph>,
186}
187
188#[derive(Clone, Debug)]
189struct DevOverlayCache {
190    text: String,
191    viewport_width_bits: u32,
192    viewport_height_bits: u32,
193}
194
195impl WgpuRenderer {
196    /// Create a new WGPU renderer.
197    ///
198    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
199    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
200    ///
201    /// Call [`init_gpu`][Self::init_gpu] before rendering.
202    pub fn new(fonts: &[&[u8]]) -> Self {
203        Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
204    }
205
206    pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
207        Self {
208            scene: Scene::new(),
209            gpu_renderer: None,
210            text_state: text_system.render_state(),
211            text_fonts: text_system.software_fonts(),
212            app_context: None,
213            root_scale: 1.0,
214            dev_overlay_cache: None,
215            dev_overlay_graph: None,
216        }
217    }
218
219    /// Initialize GPU resources with a WGPU device and queue.
220    pub fn init_gpu(
221        &mut self,
222        device: Arc<wgpu::Device>,
223        queue: Arc<wgpu::Queue>,
224        surface_format: wgpu::TextureFormat,
225        adapter_backend: wgpu::Backend,
226    ) {
227        self.gpu_renderer = Some(GpuRenderer::new(
228            device,
229            queue,
230            surface_format,
231            adapter_backend,
232            self.text_fonts.clone(),
233        ));
234    }
235
236    /// Set root scale factor for text rendering (e.g., density scaling on Android)
237    pub fn set_root_scale(&mut self, scale: f32) {
238        self.root_scale = scale;
239    }
240
241    pub fn root_scale(&self) -> f32 {
242        self.root_scale
243    }
244
245    /// Render the scene to a texture view.
246    pub fn render(
247        &mut self,
248        view: &wgpu::TextureView,
249        width: u32,
250        height: u32,
251    ) -> Result<(), WgpuRendererError> {
252        if let Some(gpu_renderer) = &mut self.gpu_renderer {
253            let graph = self
254                .scene
255                .graph
256                .as_ref()
257                .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
258            let text_state = &mut self.text_state;
259            let root_scale = self.root_scale;
260            let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
261            let result = if let Some(app_context) = app_context {
262                app_context.enter(|| {
263                    gpu_renderer.render(
264                        text_state,
265                        view,
266                        graph,
267                        self.dev_overlay_graph.as_ref(),
268                        width,
269                        height,
270                        root_scale,
271                    )
272                })
273            } else {
274                gpu_renderer.render(
275                    text_state,
276                    view,
277                    graph,
278                    self.dev_overlay_graph.as_ref(),
279                    width,
280                    height,
281                    root_scale,
282                )
283            };
284            result.map_err(WgpuRendererError::Wgpu)
285        } else {
286            Err(WgpuRendererError::Wgpu(
287                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
288            ))
289        }
290    }
291
292    /// Render the current scene into an RGBA pixel buffer for robot tests.
293    ///
294    /// Uses the renderer's configured root scale.
295    pub fn capture_frame(
296        &mut self,
297        width: u32,
298        height: u32,
299    ) -> Result<CapturedFrame, WgpuRendererError> {
300        self.capture_frame_with_scale(width, height, self.root_scale)
301    }
302
303    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
304    pub fn capture_frame_with_scale(
305        &mut self,
306        width: u32,
307        height: u32,
308        root_scale: f32,
309    ) -> Result<CapturedFrame, WgpuRendererError> {
310        if let Some(gpu_renderer) = &mut self.gpu_renderer {
311            let graph = self
312                .scene
313                .graph
314                .as_ref()
315                .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
316            let text_state = &mut self.text_state;
317            let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
318            let pixels = if let Some(app_context) = app_context {
319                app_context.enter(|| {
320                    gpu_renderer.render_to_rgba_pixels(
321                        text_state,
322                        graph,
323                        self.dev_overlay_graph.as_ref(),
324                        width,
325                        height,
326                        root_scale,
327                    )
328                })
329            } else {
330                gpu_renderer.render_to_rgba_pixels(
331                    text_state,
332                    graph,
333                    self.dev_overlay_graph.as_ref(),
334                    width,
335                    height,
336                    root_scale,
337                )
338            }
339            .map_err(WgpuRendererError::Wgpu)?;
340            Ok(CapturedFrame {
341                width,
342                height,
343                pixels,
344            })
345        } else {
346            Err(WgpuRendererError::Wgpu(
347                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
348            ))
349        }
350    }
351
352    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
353        self.gpu_renderer
354            .as_ref()
355            .and_then(GpuRenderer::last_frame_stats)
356    }
357
358    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
359        let mut stats = self
360            .gpu_renderer
361            .as_ref()
362            .map(GpuRenderer::debug_cpu_allocation_stats)
363            .unwrap_or_default();
364        stats.scene_graph_node_count = self
365            .scene
366            .graph
367            .as_ref()
368            .map(RenderGraph::node_count)
369            .unwrap_or(0);
370        stats.scene_graph_heap_bytes = self
371            .scene
372            .graph
373            .as_ref()
374            .map(RenderGraph::heap_bytes)
375            .unwrap_or(0);
376        stats.scene_hits_len = self.scene.hits.len();
377        stats.scene_hits_cap = self.scene.hits.capacity();
378        stats.scene_node_index_len = self.scene.node_index.len();
379        stats.scene_node_index_cap = self.scene.node_index.capacity();
380        stats
381    }
382
383    /// Return the WGPU device when GPU resources are initialized.
384    pub fn try_device(&self) -> Option<&wgpu::Device> {
385        self.gpu_renderer.as_ref().map(|r| &*r.device)
386    }
387}
388
389impl Default for WgpuRenderer {
390    fn default() -> Self {
391        Self::new(&[])
392    }
393}
394
395impl Renderer for WgpuRenderer {
396    type Scene = Scene;
397    type Error = WgpuRendererError;
398
399    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
400        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
401            self.text_fonts.clone(),
402            8192,
403        ));
404        self.app_context = Some(app_context.downgrade());
405    }
406
407    fn scene(&self) -> &Self::Scene {
408        &self.scene
409    }
410
411    fn scene_mut(&mut self) -> &mut Self::Scene {
412        &mut self.scene
413    }
414
415    fn rebuild_scene(
416        &mut self,
417        layout_tree: &LayoutTree,
418        _viewport: Size,
419    ) -> Result<(), Self::Error> {
420        self.scene.clear();
421        self.dev_overlay_graph = None;
422        self.dev_overlay_cache = None;
423        // Build scene in logical dp - scaling happens in GPU vertex upload
424        pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
425        Ok(())
426    }
427
428    fn rebuild_scene_from_applier(
429        &mut self,
430        applier: &mut MemoryApplier,
431        root: NodeId,
432        _viewport: Size,
433    ) -> Result<(), Self::Error> {
434        self.scene.clear();
435        self.dev_overlay_graph = None;
436        self.dev_overlay_cache = None;
437        // Build scene in logical dp - scaling happens in GPU vertex upload
438        // Traverse layout nodes via applier instead of rebuilding LayoutTree
439        pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
440        Ok(())
441    }
442
443    fn update_scene_from_applier(
444        &mut self,
445        applier: &mut MemoryApplier,
446        root: NodeId,
447        viewport: Size,
448        dirty_nodes: &[NodeId],
449    ) -> Result<(), Self::Error> {
450        if dirty_nodes.is_empty() {
451            return self.rebuild_scene_from_applier(applier, root, viewport);
452        }
453        pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, true);
454        Ok(())
455    }
456
457    fn update_visual_scene_from_applier(
458        &mut self,
459        applier: &mut MemoryApplier,
460        root: NodeId,
461        viewport: Size,
462        dirty_nodes: &[NodeId],
463    ) -> Result<(), Self::Error> {
464        if dirty_nodes.is_empty() {
465            return self.rebuild_scene_from_applier(applier, root, viewport);
466        }
467        pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, false);
468        Ok(())
469    }
470
471    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
472        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
473        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
474        if self.dev_overlay_graph.is_some()
475            && self.dev_overlay_cache.as_ref().is_some_and(|cache| {
476                cache.text == key.text
477                    && cache.viewport_width_bits == key.viewport_width_bits
478                    && cache.viewport_height_bits == key.viewport_height_bits
479            })
480        {
481            return;
482        }
483        self.dev_overlay_graph = Some(
484            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
485                text,
486                viewport,
487                DEV_OVERLAY_NODE_ID,
488            ),
489        );
490        self.dev_overlay_cache = Some(DevOverlayCache {
491            text: key.text,
492            viewport_width_bits: key.viewport_width_bits,
493            viewport_height_bits: key.viewport_height_bits,
494        });
495    }
496
497    fn needs_frame_warmup(&self) -> bool {
498        self.gpu_renderer
499            .as_ref()
500            .is_some_and(GpuRenderer::needs_frame_warmup)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::pipeline::TextLayoutResolver;
508    use cranpose_render_common::graph::RenderNode;
509    use cranpose_ui_graphics::GraphicsLayer;
510    use std::cell::Cell;
511
512    static TEST_FONT: &[u8] =
513        cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
514
515    #[test]
516    fn dev_overlay_is_recorded_outside_app_graph() {
517        let mut renderer = WgpuRenderer::new(&[]);
518        renderer.draw_dev_overlay(
519            "240 FPS | avg 4.0ms | p95 4.5ms",
520            Size {
521                width: 800.0,
522                height: 600.0,
523            },
524        );
525
526        assert!(
527            renderer
528                .scene
529                .graph
530                .as_ref()
531                .is_none_or(|graph| graph.root.children.iter().all(|child| {
532                    !matches!(
533                        child,
534                        RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
535                    )
536                })),
537            "dev overlay must not be mixed into the app scene graph"
538        );
539
540        let graph = renderer.dev_overlay_graph.as_ref().expect("overlay graph");
541        let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
542            panic!("dev overlay should be the final top-level layer");
543        };
544
545        assert_eq!(overlay.node_id, Some(NodeId::MAX));
546        assert_eq!(
547            overlay.graphics_layer.compositing_strategy,
548            GraphicsLayer::default().compositing_strategy,
549            "dev overlay should not allocate an offscreen surface"
550        );
551    }
552
553    struct CountingTextMeasurer {
554        inner: SoftwareTextMeasurer,
555        layout_calls: Rc<Cell<usize>>,
556    }
557
558    impl CountingTextMeasurer {
559        fn new(layout_calls: Rc<Cell<usize>>) -> Self {
560            Self {
561                inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
562                layout_calls,
563            }
564        }
565    }
566
567    impl TextMeasurer for CountingTextMeasurer {
568        fn measure(
569            &self,
570            text: &cranpose_ui::text::AnnotatedString,
571            style: &cranpose_ui::text::TextStyle,
572        ) -> cranpose_ui::TextMetrics {
573            self.inner.measure(text, style)
574        }
575
576        fn get_offset_for_position(
577            &self,
578            text: &cranpose_ui::text::AnnotatedString,
579            style: &cranpose_ui::text::TextStyle,
580            x: f32,
581            y: f32,
582        ) -> usize {
583            self.inner.get_offset_for_position(text, style, x, y)
584        }
585
586        fn get_cursor_x_for_offset(
587            &self,
588            text: &cranpose_ui::text::AnnotatedString,
589            style: &cranpose_ui::text::TextStyle,
590            offset: usize,
591        ) -> f32 {
592            self.inner.get_cursor_x_for_offset(text, style, offset)
593        }
594
595        fn layout(
596            &self,
597            text: &cranpose_ui::text::AnnotatedString,
598            style: &cranpose_ui::text::TextStyle,
599        ) -> cranpose_ui::text_layout_result::TextLayoutResult {
600            self.layout_calls.set(self.layout_calls.get() + 1);
601            self.inner.layout(text, style)
602        }
603    }
604
605    #[test]
606    fn headless_text_measurer_uses_software_text_font() {
607        let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
608        let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
609        let style = cranpose_ui::text::TextStyle::default();
610
611        let metrics = measurer.measure(&text, &style);
612        let layout = measurer.layout(&text, &style);
613
614        assert!(metrics.width > 0.0);
615        assert!(metrics.height > 0.0);
616        assert_eq!(layout.lines.len(), metrics.line_count);
617    }
618
619    #[test]
620    fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
621        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
622        let app_context = cranpose_ui::AppContext::new();
623        renderer.attach_app_context_services(&app_context);
624
625        let metrics = app_context.enter(|| {
626            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
627            let style = cranpose_ui::text::TextStyle {
628                span_style: cranpose_ui::text::SpanStyle {
629                    font_size: cranpose_ui::text::TextUnit::Sp(14.0),
630                    ..Default::default()
631                },
632                paragraph_style: cranpose_ui::text::ParagraphStyle {
633                    platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
634                        include_font_padding: None,
635                        shaping: Some(cranpose_ui::text::TextShaping::Basic),
636                    }),
637                    ..Default::default()
638                },
639            };
640            cranpose_ui::text::measure_text(&text, &style)
641        });
642
643        assert!(
644            metrics.width > 0.0,
645            "software text service should measure text"
646        );
647        assert_eq!(
648            renderer.text_state.text_cache_len(),
649            0,
650            "WGPU must not keep a renderer-side shaping cache for measurement"
651        );
652    }
653
654    #[test]
655    fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
656        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
657        let app_context = cranpose_ui::AppContext::new();
658        renderer.attach_app_context_services(&app_context);
659
660        let prepared = app_context.enter(|| {
661            let text = cranpose_ui::text::AnnotatedString::from(
662                (0..48)
663                    .map(|line| format!("// markdown code line {line:02}"))
664                    .collect::<Vec<_>>()
665                    .join("\n"),
666            );
667            let style = cranpose_ui::text::TextStyle::default();
668            cranpose_ui::text::prepare_text_layout(
669                &text,
670                &style,
671                cranpose_ui::text::TextLayoutOptions::default(),
672                Some(952.0),
673            )
674        });
675
676        assert_eq!(prepared.metrics.line_count, 48);
677        assert!(
678            prepared.metrics.line_height > 18.0,
679            "renderer-attached text service must not use fallback monospaced line height: {:?}",
680            prepared.metrics
681        );
682        assert!(
683            prepared.metrics.height > 900.0,
684            "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
685            prepared.metrics
686        );
687    }
688
689    #[test]
690    fn render_text_layout_routes_through_attached_app_context_service() {
691        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
692        let app_context = cranpose_ui::AppContext::new();
693        renderer.attach_app_context_services(&app_context);
694        let layout_calls = Rc::new(Cell::new(0));
695        app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
696
697        app_context.enter(|| {
698            let text = cranpose_ui::text::AnnotatedString::from("render text");
699            let style = cranpose_ui::text::TextStyle::default();
700            let layout = renderer.text_state.layout_text(&text, &style);
701            assert!(layout.width > 0.0);
702        });
703
704        assert_eq!(layout_calls.get(), 1);
705    }
706}