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