Skip to main content

cranpose_render_wgpu/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub(crate) use cranpose_render_common::debug_toggles;
4pub use debug_toggles::{
5    DebugToggle, debug_toggle, debug_toggle_os, set_debug_toggle, set_debug_toggle_os,
6};
7pub use offscreen::COMPOSITION_BYTES_PER_PIXEL;
8pub use render::presentable_root_usages;
9mod ablation;
10mod arc_trig_fill;
11mod capture_hash;
12mod collect;
13mod depth_gate;
14mod draw_pass;
15mod effect_renderer;
16mod fast_cores;
17mod frame;
18mod geometry;
19mod idle_pool;
20mod layer_cache;
21pub use fast_cores::pin_current_thread_to_fast_cores;
22mod fixed_pipeline;
23mod frame_graph;
24mod frame_packet;
25mod frontend;
26mod glass_split;
27/// The GLSL templates of the built-in shader modules, which `build.rs` wrote.
28#[cfg(all(target_arch = "wasm32", feature = "gl-templates"))]
29mod gl_templates {
30    include!(concat!(env!("OUT_DIR"), "/gl_templates.rs"));
31}
32#[cfg(test)]
33#[path = "tests/gles_lowering.rs"]
34mod gles_lowering;
35mod glyph_run;
36mod glyph_run_arena;
37pub(crate) mod gpu_stats;
38mod initial_present;
39mod lazy_resource;
40mod offscreen;
41mod opaque_prefix;
42mod output_conversion;
43pub(crate) mod pass_timing;
44mod pipeline;
45mod pipeline_compiler;
46#[cfg(not(target_arch = "wasm32"))]
47pub mod pipeline_disk_cache;
48mod pipeline_recorder;
49mod pipeline_records;
50#[cfg(not(target_arch = "wasm32"))]
51mod present_runtime;
52mod record_columns;
53mod render;
54mod rrect_shadow;
55mod run_geometry;
56mod run_store;
57mod scene;
58mod shader_cache;
59#[cfg(not(target_arch = "wasm32"))]
60mod shader_fold;
61mod shaders;
62mod shape_pipelines;
63mod shared_shader;
64#[cfg(test)]
65mod test_support;
66
67use std::{rc::Rc, sync::Arc};
68
69use cranpose_core::{MemoryApplier, NodeId};
70use cranpose_render_common::{
71    RenderScene, Renderer,
72    graph::RenderGraph,
73    software_text_raster::{
74        SoftwareTextFontSet, SoftwareTextMeasurer, software_text_font_set_from_fonts_or_default,
75    },
76};
77use cranpose_ui::{LayoutTree, TextMeasurer};
78use cranpose_ui_graphics::{Rect, Size};
79pub use frame_packet::PresentTimings;
80
81/// Platform code the present thread runs around every present to a surface,
82/// for what only the platform can do with its swapchain, such as asking the
83/// display when earlier frames reached the screen.
84#[cfg(not(target_arch = "wasm32"))]
85pub trait PresentObserver: Send {
86    /// Runs just before `surface` presents a frame.
87    fn before_present(&mut self, surface: &wgpu::Surface<'static>);
88    /// Runs just after `surface` presented it.
89    fn after_present(&mut self, surface: &wgpu::Surface<'static>);
90
91    /// Runs before `surface` is configured anew, while its current swapchain
92    /// still stands. Does nothing unless the platform needs it to.
93    fn before_reconfigure(&mut self, _surface: &wgpu::Surface<'static>) {}
94}
95use frame_packet::RenderReturns;
96#[doc(hidden)]
97pub use frame_packet::{CancelReason, PresentOutcome};
98use frontend::{DevOverlayCache, RendererFrontend};
99pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
100pub use initial_present::{clear_to_background, clear_to_default_background};
101pub use pass_timing::{GpuPassTimingEntry, GpuPassTimingReport};
102use pipeline_compiler::PipelineCompilation;
103#[cfg(not(target_arch = "wasm32"))]
104use present_runtime::{
105    PresentControl, PresentHandle, PresentMsg, PresentRuntimeInit, PresentState,
106};
107use render::{GpuRenderer, GpuRendererInit};
108pub use render::{
109    frame_clear_color, frames_presented, pipelines_created, pipelines_created_off_frame,
110};
111pub use scene::{HitRegion, Scene};
112pub use shared_shader::shader_stages_folded;
113
114/// The optional device features the renderer exploits when the adapter
115/// offers them: pipeline caching (see `pipeline_disk_cache`), the
116/// timestamp queries behind `CRANPOSE_GPU_PASS_TIMING`, and, on a GPU
117/// that shares the host's memory, vertex and uniform buffers the CPU
118/// writes directly, so a frame's uploads need no copy. Every platform's
119/// `request_device` passes this so a profiling toggle never needs a rebuilt
120/// binary; intersecting with the adapter's own features keeps the request
121/// valid on adapters without them.
122pub fn optional_device_features(adapter: &wgpu::Adapter) -> wgpu::Features {
123    let mut wanted = wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY;
124    if matches!(
125        adapter.get_info().device_type,
126        wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::Cpu
127    ) {
128        wanted |= wgpu::Features::MAPPABLE_PRIMARY_BUFFERS;
129    }
130    adapter.features() & wanted
131}
132
133#[doc(hidden)]
134pub fn offscreen_render_target_for_tests(
135    device: &wgpu::Device,
136    width: u32,
137    height: u32,
138    label: &str,
139) -> (wgpu::Texture, wgpu::TextureView) {
140    let texture = offscreen::create_2d_texture(
141        device,
142        wgpu::TextureFormat::Rgba8Unorm,
143        width,
144        height,
145        wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
146        Some(label),
147    );
148    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
149    (texture, view)
150}
151
152pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
153    [
154        [rect.x, rect.y],
155        [rect.x + rect.width, rect.y],
156        [rect.x, rect.y + rect.height],
157        [rect.x + rect.width, rect.y + rect.height],
158    ]
159}
160
161#[derive(Debug)]
162pub enum WgpuRendererError {
163    Layout(String),
164    Wgpu(String),
165}
166
167/// CPU-readable RGBA frame captured from the renderer output.
168#[derive(Debug, Clone)]
169pub struct CapturedFrame {
170    pub width: u32,
171    pub height: u32,
172    pub pixels: Vec<u8>,
173}
174
175#[doc(hidden)]
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct DebugCpuAllocationStats {
178    pub scene_graph_node_count: usize,
179    pub scene_graph_heap_bytes: usize,
180    pub scene_hits_len: usize,
181    pub scene_hits_cap: usize,
182    pub scene_node_index_len: usize,
183    pub scene_node_index_cap: usize,
184    pub text_renderer_pool_len: usize,
185    pub text_renderer_pool_cap: usize,
186    pub image_texture_cache_len: usize,
187    pub image_texture_cache_cap: usize,
188    pub run_arena_staging_bytes: usize,
189    pub run_store_bytes: usize,
190    pub run_store_runs: usize,
191    pub scratch_image_vertices_cap: usize,
192    pub scratch_image_indices_cap: usize,
193    pub scratch_image_cmds_cap: usize,
194    pub scratch_glyph_instances_cap: usize,
195    pub layer_cache_len: usize,
196    pub layer_cache_bytes: u64,
197}
198
199/// The device a renderer draws with and the surface format it presents.
200struct GpuTarget {
201    device: Arc<wgpu::Device>,
202    queue: Arc<wgpu::Queue>,
203    surface_format: wgpu::TextureFormat,
204    adapter_backend: wgpu::Backend,
205    adapter_downlevel: wgpu::DownlevelFlags,
206}
207
208pub(crate) struct TextSystemState {
209    measurer: SoftwareTextMeasurer,
210}
211
212impl TextSystemState {
213    fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
214        Self {
215            measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
216        }
217    }
218
219    pub(crate) fn text_cache_len(&self) -> usize {
220        0
221    }
222}
223
224impl pipeline::TextLayoutResolver for TextSystemState {
225    fn layout_text(
226        &mut self,
227        text: &cranpose_ui::text::AnnotatedString,
228        style: &cranpose_ui::text::TextStyle,
229    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
230        if cranpose_ui::has_current_app_context() {
231            cranpose_ui::text::layout_text(text, style)
232        } else {
233            self.measurer.layout(text, style)
234        }
235    }
236}
237
238#[derive(Clone)]
239pub struct WgpuTextSystem {
240    software_fonts: SoftwareTextFontSet,
241}
242
243impl WgpuTextSystem {
244    pub fn from_fonts(fonts: &[&'static [u8]]) -> Self {
245        Self {
246            software_fonts: software_text_font_set_from_fonts_or_default(fonts),
247        }
248    }
249
250    /// Adopt a font set an app already built — the path app-supplied families
251    /// take, where faces were parsed once at startup rather than from static
252    /// byte slices here.
253    pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
254        Self { software_fonts }
255    }
256
257    pub(crate) fn render_state(&self) -> TextSystemState {
258        TextSystemState::from_font_set(self.software_fonts.clone())
259    }
260
261    pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
262        self.software_fonts.clone()
263    }
264}
265
266/// Create an accurate WGPU text measurer for headless tests without launching a window.
267pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
268    headless_text_measurer_with_fonts(&[])
269}
270
271/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
272pub fn headless_text_measurer_with_fonts(fonts: &[&'static [u8]]) -> Rc<dyn TextMeasurer> {
273    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
274}
275
276enum PresentBackend {
277    None,
278    Sync(Box<GpuRenderer>),
279    #[cfg(not(target_arch = "wasm32"))]
280    Threaded(PresentHandle),
281}
282
283/// What [`WgpuRenderer::publish_frame`] did.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub enum PublishOutcome {
286    /// No scene graph exists; nothing to lower.
287    NoGraph,
288    /// The depth-one slot is occupied (or the renderer is not in threaded
289    /// mode): NO packet was built — backpressure lands before lowering.
290    NoCredit,
291    /// A packet was built and handed to the present runtime.
292    Published,
293}
294
295/// WGPU-based renderer for GPU-accelerated 2D rendering.
296///
297/// This renderer supports:
298/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
299/// - Gradients (solid, linear, radial)
300/// - GPU text rendering via retained raster image batches
301/// - Cross-platform support (Desktop, Web, Android)
302pub struct WgpuRenderer {
303    frontend: RendererFrontend,
304    backend: PresentBackend,
305    renderer_epoch: u64,
306    surface_epoch: u64,
307}
308
309impl WgpuRenderer {
310    /// Create a new WGPU renderer.
311    ///
312    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
313    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
314    ///
315    /// Call [`init_gpu`][Self::init_gpu] before rendering.
316    pub fn new(fonts: &[&'static [u8]]) -> Self {
317        Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
318    }
319
320    /// Create a renderer over an already-parsed font set.
321    ///
322    /// Measurement and rasterization both take clones of this one set, so an
323    /// app-supplied family resolves identically on both sides.
324    pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
325        Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
326    }
327
328    pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
329        Self {
330            frontend: RendererFrontend::new(
331                text_system.render_state(),
332                text_system.software_fonts(),
333            ),
334            backend: PresentBackend::None,
335            renderer_epoch: 0,
336            surface_epoch: 0,
337        }
338    }
339
340    fn sync_gpu_renderer(&self) -> Option<&GpuRenderer> {
341        match &self.backend {
342            PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_ref()),
343            _ => None,
344        }
345    }
346
347    #[cfg(not(target_arch = "wasm32"))]
348    fn present_handle_mut(&mut self) -> Option<&mut PresentHandle> {
349        match &mut self.backend {
350            PresentBackend::Threaded(handle) => Some(handle),
351            _ => None,
352        }
353    }
354
355    fn retire_live_backend(&mut self) {
356        #[allow(unused_mut)]
357        let mut backend = std::mem::replace(&mut self.backend, PresentBackend::None);
358        #[cfg(not(target_arch = "wasm32"))]
359        if let PresentBackend::Threaded(handle) = &mut backend {
360            while let Some(returns) = handle.try_drain() {
361                self.frontend.apply_returns(returns);
362            }
363            handle.shutdown();
364        }
365        drop(backend);
366    }
367
368    /// Initialize GPU resources with a WGPU device and queue.
369    ///
370    /// Replacing a live renderer (Android surface recreation, device loss)
371    /// bumps the renderer epoch, so a packet built against the previous
372    /// renderer is cancelled instead of drawn.
373    pub fn init_gpu(
374        &mut self,
375        device: Arc<wgpu::Device>,
376        queue: Arc<wgpu::Queue>,
377        surface_format: wgpu::TextureFormat,
378        adapter_backend: wgpu::Backend,
379        adapter_downlevel: wgpu::DownlevelFlags,
380    ) {
381        let target = GpuTarget {
382            device,
383            queue,
384            surface_format,
385            adapter_backend,
386            adapter_downlevel,
387        };
388        self.install_gpu(target, PipelineCompilation::Background);
389    }
390
391    /// [`init_gpu`][Self::init_gpu], compiling every pipeline where it is
392    /// first needed instead of on the background compiler: no frame draws
393    /// with a stand-in, which a reference frame in a test must not.
394    #[doc(hidden)]
395    pub fn init_gpu_compiling_inline_for_tests(
396        &mut self,
397        device: Arc<wgpu::Device>,
398        queue: Arc<wgpu::Queue>,
399        surface_format: wgpu::TextureFormat,
400        adapter_backend: wgpu::Backend,
401        adapter_downlevel: wgpu::DownlevelFlags,
402    ) {
403        let target = GpuTarget {
404            device,
405            queue,
406            surface_format,
407            adapter_backend,
408            adapter_downlevel,
409        };
410        self.install_gpu(target, PipelineCompilation::Inline);
411    }
412
413    fn install_gpu(&mut self, target: GpuTarget, pipeline_compilation: PipelineCompilation) {
414        let init = self.next_gpu_init(target, pipeline_compilation);
415        self.backend = PresentBackend::Sync(Box::new(GpuRenderer::new(init)));
416    }
417
418    /// Retires the live backend and bumps the renderer epoch, then describes
419    /// the renderer that replaces it on `target`.
420    fn next_gpu_init(
421        &mut self,
422        target: GpuTarget,
423        pipeline_compilation: PipelineCompilation,
424    ) -> GpuRendererInit {
425        self.retire_live_backend();
426        self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
427        GpuRendererInit {
428            device: target.device,
429            queue: target.queue,
430            surface_format: target.surface_format,
431            adapter_backend: target.adapter_backend,
432            adapter_downlevel: target.adapter_downlevel,
433            text_fonts: self.frontend.text_fonts.clone(),
434            renderer_epoch: self.renderer_epoch,
435            pipeline_compilation,
436        }
437    }
438
439    /// [`init_gpu`][Self::init_gpu] for the threaded present runtime
440    /// (Android): the same epoch bump and planner replacement hygiene, but
441    /// instead of constructing a `GpuRenderer` here, everything it needs —
442    /// all owned, all `Send` — crosses to a spawned present thread that
443    /// constructs its own (its `Rc` caches are thread-confined). Frames
444    /// then flow through [`publish_frame`][Self::publish_frame] /
445    /// [`drain_present_returns`][Self::drain_present_returns] under the
446    /// depth-one credit protocol instead of [`render`][Self::render].
447    ///
448    /// * `waker` — wakes the producer's event loop after every returns
449    ///   send (the Android frame waker).
450    /// * `clock` — producer's monotonic nanosecond clock, so present-side
451    ///   [`PresentTimings`] share the producer telemetry's clock domain;
452    ///   `None` leaves timings at zero.
453    /// * `observer` — runs on the present thread around every present to the
454    ///   surface.
455    #[cfg(not(target_arch = "wasm32"))]
456    #[expect(clippy::too_many_arguments)]
457    pub fn init_gpu_threaded(
458        &mut self,
459        device: Arc<wgpu::Device>,
460        queue: Arc<wgpu::Queue>,
461        surface_format: wgpu::TextureFormat,
462        adapter_backend: wgpu::Backend,
463        adapter_downlevel: wgpu::DownlevelFlags,
464        waker: Arc<dyn Fn() + Send + Sync>,
465        clock: Option<Arc<dyn Fn() -> i64 + Send + Sync>>,
466        observer: Option<Box<dyn PresentObserver>>,
467    ) -> Result<(), WgpuRendererError> {
468        let target = GpuTarget {
469            device,
470            queue,
471            surface_format,
472            adapter_backend,
473            adapter_downlevel,
474        };
475        let init = PresentRuntimeInit {
476            gpu: self.next_gpu_init(target, PipelineCompilation::Background),
477            clock,
478            observer,
479        };
480        let handle = PresentHandle::spawn(init, waker).map_err(WgpuRendererError::Wgpu)?;
481        self.backend = PresentBackend::Threaded(handle);
482        Ok(())
483    }
484
485    #[cfg(not(target_arch = "wasm32"))]
486    #[doc(hidden)]
487    pub fn init_gpu_inline_for_tests(
488        &mut self,
489        device: Arc<wgpu::Device>,
490        queue: Arc<wgpu::Queue>,
491        surface_format: wgpu::TextureFormat,
492        adapter_backend: wgpu::Backend,
493        adapter_downlevel: wgpu::DownlevelFlags,
494    ) -> InlinePresentRuntime {
495        let target = GpuTarget {
496            device,
497            queue,
498            surface_format,
499            adapter_backend,
500            adapter_downlevel,
501        };
502        let init = PresentRuntimeInit {
503            gpu: self.next_gpu_init(target, PipelineCompilation::Background),
504            clock: None,
505            observer: None,
506        };
507        let (handle, state, msg_rx) = PresentHandle::new_inline(init, Arc::new(|| {}));
508        self.backend = PresentBackend::Threaded(handle);
509        InlinePresentRuntime {
510            state,
511            msg_rx,
512            shutdown_seen: false,
513        }
514    }
515
516    /// Record that the surface was reconfigured (resize, format change,
517    /// swapchain recreation): bumps the surface epoch stamped into every
518    /// subsequent packet, so a packet built against the previous
519    /// configuration is cancelled by the present stage instead of drawn.
520    pub fn note_surface_reconfigured(&mut self) {
521        self.surface_epoch = self.surface_epoch.wrapping_add(1);
522    }
523
524    /// Clears each frame to nothing instead of the framework's background,
525    /// for a window whose surface composites with the desktop behind it. A
526    /// renderer starts opaque; `false` puts the background back. Takes
527    /// effect from the next frame, on either present backend.
528    pub fn set_transparent_background(&mut self, transparent: bool) {
529        self.frontend.transparent_background = transparent;
530    }
531
532    pub fn root_scale(&self) -> f32 {
533        self.frontend.root_scale
534    }
535
536    /// Render the scene to a texture view.
537    ///
538    /// Producer first, present second: the frontend collects the frame into
539    /// a `frame_packet::FramePacket` (root and dev overlay alike), the GPU
540    /// renderer consumes it, and the present stage's returns fold back into
541    /// the frontend afterwards.
542    pub fn render(
543        &mut self,
544        texture: &wgpu::Texture,
545        view: &wgpu::TextureView,
546        width: u32,
547        height: u32,
548    ) -> Result<(), WgpuRendererError> {
549        self.render_frame(texture, view, width, height)
550    }
551
552    /// Renders the frame into a presentable image. When the image carries the
553    /// composition format and the capture usages, the scene renders into it
554    /// directly and no output conversion pass runs.
555    pub fn render_surface_texture(
556        &mut self,
557        texture: &wgpu::Texture,
558        view: &wgpu::TextureView,
559        width: u32,
560        height: u32,
561    ) -> Result<(), WgpuRendererError> {
562        self.render_frame(texture, view, width, height)
563    }
564
565    /// Presents a surface image this renderer drew with
566    /// [`render_surface_texture`](Self::render_surface_texture), on the queue
567    /// that recorded it.
568    ///
569    /// Before [`init_gpu`](Self::init_gpu) there is no queue, and the image is
570    /// released without being shown.
571    pub fn present(&self, frame: wgpu::SurfaceTexture) {
572        match self.sync_gpu_renderer() {
573            Some(gpu_renderer) => gpu_renderer.queue.present(frame),
574            None => log::debug!("surface image released: the renderer has no GPU queue"),
575        }
576    }
577
578    fn render_frame(
579        &mut self,
580        texture: &wgpu::Texture,
581        view: &wgpu::TextureView,
582        width: u32,
583        height: u32,
584    ) -> Result<(), WgpuRendererError> {
585        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
586            return Err(WgpuRendererError::Wgpu(
587                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
588                    .to_string(),
589            ));
590        };
591        let packet = self
592            .frontend
593            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
594            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
595        let mut returns = RenderReturns::default();
596        let result = gpu_renderer.render(
597            texture,
598            view,
599            width,
600            height,
601            packet,
602            self.surface_epoch,
603            &mut returns,
604        );
605        self.frontend.apply_returns(returns);
606        result.map_err(WgpuRendererError::Wgpu)
607    }
608
609    /// Render the current scene into an RGBA pixel buffer for robot tests.
610    ///
611    /// Uses the renderer's configured root scale.
612    pub fn capture_frame(
613        &mut self,
614        width: u32,
615        height: u32,
616    ) -> Result<CapturedFrame, WgpuRendererError> {
617        self.capture_frame_with_scale(width, height, self.frontend.root_scale)
618    }
619
620    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
621    /// Builds every pipeline the pipeline cache file records on the
622    /// background compiler, and returns whether they were all built before
623    /// `timeout`, or before `stop` said to stop. An app runs it after an
624    /// update, before its next launch, so that launch finds them compiled;
625    /// the renderer writes the file when it is dropped, and drops the builds
626    /// not started. It draws nothing, and needs a renderer initialized for
627    /// synchronous rendering.
628    #[cfg(not(target_arch = "wasm32"))]
629    pub fn prepare_recorded_pipelines(
630        &mut self,
631        timeout: std::time::Duration,
632        stop: &dyn Fn() -> bool,
633    ) -> bool {
634        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
635            return false;
636        };
637        let deadline = web_time::Instant::now() + timeout;
638        let mut compiling = gpu_renderer.prepare_recorded();
639        while compiling {
640            if web_time::Instant::now() >= deadline || stop() {
641                return false;
642            }
643            std::thread::sleep(std::time::Duration::from_millis(10));
644            compiling = gpu_renderer.compiling();
645        }
646        true
647    }
648
649    pub fn capture_frame_with_scale(
650        &mut self,
651        width: u32,
652        height: u32,
653        root_scale: f32,
654    ) -> Result<CapturedFrame, WgpuRendererError> {
655        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
656            return Err(WgpuRendererError::Wgpu(
657                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
658                    .to_string(),
659            ));
660        };
661        let packet = self
662            .frontend
663            .build_frame_packet_with_scale(
664                width,
665                height,
666                root_scale,
667                self.renderer_epoch,
668                self.surface_epoch,
669            )
670            .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
671        let mut returns = RenderReturns::default();
672        let result = gpu_renderer.render_to_rgba_pixels(
673            width,
674            height,
675            packet,
676            self.surface_epoch,
677            &mut returns,
678        );
679        self.frontend.apply_returns(returns);
680        let pixels = result.map_err(WgpuRendererError::Wgpu)?;
681        Ok(CapturedFrame {
682            width,
683            height,
684            pixels,
685        })
686    }
687
688    /// Threaded mode: whether the depth-one slot has room for a packet.
689    /// The Android loop checks this BEFORE `shell.update()` so
690    /// backpressure lands before the expensive update/lowering work.
691    /// Always `true` on the sync path, which has no slot to fill.
692    #[cfg(not(target_arch = "wasm32"))]
693    pub fn has_frame_credit(&self) -> bool {
694        match &self.backend {
695            PresentBackend::Threaded(handle) => handle.has_credit(),
696            PresentBackend::Sync(_) | PresentBackend::None => true,
697        }
698    }
699
700    /// Threaded mode: whether the present thread has handed back a frame
701    /// that [`drain_present_returns`][Self::drain_present_returns] has not
702    /// folded back yet, so draining it would free a publish credit. A loop
703    /// out of credit can work on other things until this turns true.
704    /// Always `false` outside threaded mode.
705    #[cfg(not(target_arch = "wasm32"))]
706    pub fn frame_credit_returned(&self) -> bool {
707        match &self.backend {
708            PresentBackend::Threaded(handle) => handle.has_undrained_return(),
709            PresentBackend::Sync(_) | PresentBackend::None => false,
710        }
711    }
712
713    /// Threaded mode: lower the current scene into a packet and hand it to
714    /// the present runtime. Credit is checked FIRST — a `NoCredit` return
715    /// means no packet was built at all (`frame_sequence` does not
716    /// advance). Returns `NoCredit` (with an error log) when the renderer
717    /// is not in threaded mode.
718    #[cfg(not(target_arch = "wasm32"))]
719    pub fn publish_frame(&mut self, width: u32, height: u32) -> PublishOutcome {
720        let PresentBackend::Threaded(handle) = &mut self.backend else {
721            log::error!("publish_frame called without a threaded present runtime");
722            return PublishOutcome::NoCredit;
723        };
724        if !handle.has_credit() {
725            return PublishOutcome::NoCredit;
726        }
727        let Some(packet) = self.frontend.build_frame_packet(
728            width,
729            height,
730            self.renderer_epoch,
731            self.surface_epoch,
732        ) else {
733            return PublishOutcome::NoGraph;
734        };
735        match handle.publish(packet) {
736            Ok(()) => PublishOutcome::Published,
737            Err(packet) => {
738                let mut returns = RenderReturns::default();
739                let _ = GpuRenderer::cancel_packet(
740                    *packet,
741                    CancelReason::SurfaceUnavailable,
742                    &mut returns,
743                );
744                self.frontend.apply_returns(returns);
745                log::error!("present runtime unavailable; frame recovered, not published");
746                PublishOutcome::NoCredit
747            }
748        }
749    }
750
751    /// Threaded mode: fold every pending `RenderReturns` back into
752    /// producer state and free the publish credit. Returns how many were
753    /// drained. No-op outside threaded mode.
754    #[cfg(not(target_arch = "wasm32"))]
755    pub fn drain_present_returns(&mut self) -> usize {
756        self.drain_present_returns_with(&mut |_, _, _| {})
757    }
758
759    /// [`drain_present_returns`][Self::drain_present_returns], reporting
760    /// each drained frame's id, outcome and present-thread timings — the
761    /// Android loop feeds its frame telemetry from this.
762    #[cfg(not(target_arch = "wasm32"))]
763    pub fn drain_present_returns_with(
764        &mut self,
765        on_return: &mut dyn FnMut(u64, PresentOutcome, PresentTimings),
766    ) -> usize {
767        let mut drained = 0;
768        loop {
769            let returns = {
770                let PresentBackend::Threaded(handle) = &mut self.backend else {
771                    break;
772                };
773                match handle.try_drain() {
774                    Some(returns) => returns,
775                    None => break,
776                }
777            };
778            drained += 1;
779            let frame_id = returns.frame_id;
780            let outcome = returns.outcome;
781            let timings = returns.timings;
782            self.frontend.apply_returns(returns);
783            on_return(frame_id, outcome, timings);
784        }
785        drained
786    }
787
788    /// Threaded mode: install a (re)created surface on the present thread.
789    /// Nothing waits for the present thread to set it up: frames published
790    /// afterwards queue behind it, so the caller composes its next frame
791    /// while the surface is configured. The caller must have bumped the
792    /// surface epoch first (`note_surface_reconfigured`
793    /// [Self::note_surface_reconfigured]) when the message invalidates
794    /// in-flight packets; the message carries the current epoch. Returns
795    /// false when the present thread is gone.
796    #[cfg(not(target_arch = "wasm32"))]
797    pub fn present_replace_surface(
798        &mut self,
799        surface: wgpu::Surface<'static>,
800        config: wgpu::SurfaceConfiguration,
801    ) -> bool {
802        let surface_epoch = self.surface_epoch;
803        let Some(handle) = self.present_handle_mut() else {
804            log::error!("present_replace_surface called without a threaded present runtime");
805            return false;
806        };
807        let sent = handle.send_control(PresentControl::ReplaceSurface {
808            surface,
809            config,
810            surface_epoch,
811        });
812        if !sent {
813            log::error!("[present-runtime] replace surface: runtime is gone");
814        }
815        sent
816    }
817
818    /// Threaded mode: reconfigure the present thread's surface (resize)
819    /// and wait for the acknowledgement. Same epoch contract as
820    /// [`present_replace_surface`][Self::present_replace_surface].
821    #[cfg(not(target_arch = "wasm32"))]
822    pub fn present_reconfigure(&mut self, config: wgpu::SurfaceConfiguration) -> bool {
823        let surface_epoch = self.surface_epoch;
824        let Some(handle) = self.present_handle_mut() else {
825            log::error!("present_reconfigure called without a threaded present runtime");
826            return false;
827        };
828        handle.send_control_and_wait(
829            move |ack| PresentControl::Reconfigure {
830                config,
831                surface_epoch,
832                ack,
833            },
834            "reconfigure surface",
835        )
836    }
837
838    /// Threaded mode: drop the present thread's surface (the window died;
839    /// the renderer survives for the next one) and wait for the
840    /// acknowledgement. Bump the epoch first so in-flight packets cancel.
841    #[cfg(not(target_arch = "wasm32"))]
842    pub fn present_drop_surface(&mut self) -> bool {
843        let Some(handle) = self.present_handle_mut() else {
844            log::error!("present_drop_surface called without a threaded present runtime");
845            return false;
846        };
847        handle.send_control_and_wait(|ack| PresentControl::DropSurface { ack }, "drop surface")
848    }
849
850    /// Threaded mode: drain outstanding returns, stop the present thread
851    /// and join it. The renderer returns to the uninitialized state.
852    #[cfg(not(target_arch = "wasm32"))]
853    pub fn shutdown_present_runtime(&mut self) {
854        if matches!(self.backend, PresentBackend::Threaded(_)) {
855            self.retire_live_backend();
856        }
857    }
858
859    #[cfg(not(target_arch = "wasm32"))]
860    #[doc(hidden)]
861    pub fn present_attach_offscreen_for_tests(&mut self, width: u32, height: u32) -> bool {
862        let Some(handle) = self.present_handle_mut() else {
863            return false;
864        };
865        handle.send_control_and_wait(
866            move |ack| PresentControl::AttachOffscreenTargetForTests { width, height, ack },
867            "attach offscreen target",
868        )
869    }
870
871    #[cfg(not(target_arch = "wasm32"))]
872    #[doc(hidden)]
873    pub fn send_attach_offscreen_unacked_for_tests(
874        &mut self,
875        width: u32,
876        height: u32,
877    ) -> Option<std::sync::mpsc::Receiver<()>> {
878        let handle = self.present_handle_mut()?;
879        handle.send_control_unacked(move |ack| PresentControl::AttachOffscreenTargetForTests {
880            width,
881            height,
882            ack,
883        })
884    }
885
886    #[cfg(not(target_arch = "wasm32"))]
887    #[doc(hidden)]
888    pub fn send_reconfigure_unacked_for_tests(
889        &mut self,
890        config: wgpu::SurfaceConfiguration,
891    ) -> Option<std::sync::mpsc::Receiver<()>> {
892        let surface_epoch = self.surface_epoch;
893        let handle = self.present_handle_mut()?;
894        handle.send_control_unacked(move |ack| PresentControl::Reconfigure {
895            config,
896            surface_epoch,
897            ack,
898        })
899    }
900
901    #[cfg(not(target_arch = "wasm32"))]
902    #[doc(hidden)]
903    pub fn send_drop_surface_unacked_for_tests(&mut self) -> Option<std::sync::mpsc::Receiver<()>> {
904        let handle = self.present_handle_mut()?;
905        handle.send_control_unacked(|ack| PresentControl::DropSurface { ack })
906    }
907
908    /// The OS id of the thread that presents frames, once that thread has
909    /// started, on systems whose scheduler hints name threads by one.
910    /// `None` when frames present on the calling thread.
911    pub fn present_thread_id(&self) -> Option<i32> {
912        match &self.backend {
913            #[cfg(not(target_arch = "wasm32"))]
914            PresentBackend::Threaded(handle) => {
915                let id = handle
916                    .status()
917                    .thread_id
918                    .load(std::sync::atomic::Ordering::Relaxed);
919                (id != 0).then_some(id)
920            }
921            _ => None,
922        }
923    }
924
925    /// The producer's monotone packet sequence: the `frame_id` stamped on
926    /// the most recently lowered packet. After a `Published` outcome this
927    /// is the published frame's id (the Android loop keys its telemetry on
928    /// it); it also proves a `NoCredit` publish never lowered a frame.
929    pub fn last_published_frame_id(&self) -> u64 {
930        self.frontend.frame_sequence
931    }
932
933    #[cfg(not(target_arch = "wasm32"))]
934    #[doc(hidden)]
935    pub fn present_status_snapshot_for_tests(&self) -> Option<(bool, u64, u64)> {
936        match &self.backend {
937            PresentBackend::Threaded(handle) => {
938                let status = handle.status();
939                Some((
940                    status
941                        .needs_frame_warmup
942                        .load(std::sync::atomic::Ordering::Relaxed),
943                    status
944                        .presented_frames
945                        .load(std::sync::atomic::Ordering::Relaxed),
946                    status
947                        .placeholder_frames
948                        .load(std::sync::atomic::Ordering::Relaxed),
949                ))
950            }
951            _ => None,
952        }
953    }
954
955    pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
956        match &self.backend {
957            PresentBackend::Sync(gpu_renderer) => gpu_renderer.last_frame_stats(),
958            #[cfg(not(target_arch = "wasm32"))]
959            PresentBackend::Threaded(handle) => *handle
960                .status()
961                .last_frame_stats
962                .lock()
963                .unwrap_or_else(std::sync::PoisonError::into_inner),
964            PresentBackend::None => None,
965        }
966    }
967
968    /// The pipelines this renderer's first screen drew with, encoded for
969    /// [`Self::build_first_screen_pipelines`] in a later launch. `None`
970    /// until the first screen ends, two seconds after the first frame and
971    /// later by every wait for a pipeline, and for a renderer that does not
972    /// draw on the calling thread.
973    pub fn first_screen_pipelines(&self) -> Option<Vec<u8>> {
974        self.sync_gpu_renderer()
975            .and_then(GpuRenderer::first_screen_pipelines)
976    }
977
978    /// Builds now the pipelines `records` names: sets
979    /// [`Self::first_screen_pipelines`] wrote, one after another. A web page
980    /// calls this as its renderer starts, so the browser compiles them while
981    /// the page composes its first screen. Only sets drawn on this
982    /// renderer's backend by a build with the same layout of pipeline keys
983    /// build, and a renderer that does not draw on the calling thread builds
984    /// nothing.
985    pub fn build_first_screen_pipelines(&mut self, records: &[u8]) {
986        if let PresentBackend::Sync(gpu_renderer) = &mut self.backend {
987            gpu_renderer.build_first_screen(records);
988        }
989    }
990
991    /// GPU milliseconds by pass label, aggregated since the last `[GPU-PASS]`
992    /// print. Empty unless `CRANPOSE_GPU_PASS_TIMING` armed pass timing on a
993    /// device with [`wgpu::Features::TIMESTAMP_QUERY`].
994    pub fn gpu_pass_timings(&self) -> GpuPassTimingReport {
995        self.sync_gpu_renderer()
996            .map(GpuRenderer::gpu_pass_timings)
997            .unwrap_or_default()
998    }
999
1000    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
1001        let mut stats = self
1002            .sync_gpu_renderer()
1003            .map(GpuRenderer::debug_cpu_allocation_stats)
1004            .unwrap_or_default();
1005        stats.scene_graph_node_count = self
1006            .frontend
1007            .scene
1008            .graph
1009            .as_ref()
1010            .map_or(0, RenderGraph::node_count);
1011        stats.scene_graph_heap_bytes = self
1012            .frontend
1013            .scene
1014            .graph
1015            .as_ref()
1016            .map_or(0, RenderGraph::heap_bytes);
1017        stats.scene_hits_len = self.frontend.scene.hits.len();
1018        stats.scene_hits_cap = self.frontend.scene.hits.capacity();
1019        stats.scene_node_index_len = self.frontend.scene.node_index.len();
1020        stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
1021        stats
1022    }
1023
1024    /// Starts a frame that takes its pipelines from a pipeline discovery:
1025    /// the frame that discovers them, or the one that creates them again
1026    /// while the browser holds them compiled. Such a frame also builds the
1027    /// pipelines it expects a later frame to need, at its end, so the browser
1028    /// compiles them behind the frame's own or holds them with those.
1029    #[cfg(target_arch = "wasm32")]
1030    pub fn begin_discovery_frame(&self) {
1031        if let Some(gpu_renderer) = self.sync_gpu_renderer() {
1032            gpu_renderer.pipeline_compiler().begin_discovery_frame();
1033        }
1034    }
1035
1036    /// Return the WGPU device when GPU resources are initialized.
1037    /// Sync backend only (desktop/web reconfigure paths); the threaded
1038    /// runtime owns its device on the present thread.
1039    pub fn try_device(&self) -> Option<&wgpu::Device> {
1040        self.sync_gpu_renderer().map(|r| &*r.device)
1041    }
1042
1043    #[doc(hidden)]
1044    pub fn try_queue_for_tests(&self) -> Option<&wgpu::Queue> {
1045        self.sync_gpu_renderer().map(|r| &*r.queue)
1046    }
1047
1048    #[doc(hidden)]
1049    pub fn device_error_count_for_tests(&self) -> u64 {
1050        self.sync_gpu_renderer()
1051            .map_or(0, GpuRenderer::device_error_count)
1052    }
1053
1054    #[doc(hidden)]
1055    pub fn build_frame_packet_for_tests(
1056        &mut self,
1057        width: u32,
1058        height: u32,
1059    ) -> Option<HeldFramePacket> {
1060        self.frontend
1061            .build_frame_packet(width, height, self.renderer_epoch, self.surface_epoch)
1062            .map(HeldFramePacket)
1063    }
1064
1065    /// Return a producer-built packet without presenting it. This exercises
1066    /// the same bounded scene-storage recycler used by normal present returns
1067    /// in offline producer benchmarks.
1068    ///
1069    /// ```
1070    /// use cranpose_render_wgpu::{HeldFramePacket, WgpuRenderer};
1071    ///
1072    /// fn return_without_presenting(renderer: &mut WgpuRenderer, packet: HeldFramePacket) {
1073    ///     renderer.return_held_packet_for_tests(packet);
1074    /// }
1075    /// ```
1076    #[doc(hidden)]
1077    pub fn return_held_packet_for_tests(&mut self, packet: HeldFramePacket) {
1078        let packet = packet.0;
1079        self.frontend.apply_returns(RenderReturns {
1080            scene: Some(frame_packet::FrameSceneStorage {
1081                root: packet.root,
1082                overlay: packet.overlay,
1083            }),
1084            frame_id: packet.frame_id,
1085            ..RenderReturns::default()
1086        });
1087    }
1088
1089    #[doc(hidden)]
1090    pub fn render_held_packet_for_tests(
1091        &mut self,
1092        texture: &wgpu::Texture,
1093        view: &wgpu::TextureView,
1094        width: u32,
1095        height: u32,
1096        packet: HeldFramePacket,
1097    ) -> Result<PresentOutcome, WgpuRendererError> {
1098        let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
1099            return Err(WgpuRendererError::Wgpu(
1100                "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
1101                    .to_string(),
1102            ));
1103        };
1104        let mut returns = RenderReturns::default();
1105        let result = gpu_renderer.render(
1106            texture,
1107            view,
1108            width,
1109            height,
1110            packet.0,
1111            self.surface_epoch,
1112            &mut returns,
1113        );
1114        let outcome = returns.outcome;
1115        self.frontend.apply_returns(returns);
1116        result.map_err(WgpuRendererError::Wgpu)?;
1117        Ok(outcome)
1118    }
1119}
1120
1121#[doc(hidden)]
1122pub struct HeldFramePacket(frame_packet::FramePacket);
1123
1124#[cfg(not(target_arch = "wasm32"))]
1125#[doc(hidden)]
1126pub struct InlinePresentRuntime {
1127    state: PresentState,
1128    msg_rx: std::sync::mpsc::Receiver<PresentMsg>,
1129    shutdown_seen: bool,
1130}
1131
1132#[cfg(not(target_arch = "wasm32"))]
1133impl InlinePresentRuntime {
1134    pub fn pump(&mut self) -> bool {
1135        if self.shutdown_seen {
1136            return false;
1137        }
1138        while let Ok(msg) = self.msg_rx.try_recv() {
1139            if !self.state.run_once(msg) {
1140                self.shutdown_seen = true;
1141                return false;
1142            }
1143        }
1144        self.state.consume_waiting();
1145        true
1146    }
1147
1148    pub fn has_waiting_packet(&self) -> bool {
1149        self.state.has_waiting_packet()
1150    }
1151
1152    pub fn step_one_message(&mut self) -> bool {
1153        if self.shutdown_seen {
1154            return false;
1155        }
1156        match self.msg_rx.try_recv() {
1157            Ok(msg) => {
1158                if !self.state.run_once(msg) {
1159                    self.shutdown_seen = true;
1160                }
1161                true
1162            }
1163            Err(_) => false,
1164        }
1165    }
1166
1167    pub fn consume_waiting(&mut self) {
1168        self.state.consume_waiting();
1169    }
1170}
1171
1172impl Default for WgpuRenderer {
1173    fn default() -> Self {
1174        Self::new(&[])
1175    }
1176}
1177
1178impl Renderer for WgpuRenderer {
1179    type Scene = Scene;
1180    type Error = WgpuRendererError;
1181
1182    fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
1183        app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
1184            self.frontend.text_fonts.clone(),
1185            8192,
1186        ));
1187        self.frontend.app_context = Some(app_context.downgrade());
1188    }
1189
1190    fn set_root_scale(&mut self, scale: f32) {
1191        self.frontend.root_scale = scale;
1192    }
1193
1194    fn scene(&self) -> &Self::Scene {
1195        &self.frontend.scene
1196    }
1197
1198    fn scene_mut(&mut self) -> &mut Self::Scene {
1199        &mut self.frontend.scene
1200    }
1201
1202    fn rebuild_scene(
1203        &mut self,
1204        layout_tree: &LayoutTree,
1205        _viewport: Size,
1206    ) -> Result<(), Self::Error> {
1207        self.frontend.scene.clear();
1208        self.frontend.clear_fps_overlay();
1209        pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
1210        Ok(())
1211    }
1212
1213    fn rebuild_scene_from_applier(
1214        &mut self,
1215        applier: &mut MemoryApplier,
1216        root: NodeId,
1217        _viewport: Size,
1218    ) -> Result<(), Self::Error> {
1219        self.frontend.clear_fps_overlay();
1220        self.frontend.scene.rebuild_from_applier(applier, root);
1221        Ok(())
1222    }
1223
1224    fn update_scene_from_applier(
1225        &mut self,
1226        applier: &mut MemoryApplier,
1227        root: NodeId,
1228        _viewport: Size,
1229        updates: cranpose_render_common::SceneUpdates<'_>,
1230    ) -> Result<(), Self::Error> {
1231        if updates.is_empty() {
1232            self.frontend.clear_fps_overlay();
1233        }
1234        self.frontend
1235            .scene
1236            .update_from_applier(applier, root, updates, true);
1237        Ok(())
1238    }
1239
1240    fn update_visual_scene_from_applier(
1241        &mut self,
1242        applier: &mut MemoryApplier,
1243        root: NodeId,
1244        _viewport: Size,
1245        updates: cranpose_render_common::SceneUpdates<'_>,
1246    ) -> Result<(), Self::Error> {
1247        if updates.is_empty() {
1248            self.frontend.clear_fps_overlay();
1249        }
1250        self.frontend
1251            .scene
1252            .update_from_applier(applier, root, updates, false);
1253        Ok(())
1254    }
1255
1256    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1257        const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1258        let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
1259        if self.frontend.dev_overlay_graph.is_some()
1260            && self
1261                .frontend
1262                .dev_overlay_cache
1263                .as_ref()
1264                .is_some_and(|cache| {
1265                    cache.text == key.text
1266                        && cache.viewport_width_bits == key.viewport_width_bits
1267                        && cache.viewport_height_bits == key.viewport_height_bits
1268                })
1269        {
1270            return;
1271        }
1272        self.frontend.fps_overlay_graph = Some(
1273            cranpose_render_common::dev_overlay::build_dev_overlay_graph(
1274                text,
1275                viewport,
1276                DEV_OVERLAY_NODE_ID,
1277            ),
1278        );
1279        self.frontend.dev_overlay_cache = Some(DevOverlayCache {
1280            text: key.text,
1281            viewport_width_bits: key.viewport_width_bits,
1282            viewport_height_bits: key.viewport_height_bits,
1283        });
1284        self.frontend.refresh_dev_overlay();
1285    }
1286
1287    fn set_inspector_overlay(&mut self, graph: Option<cranpose_render_common::graph::RenderGraph>) {
1288        self.frontend.inspector_overlay_graph = graph;
1289        self.frontend.refresh_dev_overlay();
1290    }
1291
1292    fn needs_frame_warmup(&self) -> bool {
1293        match &self.backend {
1294            PresentBackend::Sync(gpu_renderer) => gpu_renderer.needs_frame_warmup(),
1295            #[cfg(not(target_arch = "wasm32"))]
1296            PresentBackend::Threaded(handle) => handle
1297                .status()
1298                .needs_frame_warmup
1299                .load(std::sync::atomic::Ordering::Relaxed),
1300            PresentBackend::None => false,
1301        }
1302    }
1303
1304    fn awaits_pipelines(&self) -> bool {
1305        match &self.backend {
1306            PresentBackend::Sync(gpu_renderer) => gpu_renderer.awaits_pipelines(),
1307            #[cfg(not(target_arch = "wasm32"))]
1308            PresentBackend::Threaded(handle) => {
1309                let status = handle.status();
1310                status
1311                    .drew_placeholder
1312                    .load(std::sync::atomic::Ordering::Relaxed)
1313                    && status
1314                        .landing
1315                        .get()
1316                        .is_some_and(|landing| landing.in_flight())
1317            }
1318            PresentBackend::None => false,
1319        }
1320    }
1321}
1322
1323#[cfg(test)]
1324#[path = "tests/wgpu_tests.rs"]
1325mod tests;