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 transient rim mesh path: lifetime count
502    /// of dynamic circle rims drawn as band meshes instead of full bounding
503    /// quads (`CRANPOSE_RIM_MESH` kill switch).
504    #[cfg(not(target_arch = "wasm32"))]
505    #[doc(hidden)]
506    pub fn rim_meshes_emitted(&self) -> u64 {
507        self.gpu_renderer
508            .as_ref()
509            .map(GpuRenderer::rim_meshes_emitted)
510            .unwrap_or(0)
511    }
512
513    /// Test/diagnostic view of the opaque static leading-span cache:
514    /// lifetime (hits, recaptures) — frames drawn with the cached
515    /// full-target blit standing in for the leading span, and capture
516    /// passes rendered (`CRANPOSE_STATIC_SPAN` kill switch).
517    #[cfg(not(target_arch = "wasm32"))]
518    #[doc(hidden)]
519    pub fn static_span_stats(&self) -> (u64, u64) {
520        self.gpu_renderer
521            .as_ref()
522            .map(GpuRenderer::static_span_stats)
523            .unwrap_or((0, 0))
524    }
525
526    /// Test/diagnostic view of the present store's lifetime count of
527    /// replay-ops batches dropped by the generation check. Surface (non
528    /// direct) frames must never move it: their packets carry the default
529    /// plan, which the consume gate never feeds to the store.
530    #[cfg(not(target_arch = "wasm32"))]
531    #[doc(hidden)]
532    pub fn replay_generation_drops_for_tests(&self) -> u64 {
533        self.gpu_renderer
534            .as_ref()
535            .map(GpuRenderer::replay_generation_drops)
536            .unwrap_or(0)
537    }
538
539    /// Test hook for the replay message protocol: one planner→store→planner
540    /// cycle outside a frame, the batch's generation skewed by
541    /// `generation_skew` from the store's own; returns how many captures
542    /// the store confirmed. A skew landing BELOW the store's generation
543    /// manufactures the fail-closed drop; one landing above it exercises
544    /// adopt-forward. Neither is producible synchronously through the
545    /// public render path.
546    #[cfg(not(target_arch = "wasm32"))]
547    #[doc(hidden)]
548    pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
549        self.gpu_renderer
550            .as_mut()
551            .expect("GPU renderer not initialized")
552            .replay_ops_roundtrip_for_tests(generation_skew)
553    }
554
555    /// Test hook for the cancellation protocol: builds a packet NOW,
556    /// stamped with the current epochs, and hands it to the caller instead
557    /// of rendering it — the public render path builds and consumes
558    /// atomically, so a packet in flight across an epoch change is only
559    /// constructible here.
560    #[doc(hidden)]
561    pub fn build_frame_packet_for_tests(
562        &mut self,
563        width: u32,
564        height: u32,
565    ) -> Option<HeldFramePacket> {
566        let replay_supported = self
567            .gpu_renderer
568            .as_ref()
569            .is_some_and(GpuRenderer::replay_supported);
570        self.frontend
571            .build_frame_packet(
572                width,
573                height,
574                replay_supported,
575                self.renderer_epoch,
576                self.surface_epoch,
577            )
578            .map(HeldFramePacket)
579    }
580
581    /// Test hook: consumes a held packet through the exact production seam
582    /// (`GpuRenderer::render` + `apply_returns` + ack-buffer restore) and
583    /// reports the present outcome.
584    #[doc(hidden)]
585    pub fn render_held_packet_for_tests(
586        &mut self,
587        view: &wgpu::TextureView,
588        width: u32,
589        height: u32,
590        packet: HeldFramePacket,
591    ) -> Result<PresentOutcome, WgpuRendererError> {
592        let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
593            return Err(WgpuRendererError::Wgpu(
594                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
595            ));
596        };
597        let frontend = &mut self.frontend;
598        let mut returns = RenderReturns::default();
599        let result = gpu_renderer.render(
600            view,
601            width,
602            height,
603            packet.0,
604            self.surface_epoch,
605            &mut returns,
606        );
607        let outcome = returns.outcome;
608        if let Some(confirmations) = frontend.apply_returns(returns) {
609            gpu_renderer.restore_replay_ack_confirmations(confirmations);
610        }
611        result.map_err(WgpuRendererError::Wgpu)?;
612        Ok(outcome)
613    }
614
615    /// Test hook: whether the producer pool holds a recycled direct scene —
616    /// the cancellation contract's proof the packet's scene came back.
617    #[doc(hidden)]
618    pub fn has_retained_direct_scene_for_tests(&self) -> bool {
619        self.frontend.retained_direct_scene.is_some()
620    }
621}
622
623/// Opaque handle to a built-but-unrendered frame packet, for the
624/// cancellation-protocol tests only.
625#[doc(hidden)]
626pub struct HeldFramePacket(frame_packet::FramePacket);
627
628impl Default for WgpuRenderer {
629    fn default() -> Self {
630        Self::new(&[])
631    }
632}
633
634impl Renderer for WgpuRenderer {
635    type Scene = Scene;
636    type Error = WgpuRendererError;
637
638    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
639        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
640            self.frontend.text_fonts.clone(),
641            8192,
642        ));
643        self.frontend.app_context = Some(app_context.downgrade());
644    }
645
646    fn scene(&self) -> &Self::Scene {
647        &self.frontend.scene
648    }
649
650    fn scene_mut(&mut self) -> &mut Self::Scene {
651        &mut self.frontend.scene
652    }
653
654    fn rebuild_scene(
655        &mut self,
656        layout_tree: &LayoutTree,
657        _viewport: Size,
658    ) -> Result<(), Self::Error> {
659        self.frontend.scene.clear();
660        self.frontend.dev_overlay_graph = None;
661        self.frontend.dev_overlay_cache = None;
662        // Build scene in logical dp - scaling happens in GPU vertex upload
663        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
664        Ok(())
665    }
666
667    fn rebuild_scene_from_applier(
668        &mut self,
669        applier: &mut MemoryApplier,
670        root: NodeId,
671        _viewport: Size,
672    ) -> Result<(), Self::Error> {
673        self.frontend.scene.clear();
674        self.frontend.dev_overlay_graph = None;
675        self.frontend.dev_overlay_cache = None;
676        // Build scene in logical dp - scaling happens in GPU vertex upload
677        // Traverse layout nodes via applier instead of rebuilding LayoutTree
678        pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
679        Ok(())
680    }
681
682    fn update_scene_from_applier(
683        &mut self,
684        applier: &mut MemoryApplier,
685        root: NodeId,
686        viewport: Size,
687        dirty_nodes: &[NodeId],
688    ) -> Result<(), Self::Error> {
689        if dirty_nodes.is_empty() {
690            return self.rebuild_scene_from_applier(applier, root, viewport);
691        }
692        pipeline::update_from_applier(
693            applier,
694            root,
695            &mut self.frontend.scene,
696            1.0,
697            dirty_nodes,
698            true,
699        );
700        Ok(())
701    }
702
703    fn update_visual_scene_from_applier(
704        &mut self,
705        applier: &mut MemoryApplier,
706        root: NodeId,
707        viewport: Size,
708        dirty_nodes: &[NodeId],
709    ) -> Result<(), Self::Error> {
710        if dirty_nodes.is_empty() {
711            return self.rebuild_scene_from_applier(applier, root, viewport);
712        }
713        pipeline::update_from_applier(
714            applier,
715            root,
716            &mut self.frontend.scene,
717            1.0,
718            dirty_nodes,
719            false,
720        );
721        Ok(())
722    }
723
724    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
725        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
726        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
727        if self.frontend.dev_overlay_graph.is_some()
728            && self
729                .frontend
730                .dev_overlay_cache
731                .as_ref()
732                .is_some_and(|cache| {
733                    cache.text == key.text
734                        && cache.viewport_width_bits == key.viewport_width_bits
735                        && cache.viewport_height_bits == key.viewport_height_bits
736                })
737        {
738            return;
739        }
740        self.frontend.dev_overlay_graph = Some(
741            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
742                text,
743                viewport,
744                DEV_OVERLAY_NODE_ID,
745            ),
746        );
747        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
748            text: key.text,
749            viewport_width_bits: key.viewport_width_bits,
750            viewport_height_bits: key.viewport_height_bits,
751        });
752    }
753
754    fn needs_frame_warmup(&self) -> bool {
755        self.gpu_renderer
756            .as_ref()
757            .is_some_and(GpuRenderer::needs_frame_warmup)
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::pipeline::TextLayoutResolver;
765    use cranpose_render_common::graph::RenderNode;
766    use cranpose_ui_graphics::GraphicsLayer;
767    use std::cell::Cell;
768
769    static TEST_FONT: &[u8] =
770        cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
771
772    #[test]
773    fn dev_overlay_is_recorded_outside_app_graph() {
774        let mut renderer = WgpuRenderer::new(&[]);
775        renderer.draw_dev_overlay(
776            "240 FPS | avg 4.0ms | p95 4.5ms",
777            Size {
778                width: 800.0,
779                height: 600.0,
780            },
781        );
782
783        assert!(
784            renderer
785                .frontend
786                .scene
787                .graph
788                .as_ref()
789                .is_none_or(|graph| graph.root.children.iter().all(|child| {
790                    !matches!(
791                        child,
792                        RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
793                    )
794                })),
795            "dev overlay must not be mixed into the app scene graph"
796        );
797
798        let graph = renderer
799            .frontend
800            .dev_overlay_graph
801            .as_ref()
802            .expect("overlay graph");
803        let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
804            panic!("dev overlay should be the final top-level layer");
805        };
806
807        assert_eq!(overlay.node_id, Some(NodeId::MAX));
808        assert_eq!(
809            overlay.graphics_layer.compositing_strategy,
810            GraphicsLayer::default().compositing_strategy,
811            "dev overlay should not allocate an offscreen surface"
812        );
813    }
814
815    struct CountingTextMeasurer {
816        inner: SoftwareTextMeasurer,
817        layout_calls: Rc<Cell<usize>>,
818    }
819
820    impl CountingTextMeasurer {
821        fn new(layout_calls: Rc<Cell<usize>>) -> Self {
822            Self {
823                inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
824                layout_calls,
825            }
826        }
827    }
828
829    impl TextMeasurer for CountingTextMeasurer {
830        fn measure(
831            &self,
832            text: &cranpose_ui::text::AnnotatedString,
833            style: &cranpose_ui::text::TextStyle,
834        ) -> cranpose_ui::TextMetrics {
835            self.inner.measure(text, style)
836        }
837
838        fn get_offset_for_position(
839            &self,
840            text: &cranpose_ui::text::AnnotatedString,
841            style: &cranpose_ui::text::TextStyle,
842            x: f32,
843            y: f32,
844        ) -> usize {
845            self.inner.get_offset_for_position(text, style, x, y)
846        }
847
848        fn get_cursor_x_for_offset(
849            &self,
850            text: &cranpose_ui::text::AnnotatedString,
851            style: &cranpose_ui::text::TextStyle,
852            offset: usize,
853        ) -> f32 {
854            self.inner.get_cursor_x_for_offset(text, style, offset)
855        }
856
857        fn layout(
858            &self,
859            text: &cranpose_ui::text::AnnotatedString,
860            style: &cranpose_ui::text::TextStyle,
861        ) -> cranpose_ui::text_layout_result::TextLayoutResult {
862            self.layout_calls.set(self.layout_calls.get() + 1);
863            self.inner.layout(text, style)
864        }
865    }
866
867    #[test]
868    fn headless_text_measurer_uses_software_text_font() {
869        let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
870        let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
871        let style = cranpose_ui::text::TextStyle::default();
872
873        let metrics = measurer.measure(&text, &style);
874        let layout = measurer.layout(&text, &style);
875
876        assert!(metrics.width > 0.0);
877        assert!(metrics.height > 0.0);
878        assert_eq!(layout.lines.len(), metrics.line_count);
879    }
880
881    #[test]
882    fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
883        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
884        let app_context = cranpose_ui::AppContext::new();
885        renderer.attach_app_context_services(&app_context);
886
887        let metrics = app_context.enter(|| {
888            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
889            let style = cranpose_ui::text::TextStyle {
890                span_style: cranpose_ui::text::SpanStyle {
891                    font_size: cranpose_ui::text::TextUnit::Sp(14.0),
892                    ..Default::default()
893                },
894                paragraph_style: cranpose_ui::text::ParagraphStyle {
895                    platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
896                        include_font_padding: None,
897                        shaping: Some(cranpose_ui::text::TextShaping::Basic),
898                    }),
899                    ..Default::default()
900                },
901            };
902            cranpose_ui::text::measure_text(&text, &style)
903        });
904
905        assert!(
906            metrics.width > 0.0,
907            "software text service should measure text"
908        );
909        assert_eq!(
910            renderer.frontend.text_state.text_cache_len(),
911            0,
912            "WGPU must not keep a renderer-side shaping cache for measurement"
913        );
914    }
915
916    #[test]
917    fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
918        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
919        let app_context = cranpose_ui::AppContext::new();
920        renderer.attach_app_context_services(&app_context);
921
922        let prepared = app_context.enter(|| {
923            let text = cranpose_ui::text::AnnotatedString::from(
924                (0..48)
925                    .map(|line| format!("// markdown code line {line:02}"))
926                    .collect::<Vec<_>>()
927                    .join("\n"),
928            );
929            let style = cranpose_ui::text::TextStyle::default();
930            cranpose_ui::text::prepare_text_layout(
931                &text,
932                &style,
933                cranpose_ui::text::TextLayoutOptions::default(),
934                Some(952.0),
935            )
936        });
937
938        assert_eq!(prepared.metrics.line_count, 48);
939        assert!(
940            prepared.metrics.line_height > 18.0,
941            "renderer-attached text service must not use fallback monospaced line height: {:?}",
942            prepared.metrics
943        );
944        assert!(
945            prepared.metrics.height > 900.0,
946            "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
947            prepared.metrics
948        );
949    }
950
951    #[test]
952    fn render_text_layout_routes_through_attached_app_context_service() {
953        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
954        let app_context = cranpose_ui::AppContext::new();
955        renderer.attach_app_context_services(&app_context);
956        let layout_calls = Rc::new(Cell::new(0));
957        app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
958
959        app_context.enter(|| {
960            let text = cranpose_ui::text::AnnotatedString::from("render text");
961            let style = cranpose_ui::text::TextStyle::default();
962            let layout = renderer.frontend.text_state.layout_text(&text, &style);
963            assert!(layout.width > 0.0);
964        });
965
966        assert_eq!(layout_calls.get(), 1);
967    }
968}