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/iOS).
5
6mod pipeline;
7mod render;
8mod scene;
9mod shaders;
10
11pub use scene::{ClickAction, DrawShape, HitRegion, ImageDraw, Scene, TextDraw};
12
13use cranpose_core::{MemoryApplier, NodeId};
14use cranpose_render_common::{RenderScene, Renderer};
15use cranpose_ui::{set_text_measurer, LayoutTree, TextMeasurer};
16use cranpose_ui_graphics::Size;
17use glyphon::{Attrs, Buffer, FontSystem, Metrics, Shaping};
18use lru::LruCache;
19use render::GpuRenderer;
20use rustc_hash::FxHasher;
21use std::collections::HashMap;
22use std::hash::{Hash, Hasher};
23use std::num::NonZeroUsize;
24use std::rc::Rc;
25use std::sync::{Arc, Mutex};
26
27/// Size-only cache for ultra-fast text measurement lookups.
28/// Key: (text_hash, font_size_fixed_point)
29/// Value: (text_content, size) - text stored to handle hash collisions
30type TextSizeCache = Arc<Mutex<LruCache<(u64, i32), (String, Size)>>>;
31
32#[derive(Debug)]
33pub enum WgpuRendererError {
34    Layout(String),
35    Wgpu(String),
36}
37
38/// Unified hash key for text caching - shared between measurement and rendering.
39#[derive(Clone, PartialEq, Eq, Hash)]
40pub(crate) enum TextKey {
41    Content(String),
42    Node(NodeId),
43}
44
45#[derive(Clone, PartialEq, Eq)]
46pub(crate) struct TextCacheKey {
47    key: TextKey,
48    scale_bits: u32, // f32 as bits for hashing
49}
50
51impl TextCacheKey {
52    fn new(text: &str, font_size: f32) -> Self {
53        Self {
54            key: TextKey::Content(text.to_string()),
55            scale_bits: font_size.to_bits(),
56        }
57    }
58
59    fn for_node(node_id: NodeId, font_size: f32) -> Self {
60        Self {
61            key: TextKey::Node(node_id),
62            scale_bits: font_size.to_bits(),
63        }
64    }
65}
66
67impl Hash for TextCacheKey {
68    fn hash<H: Hasher>(&self, state: &mut H) {
69        self.key.hash(state);
70        self.scale_bits.hash(state);
71    }
72}
73
74/// Cached text buffer shared between measurement and rendering
75pub(crate) struct SharedTextBuffer {
76    pub(crate) buffer: Buffer,
77    text: String,
78    font_size: f32,
79    /// Cached size to avoid recalculating on every access
80    cached_size: Option<Size>,
81}
82
83impl SharedTextBuffer {
84    /// Ensure the buffer has the correct text and font_size, only reshaping if needed
85    pub(crate) fn ensure(
86        &mut self,
87        font_system: &mut FontSystem,
88        text: &str,
89        font_size: f32,
90        attrs: Attrs,
91    ) {
92        let text_changed = self.text != text;
93        let font_changed = (self.font_size - font_size).abs() > 0.1;
94
95        // Only reshape if something actually changed
96        if !text_changed && !font_changed {
97            return; // Nothing changed, skip reshape
98        }
99
100        // Set metrics and size for unlimited layout
101        let metrics = Metrics::new(font_size, font_size * 1.4);
102        self.buffer.set_metrics(font_system, metrics);
103        self.buffer
104            .set_size(font_system, Some(f32::MAX), Some(f32::MAX));
105
106        // Set text and shape
107        self.buffer
108            .set_text(font_system, text, &attrs, Shaping::Advanced);
109        self.buffer.shape_until_scroll(font_system, false);
110
111        // Update cached values
112        self.text.clear();
113        self.text.push_str(text);
114        self.font_size = font_size;
115        self.cached_size = None; // Invalidate size cache
116    }
117
118    /// Get or calculate the size of the shaped text
119    pub(crate) fn size(&mut self, font_size: f32) -> Size {
120        if let Some(size) = self.cached_size {
121            return size;
122        }
123
124        // Calculate size from buffer
125        let mut max_width = 0.0f32;
126        let layout_runs = self.buffer.layout_runs();
127        for run in layout_runs {
128            max_width = max_width.max(run.line_w);
129        }
130        let total_height = self.buffer.lines.len() as f32 * font_size * 1.4;
131
132        let size = Size {
133            width: max_width,
134            height: total_height,
135        };
136
137        self.cached_size = Some(size);
138        size
139    }
140}
141
142/// Shared cache for text buffers used by both measurement and rendering
143pub(crate) type SharedTextCache = Arc<Mutex<HashMap<TextCacheKey, SharedTextBuffer>>>;
144
145/// Trim text cache if it exceeds MAX_CACHE_ITEMS.
146/// Removes the oldest half of entries when limit is reached.
147pub(crate) fn trim_text_cache(cache: &mut HashMap<TextCacheKey, SharedTextBuffer>) {
148    if cache.len() > MAX_CACHE_ITEMS {
149        let target_size = MAX_CACHE_ITEMS / 2;
150        let to_remove = cache.len() - target_size;
151
152        // Remove oldest entries (arbitrary keys from the front)
153        let keys_to_remove: Vec<TextCacheKey> = cache.keys().take(to_remove).cloned().collect();
154
155        for key in keys_to_remove {
156            cache.remove(&key);
157        }
158
159        log::debug!(
160            "Trimmed text cache from {} to {} entries",
161            cache.len() + to_remove,
162            cache.len()
163        );
164    }
165}
166
167/// Maximum number of cached text buffers before trimming occurs
168const MAX_CACHE_ITEMS: usize = 256;
169
170/// WGPU-based renderer for GPU-accelerated 2D rendering.
171///
172/// This renderer supports:
173/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
174/// - Gradients (solid, linear, radial)
175/// - GPU text rendering via glyphon
176/// - Cross-platform support (Desktop, Web, Mobile)
177pub struct WgpuRenderer {
178    scene: Scene,
179    gpu_renderer: Option<GpuRenderer>,
180    font_system: Arc<Mutex<FontSystem>>,
181    /// Shared text buffer cache used by both measurement and rendering
182    text_cache: SharedTextCache,
183    /// Root scale factor for text rendering (use for density scaling)
184    root_scale: f32,
185}
186
187impl WgpuRenderer {
188    /// Create a new WGPU renderer with the specified font data.
189    ///
190    /// This is the recommended constructor for applications.
191    /// Call `init_gpu` before rendering.
192    ///
193    /// # Example
194    ///
195    /// ```text
196    /// let font_light = include_bytes!("path/to/font-light.ttf");
197    /// let font_regular = include_bytes!("path/to/font-regular.ttf");
198    /// let renderer = WgpuRenderer::new_with_fonts(&[font_light, font_regular]);
199    /// ```
200    pub fn new_with_fonts(fonts: &[&[u8]]) -> Self {
201        let mut font_system = FontSystem::new();
202
203        // On Android, DO NOT load system fonts
204        // Modern Android uses Variable Fonts for Roboto which can cause
205        // rasterization corruption or font ID conflicts with glyphon.
206        // Use only our bundled static Roboto fonts for consistent rendering.
207        #[cfg(target_os = "android")]
208        {
209            log::info!("Skipping Android system fonts - using application-provided fonts");
210            // font_system.db_mut().load_fonts_dir("/system/fonts");  // DISABLED
211        }
212
213        // Load application-provided fonts
214        for (i, font_data) in fonts.iter().enumerate() {
215            log::info!("Loading font #{}, size: {} bytes", i, font_data.len());
216            font_system.db_mut().load_font_data(font_data.to_vec());
217        }
218
219        let face_count = font_system.db().faces().count();
220        log::info!("Total font faces loaded: {}", face_count);
221
222        if face_count == 0 {
223            log::error!("No fonts loaded! Text rendering will fail!");
224        }
225
226        let font_system = Arc::new(Mutex::new(font_system));
227
228        // Create shared text cache for both measurement and rendering
229        let text_cache = Arc::new(Mutex::new(HashMap::new()));
230
231        let text_measurer = WgpuTextMeasurer::new(font_system.clone(), text_cache.clone());
232        set_text_measurer(text_measurer.clone());
233
234        Self {
235            scene: Scene::new(),
236            gpu_renderer: None,
237            font_system,
238            text_cache,
239            root_scale: 1.0,
240        }
241    }
242
243    /// Create a new WGPU renderer without any fonts.
244    ///
245    /// **Warning:** This is for internal use only. Applications should use `new_with_fonts()`.
246    /// Text rendering will fail without fonts.
247    pub fn new() -> Self {
248        let font_system = FontSystem::new();
249        let font_system = Arc::new(Mutex::new(font_system));
250        let text_cache = Arc::new(Mutex::new(HashMap::new()));
251
252        let text_measurer = WgpuTextMeasurer::new(font_system.clone(), text_cache.clone());
253        set_text_measurer(text_measurer.clone());
254
255        Self {
256            scene: Scene::new(),
257            gpu_renderer: None,
258            font_system,
259            text_cache,
260            root_scale: 1.0,
261        }
262    }
263
264    /// Initialize GPU resources with a WGPU device and queue.
265    pub fn init_gpu(
266        &mut self,
267        device: Arc<wgpu::Device>,
268        queue: Arc<wgpu::Queue>,
269        surface_format: wgpu::TextureFormat,
270    ) {
271        self.gpu_renderer = Some(GpuRenderer::new(
272            device,
273            queue,
274            surface_format,
275            self.font_system.clone(),
276            self.text_cache.clone(),
277        ));
278    }
279
280    /// Set root scale factor for text rendering (e.g., density scaling on Android)
281    pub fn set_root_scale(&mut self, scale: f32) {
282        self.root_scale = scale;
283    }
284
285    /// Render the scene to a texture view.
286    pub fn render(
287        &mut self,
288        view: &wgpu::TextureView,
289        width: u32,
290        height: u32,
291    ) -> Result<(), WgpuRendererError> {
292        if let Some(gpu_renderer) = &mut self.gpu_renderer {
293            gpu_renderer
294                .render(
295                    view,
296                    &self.scene.shapes,
297                    &self.scene.images,
298                    &self.scene.texts,
299                    width,
300                    height,
301                    self.root_scale,
302                )
303                .map_err(WgpuRendererError::Wgpu)
304        } else {
305            Err(WgpuRendererError::Wgpu(
306                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
307            ))
308        }
309    }
310
311    /// Get access to the WGPU device (for surface configuration).
312    pub fn device(&self) -> &wgpu::Device {
313        self.gpu_renderer
314            .as_ref()
315            .map(|r| &*r.device)
316            .expect("GPU renderer not initialized")
317    }
318}
319
320impl Default for WgpuRenderer {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl Renderer for WgpuRenderer {
327    type Scene = Scene;
328    type Error = WgpuRendererError;
329
330    fn scene(&self) -> &Self::Scene {
331        &self.scene
332    }
333
334    fn scene_mut(&mut self) -> &mut Self::Scene {
335        &mut self.scene
336    }
337
338    fn rebuild_scene(
339        &mut self,
340        layout_tree: &LayoutTree,
341        _viewport: Size,
342    ) -> Result<(), Self::Error> {
343        self.scene.clear();
344        // Build scene in logical dp - scaling happens in GPU vertex upload
345        pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
346        Ok(())
347    }
348
349    fn rebuild_scene_from_applier(
350        &mut self,
351        applier: &mut MemoryApplier,
352        root: NodeId,
353        _viewport: Size,
354    ) -> Result<(), Self::Error> {
355        self.scene.clear();
356        // Build scene in logical dp - scaling happens in GPU vertex upload
357        // Traverse layout nodes via applier instead of rebuilding LayoutTree
358        pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
359        Ok(())
360    }
361
362    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
363        use cranpose_ui_graphics::{Brush, Color, Rect, RoundedCornerShape};
364
365        // Draw FPS text in top-right corner with semi-transparent background
366        // Position: 8px from right edge, 8px from top
367        let padding = 8.0;
368        let font_size = 14.0;
369
370        // Measure text width (approximate: ~7px per character at 14px font)
371        let char_width = 7.0;
372        let text_width = text.len() as f32 * char_width;
373        let text_height = font_size * 1.4;
374
375        let x = viewport.width - text_width - padding * 2.0;
376        let y = padding;
377
378        // Add background rectangle (dark semi-transparent)
379        let bg_rect = Rect {
380            x,
381            y,
382            width: text_width + padding,
383            height: text_height + padding / 2.0,
384        };
385        self.scene.push_shape(
386            bg_rect,
387            Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
388            Some(RoundedCornerShape::uniform(4.0)),
389            None,
390        );
391
392        // Add text (green color for visibility)
393        let text_rect = Rect {
394            x: x + padding / 2.0,
395            y: y + padding / 4.0,
396            width: text_width,
397            height: text_height,
398        };
399        self.scene.push_text(
400            NodeId::MAX,
401            text_rect,
402            Rc::from(text),
403            Color(0.0, 1.0, 0.0, 1.0), // Green
404            font_size,
405            1.0,
406            None,
407        );
408    }
409}
410
411fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
412    match style.font_size {
413        cranpose_ui::text::TextUnit::Sp(v) => v,
414        cranpose_ui::text::TextUnit::Em(v) => v * 14.0,
415        cranpose_ui::text::TextUnit::Unspecified => 14.0,
416    }
417}
418
419// Text measurer implementation for WGPU
420
421// Text measurer implementation for WGPU
422
423#[derive(Clone)]
424struct WgpuTextMeasurer {
425    font_system: Arc<Mutex<FontSystem>>,
426    size_cache: TextSizeCache,
427    /// Shared buffer cache used by both measurement and rendering
428    text_cache: SharedTextCache,
429}
430
431impl WgpuTextMeasurer {
432    fn new(font_system: Arc<Mutex<FontSystem>>, text_cache: SharedTextCache) -> Self {
433        Self {
434            font_system,
435            // Larger cache size (1024) reduces misses, FxHasher for faster lookups
436            size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
437            text_cache,
438        }
439    }
440}
441
442// Base font size in logical units (dp) - shared between measurement and rendering
443
444impl TextMeasurer for WgpuTextMeasurer {
445    fn measure(
446        &self,
447        text: &str,
448        style: &cranpose_ui::text::TextStyle,
449    ) -> cranpose_ui::TextMetrics {
450        let font_size = resolve_font_size(style);
451        let size_int = (font_size * 100.0) as i32;
452
453        // Calculate hash to avoid allocating String for lookup
454        // FxHasher is ~3x faster than DefaultHasher for short strings
455        let mut hasher = FxHasher::default();
456        text.hash(&mut hasher);
457        let text_hash = hasher.finish();
458        let cache_key = (text_hash, size_int);
459
460        // Check size cache first (fastest path)
461        {
462            let mut cache = self.size_cache.lock().unwrap();
463            if let Some((cached_text, size)) = cache.get(&cache_key) {
464                // Verify partial collision
465                if cached_text == text {
466                    return cranpose_ui::TextMetrics {
467                        width: size.width,
468                        height: size.height,
469                        line_height: font_size * 1.4,
470                        line_count: 1, // Cached entries are single-line simplified
471                    };
472                }
473            }
474        }
475
476        // Get or create text buffer
477        let text_buffer_key = TextCacheKey::new(text, font_size);
478        let mut font_system = self.font_system.lock().unwrap();
479        let mut text_cache = self.text_cache.lock().unwrap();
480
481        // Get or create buffer and calculate size
482        let size = {
483            let buffer = text_cache.entry(text_buffer_key).or_insert_with(|| {
484                let buffer =
485                    Buffer::new(&mut font_system, Metrics::new(font_size, font_size * 1.4));
486                SharedTextBuffer {
487                    buffer,
488                    text: String::new(),
489                    font_size: 0.0,
490                    cached_size: None,
491                }
492            });
493
494            // Ensure buffer has the correct text
495            buffer.ensure(&mut font_system, text, font_size, Attrs::new());
496
497            // Calculate size if not cached
498            buffer.size(font_size)
499        };
500
501        // Trim cache if needed (after we're done with buffer reference)
502        trim_text_cache(&mut text_cache);
503
504        drop(font_system);
505        drop(text_cache);
506
507        // Cache the size result
508        let mut size_cache = self.size_cache.lock().unwrap();
509        // Only allocate string on cache miss
510        size_cache.put(cache_key, (text.to_string(), size));
511
512        // Calculate line info for multiline support
513        let line_height = font_size * 1.4;
514        let line_count = text.split('\n').count().max(1);
515
516        cranpose_ui::TextMetrics {
517            width: size.width,
518            height: size.height,
519            line_height,
520            line_count,
521        }
522    }
523
524    fn get_offset_for_position(
525        &self,
526        text: &str,
527        style: &cranpose_ui::text::TextStyle,
528        x: f32,
529        y: f32,
530    ) -> usize {
531        let font_size = resolve_font_size(style);
532        if text.is_empty() {
533            return 0;
534        }
535
536        let line_height = font_size * 1.4;
537
538        // Calculate which line was clicked based on Y coordinate
539        let line_index = (y / line_height).floor().max(0.0) as usize;
540        let lines: Vec<&str> = text.split('\n').collect();
541        let target_line = line_index.min(lines.len().saturating_sub(1));
542
543        // Calculate byte offset to start of target line
544        let mut line_start_byte = 0;
545        for line in lines.iter().take(target_line) {
546            line_start_byte += line.len() + 1; // +1 for newline
547        }
548
549        // Get the text of the target line for hit testing
550        let line_text = lines.get(target_line).unwrap_or(&"");
551
552        if line_text.is_empty() {
553            return line_start_byte;
554        }
555
556        // Use glyphon's hit testing for the specific line
557        let cache_key = TextCacheKey::new(line_text, font_size);
558        let mut font_system = self.font_system.lock().unwrap();
559        let mut text_cache = self.text_cache.lock().unwrap();
560
561        let buffer = text_cache.entry(cache_key).or_insert_with(|| {
562            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, font_size * 1.4));
563            SharedTextBuffer {
564                buffer,
565                text: String::new(),
566                font_size: 0.0,
567                cached_size: None,
568            }
569        });
570
571        buffer.ensure(&mut font_system, line_text, font_size, Attrs::new());
572
573        // Find closest glyph position using layout runs
574        let mut best_offset = 0;
575        let mut best_distance = f32::INFINITY;
576
577        for run in buffer.buffer.layout_runs() {
578            let mut glyph_x = 0.0f32;
579            for glyph in run.glyphs.iter() {
580                // Check distance to left edge of glyph
581                let left_dist = (x - glyph_x).abs();
582                if left_dist < best_distance {
583                    best_distance = left_dist;
584                    // glyph.start is byte index in line_text
585                    best_offset = glyph.start;
586                }
587
588                // Update x position for next glyph
589                glyph_x += glyph.w;
590
591                // Check distance to right edge (after glyph)
592                let right_dist = (x - glyph_x).abs();
593                if right_dist < best_distance {
594                    best_distance = right_dist;
595                    best_offset = glyph.end;
596                }
597            }
598        }
599
600        // Return absolute byte offset (line start + offset within line)
601        line_start_byte + best_offset.min(line_text.len())
602    }
603
604    fn get_cursor_x_for_offset(
605        &self,
606        text: &str,
607        style: &cranpose_ui::text::TextStyle,
608        offset: usize,
609    ) -> f32 {
610        let clamped_offset = offset.min(text.len());
611        if clamped_offset == 0 {
612            return 0.0;
613        }
614
615        // Measure text up to offset
616        let prefix = &text[..clamped_offset];
617        self.measure(prefix, style).width
618    }
619
620    fn layout(
621        &self,
622        text: &str,
623        style: &cranpose_ui::text::TextStyle,
624    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
625        use cranpose_ui::text_layout_result::{LineLayout, TextLayoutResult};
626
627        let font_size = resolve_font_size(style);
628        let line_height = font_size * 1.4;
629
630        // Get buffer to extract glyph positions
631        let cache_key = TextCacheKey::new(text, font_size);
632        let mut font_system = self.font_system.lock().unwrap();
633        let mut text_cache = self.text_cache.lock().unwrap();
634
635        let buffer = text_cache.entry(cache_key.clone()).or_insert_with(|| {
636            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
637            SharedTextBuffer {
638                buffer,
639                text: String::new(),
640                font_size: 0.0,
641                cached_size: None,
642            }
643        });
644        buffer.ensure(&mut font_system, text, font_size, Attrs::new());
645
646        // Extract glyph positions from layout runs
647        let mut glyph_x_positions = Vec::new();
648        let mut char_to_byte = Vec::new();
649        let mut lines = Vec::new();
650
651        let mut current_line_y = 0.0f32;
652        let mut line_start_offset = 0usize;
653
654        for run in buffer.buffer.layout_runs() {
655            let line_idx = run.line_i;
656            let line_y = line_idx as f32 * line_height;
657
658            // Track line boundaries
659            if lines.is_empty() || line_y != current_line_y {
660                if !lines.is_empty() {
661                    // Close previous line
662                    if let Some(_last) = lines.last_mut() {
663                        // end_offset will be updated when we see a newline or end
664                    }
665                }
666                current_line_y = line_y;
667            }
668
669            for glyph in run.glyphs.iter() {
670                glyph_x_positions.push(glyph.x);
671                char_to_byte.push(glyph.start);
672
673                // Track line end
674                if glyph.end > line_start_offset {
675                    line_start_offset = glyph.end;
676                }
677            }
678        }
679
680        // Add end position
681        let total_width = self.measure(text, style).width;
682        glyph_x_positions.push(total_width);
683        char_to_byte.push(text.len());
684
685        // Build lines from text newlines
686        let mut y = 0.0f32;
687        let mut line_start = 0usize;
688        for (i, line_text) in text.split('\n').enumerate() {
689            let line_end = if i == text.split('\n').count() - 1 {
690                text.len()
691            } else {
692                line_start + line_text.len()
693            };
694
695            lines.push(LineLayout {
696                start_offset: line_start,
697                end_offset: line_end,
698                y,
699                height: line_height,
700            });
701
702            line_start = line_end + 1;
703            y += line_height;
704        }
705
706        if lines.is_empty() {
707            lines.push(LineLayout {
708                start_offset: 0,
709                end_offset: 0,
710                y: 0.0,
711                height: line_height,
712            });
713        }
714
715        let metrics = self.measure(text, style);
716        TextLayoutResult::new(
717            metrics.width,
718            metrics.height,
719            line_height,
720            glyph_x_positions,
721            char_to_byte,
722            lines,
723            text,
724        )
725    }
726}