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 display_clip;
11mod effect_renderer;
12mod frame_graph;
13mod frame_packet;
14mod frontend;
15pub(crate) mod gpu_stats;
16mod layer_events;
17mod layer_surface_cache;
18mod lazy_resource;
19mod normalized_scene;
20mod offscreen;
21mod output_conversion;
22mod pipeline;
23#[cfg(not(target_arch = "wasm32"))]
24mod pipeline_disk_cache;
25#[cfg(not(target_arch = "wasm32"))]
26mod present_runtime;
27mod render;
28mod run_entry;
29mod scene;
30#[cfg(not(target_arch = "wasm32"))]
31mod segment_surface;
32mod shader_cache;
33mod shaders;
34#[cfg(not(target_arch = "wasm32"))]
35mod shape_replay;
36#[cfg(not(target_arch = "wasm32"))]
37mod stage_executor;
38mod surface_executor;
39mod surface_plan;
40mod surface_requirements;
41#[cfg(test)]
42mod test_support;
43
44#[doc(hidden)]
45#[cfg(not(target_arch = "wasm32"))]
46pub use display_clip::pixel_is_visible as display_clip_pixel_is_visible;
47pub use display_clip::DisplayVisibleRegion;
48pub use frame_packet::PresentTimings;
49#[doc(hidden)]
50pub use frame_packet::{CancelReason, PresentOutcome};
51pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
52#[doc(hidden)]
53#[cfg(not(target_arch = "wasm32"))]
54pub use pipeline::retained_feed_generation;
55pub use render::frames_presented;
56pub use scene::{ClickAction, HitRegion, Scene};
57#[doc(hidden)]
58#[cfg(not(target_arch = "wasm32"))]
59pub use shape_replay::feed_live_stats as command_feed_live_stats;
60#[doc(hidden)]
61#[cfg(not(target_arch = "wasm32"))]
62pub use shape_replay::{inject_feed_capture_for_tests, pending_feed_capture_count_for_tests};
63#[doc(hidden)]
64#[cfg(not(target_arch = "wasm32"))]
65pub use shape_replay::{planner_replay_queue_stats_for_tests, recycled_ops_capacities_for_tests};
66
67use cranpose_core::{MemoryApplier, NodeId};
68use cranpose_render_common::{
69    graph::RenderGraph,
70    software_text_raster::{
71        software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
72    },
73    RenderScene, Renderer,
74};
75use cranpose_ui::{LayoutTree, TextMeasurer};
76use cranpose_ui_graphics::{Rect, Size};
77use frame_packet::RenderReturns;
78#[cfg(not(target_arch = "wasm32"))]
79use frame_packet::ReplayConfirmation;
80use frontend::{DevOverlayCache, RendererFrontend};
81#[cfg(not(target_arch = "wasm32"))]
82use present_runtime::{
83    PresentControl, PresentHandle, PresentMsg, PresentRuntimeInit, PresentState,
84};
85use render::GpuRenderer;
86use std::rc::Rc;
87use std::sync::Arc;
88
89/// Convert an axis-aligned rectangle to four corner positions (TL, TR, BL, BR).
90pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
91    [
92        [rect.x, rect.y],
93        [rect.x + rect.width, rect.y],
94        [rect.x, rect.y + rect.height],
95        [rect.x + rect.width, rect.y + rect.height],
96    ]
97}
98
99#[derive(Debug)]
100pub enum WgpuRendererError {
101    Layout(String),
102    Wgpu(String),
103}
104
105/// CPU-readable RGBA frame captured from the renderer output.
106#[derive(Debug, Clone)]
107pub struct CapturedFrame {
108    pub width: u32,
109    pub height: u32,
110    pub pixels: Vec<u8>,
111}
112
113#[doc(hidden)]
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct DebugCpuAllocationStats {
116    pub scene_graph_node_count: usize,
117    pub scene_graph_heap_bytes: usize,
118    pub scene_hits_len: usize,
119    pub scene_hits_cap: usize,
120    pub scene_node_index_len: usize,
121    pub scene_node_index_cap: usize,
122    pub text_renderer_pool_len: usize,
123    pub text_renderer_pool_cap: usize,
124    pub swash_image_cache_len: usize,
125    pub swash_image_cache_cap: usize,
126    pub swash_outline_cache_len: usize,
127    pub swash_outline_cache_cap: usize,
128    pub image_texture_cache_len: usize,
129    pub image_texture_cache_cap: usize,
130    pub scratch_shape_data_cap: usize,
131    pub scratch_gradients_cap: usize,
132    pub scratch_image_vertices_cap: usize,
133    pub scratch_image_indices_cap: usize,
134    pub scratch_image_cmds_cap: usize,
135    pub scratch_segment_items_cap: usize,
136    pub scratch_effect_ranges_cap: usize,
137    pub scratch_layer_events_cap: usize,
138    pub staged_upload_bytes_cap: usize,
139    pub staged_upload_copies_cap: usize,
140    pub layer_surface_cache_len: usize,
141    pub layer_surface_cache_cap: usize,
142    pub layer_surface_cache_identity_len: usize,
143    pub layer_surface_cache_identity_cap: usize,
144    pub layer_surface_rect_cache_len: usize,
145    pub layer_surface_rect_cache_cap: usize,
146    pub layer_surface_requirements_cache_len: usize,
147    pub layer_surface_requirements_cache_cap: usize,
148    pub layer_cache_seen_this_frame_len: usize,
149    pub layer_cache_seen_this_frame_cap: usize,
150}
151
152pub(crate) struct TextSystemState {
153    measurer: SoftwareTextMeasurer,
154}
155
156impl TextSystemState {
157    fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
158        Self {
159            measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
160        }
161    }
162
163    pub(crate) fn text_cache_len(&self) -> usize {
164        0
165    }
166}
167
168impl pipeline::TextLayoutResolver for TextSystemState {
169    fn layout_text(
170        &mut self,
171        text: &cranpose_ui::text::AnnotatedString,
172        style: &cranpose_ui::text::TextStyle,
173    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
174        if cranpose_ui::has_current_app_context() {
175            cranpose_ui::text::layout_text(text, style)
176        } else {
177            self.measurer.layout(text, style)
178        }
179    }
180}
181
182#[derive(Clone)]
183pub struct WgpuTextSystem {
184    software_fonts: SoftwareTextFontSet,
185}
186
187impl WgpuTextSystem {
188    pub fn from_fonts(fonts: &[&[u8]]) -> Self {
189        Self {
190            software_fonts: software_text_font_set_from_fonts_or_default(fonts),
191        }
192    }
193
194    /// Adopt a font set an app already built — the path app-supplied families
195    /// take, where faces were parsed once at startup rather than from static
196    /// byte slices here.
197    pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
198        Self { software_fonts }
199    }
200
201    pub(crate) fn render_state(&self) -> TextSystemState {
202        TextSystemState::from_font_set(self.software_fonts.clone())
203    }
204
205    pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
206        self.software_fonts.clone()
207    }
208}
209
210/// Create an accurate WGPU text measurer for headless tests without launching a window.
211pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
212    headless_text_measurer_with_fonts(&[])
213}
214
215/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
216pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
217    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
218}
219
220/// Which present stage a [`WgpuRenderer`] drives.
221///
222/// `Sync` is today's synchronous path (desktop, web, iOS, tests): the
223/// producer builds a packet and consumes it in the same call. `Threaded`
224/// is the Android depth-one runtime: the `GpuRenderer` lives on the
225/// present thread and only a `PresentHandle` stays here. Boxed because a
226/// `GpuRenderer` is kilobytes of retained state, moved only at init.
227enum PresentBackend {
228    /// No GPU yet (`init_gpu`/`init_gpu_threaded` not called).
229    None,
230    /// The synchronous present stage, consumed in `render`.
231    Sync(Box<GpuRenderer>),
232    /// The spawned (or inline-for-tests) present runtime.
233    #[cfg(not(target_arch = "wasm32"))]
234    Threaded(PresentHandle),
235}
236
237/// What [`WgpuRenderer::publish_frame`] did.
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum PublishOutcome {
240    /// No scene graph exists; nothing to lower.
241    NoGraph,
242    /// The depth-one slot is occupied (or the renderer is not in threaded
243    /// mode): NO packet was built — backpressure lands before lowering.
244    NoCredit,
245    /// A packet was built and handed to the present runtime.
246    Published,
247}
248
249/// WGPU-based renderer for GPU-accelerated 2D rendering.
250///
251/// This renderer supports:
252/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
253/// - Gradients (solid, linear, radial)
254/// - GPU text rendering via retained raster image batches
255/// - Cross-platform support (Desktop, Web, Android)
256pub struct WgpuRenderer {
257    /// Producer stage: scene graph, text layout, and the lowering of every
258    /// frame into a [`frame_packet::FramePacket`].
259    frontend: RendererFrontend,
260    /// Present stage: consumes packets and draws; it never lowers.
261    backend: PresentBackend,
262    /// Threaded mode: the emptied ack-confirmations buffer drained from
263    /// the planner, parked here until the next packet carries it back to
264    /// the present-side store (capacity round-trip, no per-frame alloc).
265    #[cfg(not(target_arch = "wasm32"))]
266    pending_recycled_confirmations: Option<Vec<ReplayConfirmation>>,
267    /// Which `GpuRenderer` instance packets are currently built against:
268    /// bumped by every [`init_gpu`][Self::init_gpu] (first init → 1) and
269    /// stamped into each packet, so a packet that outlives its renderer is
270    /// cancelled by the present stage instead of drawn.
271    renderer_epoch: u64,
272    /// Which surface configuration packets are currently built against:
273    /// bumped by [`note_surface_reconfigured`][Self::note_surface_reconfigured]
274    /// and stamped into each packet, so a packet that straddles a surface
275    /// reconfigure is cancelled by the present stage instead of drawn.
276    surface_epoch: u64,
277    /// The display's visible region from
278    /// [`set_display_visible_region`][Self::set_display_visible_region]:
279    /// the part of the surface the panel physically shows. Held here so a
280    /// `GpuRenderer` replaced by `init_gpu` inherits it. Never derived
281    /// from app content — only the platform layer (or a host standing in
282    /// for it) may set it.
283    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
284    display_visible_region: DisplayVisibleRegion,
285}
286
287impl WgpuRenderer {
288    fn update_scene_and_plan_memo(
289        &mut self,
290        applier: &mut MemoryApplier,
291        root: NodeId,
292        dirty_nodes: &[NodeId],
293        refresh_hits: bool,
294    ) {
295        let mut changed_nodes = std::mem::take(&mut self.frontend.changed_nodes);
296        let outcome = pipeline::update_from_applier(
297            applier,
298            root,
299            &mut self.frontend.scene,
300            1.0,
301            dirty_nodes,
302            refresh_hits,
303            &mut changed_nodes,
304        );
305        match outcome {
306            pipeline::SceneUpdateOutcome::Patched => {
307                for node_id in &changed_nodes {
308                    self.frontend
309                        .layer_surface_requirements_cache
310                        .remove(node_id);
311                }
312            }
313            pipeline::SceneUpdateOutcome::Rebuilt => {
314                self.frontend.layer_surface_requirements_cache.clear();
315            }
316        }
317        changed_nodes.clear();
318        self.frontend.changed_nodes = changed_nodes;
319    }
320
321    /// Create a new WGPU renderer.
322    ///
323    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
324    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
325    ///
326    /// Call [`init_gpu`][Self::init_gpu] before rendering.
327    pub fn new(fonts: &[&[u8]]) -> Self {
328        Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
329    }
330
331    /// Create a renderer over an already-parsed font set.
332    ///
333    /// Measurement and rasterization both take clones of this one set, so an
334    /// app-supplied family resolves identically on both sides.
335    pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
336        Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
337    }
338
339    pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
340        Self {
341            frontend: RendererFrontend::new(
342                text_system.render_state(),
343                text_system.software_fonts(),
344            ),
345            backend: PresentBackend::None,
346            #[cfg(not(target_arch = "wasm32"))]
347            pending_recycled_confirmations: None,
348            renderer_epoch: 0,
349            surface_epoch: 0,
350            display_visible_region: DisplayVisibleRegion::Full,
351        }
352    }
353
354    /// The synchronous present backend, when that is what is live.
355    fn sync_gpu_renderer(&self) -> Option<&GpuRenderer> {
356        match &self.backend {
357            PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_ref()),
358            _ => None,
359        }
360    }
361
362    #[cfg(not(target_arch = "wasm32"))]
363    fn sync_gpu_renderer_mut(&mut self) -> Option<&mut GpuRenderer> {
364        match &mut self.backend {
365            PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_mut()),
366            _ => None,
367        }
368    }
369
370    /// The threaded present handle, when that is what is live.
371    #[cfg(not(target_arch = "wasm32"))]
372    fn present_handle_mut(&mut self) -> Option<&mut PresentHandle> {
373        match &mut self.backend {
374            PresentBackend::Threaded(handle) => Some(handle),
375            _ => None,
376        }
377    }
378
379    /// Retire whatever present backend is live before a replacement init:
380    /// drain and fold in any pending threaded returns (their buffers are
381    /// still recyclable), shut the runtime down, and run the planner's
382    /// renderer-replacement hygiene — retained slots retired, confirmations
383    /// revoked, feed generation bumped — exactly as `init_gpu` always did
384    /// for a live sync renderer.
385    fn retire_live_backend(&mut self) {
386        #[allow(unused_mut)]
387        let mut backend = std::mem::replace(&mut self.backend, PresentBackend::None);
388        if matches!(backend, PresentBackend::None) {
389            return;
390        }
391        #[cfg(not(target_arch = "wasm32"))]
392        {
393            if let PresentBackend::Threaded(handle) = &mut backend {
394                // Early acks first (they precede their frames' returns);
395                // the imminent `renderer_replaced` revokes every
396                // confirmation anyway, but the buffers are still worth
397                // recycling.
398                while let Some((ack, recycled)) = handle.try_drain_ack() {
399                    let confirmations = crate::shape_replay::SHAPE_REPLAY
400                        .with(|state| state.borrow_mut().apply_ack(ack, recycled));
401                    self.stash_recycled_confirmations(confirmations);
402                }
403                while let Some(returns) = handle.try_drain() {
404                    if let Some(confirmations) = self.frontend.apply_returns(returns) {
405                        self.stash_recycled_confirmations(confirmations);
406                    }
407                }
408                handle.shutdown();
409            }
410            crate::shape_replay::SHAPE_REPLAY.with(|state| state.borrow_mut().renderer_replaced());
411            log::warn!(
412                "[command-feed] renderer replaced: retained slots retired, \
413                 confirmations revoked, feed generation bumped"
414            );
415        }
416        drop(backend);
417    }
418
419    /// Park a planner-drained confirmations buffer for the next packet,
420    /// keeping the larger capacity if one is somehow already parked.
421    #[cfg(not(target_arch = "wasm32"))]
422    fn stash_recycled_confirmations(&mut self, confirmations: Vec<ReplayConfirmation>) {
423        match &self.pending_recycled_confirmations {
424            Some(parked) if parked.capacity() >= confirmations.capacity() => {}
425            _ => self.pending_recycled_confirmations = Some(confirmations),
426        }
427    }
428
429    /// Threaded mode: fold every pending early [`ReplayAck`] into the
430    /// planner (`frame_packet::ReplayAck` — confirmations register feed
431    /// slots, the batch's emptied buffers recycle). The present thread
432    /// sends these BEFORE surface acquire, so draining here — ahead of
433    /// the next `build_frame_packet` — gives a capture the same one-frame
434    /// confirmation latency the synchronous path has. Returns how many
435    /// acks were applied; no-op outside threaded mode.
436    ///
437    /// `pub` + hidden for the contract tests, which pin the ack arriving
438    /// AHEAD of its frame's returns; the renderer's own callers are
439    /// `publish_frame` and `drain_present_returns_with`
440    /// (`retire_live_backend` drains the channel inline while the backend
441    /// is detached).
442    #[cfg(not(target_arch = "wasm32"))]
443    #[doc(hidden)]
444    pub fn drain_replay_acks(&mut self) -> usize {
445        let mut drained = 0;
446        loop {
447            let (ack, recycled) = {
448                let PresentBackend::Threaded(handle) = &mut self.backend else {
449                    break;
450                };
451                match handle.try_drain_ack() {
452                    Some(batch) => batch,
453                    None => break,
454                }
455            };
456            drained += 1;
457            let confirmations = crate::shape_replay::SHAPE_REPLAY
458                .with(|state| state.borrow_mut().apply_ack(ack, recycled));
459            self.stash_recycled_confirmations(confirmations);
460        }
461        drained
462    }
463
464    /// Initialize GPU resources with a WGPU device and queue.
465    ///
466    /// Replacing a live renderer (Android surface recreation, device loss)
467    /// drops every retained replay slot with the old `GpuRenderer`, so the
468    /// bypass contract fails closed BEFORE the new renderer exists: every
469    /// slot confirmation is revoked and the feed generation bumped — no
470    /// scene build may omit primitives against buffers that died, and
471    /// already-built frames rematerialize their bypassed spans instead of
472    /// referencing the dead renderer's slot ids.
473    pub fn init_gpu(
474        &mut self,
475        device: Arc<wgpu::Device>,
476        queue: Arc<wgpu::Queue>,
477        surface_format: wgpu::TextureFormat,
478        adapter_backend: wgpu::Backend,
479        adapter_downlevel: wgpu::DownlevelFlags,
480    ) {
481        self.retire_live_backend();
482        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
483        // The new store adopts the producer's CURRENT feed generation —
484        // read after `renderer_replaced` bumped it, so packets planned
485        // against the dead store fail the store's generation gate.
486        #[cfg(not(target_arch = "wasm32"))]
487        let store_feed_generation = pipeline::retained_feed_generation();
488        #[cfg(target_arch = "wasm32")]
489        let store_feed_generation = 0;
490        self.backend = PresentBackend::Sync(Box::new(GpuRenderer::new(
491            device,
492            queue,
493            surface_format,
494            adapter_backend,
495            adapter_downlevel,
496            self.frontend.text_fonts.clone(),
497            self.renderer_epoch,
498            store_feed_generation,
499        )));
500        #[cfg(not(target_arch = "wasm32"))]
501        {
502            let region = self.display_visible_region;
503            if let Some(gpu_renderer) = self.sync_gpu_renderer_mut() {
504                gpu_renderer.set_display_visible_region(region);
505            }
506        }
507    }
508
509    /// The display's visible region — the part of this renderer's
510    /// full-screen surface the panel physically shows. Set by the
511    /// platform layer (never by app content); the renderer then culls
512    /// everything outside the region on the full-frame pass, for any app
513    /// and any layout. The round display is the first provider: Android's
514    /// `AConfiguration` screenRound maps to
515    /// [`DisplayVisibleRegion::InscribedCircle`] for a non-multi-window
516    /// activity. Future providers (display cutouts/insets, host-declared
517    /// clips) plug in as new region variants without touching the cull
518    /// machinery. Default [`DisplayVisibleRegion::Full`]: rendering is
519    /// bitwise identical to a renderer without this capability.
520    ///
521    /// Threaded mode routes the region to the present thread as a control
522    /// message; the message queue is FIFO, so it lands before any packet
523    /// published after this call — the same ordering the sync path's
524    /// direct call has.
525    pub fn set_display_visible_region(&mut self, region: DisplayVisibleRegion) {
526        self.display_visible_region = region;
527        #[cfg(not(target_arch = "wasm32"))]
528        match &mut self.backend {
529            PresentBackend::Sync(gpu_renderer) => {
530                gpu_renderer.set_display_visible_region(region);
531            }
532            PresentBackend::Threaded(handle) => {
533                handle.send_display_visible_region(region);
534            }
535            PresentBackend::None => {}
536        }
537    }
538
539    /// [`init_gpu`][Self::init_gpu] for the threaded present runtime
540    /// (Android): the same epoch bump and planner replacement hygiene, but
541    /// instead of constructing a `GpuRenderer` here, everything it needs —
542    /// all owned, all `Send` — crosses to a spawned present thread that
543    /// constructs its own (its `Rc` caches are thread-confined). Frames
544    /// then flow through [`publish_frame`][Self::publish_frame] /
545    /// [`drain_present_returns`][Self::drain_present_returns] under the
546    /// depth-one credit protocol instead of [`render`][Self::render].
547    ///
548    /// * `waker` — wakes the producer's event loop after every returns
549    ///   send (the Android frame waker).
550    /// * `clock` — producer's monotonic nanosecond clock, so present-side
551    ///   [`PresentTimings`] share the producer telemetry's clock domain;
552    ///   `None` leaves timings at zero.
553    #[cfg(not(target_arch = "wasm32"))]
554    #[allow(clippy::too_many_arguments)]
555    pub fn init_gpu_threaded(
556        &mut self,
557        device: Arc<wgpu::Device>,
558        queue: Arc<wgpu::Queue>,
559        surface_format: wgpu::TextureFormat,
560        adapter_backend: wgpu::Backend,
561        adapter_downlevel: wgpu::DownlevelFlags,
562        waker: Arc<dyn Fn() + Send + Sync>,
563        clock: Option<Arc<dyn Fn() -> i64 + Send + Sync>>,
564    ) -> Result<(), WgpuRendererError> {
565        self.retire_live_backend();
566        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
567        // As in `init_gpu`: read AFTER the hygiene bump.
568        let store_feed_generation = pipeline::retained_feed_generation();
569        let init = PresentRuntimeInit {
570            device,
571            queue,
572            surface_format,
573            adapter_backend,
574            adapter_downlevel,
575            text_fonts: self.frontend.text_fonts.clone(),
576            renderer_epoch: self.renderer_epoch,
577            store_feed_generation,
578            clock,
579            display_visible_region: self.display_visible_region,
580        };
581        let handle = PresentHandle::spawn(init, waker).map_err(WgpuRendererError::Wgpu)?;
582        self.backend = PresentBackend::Threaded(handle);
583        Ok(())
584    }
585
586    /// [`init_gpu_threaded`][Self::init_gpu_threaded] without the thread:
587    /// the state machine and its message queue are handed back for the
588    /// test to pump by hand — the same prepare/validate/present protocol,
589    /// deterministically observable.
590    #[cfg(not(target_arch = "wasm32"))]
591    #[doc(hidden)]
592    pub fn init_gpu_inline_for_tests(
593        &mut self,
594        device: Arc<wgpu::Device>,
595        queue: Arc<wgpu::Queue>,
596        surface_format: wgpu::TextureFormat,
597        adapter_backend: wgpu::Backend,
598        adapter_downlevel: wgpu::DownlevelFlags,
599    ) -> InlinePresentRuntime {
600        self.retire_live_backend();
601        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
602        let store_feed_generation = pipeline::retained_feed_generation();
603        let init = PresentRuntimeInit {
604            device,
605            queue,
606            surface_format,
607            adapter_backend,
608            adapter_downlevel,
609            text_fonts: self.frontend.text_fonts.clone(),
610            renderer_epoch: self.renderer_epoch,
611            store_feed_generation,
612            clock: None,
613            display_visible_region: self.display_visible_region,
614        };
615        let (handle, state, msg_rx) = PresentHandle::new_inline(init, Arc::new(|| {}));
616        self.backend = PresentBackend::Threaded(handle);
617        InlinePresentRuntime {
618            state,
619            msg_rx,
620            shutdown_seen: false,
621        }
622    }
623
624    /// Record that the surface was reconfigured (resize, format change,
625    /// swapchain recreation): bumps the surface epoch stamped into every
626    /// subsequent packet, so a packet built against the previous
627    /// configuration is cancelled by the present stage instead of drawn.
628    pub fn note_surface_reconfigured(&mut self) {
629        self.surface_epoch = self.surface_epoch.wrapping_add(1);
630    }
631
632    /// Set root scale factor for text rendering (e.g., density scaling on Android)
633    pub fn set_root_scale(&mut self, scale: f32) {
634        self.frontend.root_scale = scale;
635    }
636
637    pub fn root_scale(&self) -> f32 {
638        self.frontend.root_scale
639    }
640
641    /// Render the scene to a texture view.
642    ///
643    /// Producer first, present second: the frontend lowers the frame into a
644    /// [`frame_packet::FramePacket`] (direct root, root surface, and dev
645    /// overlay alike), the GPU renderer consumes it, and the present
646    /// stage's returns — the recycled scene and the replay ack — fold back
647    /// into the frontend afterwards.
648    pub fn render(
649        &mut self,
650        view: &wgpu::TextureView,
651        width: u32,
652        height: u32,
653    ) -> Result<(), WgpuRendererError> {
654        self.render_frame(view, width, height)
655    }
656
657    pub fn render_surface_texture(
658        &mut self,
659        view: &wgpu::TextureView,
660        width: u32,
661        height: u32,
662    ) -> Result<(), WgpuRendererError> {
663        self.render_frame(view, width, height)
664    }
665
666    fn render_frame(
667        &mut self,
668        view: &wgpu::TextureView,
669        width: u32,
670        height: u32,
671    ) -> Result<(), WgpuRendererError> {
672        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
673            return Err(WgpuRendererError::Wgpu(
674                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
675                    .to_string(),
676            ));
677        };
678        let packet = self
679            .frontend
680            .build_frame_packet(
681                width,
682                height,
683                gpu_renderer.replay_supported(),
684                self.renderer_epoch,
685                self.surface_epoch,
686            )
687            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
688        let frontend = &mut self.frontend;
689        let mut returns = RenderReturns::default();
690        // Packet consumption runs OUTSIDE the producer's app context on
691        // purpose: the packet is the complete frame, so the present stage
692        // must never need the context — running bare proves it every frame.
693        let result = gpu_renderer.render(
694            view,
695            width,
696            height,
697            packet,
698            self.surface_epoch,
699            &mut returns,
700        );
701        if let Some(confirmations) = frontend.apply_returns(returns) {
702            gpu_renderer.restore_replay_ack_confirmations(confirmations);
703        }
704        result.map_err(WgpuRendererError::Wgpu)
705    }
706
707    /// Render the current scene into an RGBA pixel buffer for robot tests.
708    ///
709    /// Uses the renderer's configured root scale.
710    pub fn capture_frame(
711        &mut self,
712        width: u32,
713        height: u32,
714    ) -> Result<CapturedFrame, WgpuRendererError> {
715        self.capture_frame_with_scale(width, height, self.frontend.root_scale)
716    }
717
718    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
719    pub fn capture_frame_with_scale(
720        &mut self,
721        width: u32,
722        height: u32,
723        root_scale: f32,
724    ) -> Result<CapturedFrame, WgpuRendererError> {
725        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
726            return Err(WgpuRendererError::Wgpu(
727                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
728                    .to_string(),
729            ));
730        };
731        let packet = self
732            .frontend
733            .build_frame_packet_with_scale(
734                width,
735                height,
736                root_scale,
737                gpu_renderer.replay_supported(),
738                self.renderer_epoch,
739                self.surface_epoch,
740            )
741            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
742        let frontend = &mut self.frontend;
743        let mut returns = RenderReturns::default();
744        // Bare like `render`: the capture path consumes the packet with no
745        // producer app context current.
746        let result = gpu_renderer.render_to_rgba_pixels(
747            width,
748            height,
749            packet,
750            self.surface_epoch,
751            &mut returns,
752        );
753        if let Some(confirmations) = frontend.apply_returns(returns) {
754            gpu_renderer.restore_replay_ack_confirmations(confirmations);
755        }
756        let pixels = result.map_err(WgpuRendererError::Wgpu)?;
757        Ok(CapturedFrame {
758            width,
759            height,
760            pixels,
761        })
762    }
763
764    /// Threaded mode: whether the depth-one slot has room for a packet.
765    /// The Android loop checks this BEFORE `shell.update()` so
766    /// backpressure lands before the expensive update/lowering work.
767    /// Always `true` on the sync path, which has no slot to fill.
768    #[cfg(not(target_arch = "wasm32"))]
769    pub fn has_frame_credit(&self) -> bool {
770        match &self.backend {
771            PresentBackend::Threaded(handle) => handle.has_credit(),
772            PresentBackend::Sync(_) | PresentBackend::None => true,
773        }
774    }
775
776    /// Threaded mode: lower the current scene into a packet and hand it to
777    /// the present runtime. Credit is checked FIRST — a `NoCredit` return
778    /// means no packet was built at all (`frame_sequence` does not
779    /// advance). Returns `NoCredit` (with an error log) when the renderer
780    /// is not in threaded mode.
781    #[cfg(not(target_arch = "wasm32"))]
782    pub fn publish_frame(&mut self, width: u32, height: u32) -> PublishOutcome {
783        // Fold in every early replay ack BEFORE lowering: the collect
784        // inside `build_frame_packet` is where the bypass gate and
785        // `feed_slots` are read, so an ack applied here serves the very
786        // frame about to be planned — the same one-frame confirmation
787        // latency the sync path has (see `PresentState::consume_packet`).
788        self.drain_replay_acks();
789        let PresentBackend::Threaded(handle) = &mut self.backend else {
790            log::error!("publish_frame called without a threaded present runtime");
791            return PublishOutcome::NoCredit;
792        };
793        if !handle.has_credit() {
794            return PublishOutcome::NoCredit;
795        }
796        let replay_supported = handle
797            .status()
798            .replay_supported
799            .load(std::sync::atomic::Ordering::Relaxed);
800        let Some(mut packet) = self.frontend.build_frame_packet(
801            width,
802            height,
803            replay_supported,
804            self.renderer_epoch,
805            self.surface_epoch,
806        ) else {
807            return PublishOutcome::NoGraph;
808        };
809        packet.recycled_confirmations = self.pending_recycled_confirmations.take();
810        match handle.publish(packet) {
811            Ok(()) => PublishOutcome::Published,
812            Err(mut packet) => {
813                // The runtime is gone (panic/shutdown race). Recover every
814                // buffer the packet carried instead of leaking it.
815                if let Some(confirmations) = packet.recycled_confirmations.take() {
816                    self.pending_recycled_confirmations = Some(confirmations);
817                }
818                let mut returns = RenderReturns::default();
819                let _ = GpuRenderer::cancel_packet(
820                    *packet,
821                    CancelReason::SurfaceUnavailable,
822                    &mut returns,
823                );
824                if let Some(confirmations) = self.frontend.apply_returns(returns) {
825                    self.stash_recycled_confirmations(confirmations);
826                }
827                log::error!("present runtime unavailable; frame recovered, not published");
828                PublishOutcome::NoCredit
829            }
830        }
831    }
832
833    /// Threaded mode: fold every pending [`RenderReturns`] back into
834    /// producer state (scene recycling, replay ack, planner re-queue of
835    /// cancelled plans) and free the publish credit. Returns how many were
836    /// drained. No-op outside threaded mode.
837    #[cfg(not(target_arch = "wasm32"))]
838    pub fn drain_present_returns(&mut self) -> usize {
839        self.drain_present_returns_with(&mut |_, _, _| {})
840    }
841
842    /// [`drain_present_returns`][Self::drain_present_returns], reporting
843    /// each drained frame's id, outcome and present-thread timings — the
844    /// Android loop feeds its frame telemetry from this.
845    #[cfg(not(target_arch = "wasm32"))]
846    pub fn drain_present_returns_with(
847        &mut self,
848        on_return: &mut dyn FnMut(u64, PresentOutcome, PresentTimings),
849    ) -> usize {
850        // Acks first: a frame's early ack always precedes its returns, and
851        // draining both here keeps the ack channel empty across idle
852        // iterations that never reach `publish_frame`.
853        self.drain_replay_acks();
854        let mut drained = 0;
855        loop {
856            // Scoped so the handle borrow ends before `apply_returns`
857            // needs the frontend through `&mut self`.
858            let returns = {
859                let PresentBackend::Threaded(handle) = &mut self.backend else {
860                    break;
861                };
862                match handle.try_drain() {
863                    Some(returns) => returns,
864                    None => break,
865                }
866            };
867            drained += 1;
868            let frame_id = returns.frame_id;
869            let outcome = returns.outcome;
870            let timings = returns.timings;
871            if let Some(confirmations) = self.frontend.apply_returns(returns) {
872                self.stash_recycled_confirmations(confirmations);
873            }
874            on_return(frame_id, outcome, timings);
875        }
876        drained
877    }
878
879    /// Threaded mode: install a (re)created surface on the present thread
880    /// and wait for the acknowledgement. The caller must have bumped the
881    /// surface epoch first ([`note_surface_reconfigured`]
882    /// [Self::note_surface_reconfigured]) when the message invalidates
883    /// in-flight packets; the message carries the current epoch.
884    #[cfg(not(target_arch = "wasm32"))]
885    pub fn present_replace_surface(
886        &mut self,
887        surface: wgpu::Surface<'static>,
888        config: wgpu::SurfaceConfiguration,
889    ) -> bool {
890        let surface_epoch = self.surface_epoch;
891        let Some(handle) = self.present_handle_mut() else {
892            log::error!("present_replace_surface called without a threaded present runtime");
893            return false;
894        };
895        handle.send_control_and_wait(
896            move |ack| PresentControl::ReplaceSurface {
897                surface,
898                config,
899                surface_epoch,
900                ack,
901            },
902            "replace surface",
903        )
904    }
905
906    /// Threaded mode: reconfigure the present thread's surface (resize)
907    /// and wait for the acknowledgement. Same epoch contract as
908    /// [`present_replace_surface`][Self::present_replace_surface].
909    #[cfg(not(target_arch = "wasm32"))]
910    pub fn present_reconfigure(&mut self, config: wgpu::SurfaceConfiguration) -> bool {
911        let surface_epoch = self.surface_epoch;
912        let Some(handle) = self.present_handle_mut() else {
913            log::error!("present_reconfigure called without a threaded present runtime");
914            return false;
915        };
916        handle.send_control_and_wait(
917            move |ack| PresentControl::Reconfigure {
918                config,
919                surface_epoch,
920                ack,
921            },
922            "reconfigure surface",
923        )
924    }
925
926    /// Threaded mode: drop the present thread's surface (the window died;
927    /// the renderer survives for the next one) and wait for the
928    /// acknowledgement. Bump the epoch first so in-flight packets cancel.
929    #[cfg(not(target_arch = "wasm32"))]
930    pub fn present_drop_surface(&mut self) -> bool {
931        let Some(handle) = self.present_handle_mut() else {
932            log::error!("present_drop_surface called without a threaded present runtime");
933            return false;
934        };
935        handle.send_control_and_wait(|ack| PresentControl::DropSurface { ack }, "drop surface")
936    }
937
938    /// Threaded mode: drain outstanding returns, stop the present thread
939    /// and join it. The renderer returns to the uninitialized state.
940    #[cfg(not(target_arch = "wasm32"))]
941    pub fn shutdown_present_runtime(&mut self) {
942        if matches!(self.backend, PresentBackend::Threaded(_)) {
943            self.retire_live_backend();
944        }
945    }
946
947    /// Test hook: attach the present runtime's offscreen surrogate target
948    /// so packets render headlessly, and wait for the acknowledgement.
949    #[cfg(not(target_arch = "wasm32"))]
950    #[doc(hidden)]
951    pub fn present_attach_offscreen_for_tests(&mut self, width: u32, height: u32) -> bool {
952        let Some(handle) = self.present_handle_mut() else {
953            return false;
954        };
955        handle.send_control_and_wait(
956            move |ack| PresentControl::AttachOffscreenTargetForTests { width, height, ack },
957            "attach offscreen target",
958        )
959    }
960
961    /// Test hook: send the offscreen-target attach WITHOUT waiting for
962    /// the ack — the inline mode has no thread to ack until pumped.
963    #[cfg(not(target_arch = "wasm32"))]
964    #[doc(hidden)]
965    pub fn send_attach_offscreen_unacked_for_tests(
966        &mut self,
967        width: u32,
968        height: u32,
969    ) -> Option<std::sync::mpsc::Receiver<()>> {
970        let handle = self.present_handle_mut()?;
971        handle.send_control_unacked(move |ack| PresentControl::AttachOffscreenTargetForTests {
972            width,
973            height,
974            ack,
975        })
976    }
977
978    /// Test hook: send a `Reconfigure` WITHOUT waiting for the ack,
979    /// returning the ack receiver — the inline protocol tests assert the
980    /// invalidation-before-ack ordering with it.
981    #[cfg(not(target_arch = "wasm32"))]
982    #[doc(hidden)]
983    pub fn send_reconfigure_unacked_for_tests(
984        &mut self,
985        config: wgpu::SurfaceConfiguration,
986    ) -> Option<std::sync::mpsc::Receiver<()>> {
987        let surface_epoch = self.surface_epoch;
988        let handle = self.present_handle_mut()?;
989        handle.send_control_unacked(move |ack| PresentControl::Reconfigure {
990            config,
991            surface_epoch,
992            ack,
993        })
994    }
995
996    /// Test hook: send a `DropSurface` WITHOUT waiting for the ack.
997    #[cfg(not(target_arch = "wasm32"))]
998    #[doc(hidden)]
999    pub fn send_drop_surface_unacked_for_tests(&mut self) -> Option<std::sync::mpsc::Receiver<()>> {
1000        let handle = self.present_handle_mut()?;
1001        handle.send_control_unacked(|ack| PresentControl::DropSurface { ack })
1002    }
1003
1004    /// The producer's monotone packet sequence: the `frame_id` stamped on
1005    /// the most recently lowered packet. After a `Published` outcome this
1006    /// is the published frame's id (the Android loop keys its telemetry on
1007    /// it); it also proves a `NoCredit` publish never lowered a frame.
1008    pub fn last_published_frame_id(&self) -> u64 {
1009        self.frontend.frame_sequence
1010    }
1011
1012    /// Test hook: park a recycled-confirmations buffer with `capacity` as
1013    /// if a previous frame's ack had been drained — the capacity
1014    /// round-trip test's deterministic seed (a real confirmed capture
1015    /// needs the multi-frame verification heuristics).
1016    #[cfg(not(target_arch = "wasm32"))]
1017    #[doc(hidden)]
1018    pub fn seed_recycled_confirmations_for_tests(&mut self, capacity: usize) {
1019        self.pending_recycled_confirmations = Some(Vec::with_capacity(capacity));
1020    }
1021
1022    /// Test inspector: capacity of the parked recycled-confirmations
1023    /// buffer (`None` when nothing is parked — e.g. right after a publish
1024    /// carried it back to the store).
1025    #[cfg(not(target_arch = "wasm32"))]
1026    #[doc(hidden)]
1027    pub fn pending_recycled_confirmations_capacity_for_tests(&self) -> Option<usize> {
1028        self.pending_recycled_confirmations
1029            .as_ref()
1030            .map(Vec::capacity)
1031    }
1032
1033    /// Test inspector: the shared present-status snapshot's
1034    /// (needs_frame_warmup, replay_supported, presented_frames) triple.
1035    #[cfg(not(target_arch = "wasm32"))]
1036    #[doc(hidden)]
1037    pub fn present_status_snapshot_for_tests(&self) -> Option<(bool, bool, u64)> {
1038        match &self.backend {
1039            PresentBackend::Threaded(handle) => {
1040                let status = handle.status();
1041                Some((
1042                    status
1043                        .needs_frame_warmup
1044                        .load(std::sync::atomic::Ordering::Relaxed),
1045                    status
1046                        .replay_supported
1047                        .load(std::sync::atomic::Ordering::Relaxed),
1048                    status
1049                        .presented_frames
1050                        .load(std::sync::atomic::Ordering::Relaxed),
1051                ))
1052            }
1053            _ => None,
1054        }
1055    }
1056
1057    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
1058        self.sync_gpu_renderer()
1059            .and_then(GpuRenderer::last_frame_stats)
1060    }
1061
1062    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
1063        let mut stats = self
1064            .sync_gpu_renderer()
1065            .map(GpuRenderer::debug_cpu_allocation_stats)
1066            .unwrap_or_default();
1067        stats.scene_graph_node_count = self
1068            .frontend
1069            .scene
1070            .graph
1071            .as_ref()
1072            .map(RenderGraph::node_count)
1073            .unwrap_or(0);
1074        stats.scene_graph_heap_bytes = self
1075            .frontend
1076            .scene
1077            .graph
1078            .as_ref()
1079            .map(RenderGraph::heap_bytes)
1080            .unwrap_or(0);
1081        stats.scene_hits_len = self.frontend.scene.hits.len();
1082        stats.scene_hits_cap = self.frontend.scene.hits.capacity();
1083        stats.scene_node_index_len = self.frontend.scene.node_index.len();
1084        stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
1085        // The producer frontend owns the renderer's only lowering-memo
1086        // pair (the present backend reports zeros); add it here so its
1087        // retained capacity stays visible to leak tooling.
1088        stats.layer_surface_rect_cache_len += self.frontend.layer_surface_rect_cache.len();
1089        stats.layer_surface_rect_cache_cap += self.frontend.layer_surface_rect_cache.capacity();
1090        stats.layer_surface_requirements_cache_len +=
1091            self.frontend.layer_surface_requirements_cache.len();
1092        stats.layer_surface_requirements_cache_cap +=
1093            self.frontend.layer_surface_requirements_cache.capacity();
1094        stats
1095    }
1096
1097    /// Return the WGPU device when GPU resources are initialized.
1098    /// Sync backend only (desktop/web reconfigure paths); the threaded
1099    /// runtime owns its device on the present thread.
1100    pub fn try_device(&self) -> Option<&wgpu::Device> {
1101        self.sync_gpu_renderer().map(|r| &*r.device)
1102    }
1103
1104    /// Test/diagnostic view of the device-error sentry: lifetime
1105    /// uncaptured wgpu errors recorded on the sync renderer's device
1106    /// (`CRANPOSE_SURVIVE_GPU_ERRORS` kill switch).
1107    #[doc(hidden)]
1108    pub fn device_error_count_for_tests(&self) -> u64 {
1109        self.sync_gpu_renderer()
1110            .map(GpuRenderer::device_error_count)
1111            .unwrap_or(0)
1112    }
1113
1114    /// Test/diagnostic view of the latched instanced-quad selection: `true`
1115    /// when the live GPU renderer's ordinary shape draws ride
1116    /// `vs_shape_instanced` (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0
1117    /// at construction).
1118    #[cfg(not(target_arch = "wasm32"))]
1119    #[doc(hidden)]
1120    pub fn instanced_quads_active(&self) -> bool {
1121        self.sync_gpu_renderer()
1122            .is_some_and(GpuRenderer::instanced_quads_active)
1123    }
1124
1125    /// Test/diagnostic view of retained arc meshes: (slots holding a mesh,
1126    /// total live replay slots).
1127    #[cfg(not(target_arch = "wasm32"))]
1128    #[doc(hidden)]
1129    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
1130        self.sync_gpu_renderer()
1131            .map(GpuRenderer::replay_slot_mesh_stats)
1132            .unwrap_or((0, 0))
1133    }
1134
1135    /// Test/diagnostic view of the retained capture size gate, summed over
1136    /// live slots holding a mesh: (arc bands meshed, stroked-circle rim
1137    /// bands meshed, passthrough quads).
1138    #[cfg(not(target_arch = "wasm32"))]
1139    #[doc(hidden)]
1140    pub fn replay_slot_mesh_engagement(&self) -> (usize, usize, usize) {
1141        self.sync_gpu_renderer()
1142            .map(GpuRenderer::replay_slot_mesh_engagement)
1143            .unwrap_or((0, 0, 0))
1144    }
1145
1146    /// Test/diagnostic view of the retained bundle cache: lifetime
1147    /// (rebuilds, cached executes).
1148    #[cfg(not(target_arch = "wasm32"))]
1149    #[doc(hidden)]
1150    pub fn retained_bundle_stats(&self) -> (u64, u64) {
1151        self.sync_gpu_renderer()
1152            .map(GpuRenderer::retained_bundle_stats)
1153            .unwrap_or((0, 0))
1154    }
1155
1156    /// Test/diagnostic view of the transient rim mesh path: lifetime count
1157    /// of dynamic circle rims drawn as band meshes instead of full bounding
1158    /// quads (`CRANPOSE_RIM_MESH` kill switch).
1159    #[cfg(not(target_arch = "wasm32"))]
1160    #[doc(hidden)]
1161    pub fn rim_meshes_emitted(&self) -> u64 {
1162        self.sync_gpu_renderer()
1163            .map(GpuRenderer::rim_meshes_emitted)
1164            .unwrap_or(0)
1165    }
1166
1167    /// Test/diagnostic view of the opaque static leading-span cache:
1168    /// lifetime (hits, recaptures) — frames drawn with the cached
1169    /// full-target blit standing in for the leading span, and capture
1170    /// passes rendered (`CRANPOSE_STATIC_SPAN` kill switch).
1171    #[cfg(not(target_arch = "wasm32"))]
1172    #[doc(hidden)]
1173    pub fn static_span_stats(&self) -> (u64, u64) {
1174        self.sync_gpu_renderer()
1175            .map(GpuRenderer::static_span_stats)
1176            .unwrap_or((0, 0))
1177    }
1178
1179    /// Test/diagnostic view of the retained-segment surface cache
1180    /// (`CRANPOSE_SEGMENT_SURFACE` opt-in): lifetime (captures, composite
1181    /// draws, dirty recaptures, churn rejections, economics rejections).
1182    #[cfg(not(target_arch = "wasm32"))]
1183    #[doc(hidden)]
1184    pub fn segment_surface_stats(&self) -> (u64, u64, u64, u64, u64) {
1185        self.sync_gpu_renderer()
1186            .map(GpuRenderer::segment_surface_stats)
1187            .unwrap_or((0, 0, 0, 0, 0))
1188    }
1189
1190    /// Test/diagnostic view of the present store's lifetime count of
1191    /// replay-ops batches dropped by the generation check. Surface (non
1192    /// direct) frames must never move it: their packets carry the default
1193    /// plan, which the consume gate never feeds to the store.
1194    #[cfg(not(target_arch = "wasm32"))]
1195    #[doc(hidden)]
1196    pub fn replay_generation_drops_for_tests(&self) -> u64 {
1197        self.sync_gpu_renderer()
1198            .map(GpuRenderer::replay_generation_drops)
1199            .unwrap_or(0)
1200    }
1201
1202    /// Test hook for the replay message protocol: one planner→store→planner
1203    /// cycle outside a frame, the batch's generation skewed by
1204    /// `generation_skew` from the store's own; returns how many captures
1205    /// the store confirmed. A skew landing BELOW the store's generation
1206    /// manufactures the fail-closed drop; one landing above it exercises
1207    /// adopt-forward. Neither is producible synchronously through the
1208    /// public render path.
1209    #[cfg(not(target_arch = "wasm32"))]
1210    #[doc(hidden)]
1211    pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
1212        self.sync_gpu_renderer_mut()
1213            .expect("GPU renderer not initialized")
1214            .replay_ops_roundtrip_for_tests(generation_skew)
1215    }
1216
1217    /// Test hook for the cancellation protocol: builds a packet NOW,
1218    /// stamped with the current epochs, and hands it to the caller instead
1219    /// of rendering it — the public render path builds and consumes
1220    /// atomically, so a packet in flight across an epoch change is only
1221    /// constructible here.
1222    #[doc(hidden)]
1223    pub fn build_frame_packet_for_tests(
1224        &mut self,
1225        width: u32,
1226        height: u32,
1227    ) -> Option<HeldFramePacket> {
1228        let replay_supported = match &self.backend {
1229            PresentBackend::Sync(gpu_renderer) => gpu_renderer.replay_supported(),
1230            #[cfg(not(target_arch = "wasm32"))]
1231            PresentBackend::Threaded(handle) => handle
1232                .status()
1233                .replay_supported
1234                .load(std::sync::atomic::Ordering::Relaxed),
1235            PresentBackend::None => false,
1236        };
1237        self.frontend
1238            .build_frame_packet(
1239                width,
1240                height,
1241                replay_supported,
1242                self.renderer_epoch,
1243                self.surface_epoch,
1244            )
1245            .map(HeldFramePacket)
1246    }
1247
1248    /// Test hook: consumes a held packet through the exact production seam
1249    /// (`GpuRenderer::render` + `apply_returns` + ack-buffer restore) and
1250    /// reports the present outcome.
1251    #[doc(hidden)]
1252    pub fn render_held_packet_for_tests(
1253        &mut self,
1254        view: &wgpu::TextureView,
1255        width: u32,
1256        height: u32,
1257        packet: HeldFramePacket,
1258    ) -> Result<PresentOutcome, WgpuRendererError> {
1259        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
1260            return Err(WgpuRendererError::Wgpu(
1261                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
1262                    .to_string(),
1263            ));
1264        };
1265        let frontend = &mut self.frontend;
1266        let mut returns = RenderReturns::default();
1267        let result = gpu_renderer.render(
1268            view,
1269            width,
1270            height,
1271            packet.0,
1272            self.surface_epoch,
1273            &mut returns,
1274        );
1275        let outcome = returns.outcome;
1276        if let Some(confirmations) = frontend.apply_returns(returns) {
1277            gpu_renderer.restore_replay_ack_confirmations(confirmations);
1278        }
1279        result.map_err(WgpuRendererError::Wgpu)?;
1280        Ok(outcome)
1281    }
1282
1283    /// Test hook: whether the producer pool holds a recycled direct scene —
1284    /// the cancellation contract's proof the packet's scene came back.
1285    #[doc(hidden)]
1286    pub fn has_retained_direct_scene_for_tests(&self) -> bool {
1287        !self.frontend.retained_direct_scenes.is_empty()
1288    }
1289}
1290
1291/// Opaque handle to a built-but-unrendered frame packet, for the
1292/// cancellation-protocol tests only.
1293#[doc(hidden)]
1294pub struct HeldFramePacket(frame_packet::FramePacket);
1295
1296/// The present runtime's state machine WITHOUT its thread, handed out by
1297/// [`WgpuRenderer::init_gpu_inline_for_tests`]: the protocol tests pump
1298/// the message queue by hand and inspect present-side state directly.
1299#[cfg(not(target_arch = "wasm32"))]
1300#[doc(hidden)]
1301pub struct InlinePresentRuntime {
1302    state: PresentState,
1303    msg_rx: std::sync::mpsc::Receiver<PresentMsg>,
1304    shutdown_seen: bool,
1305}
1306
1307#[cfg(not(target_arch = "wasm32"))]
1308impl InlinePresentRuntime {
1309    /// Process every pending message exactly like one wakeful pass of the
1310    /// present thread's loop (controls drained against the waiting packet
1311    /// first, then the packet consumes). Returns `false` once `Shutdown`
1312    /// has been processed.
1313    pub fn pump(&mut self) -> bool {
1314        if self.shutdown_seen {
1315            return false;
1316        }
1317        while let Ok(msg) = self.msg_rx.try_recv() {
1318            if !self.state.run_once(msg) {
1319                self.shutdown_seen = true;
1320                return false;
1321            }
1322        }
1323        self.state.consume_waiting();
1324        true
1325    }
1326
1327    /// Whether a packet sits unconsumed in the depth-one slot (only
1328    /// observable between a partial pump's steps; `pump` always consumes).
1329    pub fn has_waiting_packet(&self) -> bool {
1330        self.state.has_waiting_packet()
1331    }
1332
1333    /// Receive and process exactly one queued message (no packet
1334    /// consumption) — for tests that need to interleave control against a
1335    /// waiting packet.
1336    pub fn step_one_message(&mut self) -> bool {
1337        if self.shutdown_seen {
1338            return false;
1339        }
1340        match self.msg_rx.try_recv() {
1341            Ok(msg) => {
1342                if !self.state.run_once(msg) {
1343                    self.shutdown_seen = true;
1344                }
1345                true
1346            }
1347            Err(_) => false,
1348        }
1349    }
1350
1351    /// Consume the waiting packet, if any, exactly like the thread loop's
1352    /// idle branch.
1353    pub fn consume_waiting(&mut self) {
1354        self.state.consume_waiting();
1355    }
1356
1357    /// Store-side ack confirmations buffer capacity — the threaded
1358    /// capacity round-trip's observable end state.
1359    pub fn store_ack_confirmations_capacity(&self) -> usize {
1360        self.state.store_ack_confirmations_capacity()
1361    }
1362}
1363
1364impl Default for WgpuRenderer {
1365    fn default() -> Self {
1366        Self::new(&[])
1367    }
1368}
1369
1370impl Renderer for WgpuRenderer {
1371    type Scene = Scene;
1372    type Error = WgpuRendererError;
1373
1374    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
1375        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
1376            self.frontend.text_fonts.clone(),
1377            8192,
1378        ));
1379        self.frontend.app_context = Some(app_context.downgrade());
1380    }
1381
1382    fn scene(&self) -> &Self::Scene {
1383        &self.frontend.scene
1384    }
1385
1386    fn scene_mut(&mut self) -> &mut Self::Scene {
1387        &mut self.frontend.scene
1388    }
1389
1390    fn rebuild_scene(
1391        &mut self,
1392        layout_tree: &LayoutTree,
1393        _viewport: Size,
1394    ) -> Result<(), Self::Error> {
1395        self.frontend.scene.clear();
1396        self.frontend.layer_surface_requirements_cache.clear();
1397        self.frontend.dev_overlay_graph = None;
1398        self.frontend.dev_overlay_cache = None;
1399        // Build scene in logical dp - scaling happens in GPU vertex upload
1400        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
1401        Ok(())
1402    }
1403
1404    fn rebuild_scene_from_applier(
1405        &mut self,
1406        applier: &mut MemoryApplier,
1407        root: NodeId,
1408        _viewport: Size,
1409    ) -> Result<(), Self::Error> {
1410        self.frontend.scene.clear();
1411        self.frontend.layer_surface_requirements_cache.clear();
1412        self.frontend.dev_overlay_graph = None;
1413        self.frontend.dev_overlay_cache = None;
1414        // Build scene in logical dp - scaling happens in GPU vertex upload
1415        // Traverse layout nodes via applier instead of rebuilding LayoutTree
1416        pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
1417        Ok(())
1418    }
1419
1420    fn update_scene_from_applier(
1421        &mut self,
1422        applier: &mut MemoryApplier,
1423        root: NodeId,
1424        viewport: Size,
1425        dirty_nodes: &[NodeId],
1426    ) -> Result<(), Self::Error> {
1427        if dirty_nodes.is_empty() {
1428            return self.rebuild_scene_from_applier(applier, root, viewport);
1429        }
1430        self.update_scene_and_plan_memo(applier, root, dirty_nodes, true);
1431        Ok(())
1432    }
1433
1434    fn update_visual_scene_from_applier(
1435        &mut self,
1436        applier: &mut MemoryApplier,
1437        root: NodeId,
1438        viewport: Size,
1439        dirty_nodes: &[NodeId],
1440    ) -> Result<(), Self::Error> {
1441        if dirty_nodes.is_empty() {
1442            return self.rebuild_scene_from_applier(applier, root, viewport);
1443        }
1444        self.update_scene_and_plan_memo(applier, root, dirty_nodes, false);
1445        Ok(())
1446    }
1447
1448    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1449        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1450        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
1451        if self.frontend.dev_overlay_graph.is_some()
1452            && self
1453                .frontend
1454                .dev_overlay_cache
1455                .as_ref()
1456                .is_some_and(|cache| {
1457                    cache.text == key.text
1458                        && cache.viewport_width_bits == key.viewport_width_bits
1459                        && cache.viewport_height_bits == key.viewport_height_bits
1460                })
1461        {
1462            return;
1463        }
1464        self.frontend.dev_overlay_graph = Some(
1465            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
1466                text,
1467                viewport,
1468                DEV_OVERLAY_NODE_ID,
1469            ),
1470        );
1471        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
1472            text: key.text,
1473            viewport_width_bits: key.viewport_width_bits,
1474            viewport_height_bits: key.viewport_height_bits,
1475        });
1476    }
1477
1478    fn needs_frame_warmup(&self) -> bool {
1479        match &self.backend {
1480            PresentBackend::Sync(gpu_renderer) => gpu_renderer.needs_frame_warmup(),
1481            // The producer reads this every loop iteration; in threaded
1482            // mode it is the present thread's atomic snapshot, never the
1483            // renderer itself.
1484            #[cfg(not(target_arch = "wasm32"))]
1485            PresentBackend::Threaded(handle) => handle
1486                .status()
1487                .needs_frame_warmup
1488                .load(std::sync::atomic::Ordering::Relaxed),
1489            PresentBackend::None => false,
1490        }
1491    }
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496    use super::*;
1497    use crate::pipeline::TextLayoutResolver;
1498    use cranpose_render_common::graph::RenderNode;
1499    use cranpose_ui_graphics::GraphicsLayer;
1500    use std::cell::Cell;
1501
1502    static TEST_FONT: &[u8] =
1503        cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
1504
1505    #[test]
1506    fn dev_overlay_is_recorded_outside_app_graph() {
1507        let mut renderer = WgpuRenderer::new(&[]);
1508        renderer.draw_dev_overlay(
1509            "240 FPS | avg 4.0ms | p95 4.5ms",
1510            Size {
1511                width: 800.0,
1512                height: 600.0,
1513            },
1514        );
1515
1516        assert!(
1517            renderer
1518                .frontend
1519                .scene
1520                .graph
1521                .as_ref()
1522                .is_none_or(|graph| graph.root.children.iter().all(|child| {
1523                    !matches!(
1524                        child,
1525                        RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
1526                    )
1527                })),
1528            "dev overlay must not be mixed into the app scene graph"
1529        );
1530
1531        let graph = renderer
1532            .frontend
1533            .dev_overlay_graph
1534            .as_ref()
1535            .expect("overlay graph");
1536        let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
1537            panic!("dev overlay should be the final top-level layer");
1538        };
1539
1540        assert_eq!(overlay.node_id, Some(NodeId::MAX));
1541        assert_eq!(
1542            overlay.graphics_layer.compositing_strategy,
1543            GraphicsLayer::default().compositing_strategy,
1544            "dev overlay should not allocate an offscreen surface"
1545        );
1546    }
1547
1548    struct CountingTextMeasurer {
1549        inner: SoftwareTextMeasurer,
1550        layout_calls: Rc<Cell<usize>>,
1551    }
1552
1553    impl CountingTextMeasurer {
1554        fn new(layout_calls: Rc<Cell<usize>>) -> Self {
1555            Self {
1556                inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
1557                layout_calls,
1558            }
1559        }
1560    }
1561
1562    impl TextMeasurer for CountingTextMeasurer {
1563        fn measure(
1564            &self,
1565            text: &cranpose_ui::text::AnnotatedString,
1566            style: &cranpose_ui::text::TextStyle,
1567        ) -> cranpose_ui::TextMetrics {
1568            self.inner.measure(text, style)
1569        }
1570
1571        fn get_offset_for_position(
1572            &self,
1573            text: &cranpose_ui::text::AnnotatedString,
1574            style: &cranpose_ui::text::TextStyle,
1575            x: f32,
1576            y: f32,
1577        ) -> usize {
1578            self.inner.get_offset_for_position(text, style, x, y)
1579        }
1580
1581        fn get_cursor_x_for_offset(
1582            &self,
1583            text: &cranpose_ui::text::AnnotatedString,
1584            style: &cranpose_ui::text::TextStyle,
1585            offset: usize,
1586        ) -> f32 {
1587            self.inner.get_cursor_x_for_offset(text, style, offset)
1588        }
1589
1590        fn layout(
1591            &self,
1592            text: &cranpose_ui::text::AnnotatedString,
1593            style: &cranpose_ui::text::TextStyle,
1594        ) -> cranpose_ui::text_layout_result::TextLayoutResult {
1595            self.layout_calls.set(self.layout_calls.get() + 1);
1596            self.inner.layout(text, style)
1597        }
1598    }
1599
1600    #[test]
1601    fn headless_text_measurer_uses_software_text_font() {
1602        let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
1603        let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
1604        let style = cranpose_ui::text::TextStyle::default();
1605
1606        let metrics = measurer.measure(&text, &style);
1607        let layout = measurer.layout(&text, &style);
1608
1609        assert!(metrics.width > 0.0);
1610        assert!(metrics.height > 0.0);
1611        assert_eq!(layout.lines.len(), metrics.line_count);
1612    }
1613
1614    #[test]
1615    fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
1616        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1617        let app_context = cranpose_ui::AppContext::new();
1618        renderer.attach_app_context_services(&app_context);
1619
1620        let metrics = app_context.enter(|| {
1621            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
1622            let style = cranpose_ui::text::TextStyle {
1623                span_style: cranpose_ui::text::SpanStyle {
1624                    font_size: cranpose_ui::text::TextUnit::Sp(14.0),
1625                    ..Default::default()
1626                },
1627                paragraph_style: cranpose_ui::text::ParagraphStyle {
1628                    platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
1629                        include_font_padding: None,
1630                        shaping: Some(cranpose_ui::text::TextShaping::Basic),
1631                    }),
1632                    ..Default::default()
1633                },
1634            };
1635            cranpose_ui::text::measure_text(&text, &style)
1636        });
1637
1638        assert!(
1639            metrics.width > 0.0,
1640            "software text service should measure text"
1641        );
1642        assert_eq!(
1643            renderer.frontend.text_state.text_cache_len(),
1644            0,
1645            "WGPU must not keep a renderer-side shaping cache for measurement"
1646        );
1647    }
1648
1649    #[test]
1650    fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
1651        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1652        let app_context = cranpose_ui::AppContext::new();
1653        renderer.attach_app_context_services(&app_context);
1654
1655        let prepared = app_context.enter(|| {
1656            let text = cranpose_ui::text::AnnotatedString::from(
1657                (0..48)
1658                    .map(|line| format!("// markdown code line {line:02}"))
1659                    .collect::<Vec<_>>()
1660                    .join("\n"),
1661            );
1662            let style = cranpose_ui::text::TextStyle::default();
1663            cranpose_ui::text::prepare_text_layout(
1664                &text,
1665                &style,
1666                cranpose_ui::text::TextLayoutOptions::default(),
1667                Some(952.0),
1668            )
1669        });
1670
1671        assert_eq!(prepared.metrics.line_count, 48);
1672        assert!(
1673            prepared.metrics.line_height > 18.0,
1674            "renderer-attached text service must not use fallback monospaced line height: {:?}",
1675            prepared.metrics
1676        );
1677        assert!(
1678            prepared.metrics.height > 900.0,
1679            "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
1680            prepared.metrics
1681        );
1682    }
1683
1684    #[test]
1685    fn render_text_layout_routes_through_attached_app_context_service() {
1686        let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1687        let app_context = cranpose_ui::AppContext::new();
1688        renderer.attach_app_context_services(&app_context);
1689        let layout_calls = Rc::new(Cell::new(0));
1690        app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
1691
1692        app_context.enter(|| {
1693            let text = cranpose_ui::text::AnnotatedString::from("render text");
1694            let style = cranpose_ui::text::TextStyle::default();
1695            let layout = renderer.frontend.text_state.layout_text(&text, &style);
1696            assert!(layout.width > 0.0);
1697        });
1698
1699        assert_eq!(layout_calls.get(), 1);
1700    }
1701}