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,
404            1.0,
405            None,
406        );
407    }
408}
409
410fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
411    match style.font_size {
412        cranpose_ui::text::TextUnit::Sp(v) => v,
413        cranpose_ui::text::TextUnit::Em(v) => v * 14.0,
414        cranpose_ui::text::TextUnit::Unspecified => 14.0,
415    }
416}
417
418// Text measurer implementation for WGPU
419
420// Text measurer implementation for WGPU
421
422#[derive(Clone)]
423struct WgpuTextMeasurer {
424    font_system: Arc<Mutex<FontSystem>>,
425    size_cache: TextSizeCache,
426    /// Shared buffer cache used by both measurement and rendering
427    text_cache: SharedTextCache,
428}
429
430impl WgpuTextMeasurer {
431    fn new(font_system: Arc<Mutex<FontSystem>>, text_cache: SharedTextCache) -> Self {
432        Self {
433            font_system,
434            // Larger cache size (1024) reduces misses, FxHasher for faster lookups
435            size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
436            text_cache,
437        }
438    }
439}
440
441// Base font size in logical units (dp) - shared between measurement and rendering
442
443impl TextMeasurer for WgpuTextMeasurer {
444    fn measure(
445        &self,
446        text: &str,
447        style: &cranpose_ui::text::TextStyle,
448    ) -> cranpose_ui::TextMetrics {
449        let font_size = resolve_font_size(style);
450        let size_int = (font_size * 100.0) as i32;
451
452        // Calculate hash to avoid allocating String for lookup
453        // FxHasher is ~3x faster than DefaultHasher for short strings
454        let mut hasher = FxHasher::default();
455        text.hash(&mut hasher);
456        let text_hash = hasher.finish();
457        let cache_key = (text_hash, size_int);
458
459        // Check size cache first (fastest path)
460        {
461            let mut cache = self.size_cache.lock().unwrap();
462            if let Some((cached_text, size)) = cache.get(&cache_key) {
463                // Verify partial collision
464                if cached_text == text {
465                    return cranpose_ui::TextMetrics {
466                        width: size.width,
467                        height: size.height,
468                        line_height: font_size * 1.4,
469                        line_count: 1, // Cached entries are single-line simplified
470                    };
471                }
472            }
473        }
474
475        // Get or create text buffer
476        let text_buffer_key = TextCacheKey::new(text, font_size);
477        let mut font_system = self.font_system.lock().unwrap();
478        let mut text_cache = self.text_cache.lock().unwrap();
479
480        // Get or create buffer and calculate size
481        let size = {
482            let buffer = text_cache.entry(text_buffer_key).or_insert_with(|| {
483                let buffer =
484                    Buffer::new(&mut font_system, Metrics::new(font_size, font_size * 1.4));
485                SharedTextBuffer {
486                    buffer,
487                    text: String::new(),
488                    font_size: 0.0,
489                    cached_size: None,
490                }
491            });
492
493            // Ensure buffer has the correct text
494            buffer.ensure(&mut font_system, text, font_size, Attrs::new());
495
496            // Calculate size if not cached
497            buffer.size(font_size)
498        };
499
500        // Trim cache if needed (after we're done with buffer reference)
501        trim_text_cache(&mut text_cache);
502
503        drop(font_system);
504        drop(text_cache);
505
506        // Cache the size result
507        let mut size_cache = self.size_cache.lock().unwrap();
508        // Only allocate string on cache miss
509        size_cache.put(cache_key, (text.to_string(), size));
510
511        // Calculate line info for multiline support
512        let line_height = font_size * 1.4;
513        let line_count = text.split('\n').count().max(1);
514
515        cranpose_ui::TextMetrics {
516            width: size.width,
517            height: size.height,
518            line_height,
519            line_count,
520        }
521    }
522
523    fn get_offset_for_position(
524        &self,
525        text: &str,
526        style: &cranpose_ui::text::TextStyle,
527        x: f32,
528        y: f32,
529    ) -> usize {
530        let font_size = resolve_font_size(style);
531        if text.is_empty() {
532            return 0;
533        }
534
535        let line_height = font_size * 1.4;
536
537        // Calculate which line was clicked based on Y coordinate
538        let line_index = (y / line_height).floor().max(0.0) as usize;
539        let lines: Vec<&str> = text.split('\n').collect();
540        let target_line = line_index.min(lines.len().saturating_sub(1));
541
542        // Calculate byte offset to start of target line
543        let mut line_start_byte = 0;
544        for line in lines.iter().take(target_line) {
545            line_start_byte += line.len() + 1; // +1 for newline
546        }
547
548        // Get the text of the target line for hit testing
549        let line_text = lines.get(target_line).unwrap_or(&"");
550
551        if line_text.is_empty() {
552            return line_start_byte;
553        }
554
555        // Use glyphon's hit testing for the specific line
556        let cache_key = TextCacheKey::new(line_text, font_size);
557        let mut font_system = self.font_system.lock().unwrap();
558        let mut text_cache = self.text_cache.lock().unwrap();
559
560        let buffer = text_cache.entry(cache_key).or_insert_with(|| {
561            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, font_size * 1.4));
562            SharedTextBuffer {
563                buffer,
564                text: String::new(),
565                font_size: 0.0,
566                cached_size: None,
567            }
568        });
569
570        buffer.ensure(&mut font_system, line_text, font_size, Attrs::new());
571
572        // Find closest glyph position using layout runs
573        let mut best_offset = 0;
574        let mut best_distance = f32::INFINITY;
575
576        for run in buffer.buffer.layout_runs() {
577            let mut glyph_x = 0.0f32;
578            for glyph in run.glyphs.iter() {
579                // Check distance to left edge of glyph
580                let left_dist = (x - glyph_x).abs();
581                if left_dist < best_distance {
582                    best_distance = left_dist;
583                    // glyph.start is byte index in line_text
584                    best_offset = glyph.start;
585                }
586
587                // Update x position for next glyph
588                glyph_x += glyph.w;
589
590                // Check distance to right edge (after glyph)
591                let right_dist = (x - glyph_x).abs();
592                if right_dist < best_distance {
593                    best_distance = right_dist;
594                    best_offset = glyph.end;
595                }
596            }
597        }
598
599        // Return absolute byte offset (line start + offset within line)
600        line_start_byte + best_offset.min(line_text.len())
601    }
602
603    fn get_cursor_x_for_offset(
604        &self,
605        text: &str,
606        style: &cranpose_ui::text::TextStyle,
607        offset: usize,
608    ) -> f32 {
609        let clamped_offset = offset.min(text.len());
610        if clamped_offset == 0 {
611            return 0.0;
612        }
613
614        // Measure text up to offset
615        let prefix = &text[..clamped_offset];
616        self.measure(prefix, style).width
617    }
618
619    fn layout(
620        &self,
621        text: &str,
622        style: &cranpose_ui::text::TextStyle,
623    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
624        use cranpose_ui::text_layout_result::{LineLayout, TextLayoutResult};
625
626        let font_size = resolve_font_size(style);
627        let line_height = font_size * 1.4;
628
629        // Get buffer to extract glyph positions
630        let cache_key = TextCacheKey::new(text, font_size);
631        let mut font_system = self.font_system.lock().unwrap();
632        let mut text_cache = self.text_cache.lock().unwrap();
633
634        let buffer = text_cache.entry(cache_key.clone()).or_insert_with(|| {
635            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
636            SharedTextBuffer {
637                buffer,
638                text: String::new(),
639                font_size: 0.0,
640                cached_size: None,
641            }
642        });
643        buffer.ensure(&mut font_system, text, font_size, Attrs::new());
644
645        // Extract glyph positions from layout runs
646        let mut glyph_x_positions = Vec::new();
647        let mut char_to_byte = Vec::new();
648        let mut lines = Vec::new();
649
650        let mut current_line_y = 0.0f32;
651        let mut line_start_offset = 0usize;
652
653        for run in buffer.buffer.layout_runs() {
654            let line_idx = run.line_i;
655            let line_y = line_idx as f32 * line_height;
656
657            // Track line boundaries
658            if lines.is_empty() || line_y != current_line_y {
659                if !lines.is_empty() {
660                    // Close previous line
661                    if let Some(_last) = lines.last_mut() {
662                        // end_offset will be updated when we see a newline or end
663                    }
664                }
665                current_line_y = line_y;
666            }
667
668            for glyph in run.glyphs.iter() {
669                glyph_x_positions.push(glyph.x);
670                char_to_byte.push(glyph.start);
671
672                // Track line end
673                if glyph.end > line_start_offset {
674                    line_start_offset = glyph.end;
675                }
676            }
677        }
678
679        // Add end position
680        let total_width = self.measure(text, style).width;
681        glyph_x_positions.push(total_width);
682        char_to_byte.push(text.len());
683
684        // Build lines from text newlines
685        let mut y = 0.0f32;
686        let mut line_start = 0usize;
687        for (i, line_text) in text.split('\n').enumerate() {
688            let line_end = if i == text.split('\n').count() - 1 {
689                text.len()
690            } else {
691                line_start + line_text.len()
692            };
693
694            lines.push(LineLayout {
695                start_offset: line_start,
696                end_offset: line_end,
697                y,
698                height: line_height,
699            });
700
701            line_start = line_end + 1;
702            y += line_height;
703        }
704
705        if lines.is_empty() {
706            lines.push(LineLayout {
707                start_offset: 0,
708                end_offset: 0,
709                y: 0.0,
710                height: line_height,
711            });
712        }
713
714        let metrics = self.measure(text, style);
715        TextLayoutResult::new(
716            metrics.width,
717            metrics.height,
718            line_height,
719            glyph_x_positions,
720            char_to_byte,
721            lines,
722            text,
723        )
724    }
725}