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.
5
6#![deny(unsafe_code)]
7
8#[cfg(not(target_arch = "wasm32"))]
9mod cost_tuner;
10mod effect_renderer;
11mod frame_graph;
12mod frame_packet;
13mod frontend;
14pub(crate) mod gpu_stats;
15mod layer_events;
16mod layer_surface_cache;
17mod normalized_scene;
18mod offscreen;
19mod pipeline;
20mod render;
21mod run_entry;
22mod scene;
23mod shader_cache;
24mod shaders;
25#[cfg(not(target_arch = "wasm32"))]
26mod shape_replay;
27#[cfg(not(target_arch = "wasm32"))]
28mod stage_executor;
29mod surface_executor;
30mod surface_plan;
31mod surface_requirements;
32#[cfg(test)]
33mod test_support;
34
35#[doc(hidden)]
36pub use frame_packet::{CancelReason, PresentOutcome};
37pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
38#[doc(hidden)]
39#[cfg(not(target_arch = "wasm32"))]
40pub use pipeline::retained_feed_generation;
41pub use render::frames_presented;
42pub use scene::{ClickAction, HitRegion, Scene};
43#[doc(hidden)]
44#[cfg(not(target_arch = "wasm32"))]
45pub use shape_replay::feed_live_stats as command_feed_live_stats;
46#[doc(hidden)]
47#[cfg(not(target_arch = "wasm32"))]
48pub use shape_replay::{inject_feed_capture_for_tests, pending_feed_capture_count_for_tests};
49#[doc(hidden)]
50#[cfg(not(target_arch = "wasm32"))]
51pub use shape_replay::{planner_replay_queue_stats_for_tests, recycled_ops_capacities_for_tests};
52
53use cranpose_core::{MemoryApplier, NodeId};
54use cranpose_render_common::{
55    graph::RenderGraph,
56    software_text_raster::{
57        software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
58    },
59    RenderScene, Renderer,
60};
61use cranpose_ui::{LayoutTree, TextMeasurer};
62use cranpose_ui_graphics::{Rect, Size};
63use frame_packet::RenderReturns;
64use frontend::{DevOverlayCache, RendererFrontend};
65use render::GpuRenderer;
66use std::rc::Rc;
67use std::sync::Arc;
68
69/// Convert an axis-aligned rectangle to four corner positions (TL, TR, BL, BR).
70pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
71    [
72        [rect.x, rect.y],
73        [rect.x + rect.width, rect.y],
74        [rect.x, rect.y + rect.height],
75        [rect.x + rect.width, rect.y + rect.height],
76    ]
77}
78
79#[derive(Debug)]
80pub enum WgpuRendererError {
81    Layout(String),
82    Wgpu(String),
83}
84
85/// CPU-readable RGBA frame captured from the renderer output.
86#[derive(Debug, Clone)]
87pub struct CapturedFrame {
88    pub width: u32,
89    pub height: u32,
90    pub pixels: Vec<u8>,
91}
92
93#[doc(hidden)]
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct DebugCpuAllocationStats {
96    pub scene_graph_node_count: usize,
97    pub scene_graph_heap_bytes: usize,
98    pub scene_hits_len: usize,
99    pub scene_hits_cap: usize,
100    pub scene_node_index_len: usize,
101    pub scene_node_index_cap: usize,
102    pub text_renderer_pool_len: usize,
103    pub text_renderer_pool_cap: usize,
104    pub swash_image_cache_len: usize,
105    pub swash_image_cache_cap: usize,
106    pub swash_outline_cache_len: usize,
107    pub swash_outline_cache_cap: usize,
108    pub image_texture_cache_len: usize,
109    pub image_texture_cache_cap: usize,
110    pub scratch_shape_data_cap: usize,
111    pub scratch_gradients_cap: usize,
112    pub scratch_image_vertices_cap: usize,
113    pub scratch_image_indices_cap: usize,
114    pub scratch_image_cmds_cap: usize,
115    pub scratch_segment_items_cap: usize,
116    pub scratch_effect_ranges_cap: usize,
117    pub scratch_layer_events_cap: usize,
118    pub staged_upload_bytes_cap: usize,
119    pub staged_upload_copies_cap: usize,
120    pub layer_surface_cache_len: usize,
121    pub layer_surface_cache_cap: usize,
122    pub layer_surface_cache_identity_len: usize,
123    pub layer_surface_cache_identity_cap: usize,
124    pub layer_surface_rect_cache_len: usize,
125    pub layer_surface_rect_cache_cap: usize,
126    pub layer_surface_requirements_cache_len: usize,
127    pub layer_surface_requirements_cache_cap: usize,
128    pub layer_cache_seen_this_frame_len: usize,
129    pub layer_cache_seen_this_frame_cap: usize,
130}
131
132pub(crate) struct TextSystemState {
133    measurer: SoftwareTextMeasurer,
134}
135
136impl TextSystemState {
137    fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
138        Self {
139            measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
140        }
141    }
142
143    pub(crate) fn text_cache_len(&self) -> usize {
144        0
145    }
146}
147
148impl pipeline::TextLayoutResolver for TextSystemState {
149    fn layout_text(
150        &mut self,
151        text: &cranpose_ui::text::AnnotatedString,
152        style: &cranpose_ui::text::TextStyle,
153    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
154        if cranpose_ui::has_current_app_context() {
155            cranpose_ui::text::layout_text(text, style)
156        } else {
157            self.measurer.layout(text, style)
158        }
159    }
160}
161
162#[derive(Clone)]
163pub struct WgpuTextSystem {
164    software_fonts: SoftwareTextFontSet,
165}
166
167impl WgpuTextSystem {
168    pub fn from_fonts(fonts: &[&[u8]]) -> Self {
169        Self {
170            software_fonts: software_text_font_set_from_fonts_or_default(fonts),
171        }
172    }
173
174    /// Adopt a font set an app already built — the path app-supplied families
175    /// take, where faces were parsed once at startup rather than from static
176    /// byte slices here.
177    pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
178        Self { software_fonts }
179    }
180
181    pub(crate) fn render_state(&self) -> TextSystemState {
182        TextSystemState::from_font_set(self.software_fonts.clone())
183    }
184
185    pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
186        self.software_fonts.clone()
187    }
188}
189
190/// Create an accurate WGPU text measurer for headless tests without launching a window.
191pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
192    headless_text_measurer_with_fonts(&[])
193}
194
195/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
196pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
197    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
198}
199
200/// WGPU-based renderer for GPU-accelerated 2D rendering.
201///
202/// This renderer supports:
203/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
204/// - Gradients (solid, linear, radial)
205/// - GPU text rendering via retained raster image batches
206/// - Cross-platform support (Desktop, Web, Android)
207pub struct WgpuRenderer {
208    /// Producer stage: scene graph, text layout, and the lowering of every
209    /// frame into a [`frame_packet::FramePacket`].
210    frontend: RendererFrontend,
211    /// Present stage: consumes packets and draws; it never lowers.
212    gpu_renderer: Option<GpuRenderer>,
213    /// Which `GpuRenderer` instance packets are currently built against:
214    /// bumped by every [`init_gpu`][Self::init_gpu] (first init → 1) and
215    /// stamped into each packet, so a packet that outlives its renderer is
216    /// cancelled by the present stage instead of drawn.
217    renderer_epoch: u64,
218    /// Which surface configuration packets are currently built against:
219    /// bumped by [`note_surface_reconfigured`][Self::note_surface_reconfigured]
220    /// and stamped into each packet, so a packet that straddles a surface
221    /// reconfigure is cancelled by the present stage instead of drawn.
222    surface_epoch: u64,
223}
224
225impl WgpuRenderer {
226    /// Create a new WGPU renderer.
227    ///
228    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
229    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
230    ///
231    /// Call [`init_gpu`][Self::init_gpu] before rendering.
232    pub fn new(fonts: &[&[u8]]) -> Self {
233        Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
234    }
235
236    /// Create a renderer over an already-parsed font set.
237    ///
238    /// Measurement and rasterization both take clones of this one set, so an
239    /// app-supplied family resolves identically on both sides.
240    pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
241        Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
242    }
243
244    pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
245        Self {
246            frontend: RendererFrontend::new(
247                text_system.render_state(),
248                text_system.software_fonts(),
249            ),
250            gpu_renderer: None,
251            renderer_epoch: 0,
252            surface_epoch: 0,
253        }
254    }
255
256    /// Initialize GPU resources with a WGPU device and queue.
257    ///
258    /// Replacing a live renderer (Android surface recreation, device loss)
259    /// drops every retained replay slot with the old `GpuRenderer`, so the
260    /// bypass contract fails closed BEFORE the new renderer exists: every
261    /// slot confirmation is revoked and the feed generation bumped — no
262    /// scene build may omit primitives against buffers that died, and
263    /// already-built frames rematerialize their bypassed spans instead of
264    /// referencing the dead renderer's slot ids.
265    pub fn init_gpu(
266        &mut self,
267        device: Arc<wgpu::Device>,
268        queue: Arc<wgpu::Queue>,
269        surface_format: wgpu::TextureFormat,
270        adapter_backend: wgpu::Backend,
271    ) {
272        #[cfg(not(target_arch = "wasm32"))]
273        if self.gpu_renderer.is_some() {
274            crate::shape_replay::SHAPE_REPLAY.with(|state| state.borrow_mut().renderer_replaced());
275            log::warn!(
276                "[command-feed] renderer replaced: retained slots retired, \
277                 confirmations revoked, feed generation bumped"
278            );
279        }
280        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
281        // The new store adopts the producer's CURRENT feed generation —
282        // read after `renderer_replaced` bumped it, so packets planned
283        // against the dead store fail the store's generation gate.
284        #[cfg(not(target_arch = "wasm32"))]
285        let store_feed_generation = pipeline::retained_feed_generation();
286        #[cfg(target_arch = "wasm32")]
287        let store_feed_generation = 0;
288        self.gpu_renderer = Some(GpuRenderer::new(
289            device,
290            queue,
291            surface_format,
292            adapter_backend,
293            self.frontend.text_fonts.clone(),
294            self.renderer_epoch,
295            store_feed_generation,
296        ));
297    }
298
299    /// Record that the surface was reconfigured (resize, format change,
300    /// swapchain recreation): bumps the surface epoch stamped into every
301    /// subsequent packet, so a packet built against the previous
302    /// configuration is cancelled by the present stage instead of drawn.
303    pub fn note_surface_reconfigured(&mut self) {
304        self.surface_epoch = self.surface_epoch.wrapping_add(1);
305    }
306
307    /// Set root scale factor for text rendering (e.g., density scaling on Android)
308    pub fn set_root_scale(&mut self, scale: f32) {
309        self.frontend.root_scale = scale;
310    }
311
312    pub fn root_scale(&self) -> f32 {
313        self.frontend.root_scale
314    }
315
316    /// Render the scene to a texture view.
317    ///
318    /// Producer first, present second: the frontend lowers the frame into a
319    /// [`frame_packet::FramePacket`] (direct root, root surface, and dev
320    /// overlay alike), the GPU renderer consumes it, and the present
321    /// stage's returns — the recycled scene and the replay ack — fold back
322    /// into the frontend afterwards.
323    pub fn render(
324        &mut self,
325        view: &wgpu::TextureView,
326        width: u32,
327        height: u32,
328    ) -> Result<(), WgpuRendererError> {
329        let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
330            return Err(WgpuRendererError::Wgpu(
331                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
332            ));
333        };
334        let packet = self
335            .frontend
336            .build_frame_packet(
337                width,
338                height,
339                gpu_renderer.replay_supported(),
340                self.renderer_epoch,
341                self.surface_epoch,
342            )
343            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
344        let frontend = &mut self.frontend;
345        let mut returns = RenderReturns::default();
346        // Packet consumption runs OUTSIDE the producer's app context on
347        // purpose: the packet is the complete frame, so the present stage
348        // must never need the context — running bare proves it every frame.
349        let result = gpu_renderer.render(
350            view,
351            width,
352            height,
353            packet,
354            self.surface_epoch,
355            &mut returns,
356        );
357        if let Some(confirmations) = frontend.apply_returns(returns) {
358            gpu_renderer.restore_replay_ack_confirmations(confirmations);
359        }
360        result.map_err(WgpuRendererError::Wgpu)
361    }
362
363    /// Render the current scene into an RGBA pixel buffer for robot tests.
364    ///
365    /// Uses the renderer's configured root scale.
366    pub fn capture_frame(
367        &mut self,
368        width: u32,
369        height: u32,
370    ) -> Result<CapturedFrame, WgpuRendererError> {
371        self.capture_frame_with_scale(width, height, self.frontend.root_scale)
372    }
373
374    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
375    pub fn capture_frame_with_scale(
376        &mut self,
377        width: u32,
378        height: u32,
379        root_scale: f32,
380    ) -> Result<CapturedFrame, WgpuRendererError> {
381        let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
382            return Err(WgpuRendererError::Wgpu(
383                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
384            ));
385        };
386        let packet = self
387            .frontend
388            .build_frame_packet_with_scale(
389                width,
390                height,
391                root_scale,
392                gpu_renderer.replay_supported(),
393                self.renderer_epoch,
394                self.surface_epoch,
395            )
396            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
397        let frontend = &mut self.frontend;
398        let mut returns = RenderReturns::default();
399        // Bare like `render`: the capture path consumes the packet with no
400        // producer app context current.
401        let result = gpu_renderer.render_to_rgba_pixels(
402            width,
403            height,
404            packet,
405            self.surface_epoch,
406            &mut returns,
407        );
408        if let Some(confirmations) = frontend.apply_returns(returns) {
409            gpu_renderer.restore_replay_ack_confirmations(confirmations);
410        }
411        let pixels = result.map_err(WgpuRendererError::Wgpu)?;
412        Ok(CapturedFrame {
413            width,
414            height,
415            pixels,
416        })
417    }
418
419    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
420        self.gpu_renderer
421            .as_ref()
422            .and_then(GpuRenderer::last_frame_stats)
423    }
424
425    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
426        let mut stats = self
427            .gpu_renderer
428            .as_ref()
429            .map(GpuRenderer::debug_cpu_allocation_stats)
430            .unwrap_or_default();
431        stats.scene_graph_node_count = self
432            .frontend
433            .scene
434            .graph
435            .as_ref()
436            .map(RenderGraph::node_count)
437            .unwrap_or(0);
438        stats.scene_graph_heap_bytes = self
439            .frontend
440            .scene
441            .graph
442            .as_ref()
443            .map(RenderGraph::heap_bytes)
444            .unwrap_or(0);
445        stats.scene_hits_len = self.frontend.scene.hits.len();
446        stats.scene_hits_cap = self.frontend.scene.hits.capacity();
447        stats.scene_node_index_len = self.frontend.scene.node_index.len();
448        stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
449        // The producer frontend owns the renderer's only lowering-memo
450        // pair (the present backend reports zeros); add it here so its
451        // retained capacity stays visible to leak tooling.
452        stats.layer_surface_rect_cache_len += self.frontend.layer_surface_rect_cache.len();
453        stats.layer_surface_rect_cache_cap += self.frontend.layer_surface_rect_cache.capacity();
454        stats.layer_surface_requirements_cache_len +=
455            self.frontend.layer_surface_requirements_cache.len();
456        stats.layer_surface_requirements_cache_cap +=
457            self.frontend.layer_surface_requirements_cache.capacity();
458        stats
459    }
460
461    /// Return the WGPU device when GPU resources are initialized.
462    pub fn try_device(&self) -> Option<&wgpu::Device> {
463        self.gpu_renderer.as_ref().map(|r| &*r.device)
464    }
465
466    /// Test/diagnostic view of the latched instanced-quad selection: `true`
467    /// when the live GPU renderer's ordinary shape draws ride
468    /// `vs_shape_instanced` (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0
469    /// at construction).
470    #[cfg(not(target_arch = "wasm32"))]
471    #[doc(hidden)]
472    pub fn instanced_quads_active(&self) -> bool {
473        self.gpu_renderer
474            .as_ref()
475            .is_some_and(GpuRenderer::instanced_quads_active)
476    }
477
478    /// Test/diagnostic view of retained arc meshes: (slots holding a mesh,
479    /// total live replay slots).
480    #[cfg(not(target_arch = "wasm32"))]
481    #[doc(hidden)]
482    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
483        self.gpu_renderer
484            .as_ref()
485            .map(GpuRenderer::replay_slot_mesh_stats)
486            .unwrap_or((0, 0))
487    }
488
489    /// Test/diagnostic view of the retained bundle cache: lifetime
490    /// (rebuilds, cached executes).
491    #[cfg(not(target_arch = "wasm32"))]
492    #[doc(hidden)]
493    pub fn retained_bundle_stats(&self) -> (u64, u64) {
494        self.gpu_renderer
495            .as_ref()
496            .map(GpuRenderer::retained_bundle_stats)
497            .unwrap_or((0, 0))
498    }
499
500    /// Test/diagnostic view of the present store's lifetime count of
501    /// replay-ops batches dropped by the generation check. Surface (non
502    /// direct) frames must never move it: their packets carry the default
503    /// plan, which the consume gate never feeds to the store.
504    #[cfg(not(target_arch = "wasm32"))]
505    #[doc(hidden)]
506    pub fn replay_generation_drops_for_tests(&self) -> u64 {
507        self.gpu_renderer
508            .as_ref()
509            .map(GpuRenderer::replay_generation_drops)
510            .unwrap_or(0)
511    }
512
513    /// Test hook for the replay message protocol: one planner→store→planner
514    /// cycle outside a frame, the batch's generation skewed by
515    /// `generation_skew` from the store's own; returns how many captures
516    /// the store confirmed. A skew landing BELOW the store's generation
517    /// manufactures the fail-closed drop; one landing above it exercises
518    /// adopt-forward. Neither is producible synchronously through the
519    /// public render path.
520    #[cfg(not(target_arch = "wasm32"))]
521    #[doc(hidden)]
522    pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
523        self.gpu_renderer
524            .as_mut()
525            .expect("GPU renderer not initialized")
526            .replay_ops_roundtrip_for_tests(generation_skew)
527    }
528
529    /// Test hook for the cancellation protocol: builds a packet NOW,
530    /// stamped with the current epochs, and hands it to the caller instead
531    /// of rendering it — the public render path builds and consumes
532    /// atomically, so a packet in flight across an epoch change is only
533    /// constructible here.
534    #[doc(hidden)]
535    pub fn build_frame_packet_for_tests(
536        &mut self,
537        width: u32,
538        height: u32,
539    ) -> Option<HeldFramePacket> {
540        let replay_supported = self
541            .gpu_renderer
542            .as_ref()
543            .is_some_and(GpuRenderer::replay_supported);
544        self.frontend
545            .build_frame_packet(
546                width,
547                height,
548                replay_supported,
549                self.renderer_epoch,
550                self.surface_epoch,
551            )
552            .map(HeldFramePacket)
553    }
554
555    /// Test hook: consumes a held packet through the exact production seam
556    /// (`GpuRenderer::render` + `apply_returns` + ack-buffer restore) and
557    /// reports the present outcome.
558    #[doc(hidden)]
559    pub fn render_held_packet_for_tests(
560        &mut self,
561        view: &wgpu::TextureView,
562        width: u32,
563        height: u32,
564        packet: HeldFramePacket,
565    ) -> Result<PresentOutcome, WgpuRendererError> {
566        let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
567            return Err(WgpuRendererError::Wgpu(
568                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
569            ));
570        };
571        let frontend = &mut self.frontend;
572        let mut returns = RenderReturns::default();
573        let result = gpu_renderer.render(
574            view,
575            width,
576            height,
577            packet.0,
578            self.surface_epoch,
579            &mut returns,
580        );
581        let outcome = returns.outcome;
582        if let Some(confirmations) = frontend.apply_returns(returns) {
583            gpu_renderer.restore_replay_ack_confirmations(confirmations);
584        }
585        result.map_err(WgpuRendererError::Wgpu)?;
586        Ok(outcome)
587    }
588
589    /// Test hook: whether the producer pool holds a recycled direct scene —
590    /// the cancellation contract's proof the packet's scene came back.
591    #[doc(hidden)]
592    pub fn has_retained_direct_scene_for_tests(&self) -> bool {
593        self.frontend.retained_direct_scene.is_some()
594    }
595}
596
597/// Opaque handle to a built-but-unrendered frame packet, for the
598/// cancellation-protocol tests only.
599#[doc(hidden)]
600pub struct HeldFramePacket(frame_packet::FramePacket);
601
602impl Default for WgpuRenderer {
603    fn default() -> Self {
604        Self::new(&[])
605    }
606}
607
608impl Renderer for WgpuRenderer {
609    type Scene = Scene;
610    type Error = WgpuRendererError;
611
612    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
613        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
614            self.frontend.text_fonts.clone(),
615            8192,
616        ));
617        self.frontend.app_context = Some(app_context.downgrade());
618    }
619
620    fn scene(&self) -> &Self::Scene {
621        &self.frontend.scene
622    }
623
624    fn scene_mut(&mut self) -> &mut Self::Scene {
625        &mut self.frontend.scene
626    }
627
628    fn rebuild_scene(
629        &mut self,
630        layout_tree: &LayoutTree,
631        _viewport: Size,
632    ) -> Result<(), Self::Error> {
633        self.frontend.scene.clear();
634        self.frontend.dev_overlay_graph = None;
635        self.frontend.dev_overlay_cache = None;
636        // Build scene in logical dp - scaling happens in GPU vertex upload
637        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
638        Ok(())
639    }
640
641    fn rebuild_scene_from_applier(
642        &mut self,
643        applier: &mut MemoryApplier,
644        root: NodeId,
645        _viewport: Size,
646    ) -> Result<(), Self::Error> {
647        self.frontend.scene.clear();
648        self.frontend.dev_overlay_graph = None;
649        self.frontend.dev_overlay_cache = None;
650        // Build scene in logical dp - scaling happens in GPU vertex upload
651        // Traverse layout nodes via applier instead of rebuilding LayoutTree
652        pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
653        Ok(())
654    }
655
656    fn update_scene_from_applier(
657        &mut self,
658        applier: &mut MemoryApplier,
659        root: NodeId,
660        viewport: Size,
661        dirty_nodes: &[NodeId],
662    ) -> Result<(), Self::Error> {
663        if dirty_nodes.is_empty() {
664            return self.rebuild_scene_from_applier(applier, root, viewport);
665        }
666        pipeline::update_from_applier(
667            applier,
668            root,
669            &mut self.frontend.scene,
670            1.0,
671            dirty_nodes,
672            true,
673        );
674        Ok(())
675    }
676
677    fn update_visual_scene_from_applier(
678        &mut self,
679        applier: &mut MemoryApplier,
680        root: NodeId,
681        viewport: Size,
682        dirty_nodes: &[NodeId],
683    ) -> Result<(), Self::Error> {
684        if dirty_nodes.is_empty() {
685            return self.rebuild_scene_from_applier(applier, root, viewport);
686        }
687        pipeline::update_from_applier(
688            applier,
689            root,
690            &mut self.frontend.scene,
691            1.0,
692            dirty_nodes,
693            false,
694        );
695        Ok(())
696    }
697
698    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
699        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
700        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
701        if self.frontend.dev_overlay_graph.is_some()
702            && self
703                .frontend
704                .dev_overlay_cache
705                .as_ref()
706                .is_some_and(|cache| {
707                    cache.text == key.text
708                        && cache.viewport_width_bits == key.viewport_width_bits
709                        && cache.viewport_height_bits == key.viewport_height_bits
710                })
711        {
712            return;
713        }
714        self.frontend.dev_overlay_graph = Some(
715            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
716                text,
717                viewport,
718                DEV_OVERLAY_NODE_ID,
719            ),
720        );
721        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
722            text: key.text,
723            viewport_width_bits: key.viewport_width_bits,
724            viewport_height_bits: key.viewport_height_bits,
725        });
726    }
727
728    fn needs_frame_warmup(&self) -> bool {
729        self.gpu_renderer
730            .as_ref()
731            .is_some_and(GpuRenderer::needs_frame_warmup)
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::pipeline::TextLayoutResolver;
739    use cranpose_render_common::graph::RenderNode;
740    use cranpose_ui_graphics::GraphicsLayer;
741    use std::cell::Cell;
742
743    static TEST_FONT: &[u8] =
744        cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
745
746    #[test]
747    fn dev_overlay_is_recorded_outside_app_graph() {
748        let mut renderer = WgpuRenderer::new(&[]);
749        renderer.draw_dev_overlay(
750            "240 FPS | avg 4.0ms | p95 4.5ms",
751            Size {
752                width: 800.0,
753                height: 600.0,
754            },
755        );
756
757        assert!(
758            renderer
759                .frontend
760                .scene
761                .graph
762                .as_ref()
763                .is_none_or(|graph| graph.root.children.iter().all(|child| {
764                    !matches!(
765                        child,
766                        RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
767                    )
768                })),
769            "dev overlay must not be mixed into the app scene graph"
770        );
771
772        let graph = renderer
773            .frontend
774            .dev_overlay_graph
775            .as_ref()
776            .expect("overlay graph");
777        let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
778            panic!("dev overlay should be the final top-level layer");
779        };
780
781        assert_eq!(overlay.node_id, Some(NodeId::MAX));
782        assert_eq!(
783            overlay.graphics_layer.compositing_strategy,
784            GraphicsLayer::default().compositing_strategy,
785            "dev overlay should not allocate an offscreen surface"
786        );
787    }
788
789    struct CountingTextMeasurer {
790        inner: SoftwareTextMeasurer,
791        layout_calls: Rc<Cell<usize>>,
792    }
793
794    impl CountingTextMeasurer {
795        fn new(layout_calls: Rc<Cell<usize>>) -> Self {
796            Self {
797                inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
798                layout_calls,
799            }
800        }
801    }
802
803    impl TextMeasurer for CountingTextMeasurer {
804        fn measure(
805            &self,
806            text: &cranpose_ui::text::AnnotatedString,
807            style: &cranpose_ui::text::TextStyle,
808        ) -> cranpose_ui::TextMetrics {
809            self.inner.measure(text, style)
810        }
811
812        fn get_offset_for_position(
813            &self,
814            text: &cranpose_ui::text::AnnotatedString,
815            style: &cranpose_ui::text::TextStyle,
816            x: f32,
817            y: f32,
818        ) -> usize {
819            self.inner.get_offset_for_position(text, style, x, y)
820        }
821
822        fn get_cursor_x_for_offset(
823            &self,
824            text: &cranpose_ui::text::AnnotatedString,
825            style: &cranpose_ui::text::TextStyle,
826            offset: usize,
827        ) -> f32 {
828            self.inner.get_cursor_x_for_offset(text, style, offset)
829        }
830
831        fn layout(
832            &self,
833            text: &cranpose_ui::text::AnnotatedString,
834            style: &cranpose_ui::text::TextStyle,
835        ) -> cranpose_ui::text_layout_result::TextLayoutResult {
836            self.layout_calls.set(self.layout_calls.get() + 1);
837            self.inner.layout(text, style)
838        }
839    }
840
841    #[test]
842    fn headless_text_measurer_uses_software_text_font() {
843        let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
844        let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
845        let style = cranpose_ui::text::TextStyle::default();
846
847        let metrics = measurer.measure(&text, &style);
848        let layout = measurer.layout(&text, &style);
849
850        assert!(metrics.width > 0.0);
851        assert!(metrics.height > 0.0);
852        assert_eq!(layout.lines.len(), metrics.line_count);
853    }
854
855    #[test]
856    fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
857        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
858        let app_context = cranpose_ui::AppContext::new();
859        renderer.attach_app_context_services(&app_context);
860
861        let metrics = app_context.enter(|| {
862            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
863            let style = cranpose_ui::text::TextStyle {
864                span_style: cranpose_ui::text::SpanStyle {
865                    font_size: cranpose_ui::text::TextUnit::Sp(14.0),
866                    ..Default::default()
867                },
868                paragraph_style: cranpose_ui::text::ParagraphStyle {
869                    platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
870                        include_font_padding: None,
871                        shaping: Some(cranpose_ui::text::TextShaping::Basic),
872                    }),
873                    ..Default::default()
874                },
875            };
876            cranpose_ui::text::measure_text(&text, &style)
877        });
878
879        assert!(
880            metrics.width > 0.0,
881            "software text service should measure text"
882        );
883        assert_eq!(
884            renderer.frontend.text_state.text_cache_len(),
885            0,
886            "WGPU must not keep a renderer-side shaping cache for measurement"
887        );
888    }
889
890    #[test]
891    fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
892        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
893        let app_context = cranpose_ui::AppContext::new();
894        renderer.attach_app_context_services(&app_context);
895
896        let prepared = app_context.enter(|| {
897            let text = cranpose_ui::text::AnnotatedString::from(
898                (0..48)
899                    .map(|line| format!("// markdown code line {line:02}"))
900                    .collect::<Vec<_>>()
901                    .join("\n"),
902            );
903            let style = cranpose_ui::text::TextStyle::default();
904            cranpose_ui::text::prepare_text_layout(
905                &text,
906                &style,
907                cranpose_ui::text::TextLayoutOptions::default(),
908                Some(952.0),
909            )
910        });
911
912        assert_eq!(prepared.metrics.line_count, 48);
913        assert!(
914            prepared.metrics.line_height > 18.0,
915            "renderer-attached text service must not use fallback monospaced line height: {:?}",
916            prepared.metrics
917        );
918        assert!(
919            prepared.metrics.height > 900.0,
920            "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
921            prepared.metrics
922        );
923    }
924
925    #[test]
926    fn render_text_layout_routes_through_attached_app_context_service() {
927        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
928        let app_context = cranpose_ui::AppContext::new();
929        renderer.attach_app_context_services(&app_context);
930        let layout_calls = Rc::new(Cell::new(0));
931        app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
932
933        app_context.enter(|| {
934            let text = cranpose_ui::text::AnnotatedString::from("render text");
935            let style = cranpose_ui::text::TextStyle::default();
936            let layout = renderer.frontend.text_state.layout_text(&text, &style);
937            assert!(layout.width > 0.0);
938        });
939
940        assert_eq!(layout_calls.get(), 1);
941    }
942}