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