1#![deny(unsafe_code)]
7
8mod effect_renderer;
9mod frame_graph;
10pub(crate) mod gpu_stats;
11mod layer_events;
12mod layer_surface_cache;
13mod normalized_scene;
14mod offscreen;
15mod pipeline;
16mod render;
17mod scene;
18mod shader_cache;
19mod shaders;
20mod surface_executor;
21mod surface_plan;
22mod surface_requirements;
23#[cfg(test)]
24mod test_support;
25
26pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
27pub use scene::{ClickAction, HitRegion, Scene};
28
29use cranpose_core::{MemoryApplier, NodeId};
30use cranpose_render_common::{
31 graph::RenderGraph,
32 software_text_raster::{
33 software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
34 },
35 RenderScene, Renderer,
36};
37use cranpose_ui::{LayoutTree, TextMeasurer};
38use cranpose_ui_graphics::{Rect, Size};
39use render::GpuRenderer;
40use std::rc::{Rc, Weak};
41use std::sync::Arc;
42
43pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
45 [
46 [rect.x, rect.y],
47 [rect.x + rect.width, rect.y],
48 [rect.x, rect.y + rect.height],
49 [rect.x + rect.width, rect.y + rect.height],
50 ]
51}
52
53#[derive(Debug)]
54pub enum WgpuRendererError {
55 Layout(String),
56 Wgpu(String),
57}
58
59#[derive(Debug, Clone)]
61pub struct CapturedFrame {
62 pub width: u32,
63 pub height: u32,
64 pub pixels: Vec<u8>,
65}
66
67#[doc(hidden)]
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct DebugCpuAllocationStats {
70 pub scene_graph_node_count: usize,
71 pub scene_graph_heap_bytes: usize,
72 pub scene_hits_len: usize,
73 pub scene_hits_cap: usize,
74 pub scene_node_index_len: usize,
75 pub scene_node_index_cap: usize,
76 pub text_renderer_pool_len: usize,
77 pub text_renderer_pool_cap: usize,
78 pub swash_image_cache_len: usize,
79 pub swash_image_cache_cap: usize,
80 pub swash_outline_cache_len: usize,
81 pub swash_outline_cache_cap: usize,
82 pub image_texture_cache_len: usize,
83 pub image_texture_cache_cap: usize,
84 pub scratch_shape_data_cap: usize,
85 pub scratch_gradients_cap: usize,
86 pub scratch_vertices_cap: usize,
87 pub scratch_indices_cap: usize,
88 pub scratch_image_vertices_cap: usize,
89 pub scratch_image_indices_cap: usize,
90 pub scratch_image_cmds_cap: usize,
91 pub scratch_segment_items_cap: usize,
92 pub scratch_effect_ranges_cap: usize,
93 pub scratch_layer_events_cap: usize,
94 pub staged_upload_bytes_cap: usize,
95 pub staged_upload_copies_cap: usize,
96 pub layer_surface_cache_len: usize,
97 pub layer_surface_cache_cap: usize,
98 pub layer_surface_cache_identity_len: usize,
99 pub layer_surface_cache_identity_cap: usize,
100 pub layer_surface_rect_cache_len: usize,
101 pub layer_surface_rect_cache_cap: usize,
102 pub layer_surface_requirements_cache_len: usize,
103 pub layer_surface_requirements_cache_cap: usize,
104 pub layer_cache_seen_this_frame_len: usize,
105 pub layer_cache_seen_this_frame_cap: usize,
106}
107
108pub(crate) struct TextSystemState {
109 measurer: SoftwareTextMeasurer,
110}
111
112impl TextSystemState {
113 fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
114 Self {
115 measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
116 }
117 }
118
119 pub(crate) fn text_cache_len(&self) -> usize {
120 0
121 }
122}
123
124impl pipeline::TextLayoutResolver for TextSystemState {
125 fn layout_text(
126 &mut self,
127 text: &cranpose_ui::text::AnnotatedString,
128 style: &cranpose_ui::text::TextStyle,
129 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
130 if cranpose_ui::has_current_app_context() {
131 cranpose_ui::text::layout_text(text, style)
132 } else {
133 self.measurer.layout(text, style)
134 }
135 }
136}
137
138#[derive(Clone)]
139pub struct WgpuTextSystem {
140 software_fonts: SoftwareTextFontSet,
141}
142
143impl WgpuTextSystem {
144 pub fn from_fonts(fonts: &[&[u8]]) -> Self {
145 Self {
146 software_fonts: software_text_font_set_from_fonts_or_default(fonts),
147 }
148 }
149
150 fn render_state(&self) -> TextSystemState {
151 TextSystemState::from_font_set(self.software_fonts.clone())
152 }
153
154 fn software_fonts(&self) -> SoftwareTextFontSet {
155 self.software_fonts.clone()
156 }
157}
158
159pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
161 headless_text_measurer_with_fonts(&[])
162}
163
164pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
166 Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
167}
168
169pub struct WgpuRenderer {
177 scene: Scene,
178 gpu_renderer: Option<GpuRenderer>,
179 text_state: TextSystemState,
180 text_fonts: SoftwareTextFontSet,
181 app_context: Option<Weak<cranpose_ui::AppContext>>,
182 root_scale: f32,
184 dev_overlay_cache: Option<DevOverlayCache>,
185 dev_overlay_graph: Option<RenderGraph>,
186}
187
188#[derive(Clone, Debug)]
189struct DevOverlayCache {
190 text: String,
191 viewport_width_bits: u32,
192 viewport_height_bits: u32,
193}
194
195impl WgpuRenderer {
196 pub fn new(fonts: &[&[u8]]) -> Self {
203 Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
204 }
205
206 pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
207 Self {
208 scene: Scene::new(),
209 gpu_renderer: None,
210 text_state: text_system.render_state(),
211 text_fonts: text_system.software_fonts(),
212 app_context: None,
213 root_scale: 1.0,
214 dev_overlay_cache: None,
215 dev_overlay_graph: None,
216 }
217 }
218
219 pub fn init_gpu(
221 &mut self,
222 device: Arc<wgpu::Device>,
223 queue: Arc<wgpu::Queue>,
224 surface_format: wgpu::TextureFormat,
225 adapter_backend: wgpu::Backend,
226 ) {
227 self.gpu_renderer = Some(GpuRenderer::new(
228 device,
229 queue,
230 surface_format,
231 adapter_backend,
232 self.text_fonts.clone(),
233 ));
234 }
235
236 pub fn set_root_scale(&mut self, scale: f32) {
238 self.root_scale = scale;
239 }
240
241 pub fn root_scale(&self) -> f32 {
242 self.root_scale
243 }
244
245 pub fn render(
247 &mut self,
248 view: &wgpu::TextureView,
249 width: u32,
250 height: u32,
251 ) -> Result<(), WgpuRendererError> {
252 if let Some(gpu_renderer) = &mut self.gpu_renderer {
253 let graph = self
254 .scene
255 .graph
256 .as_ref()
257 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
258 let text_state = &mut self.text_state;
259 let root_scale = self.root_scale;
260 let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
261 let result = if let Some(app_context) = app_context {
262 app_context.enter(|| {
263 gpu_renderer.render(
264 text_state,
265 view,
266 graph,
267 self.dev_overlay_graph.as_ref(),
268 width,
269 height,
270 root_scale,
271 )
272 })
273 } else {
274 gpu_renderer.render(
275 text_state,
276 view,
277 graph,
278 self.dev_overlay_graph.as_ref(),
279 width,
280 height,
281 root_scale,
282 )
283 };
284 result.map_err(WgpuRendererError::Wgpu)
285 } else {
286 Err(WgpuRendererError::Wgpu(
287 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
288 ))
289 }
290 }
291
292 pub fn capture_frame(
296 &mut self,
297 width: u32,
298 height: u32,
299 ) -> Result<CapturedFrame, WgpuRendererError> {
300 self.capture_frame_with_scale(width, height, self.root_scale)
301 }
302
303 pub fn capture_frame_with_scale(
305 &mut self,
306 width: u32,
307 height: u32,
308 root_scale: f32,
309 ) -> Result<CapturedFrame, WgpuRendererError> {
310 if let Some(gpu_renderer) = &mut self.gpu_renderer {
311 let graph = self
312 .scene
313 .graph
314 .as_ref()
315 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
316 let text_state = &mut self.text_state;
317 let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
318 let pixels = if let Some(app_context) = app_context {
319 app_context.enter(|| {
320 gpu_renderer.render_to_rgba_pixels(
321 text_state,
322 graph,
323 self.dev_overlay_graph.as_ref(),
324 width,
325 height,
326 root_scale,
327 )
328 })
329 } else {
330 gpu_renderer.render_to_rgba_pixels(
331 text_state,
332 graph,
333 self.dev_overlay_graph.as_ref(),
334 width,
335 height,
336 root_scale,
337 )
338 }
339 .map_err(WgpuRendererError::Wgpu)?;
340 Ok(CapturedFrame {
341 width,
342 height,
343 pixels,
344 })
345 } else {
346 Err(WgpuRendererError::Wgpu(
347 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
348 ))
349 }
350 }
351
352 pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
353 self.gpu_renderer
354 .as_ref()
355 .and_then(GpuRenderer::last_frame_stats)
356 }
357
358 pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
359 let mut stats = self
360 .gpu_renderer
361 .as_ref()
362 .map(GpuRenderer::debug_cpu_allocation_stats)
363 .unwrap_or_default();
364 stats.scene_graph_node_count = self
365 .scene
366 .graph
367 .as_ref()
368 .map(RenderGraph::node_count)
369 .unwrap_or(0);
370 stats.scene_graph_heap_bytes = self
371 .scene
372 .graph
373 .as_ref()
374 .map(RenderGraph::heap_bytes)
375 .unwrap_or(0);
376 stats.scene_hits_len = self.scene.hits.len();
377 stats.scene_hits_cap = self.scene.hits.capacity();
378 stats.scene_node_index_len = self.scene.node_index.len();
379 stats.scene_node_index_cap = self.scene.node_index.capacity();
380 stats
381 }
382
383 pub fn try_device(&self) -> Option<&wgpu::Device> {
385 self.gpu_renderer.as_ref().map(|r| &*r.device)
386 }
387}
388
389impl Default for WgpuRenderer {
390 fn default() -> Self {
391 Self::new(&[])
392 }
393}
394
395impl Renderer for WgpuRenderer {
396 type Scene = Scene;
397 type Error = WgpuRendererError;
398
399 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
400 app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
401 self.text_fonts.clone(),
402 8192,
403 ));
404 self.app_context = Some(app_context.downgrade());
405 }
406
407 fn scene(&self) -> &Self::Scene {
408 &self.scene
409 }
410
411 fn scene_mut(&mut self) -> &mut Self::Scene {
412 &mut self.scene
413 }
414
415 fn rebuild_scene(
416 &mut self,
417 layout_tree: &LayoutTree,
418 _viewport: Size,
419 ) -> Result<(), Self::Error> {
420 self.scene.clear();
421 self.dev_overlay_graph = None;
422 self.dev_overlay_cache = None;
423 pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
425 Ok(())
426 }
427
428 fn rebuild_scene_from_applier(
429 &mut self,
430 applier: &mut MemoryApplier,
431 root: NodeId,
432 _viewport: Size,
433 ) -> Result<(), Self::Error> {
434 self.scene.clear();
435 self.dev_overlay_graph = None;
436 self.dev_overlay_cache = None;
437 pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
440 Ok(())
441 }
442
443 fn update_scene_from_applier(
444 &mut self,
445 applier: &mut MemoryApplier,
446 root: NodeId,
447 viewport: Size,
448 dirty_nodes: &[NodeId],
449 ) -> Result<(), Self::Error> {
450 if dirty_nodes.is_empty() {
451 return self.rebuild_scene_from_applier(applier, root, viewport);
452 }
453 pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, true);
454 Ok(())
455 }
456
457 fn update_visual_scene_from_applier(
458 &mut self,
459 applier: &mut MemoryApplier,
460 root: NodeId,
461 viewport: Size,
462 dirty_nodes: &[NodeId],
463 ) -> Result<(), Self::Error> {
464 if dirty_nodes.is_empty() {
465 return self.rebuild_scene_from_applier(applier, root, viewport);
466 }
467 pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, false);
468 Ok(())
469 }
470
471 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
472 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
473 let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
474 if self.dev_overlay_graph.is_some()
475 && self.dev_overlay_cache.as_ref().is_some_and(|cache| {
476 cache.text == key.text
477 && cache.viewport_width_bits == key.viewport_width_bits
478 && cache.viewport_height_bits == key.viewport_height_bits
479 })
480 {
481 return;
482 }
483 self.dev_overlay_graph = Some(
484 cranpose_render_common::dev_overlay::build_dev_overlay_graph(
485 text,
486 viewport,
487 DEV_OVERLAY_NODE_ID,
488 ),
489 );
490 self.dev_overlay_cache = Some(DevOverlayCache {
491 text: key.text,
492 viewport_width_bits: key.viewport_width_bits,
493 viewport_height_bits: key.viewport_height_bits,
494 });
495 }
496
497 fn needs_frame_warmup(&self) -> bool {
498 self.gpu_renderer
499 .as_ref()
500 .is_some_and(GpuRenderer::needs_frame_warmup)
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::pipeline::TextLayoutResolver;
508 use cranpose_render_common::graph::RenderNode;
509 use cranpose_ui_graphics::GraphicsLayer;
510 use std::cell::Cell;
511
512 static TEST_FONT: &[u8] =
513 cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
514
515 #[test]
516 fn dev_overlay_is_recorded_outside_app_graph() {
517 let mut renderer = WgpuRenderer::new(&[]);
518 renderer.draw_dev_overlay(
519 "240 FPS | avg 4.0ms | p95 4.5ms",
520 Size {
521 width: 800.0,
522 height: 600.0,
523 },
524 );
525
526 assert!(
527 renderer
528 .scene
529 .graph
530 .as_ref()
531 .is_none_or(|graph| graph.root.children.iter().all(|child| {
532 !matches!(
533 child,
534 RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
535 )
536 })),
537 "dev overlay must not be mixed into the app scene graph"
538 );
539
540 let graph = renderer.dev_overlay_graph.as_ref().expect("overlay graph");
541 let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
542 panic!("dev overlay should be the final top-level layer");
543 };
544
545 assert_eq!(overlay.node_id, Some(NodeId::MAX));
546 assert_eq!(
547 overlay.graphics_layer.compositing_strategy,
548 GraphicsLayer::default().compositing_strategy,
549 "dev overlay should not allocate an offscreen surface"
550 );
551 }
552
553 struct CountingTextMeasurer {
554 inner: SoftwareTextMeasurer,
555 layout_calls: Rc<Cell<usize>>,
556 }
557
558 impl CountingTextMeasurer {
559 fn new(layout_calls: Rc<Cell<usize>>) -> Self {
560 Self {
561 inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
562 layout_calls,
563 }
564 }
565 }
566
567 impl TextMeasurer for CountingTextMeasurer {
568 fn measure(
569 &self,
570 text: &cranpose_ui::text::AnnotatedString,
571 style: &cranpose_ui::text::TextStyle,
572 ) -> cranpose_ui::TextMetrics {
573 self.inner.measure(text, style)
574 }
575
576 fn get_offset_for_position(
577 &self,
578 text: &cranpose_ui::text::AnnotatedString,
579 style: &cranpose_ui::text::TextStyle,
580 x: f32,
581 y: f32,
582 ) -> usize {
583 self.inner.get_offset_for_position(text, style, x, y)
584 }
585
586 fn get_cursor_x_for_offset(
587 &self,
588 text: &cranpose_ui::text::AnnotatedString,
589 style: &cranpose_ui::text::TextStyle,
590 offset: usize,
591 ) -> f32 {
592 self.inner.get_cursor_x_for_offset(text, style, offset)
593 }
594
595 fn layout(
596 &self,
597 text: &cranpose_ui::text::AnnotatedString,
598 style: &cranpose_ui::text::TextStyle,
599 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
600 self.layout_calls.set(self.layout_calls.get() + 1);
601 self.inner.layout(text, style)
602 }
603 }
604
605 #[test]
606 fn headless_text_measurer_uses_software_text_font() {
607 let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
608 let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
609 let style = cranpose_ui::text::TextStyle::default();
610
611 let metrics = measurer.measure(&text, &style);
612 let layout = measurer.layout(&text, &style);
613
614 assert!(metrics.width > 0.0);
615 assert!(metrics.height > 0.0);
616 assert_eq!(layout.lines.len(), metrics.line_count);
617 }
618
619 #[test]
620 fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
621 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
622 let app_context = cranpose_ui::AppContext::new();
623 renderer.attach_app_context_services(&app_context);
624
625 let metrics = app_context.enter(|| {
626 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
627 let style = cranpose_ui::text::TextStyle {
628 span_style: cranpose_ui::text::SpanStyle {
629 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
630 ..Default::default()
631 },
632 paragraph_style: cranpose_ui::text::ParagraphStyle {
633 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
634 include_font_padding: None,
635 shaping: Some(cranpose_ui::text::TextShaping::Basic),
636 }),
637 ..Default::default()
638 },
639 };
640 cranpose_ui::text::measure_text(&text, &style)
641 });
642
643 assert!(
644 metrics.width > 0.0,
645 "software text service should measure text"
646 );
647 assert_eq!(
648 renderer.text_state.text_cache_len(),
649 0,
650 "WGPU must not keep a renderer-side shaping cache for measurement"
651 );
652 }
653
654 #[test]
655 fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
656 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
657 let app_context = cranpose_ui::AppContext::new();
658 renderer.attach_app_context_services(&app_context);
659
660 let prepared = app_context.enter(|| {
661 let text = cranpose_ui::text::AnnotatedString::from(
662 (0..48)
663 .map(|line| format!("// markdown code line {line:02}"))
664 .collect::<Vec<_>>()
665 .join("\n"),
666 );
667 let style = cranpose_ui::text::TextStyle::default();
668 cranpose_ui::text::prepare_text_layout(
669 &text,
670 &style,
671 cranpose_ui::text::TextLayoutOptions::default(),
672 Some(952.0),
673 )
674 });
675
676 assert_eq!(prepared.metrics.line_count, 48);
677 assert!(
678 prepared.metrics.line_height > 18.0,
679 "renderer-attached text service must not use fallback monospaced line height: {:?}",
680 prepared.metrics
681 );
682 assert!(
683 prepared.metrics.height > 900.0,
684 "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
685 prepared.metrics
686 );
687 }
688
689 #[test]
690 fn render_text_layout_routes_through_attached_app_context_service() {
691 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
692 let app_context = cranpose_ui::AppContext::new();
693 renderer.attach_app_context_services(&app_context);
694 let layout_calls = Rc::new(Cell::new(0));
695 app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
696
697 app_context.enter(|| {
698 let text = cranpose_ui::text::AnnotatedString::from("render text");
699 let style = cranpose_ui::text::TextStyle::default();
700 let layout = renderer.text_state.layout_text(&text, &style);
701 assert!(layout.width > 0.0);
702 });
703
704 assert_eq!(layout_calls.get(), 1);
705 }
706}