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