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