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