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