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