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, 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.texts,
298                    width,
299                    height,
300                    self.root_scale,
301                )
302                .map_err(WgpuRendererError::Wgpu)
303        } else {
304            Err(WgpuRendererError::Wgpu(
305                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
306            ))
307        }
308    }
309
310    /// Get access to the WGPU device (for surface configuration).
311    pub fn device(&self) -> &wgpu::Device {
312        self.gpu_renderer
313            .as_ref()
314            .map(|r| &*r.device)
315            .expect("GPU renderer not initialized")
316    }
317}
318
319impl Default for WgpuRenderer {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325impl Renderer for WgpuRenderer {
326    type Scene = Scene;
327    type Error = WgpuRendererError;
328
329    fn scene(&self) -> &Self::Scene {
330        &self.scene
331    }
332
333    fn scene_mut(&mut self) -> &mut Self::Scene {
334        &mut self.scene
335    }
336
337    fn rebuild_scene(
338        &mut self,
339        layout_tree: &LayoutTree,
340        _viewport: Size,
341    ) -> Result<(), Self::Error> {
342        self.scene.clear();
343        // Build scene in logical dp - scaling happens in GPU vertex upload
344        pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
345        Ok(())
346    }
347
348    fn rebuild_scene_from_applier(
349        &mut self,
350        applier: &mut MemoryApplier,
351        root: NodeId,
352        _viewport: Size,
353    ) -> Result<(), Self::Error> {
354        self.scene.clear();
355        // Build scene in logical dp - scaling happens in GPU vertex upload
356        // Traverse layout nodes via applier instead of rebuilding LayoutTree
357        pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
358        Ok(())
359    }
360
361    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
362        use cranpose_ui_graphics::{Brush, Color, Rect, RoundedCornerShape};
363
364        // Draw FPS text in top-right corner with semi-transparent background
365        // Position: 8px from right edge, 8px from top
366        let padding = 8.0;
367        let font_size = 14.0;
368
369        // Measure text width (approximate: ~7px per character at 14px font)
370        let char_width = 7.0;
371        let text_width = text.len() as f32 * char_width;
372        let text_height = font_size * 1.4;
373
374        let x = viewport.width - text_width - padding * 2.0;
375        let y = padding;
376
377        // Add background rectangle (dark semi-transparent)
378        let bg_rect = Rect {
379            x,
380            y,
381            width: text_width + padding,
382            height: text_height + padding / 2.0,
383        };
384        self.scene.push_shape(
385            bg_rect,
386            Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
387            Some(RoundedCornerShape::uniform(4.0)),
388            None,
389        );
390
391        // Add text (green color for visibility)
392        let text_rect = Rect {
393            x: x + padding / 2.0,
394            y: y + padding / 4.0,
395            width: text_width,
396            height: text_height,
397        };
398        self.scene.push_text(
399            NodeId::MAX,
400            text_rect,
401            Rc::from(text),
402            Color(0.0, 1.0, 0.0, 1.0),  // Green
403            font_size / BASE_FONT_SIZE, // Scale relative to base
404            None,
405        );
406    }
407}
408
409// Text measurer implementation for WGPU
410
411// Text measurer implementation for WGPU
412
413#[derive(Clone)]
414struct WgpuTextMeasurer {
415    font_system: Arc<Mutex<FontSystem>>,
416    size_cache: TextSizeCache,
417    /// Shared buffer cache used by both measurement and rendering
418    text_cache: SharedTextCache,
419}
420
421impl WgpuTextMeasurer {
422    fn new(font_system: Arc<Mutex<FontSystem>>, text_cache: SharedTextCache) -> Self {
423        Self {
424            font_system,
425            // Larger cache size (1024) reduces misses, FxHasher for faster lookups
426            size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
427            text_cache,
428        }
429    }
430}
431
432// Base font size in logical units (dp) - shared between measurement and rendering
433pub(crate) const BASE_FONT_SIZE: f32 = 14.0;
434
435impl TextMeasurer for WgpuTextMeasurer {
436    fn measure(&self, text: &str) -> cranpose_ui::TextMetrics {
437        let size_int = (BASE_FONT_SIZE * 100.0) as i32;
438
439        // Calculate hash to avoid allocating String for lookup
440        // FxHasher is ~3x faster than DefaultHasher for short strings
441        let mut hasher = FxHasher::default();
442        text.hash(&mut hasher);
443        let text_hash = hasher.finish();
444        let cache_key = (text_hash, size_int);
445
446        // Check size cache first (fastest path)
447        {
448            let mut cache = self.size_cache.lock().unwrap();
449            if let Some((cached_text, size)) = cache.get(&cache_key) {
450                // Verify partial collision
451                if cached_text == text {
452                    return cranpose_ui::TextMetrics {
453                        width: size.width,
454                        height: size.height,
455                        line_height: BASE_FONT_SIZE * 1.4,
456                        line_count: 1, // Cached entries are single-line simplified
457                    };
458                }
459            }
460        }
461
462        // Get or create text buffer
463        let text_buffer_key = TextCacheKey::new(text, BASE_FONT_SIZE);
464        let mut font_system = self.font_system.lock().unwrap();
465        let mut text_cache = self.text_cache.lock().unwrap();
466
467        // Get or create buffer and calculate size
468        let size = {
469            let buffer = text_cache.entry(text_buffer_key).or_insert_with(|| {
470                let buffer = Buffer::new(
471                    &mut font_system,
472                    Metrics::new(BASE_FONT_SIZE, BASE_FONT_SIZE * 1.4),
473                );
474                SharedTextBuffer {
475                    buffer,
476                    text: String::new(),
477                    font_size: 0.0,
478                    cached_size: None,
479                }
480            });
481
482            // Ensure buffer has the correct text
483            buffer.ensure(&mut font_system, text, BASE_FONT_SIZE, Attrs::new());
484
485            // Calculate size if not cached
486            buffer.size(BASE_FONT_SIZE)
487        };
488
489        // Trim cache if needed (after we're done with buffer reference)
490        trim_text_cache(&mut text_cache);
491
492        drop(font_system);
493        drop(text_cache);
494
495        // Cache the size result
496        let mut size_cache = self.size_cache.lock().unwrap();
497        // Only allocate string on cache miss
498        size_cache.put(cache_key, (text.to_string(), size));
499
500        // Calculate line info for multiline support
501        let line_height = BASE_FONT_SIZE * 1.4;
502        let line_count = text.split('\n').count().max(1);
503
504        cranpose_ui::TextMetrics {
505            width: size.width,
506            height: size.height,
507            line_height,
508            line_count,
509        }
510    }
511
512    fn get_offset_for_position(&self, text: &str, x: f32, y: f32) -> usize {
513        if text.is_empty() {
514            return 0;
515        }
516
517        let line_height = BASE_FONT_SIZE * 1.4;
518
519        // Calculate which line was clicked based on Y coordinate
520        let line_index = (y / line_height).floor().max(0.0) as usize;
521        let lines: Vec<&str> = text.split('\n').collect();
522        let target_line = line_index.min(lines.len().saturating_sub(1));
523
524        // Calculate byte offset to start of target line
525        let mut line_start_byte = 0;
526        for line in lines.iter().take(target_line) {
527            line_start_byte += line.len() + 1; // +1 for newline
528        }
529
530        // Get the text of the target line for hit testing
531        let line_text = lines.get(target_line).unwrap_or(&"");
532
533        if line_text.is_empty() {
534            return line_start_byte;
535        }
536
537        // Use glyphon's hit testing for the specific line
538        let cache_key = TextCacheKey::new(line_text, BASE_FONT_SIZE);
539        let mut font_system = self.font_system.lock().unwrap();
540        let mut text_cache = self.text_cache.lock().unwrap();
541
542        let buffer = text_cache.entry(cache_key).or_insert_with(|| {
543            let buffer = Buffer::new(
544                &mut font_system,
545                Metrics::new(BASE_FONT_SIZE, BASE_FONT_SIZE * 1.4),
546            );
547            SharedTextBuffer {
548                buffer,
549                text: String::new(),
550                font_size: 0.0,
551                cached_size: None,
552            }
553        });
554
555        buffer.ensure(&mut font_system, line_text, BASE_FONT_SIZE, Attrs::new());
556
557        // Find closest glyph position using layout runs
558        let mut best_offset = 0;
559        let mut best_distance = f32::INFINITY;
560
561        for run in buffer.buffer.layout_runs() {
562            let mut glyph_x = 0.0f32;
563            for glyph in run.glyphs.iter() {
564                // Check distance to left edge of glyph
565                let left_dist = (x - glyph_x).abs();
566                if left_dist < best_distance {
567                    best_distance = left_dist;
568                    // glyph.start is byte index in line_text
569                    best_offset = glyph.start;
570                }
571
572                // Update x position for next glyph
573                glyph_x += glyph.w;
574
575                // Check distance to right edge (after glyph)
576                let right_dist = (x - glyph_x).abs();
577                if right_dist < best_distance {
578                    best_distance = right_dist;
579                    best_offset = glyph.end;
580                }
581            }
582        }
583
584        // Return absolute byte offset (line start + offset within line)
585        line_start_byte + best_offset.min(line_text.len())
586    }
587
588    fn get_cursor_x_for_offset(&self, text: &str, offset: usize) -> f32 {
589        let clamped_offset = offset.min(text.len());
590        if clamped_offset == 0 {
591            return 0.0;
592        }
593
594        // Measure text up to offset
595        let prefix = &text[..clamped_offset];
596        self.measure(prefix).width
597    }
598
599    fn layout(&self, text: &str) -> cranpose_ui::text_layout_result::TextLayoutResult {
600        use cranpose_ui::text_layout_result::{LineLayout, TextLayoutResult};
601
602        let line_height = BASE_FONT_SIZE * 1.4;
603
604        // Get buffer to extract glyph positions
605        let cache_key = TextCacheKey::new(text, BASE_FONT_SIZE);
606        let mut font_system = self.font_system.lock().unwrap();
607        let mut text_cache = self.text_cache.lock().unwrap();
608
609        let buffer = text_cache.entry(cache_key.clone()).or_insert_with(|| {
610            let buffer = Buffer::new(&mut font_system, Metrics::new(BASE_FONT_SIZE, line_height));
611            SharedTextBuffer {
612                buffer,
613                text: String::new(),
614                font_size: 0.0,
615                cached_size: None,
616            }
617        });
618        buffer.ensure(&mut font_system, text, BASE_FONT_SIZE, Attrs::new());
619
620        // Extract glyph positions from layout runs
621        let mut glyph_x_positions = Vec::new();
622        let mut char_to_byte = Vec::new();
623        let mut lines = Vec::new();
624
625        let mut current_line_y = 0.0f32;
626        let mut line_start_offset = 0usize;
627
628        for run in buffer.buffer.layout_runs() {
629            let line_idx = run.line_i;
630            let line_y = line_idx as f32 * line_height;
631
632            // Track line boundaries
633            if lines.is_empty() || line_y != current_line_y {
634                if !lines.is_empty() {
635                    // Close previous line
636                    if let Some(_last) = lines.last_mut() {
637                        // end_offset will be updated when we see a newline or end
638                    }
639                }
640                current_line_y = line_y;
641            }
642
643            for glyph in run.glyphs.iter() {
644                glyph_x_positions.push(glyph.x);
645                char_to_byte.push(glyph.start);
646
647                // Track line end
648                if glyph.end > line_start_offset {
649                    line_start_offset = glyph.end;
650                }
651            }
652        }
653
654        // Add end position
655        let total_width = self.measure(text).width;
656        glyph_x_positions.push(total_width);
657        char_to_byte.push(text.len());
658
659        // Build lines from text newlines
660        let mut y = 0.0f32;
661        let mut line_start = 0usize;
662        for (i, line_text) in text.split('\n').enumerate() {
663            let line_end = if i == text.split('\n').count() - 1 {
664                text.len()
665            } else {
666                line_start + line_text.len()
667            };
668
669            lines.push(LineLayout {
670                start_offset: line_start,
671                end_offset: line_end,
672                y,
673                height: line_height,
674            });
675
676            line_start = line_end + 1;
677            y += line_height;
678        }
679
680        if lines.is_empty() {
681            lines.push(LineLayout {
682                start_offset: 0,
683                end_offset: 0,
684                y: 0.0,
685                height: line_height,
686            });
687        }
688
689        let metrics = self.measure(text);
690        TextLayoutResult::new(
691            metrics.width,
692            metrics.height,
693            line_height,
694            glyph_x_positions,
695            char_to_byte,
696            lines,
697            text,
698        )
699    }
700}