ailloli_ui_render_wgpu/
renderer.rs

1//! Main GPU renderer, layer descriptions, surface lifecycle, and frame capture.
2//!
3//! [`Renderer`] accepts runtime draw-command layers, prepares glyph/icon GPU
4//! resources, builds a CPU frame plan, and records main and isolated passes.
5//! It can either own a detachable native surface or render into a host-provided
6//! [`RenderTarget`].
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use ailloli_ui_core::geometry::ClipShape;
12use ailloli_ui_core::math::Scale;
13use ailloli_ui_core::Color;
14use ailloli_ui_runtime::scene::ClipStackSnapshot;
15use ailloli_ui_runtime::{BlendMode, DrawCmd, IsolatedEffects};
16use ailloli_ui_text::FontMetrics;
17
18use crate::backdrop_blur::run_backdrop_blur;
19use crate::backdrop_capture::{
20    copy_swapchain_region_to_offscreen, lease_backdrop_slot, BackdropTable,
21};
22use crate::capture::{
23    bgra_to_rgba_in_place, bytes_per_row_padded_256, encode_png_rgba, unpad_rows_rgba,
24    CaptureParams, CapturedFrame, CapturedFrameFormat,
25};
26use crate::clip::{resolve_clip_render_plan, ClipParamsGpu, ClipRenderMode, RenderClipPlan};
27use crate::composite_blend::{draw_composite_blend, CompositeBlendPipelines};
28use crate::effect_chain::{run_effect_chain, EffectPipelines, IsolatedCompositeTable};
29use crate::error::{RendererError, TargetRecordingError};
30use crate::frame_plan::{
31    ClipBindKind, FrameRenderPlan, PipelineKind, PlannedBatch, PlannedLayer, TextureBindKind,
32};
33use crate::frame_prep::PreparedResources;
34use crate::icons::IconCache;
35use crate::isolated_budget::{IsolatedBudgetConfig, IsolatedBudgetPolicy, IsolatedDowngradeCounts};
36use crate::isolated_plan::OffscreenPassId;
37use crate::isolated_plan::PlannedIsolatedComposite;
38use crate::offscreen_pool::{OffscreenSurfacePool, PoolKey};
39use crate::passes::{apply_layer_scissor, now_ms};
40use crate::pipeline_cache::{
41    ResizeOutcome, SurfaceAttachmentState, SurfaceReattachOutcome, WgpuRenderContext,
42    WgpuSurfaceBundle,
43};
44use crate::render_target::{BorrowedRenderTarget, PhysicalExtent, RenderTarget, TargetLoadOp};
45use crate::stencil::StencilTarget;
46use crate::text::{TextAtlas, TextAtlasStats};
47use wgpu::util::DeviceExt;
48
49#[cfg(test)]
50#[path = "host_composition_tests.rs"]
51mod host_composition_tests;
52
53/// GPU renderer for a host-owned surface or externally managed render target.
54///
55/// single-pass compositing: a single `wgpu::RenderPass` is opened per frame, driven by a
56/// [`FrameRenderPlan`]. Per-layer stencil_ref counters are assigned by
57/// `FrameRenderPlan::build_cpu`, removing the previous `StencilFrameState`
58/// field.
59///
60/// # Examples
61///
62/// ```
63/// use ailloli_ui_render_wgpu::Renderer;
64///
65/// assert!(std::mem::size_of::<Renderer>() > 0);
66/// ```
67pub struct Renderer {
68    /// Managed native surface or externally managed render-target backend.
69    gpu: RenderBackend,
70
71    /// Rasterized icon textures cached by identity, scale, size, and tint.
72    icon_cache: IconCache,
73    /// Paged glyph atlas and per-frame pinning state.
74    text_atlas: TextAtlas,
75    /// UI font metrics used to align glyph baselines.
76    text_metrics: FontMetrics,
77    /// Font blobs per `face_id` (filled by `TextSystem` before each frame).
78    text_face_blobs: Arc<HashMap<u64, Arc<[u8]>>>,
79    /// Depth-stencil attachment matching the latest main target extent.
80    stencil_target: Option<StencilTarget>,
81    /// Reusable offscreen textures for isolated layers and backdrops.
82    offscreen_pool: OffscreenSurfacePool,
83    /// Lazily initialized blur and color-matrix pipelines.
84    effect_pipelines: Option<EffectPipelines>,
85    /// Lazily initialized non-normal blend-mode pipelines.
86    composite_blend_pipelines: Option<CompositeBlendPipelines>,
87    /// Counters from the most recently completed isolated-layer frame.
88    isolated_metrics: IsolatedFrameMetrics,
89    /// Per-frame isolated depth, surface, and byte limits.
90    isolated_budget: IsolatedBudgetConfig,
91    /// Optional benchmark scenario identifier attached to metrics.
92    bench_scenario: Option<String>,
93    /// Holds offscreen leases until `end_frame` after the main pass composite.
94    frame_leases: Vec<crate::offscreen_pool::LeasedOffscreen>,
95}
96
97/// Storage policy for a managed surface or externally managed target context.
98enum RenderBackend {
99    /// GPU context plus a native attachment that may be detached.
100    Surface(Box<WgpuSurfaceBundle>),
101    /// Device/queue/pipelines without native presentation ownership.
102    Detached(Box<WgpuRenderContext>),
103}
104
105impl RenderBackend {
106    /// Mutably borrows the managed surface bundle, if this backend owns one.
107    fn surface_mut(&mut self) -> Option<&mut WgpuSurfaceBundle> {
108        match self {
109            Self::Surface(bundle) => Some(bundle),
110            _ => None,
111        }
112    }
113
114    /// Returns the logical device shared by both backend modes.
115    fn device(&self) -> &wgpu::Device {
116        match self {
117            Self::Surface(bundle) => bundle.device(),
118            Self::Detached(context) => &context.device,
119        }
120    }
121
122    /// Returns the submission queue shared by both backend modes.
123    fn queue(&self) -> &wgpu::Queue {
124        match self {
125            Self::Surface(bundle) => bundle.queue(),
126            Self::Detached(context) => &context.queue,
127        }
128    }
129
130    /// Returns pipelines compiled for this backend's color format.
131    fn pipelines(&self) -> &crate::pipeline_cache::PipelineCache {
132        match self {
133            Self::Surface(bundle) => bundle.pipelines(),
134            Self::Detached(context) => &context.pipelines,
135        }
136    }
137
138    /// Returns the configured or remembered physical-pixel extent.
139    fn extent(&self) -> PhysicalExtent {
140        match self {
141            Self::Surface(bundle) => bundle.extent(),
142            Self::Detached(context) => {
143                PhysicalExtent::new(context.config.width, context.config.height)
144            }
145        }
146    }
147
148    /// Returns the color target format expected by cached pipelines.
149    fn format(&self) -> wgpu::TextureFormat {
150        match self {
151            Self::Surface(bundle) => bundle.format(),
152            Self::Detached(context) => context.config.format,
153        }
154    }
155
156    /// Applies a resize using the backend-specific configuration path.
157    ///
158    /// # Errors
159    ///
160    /// Propagates the native surface attachment, format, or configuration errors
161    /// documented by [`WgpuSurfaceBundle::try_resize`]. Detached targets resize
162    /// their virtual configuration infallibly.
163    fn try_resize(
164        &mut self,
165        new_size: PhysicalExtent,
166    ) -> Result<ResizeOutcome, crate::error::RendererError> {
167        match self {
168            Self::Surface(surface) => Ok(surface.try_resize(new_size)?),
169            Self::Detached(context) => Ok(context.try_resize(new_size)),
170        }
171    }
172
173    /// Forces native surface configuration, rejecting detached contexts.
174    ///
175    /// # Errors
176    ///
177    /// Propagates errors from [`WgpuSurfaceBundle::try_reconfigure`] for a native
178    /// surface, or returns [`RendererError::RenderTargetUnavailable`] for a
179    /// detached render context.
180    fn try_reconfigure_surface(
181        &mut self,
182        new_size: PhysicalExtent,
183    ) -> Result<ResizeOutcome, crate::error::RendererError> {
184        match self {
185            Self::Surface(surface) => surface.try_reconfigure(new_size),
186            Self::Detached(_) => Err(crate::error::RendererError::RenderTargetUnavailable(
187                "surface reconfiguration requires a surface-backed renderer",
188            )),
189        }
190    }
191
192    /// Returns native capabilities or a synthetic detached-context equivalent.
193    fn surface_capabilities(&self) -> wgpu::SurfaceCapabilities {
194        match self {
195            Self::Surface(surface) => surface.surface_capabilities(),
196            Self::Detached(context) => wgpu::SurfaceCapabilities {
197                formats: vec![context.config.format],
198                present_modes: vec![wgpu::PresentMode::Fifo],
199                alpha_modes: vec![if context.transparent {
200                    wgpu::CompositeAlphaMode::PreMultiplied
201                } else {
202                    wgpu::CompositeAlphaMode::Opaque
203                }],
204                usages: context.config.usage,
205            },
206        }
207    }
208
209    /// Returns the current reason native presentation must wait, if any.
210    fn surface_config_deferred_reason(
211        &self,
212    ) -> Option<crate::pipeline_cache::SurfaceConfigDeferredReason> {
213        match self {
214            Self::Surface(surface) => surface.surface_config_deferred_reason(),
215            Self::Detached(context) => context.surface_config_deferred_reason(),
216        }
217    }
218
219    /// Returns native adapter information or a stable detached sentinel.
220    fn adapter_info(&self) -> wgpu::AdapterInfo {
221        match self {
222            Self::Surface(surface) => surface.adapter_info(),
223            Self::Detached(_) => wgpu::AdapterInfo {
224                name: "ailloli_ui_render_wgpu detached".to_string(),
225                vendor: 0,
226                device: 0,
227                device_type: wgpu::DeviceType::Other,
228                driver: "n/a".to_string(),
229                driver_info: "n/a".to_string(),
230                backend: wgpu::Backend::Empty,
231            },
232        }
233    }
234
235    /// Runs the host's optional pre-present notification.
236    fn pre_present_notify(&self) {
237        match self {
238            Self::Surface(surface) => surface.pre_present_notify(),
239            Self::Detached(context) => context.pre_present_notify(),
240        }
241    }
242
243    /// Best-effort resize that deliberately discards errors and outcomes.
244    fn resize(&mut self, new_size: PhysicalExtent) {
245        let _ = self.try_resize(new_size);
246    }
247
248    /// Reports native attachment state; detached contexts always report detached.
249    fn attachment_state(&self) -> SurfaceAttachmentState {
250        match self {
251            Self::Surface(bundle) => bundle.attachment_state(),
252            Self::Detached(_) => SurfaceAttachmentState::Detached,
253        }
254    }
255
256    /// Borrows native or virtual target configuration.
257    fn surface_config(&self) -> Option<&wgpu::SurfaceConfiguration> {
258        match self {
259            Self::Surface(bundle) => bundle.config(),
260            Self::Detached(context) => Some(&context.config),
261        }
262    }
263
264    /// Mutably borrows the native surface or returns a typed availability error.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`RendererError::RenderTargetUnavailable`] when this backend owns
269    /// only a detached render context.
270    fn require_surface_mut(
271        &mut self,
272    ) -> Result<&mut WgpuSurfaceBundle, crate::error::RendererError> {
273        self.surface_mut()
274            .ok_or(crate::error::RendererError::RenderTargetUnavailable(
275                "renderer is not surface-backed",
276            ))
277    }
278}
279
280/// Per-frame metrics for isolated offscreen rendering (`AILLOLI_UI_GPU_DEBUG=1`).
281///
282/// Pixel and byte fields are accumulated with integer counters during a frame.
283/// A default value represents a frame that performed no isolated work.
284///
285/// # Examples
286///
287/// ```
288/// use ailloli_ui_render_wgpu::IsolatedFrameMetrics;
289///
290/// let metrics = IsolatedFrameMetrics::default();
291/// assert_eq!(metrics.isolated_pass_count, 0);
292/// assert_eq!(metrics.pool_reuse_ratio(), 0.0);
293/// ```
294#[derive(Debug, Default, Clone, Copy)]
295pub struct IsolatedFrameMetrics {
296    /// Number of offscreen isolated content passes rendered this frame.
297    pub isolated_pass_count: u32,
298    /// Total physical pixels covered by isolated offscreen targets.
299    pub offscreen_pixels_rendered: u64,
300    /// Total physical pixels processed by blur passes.
301    pub blur_pixels_total: u64,
302    /// Peak bytes retained by the offscreen surface pool.
303    pub offscreen_peak_bytes: u64,
304    /// Number of offscreen leases satisfied by reusable allocations.
305    pub pool_reuse_hits: u32,
306    /// Number of new offscreen allocations made this frame.
307    pub pool_allocs: u32,
308    /// Number of separable blur GPU passes recorded.
309    pub blur_pass_count: u32,
310    /// Number of isolated targets that required stencil attachments.
311    pub stencil_offscreen_count: u32,
312    /// Legacy count of nested-isolation downgrades.
313    pub downgrade_nested_isolated: u32,
314    /// Legacy count of oversized-target downgrades.
315    pub downgrade_oversized: u32,
316    /// Typed downgrade counts accumulated by the active budget policy.
317    pub downgrades: IsolatedDowngradeCounts,
318    /// Number of backdrop regions copied before isolated rendering.
319    pub backdrop_capture_count: u32,
320    /// Total physical pixels copied for backdrop capture.
321    pub backdrop_pixels_total: u64,
322    /// Number of blur passes applied to captured backdrop regions.
323    pub backdrop_blur_pass_count: u32,
324    /// Number of destination regions captured for non-normal blending.
325    pub blend_capture_count: u32,
326    /// Number of shader blend composites recorded.
327    pub blend_composite_count: u32,
328}
329
330impl IsolatedFrameMetrics {
331    /// Returns the total number of typed and legacy isolation downgrades.
332    ///
333    /// Addition uses ordinary `u32` arithmetic and therefore follows Rust's
334    /// debug-overflow checks; real frame counts remain far below the limit.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use ailloli_ui_render_wgpu::IsolatedFrameMetrics;
340    ///
341    /// let mut metrics = IsolatedFrameMetrics::default();
342    /// metrics.downgrade_nested_isolated = 2;
343    /// metrics.downgrade_oversized = 1;
344    /// assert_eq!(metrics.downgrade_count(), 3);
345    /// ```
346    pub fn downgrade_count(&self) -> u32 {
347        self.downgrades.total() + self.downgrade_nested_isolated + self.downgrade_oversized
348    }
349
350    /// Returns reused leases divided by all reuse/allocation outcomes.
351    ///
352    /// The range is `0.0..=1.0`; an empty denominator returns `0.0` instead of
353    /// NaN.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use ailloli_ui_render_wgpu::IsolatedFrameMetrics;
359    ///
360    /// let metrics = IsolatedFrameMetrics {
361    ///     pool_reuse_hits: 3,
362    ///     pool_allocs: 1,
363    ///     ..IsolatedFrameMetrics::default()
364    /// };
365    /// assert_eq!(metrics.pool_reuse_ratio(), 0.75);
366    /// ```
367    pub fn pool_reuse_ratio(&self) -> f64 {
368        let denom = self.pool_reuse_hits + self.pool_allocs;
369        if denom == 0 {
370            0.0
371        } else {
372            self.pool_reuse_hits as f64 / denom as f64
373        }
374    }
375}
376
377/// Options passed when creating a [`Renderer`].
378///
379/// # Examples
380///
381/// ```
382/// use ailloli_ui_render_wgpu::RendererOptions;
383///
384/// let options = RendererOptions::default();
385/// assert!(!options.transparent);
386/// assert!(options.bootstrap.is_none());
387/// assert!(options.isolated_budget.is_none());
388/// ```
389#[derive(Debug, Clone, Copy, Default)]
390pub struct RendererOptions {
391    /// Use a transparent swapchain clear (client chrome / rounded window).
392    pub transparent: bool,
393    /// Optional bootstrap override (desktop path).
394    pub bootstrap: Option<crate::pipeline_cache::SurfaceBootstrapConfig>,
395    /// Offscreen isolated budgets (`None` = [`IsolatedBudgetConfig::default`]).
396    pub isolated_budget: Option<IsolatedBudgetConfig>,
397}
398
399impl RendererOptions {
400    /// Sets an explicit native-surface bootstrap policy.
401    ///
402    /// # Examples
403    ///
404    /// ```
405    /// use ailloli_ui_render_wgpu::{
406    ///     pipeline_cache::SurfaceBootstrapConfig,
407    ///     RendererOptions,
408    /// };
409    ///
410    /// let options = RendererOptions::default()
411    ///     .with_bootstrap(SurfaceBootstrapConfig::vulkan_only());
412    /// assert!(!options.bootstrap_config().allow_fallback_backends);
413    /// ```
414    pub fn with_bootstrap(
415        mut self,
416        bootstrap: crate::pipeline_cache::SurfaceBootstrapConfig,
417    ) -> Self {
418        self.bootstrap = Some(bootstrap);
419        self
420    }
421
422    /// Returns the explicit bootstrap policy or its permissive default.
423    ///
424    /// # Examples
425    ///
426    /// ```
427    /// use ailloli_ui_render_wgpu::RendererOptions;
428    ///
429    /// assert!(RendererOptions::default().bootstrap_config().allow_fallback_backends);
430    /// ```
431    pub fn bootstrap_config(&self) -> crate::pipeline_cache::SurfaceBootstrapConfig {
432        self.bootstrap.unwrap_or_default()
433    }
434}
435
436/// One scene layer: draw commands plus a non-destructive clip stack.
437///
438/// `isolated == true` requests an offscreen pass when [`IsolatedEffects::needs_offscreen`]
439/// is true (opacity < 1, blur, etc.). isolated compositor renders content to a pooled
440/// texture, runs the effect chain, then composites into the main pass.
441///
442/// # Examples
443///
444/// ```
445/// use ailloli_ui_render_wgpu::LayerPass;
446///
447/// let layer = LayerPass::new(&[]);
448/// assert!(layer.cmds.is_empty());
449/// assert!(!layer.isolated);
450/// ```
451#[derive(Debug, Clone)]
452pub struct LayerPass<'a> {
453    /// Draw commands in stable painter's order.
454    pub cmds: &'a [DrawCmd],
455    /// Immutable snapshot of nested clip entries for this layer.
456    pub clip: ClipStackSnapshot,
457    /// Resolved scissor/shader/stencil policy for [`Self::clip`].
458    pub clip_plan: RenderClipPlan,
459    /// Whether the runtime requested isolated compositing.
460    pub isolated: bool,
461    /// Nesting depth from runtime (`PaintContext`); 0 for flat isolated layers.
462    pub isolated_depth: u8,
463    /// Opacity, filters, backdrop, and blend effects for isolated compositing.
464    pub effects: IsolatedEffects,
465}
466
467impl<'a> LayerPass<'a> {
468    /// Creates an unclipped, non-isolated layer.
469    ///
470    /// # Examples
471    ///
472    /// ```
473    /// use ailloli_ui_render_wgpu::LayerPass;
474    ///
475    /// let layer = LayerPass::new(&[]);
476    /// assert!(layer.clip.is_empty());
477    /// assert!(!layer.isolated);
478    /// ```
479    pub fn new(cmds: &'a [DrawCmd]) -> Self {
480        Self::with_clip_stack(cmds, ClipStackSnapshot::empty())
481    }
482
483    /// Creates a non-isolated layer and resolves its clip rendering plan.
484    ///
485    /// Command count participates in choosing shader versus stencil clipping.
486    ///
487    /// # Examples
488    ///
489    /// ```
490    /// use ailloli_ui_render_wgpu::LayerPass;
491    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
492    ///
493    /// let layer = LayerPass::with_clip_stack(&[], ClipStackSnapshot::empty());
494    /// assert!(layer.clip.is_empty());
495    /// ```
496    pub fn with_clip_stack(cmds: &'a [DrawCmd], clip: ClipStackSnapshot) -> Self {
497        let clip_plan = resolve_clip_render_plan(&clip, cmds.len());
498        Self {
499            cmds,
500            clip,
501            clip_plan,
502            isolated: false,
503            isolated_depth: 0,
504            effects: IsolatedEffects::default(),
505        }
506    }
507
508    /// Converts runtime scene-layer metadata into a renderer layer.
509    ///
510    /// `isolated_depth` is the runtime nesting depth; zero represents a flat
511    /// isolated layer. Effects are retained even when isolation later
512    /// downgrades under budget pressure.
513    ///
514    /// # Examples
515    ///
516    /// ```
517    /// use ailloli_ui_render_wgpu::LayerPass;
518    /// use ailloli_ui_runtime::{scene::ClipStackSnapshot, IsolatedEffects};
519    ///
520    /// let layer = LayerPass::from_scene_layer(
521    ///     &[],
522    ///     ClipStackSnapshot::empty(),
523    ///     true,
524    ///     1,
525    ///     IsolatedEffects::default(),
526    /// );
527    /// assert!(layer.isolated);
528    /// assert_eq!(layer.isolated_depth, 1);
529    /// ```
530    pub fn from_scene_layer(
531        cmds: &'a [DrawCmd],
532        clip: ClipStackSnapshot,
533        isolated: bool,
534        isolated_depth: u8,
535        effects: IsolatedEffects,
536    ) -> Self {
537        let clip_plan = resolve_clip_render_plan(&clip, cmds.len());
538        Self {
539            cmds,
540            clip,
541            clip_plan,
542            isolated,
543            isolated_depth,
544            effects,
545        }
546    }
547
548    /// Creates a non-isolated layer with one ordinary clip shape.
549    ///
550    /// # Examples
551    ///
552    /// ```
553    /// use ailloli_ui_core::{ClipShape, Rect};
554    /// use ailloli_ui_render_wgpu::LayerPass;
555    ///
556    /// let layer = LayerPass::with_clip(
557    ///     &[],
558    ///     ClipShape::Rect(Rect::new(0.0, 0.0, 20.0, 10.0)),
559    /// );
560    /// assert!(!layer.clip.is_empty());
561    /// ```
562    pub fn with_clip(cmds: &'a [DrawCmd], clip: ClipShape) -> Self {
563        Self::with_clip_stack(cmds, ClipStackSnapshot::from_clip(Some(clip), false))
564    }
565
566    /// Creates a clipped layer whose single clip is the window root.
567    ///
568    /// Root status can force stencil selection for rounded window chrome.
569    ///
570    /// # Examples
571    ///
572    /// ```
573    /// use ailloli_ui_core::{ClipShape, Rect};
574    /// use ailloli_ui_render_wgpu::LayerPass;
575    ///
576    /// let layer = LayerPass::with_window_root_clip(
577    ///     &[],
578    ///     ClipShape::Rect(Rect::new(0.0, 0.0, 20.0, 10.0)),
579    /// );
580    /// assert!(!layer.clip.is_empty());
581    /// ```
582    pub fn with_window_root_clip(cmds: &'a [DrawCmd], clip: ClipShape) -> Self {
583        Self::with_clip_stack(cmds, ClipStackSnapshot::from_clip(Some(clip), true))
584    }
585
586    /// Isolated layer with default effects (collapsed into main pass if noop).
587    ///
588    /// # Examples
589    ///
590    /// ```
591    /// use ailloli_ui_render_wgpu::LayerPass;
592    ///
593    /// let layer = LayerPass::new_isolated(&[]);
594    /// assert!(layer.isolated);
595    /// ```
596    pub fn new_isolated(cmds: &'a [DrawCmd]) -> Self {
597        Self::with_clip_stack_isolated(cmds, ClipStackSnapshot::empty())
598    }
599
600    /// Same as `with_clip_stack` but marks the layer as `isolated`.
601    ///
602    /// # Examples
603    ///
604    /// ```
605    /// use ailloli_ui_render_wgpu::LayerPass;
606    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
607    ///
608    /// let layer = LayerPass::with_clip_stack_isolated(&[], ClipStackSnapshot::empty());
609    /// assert!(layer.isolated);
610    /// ```
611    pub fn with_clip_stack_isolated(cmds: &'a [DrawCmd], clip: ClipStackSnapshot) -> Self {
612        Self::with_clip_stack_isolated_effects(cmds, clip, IsolatedEffects::default())
613    }
614
615    /// Creates an isolated layer with an explicit effect set.
616    ///
617    /// The layer may later collapse into the main pass when the effects are a
618    /// no-op or isolation is downgraded by the configured budget.
619    ///
620    /// # Examples
621    ///
622    /// ```
623    /// use ailloli_ui_render_wgpu::LayerPass;
624    /// use ailloli_ui_runtime::{scene::ClipStackSnapshot, IsolatedEffects};
625    ///
626    /// let effects = IsolatedEffects::default();
627    /// let layer = LayerPass::with_clip_stack_isolated_effects(
628    ///     &[], ClipStackSnapshot::empty(), effects,
629    /// );
630    /// assert!(layer.isolated);
631    /// ```
632    pub fn with_clip_stack_isolated_effects(
633        cmds: &'a [DrawCmd],
634        clip: ClipStackSnapshot,
635        effects: IsolatedEffects,
636    ) -> Self {
637        let mut layer = Self::with_clip_stack(cmds, clip);
638        layer.isolated = true;
639        layer.effects = effects;
640        layer
641    }
642}
643
644/// GPU uniform storage and bind group for one clip parameter set.
645struct ClipBinding {
646    /// Retains the uniform buffer for at least as long as its bind group.
647    _buffer: wgpu::Buffer,
648    /// Bind group consumed by clip-aware pipelines.
649    bind_group: wgpu::BindGroup,
650}
651
652/// Clip bindings selected per planned layer.
653struct PerLayerBindings {
654    /// Unclipped binding used when batches bypass shader clipping.
655    none_clip: ClipBinding,
656    /// Shape binding for shader-mask batches; absent when not needed.
657    shape_clip: Option<ClipBinding>,
658}
659
660/// Optional per-frame vertex buffers uploaded before the main render pass.
661struct MainPassGpuBuffers {
662    /// Solid rectangle vertex arena.
663    vertex_buf: Option<wgpu::Buffer>,
664    /// Rounded rectangle vertex arena.
665    rrect_buf: Option<wgpu::Buffer>,
666    /// Rounded border vertex arena.
667    border_rrect_buf: Option<wgpu::Buffer>,
668    /// Box shadow vertex arena.
669    shadow_buf: Option<wgpu::Buffer>,
670    /// Progress ring vertex arena.
671    ring_progress_buf: Option<wgpu::Buffer>,
672    /// Polyline stroke vertex arena.
673    stroke_buf: Option<wgpu::Buffer>,
674    /// Textured glyph/image/icon vertex arena.
675    tex_buf: Option<wgpu::Buffer>,
676    /// Rounded stencil mask vertices.
677    stencil_mask_buf: Option<wgpu::Buffer>,
678    /// Isolated-surface composite vertices.
679    composite_buf: Option<wgpu::Buffer>,
680    /// Clip bindings in planned-layer order.
681    per_layer: Vec<PerLayerBindings>,
682}
683
684/// Creates the stencil attachment for a pass that needs stencil clipping.
685///
686/// Returns `None` when stencil is not needed or no target is available. The
687/// attachment clears stencil to zero and stores the final contents.
688fn stencil_depth_attachment<'a>(
689    stencil_target: &'a Option<StencilTarget>,
690    needs: bool,
691) -> Option<wgpu::RenderPassDepthStencilAttachment<'a>> {
692    if !needs {
693        return None;
694    }
695    stencil_target
696        .as_ref()
697        .map(|target| wgpu::RenderPassDepthStencilAttachment {
698            view: &target.view,
699            depth_ops: None,
700            stencil_ops: Some(wgpu::Operations {
701                load: wgpu::LoadOp::Clear(0),
702                store: wgpu::StoreOp::Store,
703            }),
704        })
705}
706
707/// Records nonempty glyph-atlas activity in the benchmark event stream.
708///
709/// Frames with no hits, misses, rasterization, resets, blocked evictions, or
710/// skips are omitted even when pages remain active.
711fn record_text_atlas_frame(stats: TextAtlasStats) {
712    if stats.hits
713        + stats.misses
714        + stats.rasterized
715        + stats.resets
716        + stats.evictions_blocked
717        + stats.glyphs_skipped
718        == 0
719    {
720        return;
721    }
722
723    ailloli_ui_bench::record(ailloli_ui_bench::Event::TextAtlasFrame {
724        ts_ms: now_ms(),
725        hits: stats.hits,
726        misses: stats.misses,
727        rasterized: stats.rasterized,
728        resets: stats.resets,
729        evictions_blocked: stats.evictions_blocked,
730        glyphs_skipped: stats.glyphs_skipped,
731        pages_active: stats.pages_active,
732    });
733}
734
735impl Renderer {
736    /// Borrows the device for host resource creation and command recording.
737    ///
738    /// Resources created here are compatible with this renderer's pipelines and
739    /// caches. The borrow does not transfer device ownership. This works for both
740    /// surface-backed renderers and [`Self::new_with_render_context`].
741    ///
742    /// # Examples
743    ///
744    /// ```no_run
745    /// use ailloli_ui_render_wgpu::Renderer;
746    /// fn encoder(ui: &Renderer) -> wgpu::CommandEncoder {
747    ///     ui.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
748    ///         label: Some("host and UI frame"),
749    ///     })
750    /// }
751    /// ```
752    pub fn device(&self) -> &wgpu::Device {
753        self.gpu.device()
754    }
755
756    /// Borrows the shared queue for host uploads and submission.
757    ///
758    /// Submit the encoder returned by the host on this queue after recording UI.
759    /// UI atlas uploads use this same queue; recording alone does not flush them.
760    ///
761    /// # Examples
762    ///
763    /// ```no_run
764    /// use ailloli_ui_render_wgpu::Renderer;
765    /// fn submit(ui: &Renderer, encoder: wgpu::CommandEncoder) {
766    ///     ui.queue().submit([encoder.finish()]);
767    /// }
768    /// ```
769    pub fn queue(&self) -> &wgpu::Queue {
770        self.gpu.queue()
771    }
772
773    /// Records UI commands into an encoder and target owned by the host.
774    ///
775    /// This method never acquires a frame, submits an encoder, or presents.
776    /// It records complete UI render passes, not commands inside an already-open
777    /// pass. End the host's preceding render pass before calling it. The renderer
778    /// retains its pipelines, glyph/icon atlases, and offscreen pool. Its stencil
779    /// attachment is resized only when the borrowed target's extent changes;
780    /// this does not resize a managed window or reconfigure its native surface.
781    ///
782    /// `scale.dpr` is finite and strictly positive: `1.0` maps one logical UI unit
783    /// to one physical pixel, and `2.0` maps it to two. Target dimensions remain
784    /// physical pixels at either scale. A format change requires a renderer whose
785    /// pipelines were built for that format; resizing alone does not.
786    ///
787    /// # Submission order
788    ///
789    /// Preparation can enqueue atlas writes on [`Self::queue`]. They are executed
790    /// before command buffers in its next submission. Submit this UI recording on
791    /// that queue before recording another frame with the same renderer, including
792    /// calls to its managed render or capture methods. Recording several UI frames
793    /// and submitting them together is unsupported: later atlas updates or pooled
794    /// texture reuse could change resources referenced by earlier commands. A CPU
795    /// wait for GPU completion is not needed between correctly ordered submissions.
796    ///
797    /// Use [`TargetLoadOp::Load`] to overlay an initialized destination, or
798    /// [`TargetLoadOp::Clear`] to replace it. The host may record additional passes
799    /// after UI, but those passes can of course cover the UI pixels.
800    ///
801    /// # Errors
802    ///
803    /// Returns [`TargetRecordingError::InvalidRenderTarget`] for zero/oversized
804    /// extents, a pipeline-format mismatch, nonpositive/nonfinite DPR, an invalid
805    /// supplied texture descriptor, or a missing `COPY_SRC` usage for effects.
806    /// Returns [`TargetRecordingError::FrameTextureUnavailable`] when backdrop
807    /// blur or a non-normal blend is requested without a backing texture. Ordinary
808    /// UI and foreground-only effects may supply just a view.
809    ///
810    /// These checks occur before recording, uploads, or changes to UI caches.
811    /// The encoder remains usable after a returned error. The checks cannot verify
812    /// that a view actually matches its declared texture, metadata, or device;
813    /// follow the full [`BorrowedRenderTarget`] contract.
814    ///
815    /// # Panics
816    ///
817    /// Invalid GPU handles or bindings use wgpu's validation/error handling, which
818    /// may panic. An invalid layer plan can also panic. Arbitrary GPU validation
819    /// or panic failures are not rolled back and are not returned as typed errors.
820    ///
821    /// # Examples
822    ///
823    /// ```no_run
824    /// use ailloli_ui_core::Scale;
825    /// use ailloli_ui_render_wgpu::{
826    ///     BorrowedRenderTarget, LayerPass, Renderer, TargetLoadOp, TargetRecordingError,
827    /// };
828    /// fn overlay(
829    ///     ui: &mut Renderer,
830    ///     target: BorrowedRenderTarget<'_>,
831    ///     layers: &[LayerPass<'_>],
832    /// ) -> Result<(), TargetRecordingError> {
833    ///     // The host has already initialized the target on the shared queue.
834    ///     let mut encoder = ui.device().create_command_encoder(&Default::default());
835    ///     ui.record_layered_to_target_scaled(
836    ///         &mut encoder, target, layers, Scale::new(1.0), TargetLoadOp::Load,
837    ///     )?;
838    ///     ui.queue().submit([encoder.finish()]);
839    ///     // Present the host surface here if there is one.
840    ///     Ok(())
841    /// }
842    /// ```
843    pub fn record_layered_to_target_scaled(
844        &mut self,
845        encoder: &mut wgpu::CommandEncoder,
846        target: BorrowedRenderTarget<'_>,
847        layers: &[LayerPass<'_>],
848        scale: Scale,
849        load_op: TargetLoadOp,
850    ) -> Result<(), TargetRecordingError> {
851        let limit = self.device().limits().max_texture_dimension_2d;
852        if target.size.is_zero() || target.size.width > limit || target.size.height > limit {
853            return Err(TargetRecordingError::InvalidRenderTarget("invalid extent"));
854        }
855        if target.format != self.gpu.format() {
856            return Err(TargetRecordingError::InvalidRenderTarget(
857                "pipeline format mismatch",
858            ));
859        }
860        if !scale.dpr.is_finite() || scale.dpr <= 0.0 {
861            return Err(TargetRecordingError::InvalidRenderTarget("invalid scale"));
862        }
863        let needs_copy = layers.iter().any(|layer| {
864            layer.effects.backdrop_blur_radius_px > 0.0
865                || layer.effects.blend_mode != BlendMode::Normal
866        });
867        if needs_copy && target.texture.is_none() {
868            return Err(TargetRecordingError::FrameTextureUnavailable);
869        }
870        if let Some(texture) = target.texture {
871            if texture.width() != target.size.width
872                || texture.height() != target.size.height
873                || texture.depth_or_array_layers() != 1
874                || texture.dimension() != wgpu::TextureDimension::D2
875                || texture.sample_count() != 1
876                || texture.format() != target.format
877                || !texture
878                    .usage()
879                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
880            {
881                return Err(TargetRecordingError::InvalidRenderTarget(
882                    "texture descriptor mismatch",
883                ));
884            }
885            if needs_copy && !texture.usage().contains(wgpu::TextureUsages::COPY_SRC) {
886                return Err(TargetRecordingError::InvalidRenderTarget(
887                    "effects require COPY_SRC",
888                ));
889            }
890        }
891        self.record_single_pass(
892            target.view,
893            target.texture,
894            encoder,
895            target.size.width as f32,
896            target.size.height as f32,
897            scale,
898            load_op,
899            layers,
900        );
901        Ok(())
902    }
903
904    /// Creates a renderer from an owned raw-window-handle provider.
905    ///
906    /// Presentation adapters are responsible for converting their native size
907    /// to [`PhysicalExtent`] and for supplying an optional pre-present hook.
908    ///
909    /// # Errors
910    ///
911    /// Returns an error when raw handles cannot create a surface, no compatible
912    /// adapter/device can be opened, or all surface configurations fail.
913    ///
914    /// # Panics
915    ///
916    /// May panic if wgpu rejects renderer shader or pipeline creation.
917    ///
918    /// # Examples
919    ///
920    /// ```no_run
921    /// use std::sync::Arc;
922    /// use ailloli_ui_render_wgpu::{PhysicalExtent, Renderer, RendererError, RendererOptions};
923    ///
924    /// fn create<T>(target: Arc<T>) -> Result<Renderer, RendererError>
925    /// where
926    ///     T: wgpu::rwh::HasWindowHandle
927    ///         + wgpu::rwh::HasDisplayHandle
928    ///         + Send
929    ///         + Sync
930    ///         + 'static,
931    /// {
932    ///     Renderer::new_with_surface_target(
933    ///         target,
934    ///         PhysicalExtent::new(1280, 720),
935    ///         RendererOptions::default(),
936    ///         None,
937    ///     )
938    /// }
939    /// ```
940    pub fn new_with_surface_target<T>(
941        target: Arc<T>,
942        size: PhysicalExtent,
943        options: RendererOptions,
944        pre_present: Option<Arc<dyn Fn() + Send + Sync>>,
945    ) -> Result<Self, RendererError>
946    where
947        T: wgpu::rwh::HasWindowHandle + wgpu::rwh::HasDisplayHandle + Send + Sync + 'static,
948    {
949        let gpu = WgpuSurfaceBundle::new_with_surface_target(
950            target,
951            size,
952            options.transparent,
953            options.bootstrap_config(),
954            pre_present,
955        )?;
956        Self::new_from_backend(RenderBackend::Surface(Box::new(gpu)), options)
957    }
958
959    /// Allocates renderer caches and the initial stencil target for `gpu`.
960    ///
961    /// The current implementation is fallible for forward compatibility but
962    /// performs only non-fallible CPU initialization and wgpu resource creation.
963    ///
964    /// # Errors
965    ///
966    /// The current implementation never returns `Err`; the result preserves a
967    /// fallible construction boundary for future cache initialization failures.
968    /// wgpu validation failures may panic as described by its resource APIs.
969    fn new_from_backend(
970        gpu: RenderBackend,
971        options: RendererOptions,
972    ) -> Result<Self, RendererError> {
973        let text_atlas = TextAtlas::new(
974            gpu.device(),
975            gpu.queue(),
976            &gpu.pipelines().texture_bind_group_layout,
977        );
978        let text_metrics = FontMetrics::new();
979
980        let extent = gpu.extent();
981        let stencil_target = StencilTarget::new(gpu.device(), extent.width, extent.height);
982
983        Ok(Self {
984            gpu,
985            icon_cache: IconCache::new(),
986            text_atlas,
987            text_metrics,
988            text_face_blobs: Arc::new(HashMap::new()),
989            stencil_target: Some(stencil_target),
990            offscreen_pool: OffscreenSurfacePool::default(),
991            effect_pipelines: None,
992            composite_blend_pipelines: None,
993            isolated_metrics: IsolatedFrameMetrics::default(),
994            frame_leases: Vec::new(),
995            isolated_budget: options.isolated_budget.unwrap_or_default(),
996            bench_scenario: ailloli_ui_bench::bench_scenario_from_env(),
997        })
998    }
999
1000    /// Creates a renderer for an externally managed render target.
1001    ///
1002    /// The renderer retains `context` but owns no native surface; swapchain-only
1003    /// entry points return [`RendererError::RenderTargetUnavailable`].
1004    ///
1005    /// # Panics
1006    ///
1007    /// May panic if wgpu rejects atlas or stencil resource creation.
1008    ///
1009    /// # Examples
1010    ///
1011    /// ```no_run
1012    /// use ailloli_ui_render_wgpu::{Renderer, RendererOptions, WgpuRenderContext};
1013    ///
1014    /// fn create(context: WgpuRenderContext) -> Renderer {
1015    ///     Renderer::new_with_render_context(context, RendererOptions::default())
1016    /// }
1017    /// ```
1018    pub fn new_with_render_context(context: WgpuRenderContext, options: RendererOptions) -> Self {
1019        Self::new_from_backend(RenderBackend::Detached(Box::new(context)), options)
1020            .unwrap_or_else(|_| unreachable!("WgpuRenderContext constructor is non-fallible"))
1021    }
1022
1023    /// Compatibility alias for [`Self::new_with_render_context`].
1024    ///
1025    /// `options.bootstrap` has no effect until a surface-backed renderer is
1026    /// bootstrapped; detached contexts already own their device and queue.
1027    ///
1028    /// # Examples
1029    ///
1030    /// ```no_run
1031    /// use ailloli_ui_render_wgpu::{Renderer, RendererOptions, WgpuRenderContext};
1032    ///
1033    /// fn create(context: WgpuRenderContext) -> Renderer {
1034    ///     Renderer::new_with_render_context_and_bootstrap(
1035    ///         context,
1036    ///         RendererOptions::default(),
1037    ///     )
1038    /// }
1039    /// ```
1040    pub fn new_with_render_context_and_bootstrap(
1041        context: WgpuRenderContext,
1042        options: RendererOptions,
1043    ) -> Self {
1044        Self::new_with_render_context(context, options)
1045    }
1046
1047    /// Returns the active per-frame isolated-rendering budget.
1048    ///
1049    /// # Examples
1050    ///
1051    /// ```no_run
1052    /// use ailloli_ui_render_wgpu::{IsolatedBudgetConfig, Renderer};
1053    ///
1054    /// fn budget(renderer: &Renderer) -> IsolatedBudgetConfig {
1055    ///     renderer.isolated_budget_config()
1056    /// }
1057    /// ```
1058    pub fn isolated_budget_config(&self) -> IsolatedBudgetConfig {
1059        self.isolated_budget
1060    }
1061
1062    /// Replaces the budget used when planning subsequent frames.
1063    ///
1064    /// This does not mutate metrics from the previously completed frame.
1065    ///
1066    /// # Examples
1067    ///
1068    /// ```no_run
1069    /// use ailloli_ui_render_wgpu::{IsolatedBudgetConfig, Renderer};
1070    ///
1071    /// fn reset_budget(renderer: &mut Renderer) {
1072    ///     renderer.set_isolated_budget_config(IsolatedBudgetConfig::default());
1073    /// }
1074    /// ```
1075    pub fn set_isolated_budget_config(&mut self, config: IsolatedBudgetConfig) {
1076        self.isolated_budget = config;
1077    }
1078
1079    /// Returns the renderer's reusable font metrics engine.
1080    ///
1081    /// # Examples
1082    ///
1083    /// ```no_run
1084    /// use ailloli_ui_render_wgpu::Renderer;
1085    /// use ailloli_ui_text::FontMetrics;
1086    ///
1087    /// fn metrics(renderer: &Renderer) -> &FontMetrics {
1088    ///     renderer.text_measurer()
1089    /// }
1090    /// ```
1091    pub fn text_measurer(&self) -> &FontMetrics {
1092        &self.text_metrics
1093    }
1094
1095    /// Replaces font bytes indexed by runtime face identifier.
1096    ///
1097    /// The shared map is retained without copying. An absent face causes glyph
1098    /// preparation to record a missing-face event and skip that glyph.
1099    ///
1100    /// # Examples
1101    ///
1102    /// ```no_run
1103    /// use std::{collections::HashMap, sync::Arc};
1104    /// use ailloli_ui_render_wgpu::Renderer;
1105    ///
1106    /// fn clear_faces(renderer: &mut Renderer) {
1107    ///     renderer.set_text_face_blobs(Arc::new(HashMap::new()));
1108    /// }
1109    /// ```
1110    pub fn set_text_face_blobs(&mut self, blobs: Arc<HashMap<u64, Arc<[u8]>>>) {
1111        self.text_face_blobs = blobs;
1112    }
1113
1114    /// Best-effort resize of the active backend and stencil target.
1115    ///
1116    /// This method discards surface errors and recreates stencil storage even
1117    /// when native configuration was skipped or deferred. Prefer
1118    /// [`Self::try_resize`] when the host needs the precise outcome.
1119    ///
1120    /// # Examples
1121    ///
1122    /// ```no_run
1123    /// use ailloli_ui_render_wgpu::{PhysicalExtent, Renderer};
1124    ///
1125    /// fn resize(renderer: &mut Renderer) {
1126    ///     renderer.resize(PhysicalExtent::new(1920, 1080));
1127    /// }
1128    /// ```
1129    pub fn resize(&mut self, new_size: PhysicalExtent) {
1130        self.gpu.resize(new_size);
1131        if let Some(st) = self.stencil_target.as_mut() {
1132            st.recreate(self.gpu.device(), new_size.width, new_size.height);
1133        }
1134    }
1135
1136    /// Resizes the backend and recreates stencil storage after an applied change.
1137    ///
1138    /// `new_size` is in physical pixels. Zero dimensions are skipped; an
1139    /// unchanged extent leaves all resources intact.
1140    ///
1141    /// # Errors
1142    ///
1143    /// Surface-backed renderers propagate unavailability, recreation-required,
1144    /// and configuration failures. Detached contexts resize their virtual config
1145    /// without error.
1146    ///
1147    /// # Examples
1148    ///
1149    /// ```no_run
1150    /// use ailloli_ui_render_wgpu::{PhysicalExtent, Renderer, RendererError, ResizeOutcome};
1151    ///
1152    /// fn resize(renderer: &mut Renderer) -> Result<ResizeOutcome, RendererError> {
1153    ///     renderer.try_resize(PhysicalExtent::new(1920, 1080))
1154    /// }
1155    /// ```
1156    pub fn try_resize(&mut self, new_size: PhysicalExtent) -> Result<ResizeOutcome, RendererError> {
1157        let previous_size = self.gpu.extent();
1158        let out = self.gpu.try_resize(new_size);
1159        if matches!(out, Ok(ResizeOutcome::Applied)) && previous_size != new_size {
1160            if let Some(st) = self.stencil_target.as_mut() {
1161                st.recreate(self.gpu.device(), new_size.width, new_size.height);
1162            }
1163        }
1164        out
1165    }
1166
1167    /// Forces `Surface::configure`, including when the requested extent equals
1168    /// the current extent.
1169    ///
1170    /// This is the recovery entry point for `SurfaceError::Lost` and
1171    /// `SurfaceError::Outdated`. A detached render context has no presentation
1172    /// surface and therefore returns [`RendererError::RenderTargetUnavailable`].
1173    ///
1174    /// # Errors
1175    ///
1176    /// Propagates surface unavailability, format-incompatibility, or native
1177    /// configuration failure. Zero-sized requests return
1178    /// [`ResizeOutcome::SkippedZero`].
1179    ///
1180    /// # Examples
1181    ///
1182    /// ```no_run
1183    /// use ailloli_ui_render_wgpu::{PhysicalExtent, Renderer, RendererError, ResizeOutcome};
1184    ///
1185    /// fn recover(renderer: &mut Renderer) -> Result<ResizeOutcome, RendererError> {
1186    ///     renderer.try_reconfigure_surface(PhysicalExtent::new(1280, 720))
1187    /// }
1188    /// ```
1189    pub fn try_reconfigure_surface(
1190        &mut self,
1191        new_size: PhysicalExtent,
1192    ) -> Result<ResizeOutcome, RendererError> {
1193        let previous_size = self.gpu.extent();
1194        let out = self.gpu.try_reconfigure_surface(new_size);
1195        if matches!(out, Ok(ResizeOutcome::Applied)) && previous_size != new_size {
1196            if let Some(st) = self.stencil_target.as_mut() {
1197                st.recreate(self.gpu.device(), new_size.width, new_size.height);
1198            }
1199        }
1200        out
1201    }
1202
1203    /// Drops the native surface while retaining the GPU context and all
1204    /// device-bound caches for a future presentation reattachment.
1205    ///
1206    /// Returns `true` when an attachment was present. Detached render-context
1207    /// mode has no owned native surface and returns `false`.
1208    ///
1209    /// Outstanding offscreen frame leases are released first. Device-bound
1210    /// pipelines, atlases, and caches remain valid for compatible reattachment.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```no_run
1215    /// use ailloli_ui_render_wgpu::{Renderer, SurfaceAttachmentState};
1216    ///
1217    /// fn suspend(renderer: &mut Renderer) {
1218    ///     let _had_attachment = renderer.detach_surface();
1219    ///     assert_eq!(renderer.surface_attachment_state(), SurfaceAttachmentState::Detached);
1220    /// }
1221    /// ```
1222    pub fn detach_surface(&mut self) -> bool {
1223        self.frame_leases.clear();
1224        match &mut self.gpu {
1225            RenderBackend::Surface(bundle) => bundle.detach_surface(),
1226            RenderBackend::Detached(_) => false,
1227        }
1228    }
1229
1230    /// Reattaches a host target through its raw window/display handle traits.
1231    ///
1232    /// The current instance, adapter, device, queue, pipelines, atlases, and
1233    /// caches are reused when compatible. Otherwise the surface bootstrap is
1234    /// repeated and every device-bound renderer resource is rebuilt safely.
1235    /// `size` is in physical pixels.
1236    ///
1237    /// # Errors
1238    ///
1239    /// Detached-context renderers cannot acquire surface ownership. Surface
1240    /// renderers propagate raw-handle, adapter, device, and configuration errors.
1241    ///
1242    /// # Panics
1243    ///
1244    /// A required context rebuild may panic if wgpu rejects renderer pipeline
1245    /// creation.
1246    ///
1247    /// # Examples
1248    ///
1249    /// ```no_run
1250    /// use std::sync::Arc;
1251    /// use ailloli_ui_render_wgpu::{
1252    ///     PhysicalExtent, Renderer, RendererError, SurfaceReattachOutcome,
1253    /// };
1254    ///
1255    /// fn resume<T>(
1256    ///     renderer: &mut Renderer,
1257    ///     target: Arc<T>,
1258    /// ) -> Result<SurfaceReattachOutcome, RendererError>
1259    /// where
1260    ///     T: wgpu::rwh::HasWindowHandle
1261    ///         + wgpu::rwh::HasDisplayHandle
1262    ///         + Send
1263    ///         + Sync
1264    ///         + 'static,
1265    /// {
1266    ///     renderer.reattach_surface_target(target, PhysicalExtent::new(1280, 720), None)
1267    /// }
1268    /// ```
1269    pub fn reattach_surface_target<T>(
1270        &mut self,
1271        target: Arc<T>,
1272        size: PhysicalExtent,
1273        pre_present: Option<Arc<dyn Fn() + Send + Sync>>,
1274    ) -> Result<SurfaceReattachOutcome, RendererError>
1275    where
1276        T: wgpu::rwh::HasWindowHandle + wgpu::rwh::HasDisplayHandle + Send + Sync + 'static,
1277    {
1278        self.frame_leases.clear();
1279        let RenderBackend::Surface(bundle) = &mut self.gpu else {
1280            return Err(RendererError::RenderTargetUnavailable(
1281                "a detached render context cannot own a presentation surface",
1282            ));
1283        };
1284        let outcome = bundle.reattach_surface_target(target, size, pre_present)?;
1285        if matches!(outcome, SurfaceReattachOutcome::RebuiltGpuContext { .. }) {
1286            self.rebuild_device_bound_resources();
1287        } else if let Some(stencil) = self.stencil_target.as_mut() {
1288            stencil.recreate(self.gpu.device(), size.width.max(1), size.height.max(1));
1289        }
1290        Ok(outcome)
1291    }
1292
1293    /// Drops and recreates every resource tied to the current wgpu device.
1294    ///
1295    /// CPU font-face blobs, metrics, budget configuration, and benchmark
1296    /// scenario survive; GPU atlases, pools, pipelines, metrics, and leases reset.
1297    fn rebuild_device_bound_resources(&mut self) {
1298        self.frame_leases.clear();
1299        self.icon_cache = IconCache::new();
1300        self.text_atlas = TextAtlas::new(
1301            self.gpu.device(),
1302            self.gpu.queue(),
1303            &self.gpu.pipelines().texture_bind_group_layout,
1304        );
1305        self.offscreen_pool = OffscreenSurfacePool::default();
1306        self.effect_pipelines = None;
1307        self.composite_blend_pipelines = None;
1308        self.isolated_metrics = IsolatedFrameMetrics::default();
1309        let extent = self.gpu.extent();
1310        self.stencil_target = Some(StencilTarget::new(
1311            self.gpu.device(),
1312            extent.width.max(1),
1313            extent.height.max(1),
1314        ));
1315    }
1316
1317    /// Reports whether this renderer currently owns a native attachment.
1318    ///
1319    /// Externally managed render contexts always report `Detached` because the
1320    /// renderer does not own their targets.
1321    ///
1322    /// # Examples
1323    ///
1324    /// ```no_run
1325    /// use ailloli_ui_render_wgpu::{Renderer, SurfaceAttachmentState};
1326    ///
1327    /// fn state(renderer: &Renderer) -> SurfaceAttachmentState {
1328    ///     renderer.surface_attachment_state()
1329    /// }
1330    /// ```
1331    pub fn surface_attachment_state(&self) -> SurfaceAttachmentState {
1332        self.gpu.attachment_state()
1333    }
1334
1335    /// Returns the active presentation configuration, or `None` while the
1336    /// native surface is detached.
1337    ///
1338    /// Detached render contexts return their virtual configuration as `Some`;
1339    /// only a detached native surface returns `None`.
1340    ///
1341    /// # Examples
1342    ///
1343    /// ```no_run
1344    /// use ailloli_ui_render_wgpu::Renderer;
1345    ///
1346    /// fn width(renderer: &Renderer) -> Option<u32> {
1347    ///     renderer.try_surface_config().map(|config| config.width)
1348    /// }
1349    /// ```
1350    pub fn try_surface_config(&self) -> Option<&wgpu::SurfaceConfiguration> {
1351        self.gpu.surface_config()
1352    }
1353
1354    /// Returns the active presentation configuration.
1355    ///
1356    /// Hosts retaining a renderer across suspension should use
1357    /// [`Self::try_surface_config`] while the surface may be detached.
1358    ///
1359    /// # Panics
1360    ///
1361    /// Panics when a native surface-backed renderer is currently detached.
1362    ///
1363    /// # Examples
1364    ///
1365    /// ```no_run
1366    /// use ailloli_ui_render_wgpu::Renderer;
1367    ///
1368    /// fn format(renderer: &Renderer) -> wgpu::TextureFormat {
1369    ///     renderer.surface_config().format
1370    /// }
1371    /// ```
1372    pub fn surface_config(&self) -> &wgpu::SurfaceConfiguration {
1373        self.try_surface_config()
1374            .expect("surface_config called while the presentation surface is detached")
1375    }
1376
1377    /// Returns current native capabilities or a synthetic detached-context set.
1378    ///
1379    /// A detached native surface returns an empty set. A detached render context
1380    /// reports its configured format, FIFO, chosen alpha mode, and usage.
1381    ///
1382    /// # Examples
1383    ///
1384    /// ```no_run
1385    /// use ailloli_ui_render_wgpu::Renderer;
1386    ///
1387    /// fn format_count(renderer: &Renderer) -> usize {
1388    ///     renderer.surface_capabilities().formats.len()
1389    /// }
1390    /// ```
1391    pub fn surface_capabilities(&self) -> wgpu::SurfaceCapabilities {
1392        self.gpu.surface_capabilities()
1393    }
1394
1395    /// Queries native adapter information or a stable detached-context sentinel.
1396    ///
1397    /// The sentinel uses backend `Empty`, zero vendor/device identifiers, and
1398    /// the name `ailloli_ui_render_wgpu detached`.
1399    ///
1400    /// # Examples
1401    ///
1402    /// ```no_run
1403    /// use ailloli_ui_render_wgpu::Renderer;
1404    ///
1405    /// fn adapter_name(renderer: &Renderer) -> String {
1406    ///     renderer.adapter_info().name
1407    /// }
1408    /// ```
1409    pub fn adapter_info(&self) -> wgpu::AdapterInfo {
1410        self.gpu.adapter_info()
1411    }
1412
1413    /// Returns the current presentation deferral reason, if any.
1414    ///
1415    /// Externally managed contexts return `None`; detached native surfaces
1416    /// report `Detached`.
1417    ///
1418    /// # Examples
1419    ///
1420    /// ```no_run
1421    /// use ailloli_ui_render_wgpu::{Renderer, SurfaceConfigDeferredReason};
1422    ///
1423    /// fn reason(renderer: &Renderer) -> Option<SurfaceConfigDeferredReason> {
1424    ///     renderer.surface_config_deferred_reason()
1425    /// }
1426    /// ```
1427    pub fn surface_config_deferred_reason(
1428        &self,
1429    ) -> Option<crate::pipeline_cache::SurfaceConfigDeferredReason> {
1430        self.gpu.surface_config_deferred_reason()
1431    }
1432
1433    /// Metrics from the most recent frame that ran isolated offscreen passes.
1434    ///
1435    /// Frames reset the counters before planning. The returned snapshot is
1436    /// `Copy` and remains valid after subsequent rendering.
1437    ///
1438    /// # Examples
1439    ///
1440    /// ```no_run
1441    /// use ailloli_ui_render_wgpu::{IsolatedFrameMetrics, Renderer};
1442    ///
1443    /// fn metrics(renderer: &Renderer) -> IsolatedFrameMetrics {
1444    ///     renderer.isolated_frame_metrics()
1445    /// }
1446    /// ```
1447    pub fn isolated_frame_metrics(&self) -> IsolatedFrameMetrics {
1448        self.isolated_metrics
1449    }
1450
1451    /// Renders one unclipped command slice to the managed surface at DPR 1.
1452    ///
1453    /// # Errors
1454    ///
1455    /// Returns an error when this renderer has no managed surface, frame
1456    /// acquisition fails, or the acquired frame exposes no copyable texture.
1457    /// Transient capability deferral is treated as a skipped successful frame.
1458    ///
1459    /// # Examples
1460    ///
1461    /// ```no_run
1462    /// use ailloli_ui_core::Color;
1463    /// use ailloli_ui_render_wgpu::{Renderer, RendererError};
1464    ///
1465    /// fn frame(renderer: &mut Renderer) -> Result<(), RendererError> {
1466    ///     renderer.render(Color::BLACK, &[])
1467    /// }
1468    /// ```
1469    pub fn render(&mut self, clear: Color, cmds: &[DrawCmd]) -> Result<(), RendererError> {
1470        self.render_layers(clear, &[cmds])
1471    }
1472
1473    /// Renders multiple unclipped command slices at DPR 1.
1474    ///
1475    /// Layer order is painter's order; empty slices remain valid layers.
1476    ///
1477    /// # Errors
1478    ///
1479    /// Returns the same managed-surface errors as [`Self::render`].
1480    ///
1481    /// # Examples
1482    ///
1483    /// ```no_run
1484    /// use ailloli_ui_core::Color;
1485    /// use ailloli_ui_render_wgpu::{Renderer, RendererError};
1486    /// use ailloli_ui_runtime::DrawCmd;
1487    ///
1488    /// fn frame(renderer: &mut Renderer, commands: &[DrawCmd]) -> Result<(), RendererError> {
1489    ///     renderer.render_layers(Color::BLACK, &[commands, &[]])
1490    /// }
1491    /// ```
1492    pub fn render_layers(
1493        &mut self,
1494        clear: Color,
1495        layers: &[&[DrawCmd]],
1496    ) -> Result<(), RendererError> {
1497        self.render_layers_scaled(clear, layers, Scale::new(1.0))
1498    }
1499
1500    /// Renders multiple unclipped slices with an explicit logical-to-physical scale.
1501    ///
1502    /// # Errors
1503    ///
1504    /// Returns the same managed-surface errors as [`Self::render`].
1505    ///
1506    /// # Examples
1507    ///
1508    /// ```no_run
1509    /// use ailloli_ui_core::{Color, Scale};
1510    /// use ailloli_ui_render_wgpu::{Renderer, RendererError};
1511    ///
1512    /// fn frame(renderer: &mut Renderer) -> Result<(), RendererError> {
1513    ///     renderer.render_layers_scaled(Color::BLACK, &[&[]], Scale::new(2.0))
1514    /// }
1515    /// ```
1516    pub fn render_layers_scaled(
1517        &mut self,
1518        clear: Color,
1519        layers: &[&[DrawCmd]],
1520        scale: Scale,
1521    ) -> Result<(), RendererError> {
1522        let passes: Vec<LayerPass<'_>> = layers.iter().map(|cmds| LayerPass::new(cmds)).collect();
1523        self.render_layered_scaled(clear, &passes, scale)
1524    }
1525
1526    /// Renders layered draw commands to the swapchain (DPR = 1).
1527    ///
1528    /// # Errors
1529    ///
1530    /// Returns the same managed-surface errors as [`Self::render_layered_scaled`].
1531    ///
1532    /// # Examples
1533    ///
1534    /// ```no_run
1535    /// use ailloli_ui_core::Color;
1536    /// use ailloli_ui_render_wgpu::{LayerPass, Renderer, RendererError};
1537    ///
1538    /// fn frame(renderer: &mut Renderer) -> Result<(), RendererError> {
1539    ///     renderer.render_layered(Color::BLACK, &[LayerPass::new(&[])])
1540    /// }
1541    /// ```
1542    pub fn render_layered(
1543        &mut self,
1544        clear: Color,
1545        layers: &[LayerPass<'_>],
1546    ) -> Result<(), RendererError> {
1547        self.render_layered_scaled(clear, layers, Scale::new(1.0))
1548    }
1549
1550    /// Renders layered draw commands with explicit logical-to-physical scale.
1551    ///
1552    /// The method acquires, records, notifies, and presents one frame. When
1553    /// capabilities are transiently deferred it records a benchmark event and
1554    /// returns `Ok(())` without acquiring a texture.
1555    ///
1556    /// # Errors
1557    ///
1558    /// Returns an error for detached/non-surface backends, frame acquisition
1559    /// failures, or a frame without an accessible texture.
1560    ///
1561    /// # Examples
1562    ///
1563    /// ```no_run
1564    /// use ailloli_ui_core::{Color, Scale};
1565    /// use ailloli_ui_render_wgpu::{LayerPass, Renderer, RendererError};
1566    ///
1567    /// fn frame(renderer: &mut Renderer) -> Result<(), RendererError> {
1568    ///     renderer.render_layered_scaled(
1569    ///         Color::BLACK,
1570    ///         &[LayerPass::new(&[])],
1571    ///         Scale::new(1.5),
1572    ///     )
1573    /// }
1574    /// ```
1575    pub fn render_layered_scaled(
1576        &mut self,
1577        clear: Color,
1578        layers: &[LayerPass<'_>],
1579        scale: Scale,
1580    ) -> Result<(), RendererError> {
1581        if let Some(reason) = self.gpu.surface_config_deferred_reason() {
1582            ailloli_ui_bench::record(ailloli_ui_bench::Event::GetCurrentTextureErr {
1583                ts_ms: now_ms(),
1584                err: format!("surface not ready before acquire: {}", reason.as_str()),
1585            });
1586            return Ok(());
1587        }
1588
1589        let frame_start = std::time::Instant::now();
1590        let frame = self.gpu.require_surface_mut()?.acquire_frame()?;
1591        self.render_layered_from_frame(clear, layers, scale, &frame)?;
1592        self.gpu.pre_present_notify();
1593        frame.present();
1594        ailloli_ui_bench::record(ailloli_ui_bench::Event::RenderFrame {
1595            ts_ms: now_ms(),
1596            dur_us: frame_start.elapsed().as_micros(),
1597        });
1598        Ok(())
1599    }
1600
1601    /// Renders layered draw commands into an injected `RenderTarget`.
1602    ///
1603    /// The target's frame extent drives physical geometry; `scale` converts
1604    /// logical coordinates. The frame is presented after the target's
1605    /// pre-present callback.
1606    ///
1607    /// # Errors
1608    ///
1609    /// Propagates target acquisition failures and rejects frames that do not
1610    /// expose their backing texture.
1611    ///
1612    /// # Examples
1613    ///
1614    /// ```no_run
1615    /// use ailloli_ui_core::{Color, Scale};
1616    /// use ailloli_ui_render_wgpu::{LayerPass, RenderTarget, Renderer, RendererError};
1617    ///
1618    /// fn frame<T: RenderTarget + ?Sized>(
1619    ///     renderer: &mut Renderer,
1620    ///     target: &mut T,
1621    /// ) -> Result<(), RendererError> {
1622    ///     renderer.render_layered_to_target_scaled(
1623    ///         Color::BLACK,
1624    ///         &[LayerPass::new(&[])],
1625    ///         Scale::new(2.0),
1626    ///         target,
1627    ///     )
1628    /// }
1629    /// ```
1630    pub fn render_layered_to_target_scaled<T: RenderTarget + ?Sized>(
1631        &mut self,
1632        clear: Color,
1633        layers: &[LayerPass<'_>],
1634        scale: Scale,
1635        target: &mut T,
1636    ) -> Result<(), RendererError> {
1637        let frame_start = std::time::Instant::now();
1638        let acquire_start = std::time::Instant::now();
1639        let frame = target.acquire_frame()?;
1640        ailloli_ui_bench::metric(
1641            "get_current_texture_us",
1642            acquire_start.elapsed().as_micros() as f64,
1643        );
1644        self.render_layered_from_frame(clear, layers, scale, &frame)?;
1645        target.pre_present_notify();
1646        frame.present();
1647        ailloli_ui_bench::record(ailloli_ui_bench::Event::RenderFrame {
1648            ts_ms: now_ms(),
1649            dur_us: frame_start.elapsed().as_micros(),
1650        });
1651        Ok(())
1652    }
1653
1654    /// Records and submits all layer work into an already-acquired frame.
1655    ///
1656    /// The frame must expose its source texture because backdrop and blend
1657    /// planning may copy from it.
1658    ///
1659    /// # Errors
1660    ///
1661    /// Returns [`RendererError::FrameTextureUnavailable`] when the acquired
1662    /// frame exposes no source texture. Command recording and queue submission
1663    /// are otherwise represented by wgpu validation panics, not this result.
1664    fn render_layered_from_frame(
1665        &mut self,
1666        clear: Color,
1667        layers: &[LayerPass<'_>],
1668        scale: Scale,
1669        frame: &crate::render_target::RenderFrame,
1670    ) -> Result<(), RendererError> {
1671        let size = frame.size;
1672        let source_texture = frame
1673            .texture()
1674            .ok_or(RendererError::FrameTextureUnavailable)?;
1675        let view = &frame.view;
1676
1677        let mut encoder =
1678            self.gpu
1679                .device()
1680                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1681                    label: Some("encoder"),
1682                });
1683
1684        self.record_single_pass(
1685            view,
1686            Some(source_texture),
1687            &mut encoder,
1688            size.width as f32,
1689            size.height as f32,
1690            scale,
1691            TargetLoadOp::Clear(clear),
1692            layers,
1693        );
1694
1695        self.gpu.queue().submit(Some(encoder.finish()));
1696        Ok(())
1697    }
1698
1699    /// Renders one layer stack at DPR = 1 into an injected target.
1700    ///
1701    /// # Errors
1702    ///
1703    /// Returns the same target/frame errors as
1704    /// [`Self::render_layered_to_target_scaled`].
1705    ///
1706    /// # Examples
1707    ///
1708    /// ```no_run
1709    /// use ailloli_ui_core::Color;
1710    /// use ailloli_ui_render_wgpu::{LayerPass, RenderTarget, Renderer, RendererError};
1711    ///
1712    /// fn frame(
1713    ///     renderer: &mut Renderer,
1714    ///     target: &mut dyn RenderTarget,
1715    /// ) -> Result<(), RendererError> {
1716    ///     renderer.render_layered_to_target(Color::BLACK, &[LayerPass::new(&[])], target)
1717    /// }
1718    /// ```
1719    pub fn render_layered_to_target(
1720        &mut self,
1721        clear: Color,
1722        layers: &[LayerPass<'_>],
1723        target: &mut dyn RenderTarget,
1724    ) -> Result<(), RendererError> {
1725        self.render_layered_to_target_scaled(clear, layers, Scale::new(1.0), target)
1726    }
1727
1728    /// Renders and readbacks one frame without presenting (for tests / capture).
1729    ///
1730    /// This convenience overload uses DPR 1. Despite the historical wording,
1731    /// the acquired surface texture is presented after the GPU copy is queued.
1732    ///
1733    /// # Errors
1734    ///
1735    /// Propagates surface readiness/acquisition, unsupported format, buffer
1736    /// mapping, missing texture, and optional PNG encoding failures.
1737    ///
1738    /// # Examples
1739    ///
1740    /// ```no_run
1741    /// use ailloli_ui_core::Color;
1742    /// use ailloli_ui_render_wgpu::{
1743    ///     CaptureParams, CapturedFrame, LayerPass, Renderer, RendererError,
1744    /// };
1745    ///
1746    /// fn capture(renderer: &mut Renderer) -> Result<CapturedFrame, RendererError> {
1747    ///     renderer.render_layered_capture_once(
1748    ///         Color::BLACK,
1749    ///         &[LayerPass::new(&[])],
1750    ///         CaptureParams { encode_png: true },
1751    ///     )
1752    /// }
1753    /// ```
1754    pub fn render_layered_capture_once(
1755        &mut self,
1756        clear: Color,
1757        layers: &[LayerPass<'_>],
1758        params: CaptureParams,
1759    ) -> Result<CapturedFrame, RendererError> {
1760        self.render_layered_capture_once_scaled(clear, layers, Scale::new(1.0), params)
1761    }
1762
1763    /// Renders, synchronously reads back, and optionally PNG-encodes one frame.
1764    ///
1765    /// The GPU device is polled with `Maintain::Wait`; this method blocks until
1766    /// the staging buffer mapping finishes. Output is tightly packed RGBA8 in
1767    /// top-to-bottom row order. BGRA surfaces are converted in place.
1768    ///
1769    /// # Errors
1770    ///
1771    /// Returns a typed error for deferred capabilities, missing/unsupported
1772    /// frame textures, map/channel failures, or PNG encoding failure.
1773    ///
1774    /// # Examples
1775    ///
1776    /// ```no_run
1777    /// use ailloli_ui_core::{Color, Scale};
1778    /// use ailloli_ui_render_wgpu::{
1779    ///     CaptureParams, CapturedFrame, LayerPass, Renderer, RendererError,
1780    /// };
1781    ///
1782    /// fn capture(renderer: &mut Renderer) -> Result<CapturedFrame, RendererError> {
1783    ///     renderer.render_layered_capture_once_scaled(
1784    ///         Color::BLACK,
1785    ///         &[LayerPass::new(&[])],
1786    ///         Scale::new(2.0),
1787    ///         CaptureParams::default(),
1788    ///     )
1789    /// }
1790    /// ```
1791    pub fn render_layered_capture_once_scaled(
1792        &mut self,
1793        clear: Color,
1794        layers: &[LayerPass<'_>],
1795        scale: Scale,
1796        params: CaptureParams,
1797    ) -> Result<CapturedFrame, RendererError> {
1798        if let Some(reason) = self.gpu.surface_config_deferred_reason() {
1799            return Err(RendererError::SurfaceCapabilitiesUnavailable(
1800                reason.as_str(),
1801            ));
1802        }
1803
1804        let frame = self.gpu.require_surface_mut()?.acquire_frame()?;
1805        let frame_texture = frame
1806            .texture()
1807            .ok_or(RendererError::FrameTextureUnavailable)?;
1808
1809        let mut encoder =
1810            self.gpu
1811                .device()
1812                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1813                    label: Some("encoder (capture)"),
1814                });
1815
1816        self.record_single_pass(
1817            &frame.view,
1818            Some(frame_texture),
1819            &mut encoder,
1820            frame.size.width as f32,
1821            frame.size.height as f32,
1822            scale,
1823            TargetLoadOp::Clear(clear),
1824            layers,
1825        );
1826
1827        // Readback from swapchain texture.
1828        let (staging, width, height, padded_bpr, unpadded_bpr, surface_format) =
1829            self.enqueue_surface_texture_readback(&mut encoder, frame_texture)?;
1830
1831        self.gpu.queue().submit(Some(encoder.finish()));
1832        self.gpu.pre_present_notify();
1833        frame.present();
1834
1835        // Wait for GPU work to complete before mapping.
1836        self.gpu.device().poll(wgpu::Maintain::Wait);
1837
1838        let rgba = self.map_readback_to_rgba(
1839            &staging,
1840            width,
1841            height,
1842            padded_bpr,
1843            unpadded_bpr,
1844            surface_format,
1845        )?;
1846        let png_data = if params.encode_png {
1847            Some(encode_png_rgba(width, height, &rgba).map_err(RendererError::CaptureMapFailed)?)
1848        } else {
1849            None
1850        };
1851
1852        Ok(CapturedFrame {
1853            width,
1854            height,
1855            format: CapturedFrameFormat::Rgba8,
1856            rgba,
1857            png_data,
1858        })
1859    }
1860
1861    /// Allocates a 256-byte-row-aligned staging buffer and queues a full copy.
1862    ///
1863    /// Physical width and height are clamped to one. Only RGBA8 and BGRA8,
1864    /// linear or sRGB, are accepted. The returned tuple carries buffer, width,
1865    /// height, padded bytes per row, tight bytes per row, and source format.
1866    ///
1867    /// # Errors
1868    ///
1869    /// Returns [`RendererError::CaptureUnsupportedFormat`] when the render
1870    /// backend uses any format other than RGBA8 or BGRA8, linear or sRGB.
1871    fn enqueue_surface_texture_readback(
1872        &self,
1873        encoder: &mut wgpu::CommandEncoder,
1874        texture: &wgpu::Texture,
1875    ) -> Result<(wgpu::Buffer, u32, u32, u32, u32, wgpu::TextureFormat), RendererError> {
1876        let extent = self.gpu.extent();
1877        let width = extent.width.max(1);
1878        let height = extent.height.max(1);
1879
1880        let unpadded_bpr = width * 4;
1881        let padded_bpr = bytes_per_row_padded_256(unpadded_bpr);
1882
1883        let format = self.gpu.format();
1884        match format {
1885            wgpu::TextureFormat::Bgra8UnormSrgb
1886            | wgpu::TextureFormat::Bgra8Unorm
1887            | wgpu::TextureFormat::Rgba8UnormSrgb
1888            | wgpu::TextureFormat::Rgba8Unorm => {}
1889            other => return Err(RendererError::CaptureUnsupportedFormat(other)),
1890        }
1891
1892        let size = (padded_bpr as u64) * (height as u64);
1893        let staging = self.gpu.device().create_buffer(&wgpu::BufferDescriptor {
1894            label: Some("capture staging buf"),
1895            size,
1896            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1897            mapped_at_creation: false,
1898        });
1899
1900        encoder.copy_texture_to_buffer(
1901            wgpu::ImageCopyTexture {
1902                texture,
1903                mip_level: 0,
1904                origin: wgpu::Origin3d::ZERO,
1905                aspect: wgpu::TextureAspect::All,
1906            },
1907            wgpu::ImageCopyBuffer {
1908                buffer: &staging,
1909                layout: wgpu::ImageDataLayout {
1910                    offset: 0,
1911                    bytes_per_row: Some(padded_bpr),
1912                    rows_per_image: Some(height),
1913                },
1914            },
1915            wgpu::Extent3d {
1916                width,
1917                height,
1918                depth_or_array_layers: 1,
1919            },
1920        );
1921
1922        Ok((staging, width, height, padded_bpr, unpadded_bpr, format))
1923    }
1924
1925    /// Blocks for a staging-buffer map and returns tightly packed RGBA8 bytes.
1926    ///
1927    /// Padded rows are stripped and BGRA formats are channel-swapped. The
1928    /// staging buffer is unmapped before return. Mapping callback/channel errors
1929    /// become [`RendererError::CaptureMapFailed`].
1930    ///
1931    /// # Errors
1932    ///
1933    /// Returns [`RendererError::CaptureMapFailed`] when the mapping callback
1934    /// reports a GPU error or its completion channel disconnects.
1935    fn map_readback_to_rgba(
1936        &self,
1937        staging: &wgpu::Buffer,
1938        _width: u32,
1939        height: u32,
1940        padded_bpr: u32,
1941        unpadded_bpr: u32,
1942        surface_format: wgpu::TextureFormat,
1943    ) -> Result<Vec<u8>, RendererError> {
1944        let slice = staging.slice(..);
1945        let (tx, rx) = std::sync::mpsc::channel();
1946        slice.map_async(wgpu::MapMode::Read, move |res| {
1947            let _ = tx.send(res.map_err(|e| format!("{e:?}")));
1948        });
1949
1950        // Drive the mapping callback to completion.
1951        // Without this, tests (and some platforms) can hang waiting for the map_async callback.
1952        self.gpu.device().poll(wgpu::Maintain::Wait);
1953
1954        let res = rx
1955            .recv()
1956            .map_err(|e| RendererError::CaptureMapFailed(format!("map recv failed: {e}")))?;
1957        res.map_err(RendererError::CaptureMapFailed)?;
1958
1959        let mapped = slice.get_mapped_range();
1960        let mut tight = unpad_rows_rgba(
1961            &mapped,
1962            padded_bpr as usize,
1963            unpadded_bpr as usize,
1964            height as usize,
1965        );
1966        drop(mapped);
1967        staging.unmap();
1968
1969        if matches!(
1970            surface_format,
1971            wgpu::TextureFormat::Bgra8UnormSrgb | wgpu::TextureFormat::Bgra8Unorm
1972        ) {
1973            bgra_to_rgba_in_place(&mut tight);
1974        }
1975
1976        // Host surfaces use top-left origin in pixel coordinates here; the copy preserves that order.
1977        // If we ever need vertical flip for specific platforms, do it in tooling/tests.)
1978
1979        Ok(tight)
1980    }
1981
1982    /// Uploads one clip uniform and creates its renderer-layout bind group.
1983    ///
1984    /// The returned [`ClipBinding`] retains the backing buffer for the complete
1985    /// bind-group lifetime.
1986    fn create_clip_binding(&self, label: &str, params: ClipParamsGpu) -> ClipBinding {
1987        let buffer = self
1988            .gpu
1989            .device()
1990            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1991                label: Some(label),
1992                contents: bytemuck::bytes_of(&params),
1993                usage: wgpu::BufferUsages::UNIFORM,
1994            });
1995        let bind_group = self
1996            .gpu
1997            .device()
1998            .create_bind_group(&wgpu::BindGroupDescriptor {
1999                label: Some(label),
2000                layout: &self.gpu.pipelines().clip_bind_group_layout,
2001                entries: &[wgpu::BindGroupEntry {
2002                    binding: 0,
2003                    resource: buffer.as_entire_binding(),
2004                }],
2005            });
2006        ClipBinding {
2007            _buffer: buffer,
2008            bind_group,
2009        }
2010    }
2011
2012    /// single-pass compositing: records all layers into a single `wgpu::RenderPass`.
2013    ///
2014    /// The four-step layout matches the plan:
2015    ///   1. `text_atlas.start_frame()` + `PreparedResources::prepare(...)`
2016    ///      (only GPU-touching step before the pass: glyph rasterization
2017    ///      and icon cache population).
2018    ///   2. `FrameRenderPlan::build_cpu(...)` (pure CPU).
2019    ///   3. Allocate per-frame arena buffers (3-4) + per-layer clip bindings
2020    ///      **before** `begin_render_pass`.
2021    ///   4. Apply the host color load policy, then open UI passes (+ stencil clear
2022    ///      if needed); iterate `PlannedLayer`s setting scissor / stencil_ref
2023    ///      / pipeline / bind groups / vertex range for each batch.
2024    ///
2025    /// Invariants enforced here (anti-multilayer compositing traps):
2026    ///   - one `wgpu::Buffer` per arena per frame ; never rewrite a region
2027    ///     before submit,
2028    ///   - scissor is reset for every layer (`apply_layer_scissor` with the
2029    ///     layer's scissor or full-surface),
2030    ///   - stencil reference is reset for every batch (`set_stencil_reference(
2031    ///     layer.stencil_ref.unwrap_or(0))`),
2032    ///   - vertex range convention is `set_vertex_buffer(0, buffer.slice(..))`
2033    ///     + `draw(batch.vertex_range, 0..1)`.
2034    #[allow(clippy::too_many_arguments)]
2035    #[allow(clippy::too_many_lines)]
2036    fn record_single_pass(
2037        &mut self,
2038        view: &wgpu::TextureView,
2039        frame_texture: Option<&wgpu::Texture>,
2040        encoder: &mut wgpu::CommandEncoder,
2041        w: f32,
2042        h: f32,
2043        scale: Scale,
2044        load_op: TargetLoadOp,
2045        layers: &[LayerPass<'_>],
2046    ) {
2047        // Borrowed frames may alternate with a differently sized managed frame.
2048        // Restore the stencil for the actual destination, not remembered surface
2049        // dimensions. Typed borrowed-target checks have already succeeded here.
2050        if let Some(stencil) = &mut self.stencil_target {
2051            stencil.recreate(self.gpu.device(), w as u32, h as u32);
2052        }
2053        let clear = match load_op {
2054            TargetLoadOp::Load => Color::TRANSPARENT,
2055            TargetLoadOp::Clear(color) => color,
2056        };
2057        // --- Étape 1: PREP GPU (atlas + icons) ---
2058        self.text_atlas.start_frame();
2059        let prepared = PreparedResources::prepare(
2060            layers,
2061            scale,
2062            &mut self.text_atlas,
2063            &mut self.icon_cache,
2064            self.gpu.device(),
2065            self.gpu.queue(),
2066            &self.gpu.pipelines().texture_bind_group_layout,
2067            self.text_face_blobs.as_ref(),
2068        );
2069
2070        // --- Étape 2: PLAN CPU PUR ---
2071        let stencil_supported = self.stencil_target.is_some();
2072        let mut budget = IsolatedBudgetPolicy::new(self.isolated_budget);
2073        let plan = FrameRenderPlan::try_build_cpu(
2074            layers,
2075            &prepared,
2076            [w, h],
2077            scale,
2078            stencil_supported,
2079            &mut budget,
2080        )
2081        .unwrap_or_else(|e| panic!("FrameRenderPlan::try_build_cpu: {e:?}"));
2082
2083        let has_backdrop = !plan.backdrop_captures.is_empty();
2084        // Keep ordinary frames in one pass. An empty plan or a backdrop at
2085        // index zero has no preceding main segment in which to clear color.
2086        let clear_before_main = matches!(load_op, TargetLoadOp::Clear(_))
2087            && (plan.layers.is_empty()
2088                || plan
2089                    .backdrop_captures
2090                    .first()
2091                    .is_some_and(|cut| cut.split_planned_layer_idx == 0));
2092        if clear_before_main {
2093            clear_color_target(encoder, view, clear);
2094        }
2095        let clear_in_main = matches!(load_op, TargetLoadOp::Clear(_)) && !clear_before_main;
2096        self.frame_leases.clear();
2097
2098        // --- Étape 3: ALLOC GPU (vertex arenas + per-layer clip bindings) ---
2099        // One buffer per primitive type per frame (3-4 total), not 3·N.
2100        let vertex_buf = if plan.vertex_arena.is_empty() {
2101            None
2102        } else {
2103            Some(
2104                self.gpu
2105                    .device()
2106                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2107                        label: Some("frame vertex arena"),
2108                        contents: bytemuck::cast_slice(&plan.vertex_arena),
2109                        usage: wgpu::BufferUsages::VERTEX,
2110                    }),
2111            )
2112        };
2113        let rrect_buf = if plan.rrect_vertex_arena.is_empty() {
2114            None
2115        } else {
2116            Some(
2117                self.gpu
2118                    .device()
2119                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2120                        label: Some("frame rrect arena"),
2121                        contents: bytemuck::cast_slice(&plan.rrect_vertex_arena),
2122                        usage: wgpu::BufferUsages::VERTEX,
2123                    }),
2124            )
2125        };
2126        let border_rrect_buf = if plan.border_vertex_arena.is_empty() {
2127            None
2128        } else {
2129            Some(
2130                self.gpu
2131                    .device()
2132                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2133                        label: Some("frame border rrect arena"),
2134                        contents: bytemuck::cast_slice(&plan.border_vertex_arena),
2135                        usage: wgpu::BufferUsages::VERTEX,
2136                    }),
2137            )
2138        };
2139        let shadow_buf = if plan.shadow_vertex_arena.is_empty() {
2140            None
2141        } else {
2142            Some(
2143                self.gpu
2144                    .device()
2145                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2146                        label: Some("frame box shadow arena"),
2147                        contents: bytemuck::cast_slice(&plan.shadow_vertex_arena),
2148                        usage: wgpu::BufferUsages::VERTEX,
2149                    }),
2150            )
2151        };
2152        let ring_progress_buf = if plan.ring_progress_vertex_arena.is_empty() {
2153            None
2154        } else {
2155            Some(
2156                self.gpu
2157                    .device()
2158                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2159                        label: Some("frame ring progress arena"),
2160                        contents: bytemuck::cast_slice(&plan.ring_progress_vertex_arena),
2161                        usage: wgpu::BufferUsages::VERTEX,
2162                    }),
2163            )
2164        };
2165        let stroke_buf = if plan.stroke_vertex_arena.is_empty() {
2166            None
2167        } else {
2168            Some(
2169                self.gpu
2170                    .device()
2171                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2172                        label: Some("frame stroke arena"),
2173                        contents: bytemuck::cast_slice(&plan.stroke_vertex_arena),
2174                        usage: wgpu::BufferUsages::VERTEX,
2175                    }),
2176            )
2177        };
2178        let tex_buf = if plan.tex_vertex_arena.is_empty() {
2179            None
2180        } else {
2181            Some(
2182                self.gpu
2183                    .device()
2184                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2185                        label: Some("frame tex arena"),
2186                        contents: bytemuck::cast_slice(&plan.tex_vertex_arena),
2187                        usage: wgpu::BufferUsages::VERTEX,
2188                    }),
2189            )
2190        };
2191        let stencil_mask_buf = if plan.stencil_mask_arena.is_empty() {
2192            None
2193        } else {
2194            Some(
2195                self.gpu
2196                    .device()
2197                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2198                        label: Some("frame stencil mask arena"),
2199                        contents: bytemuck::cast_slice(&plan.stencil_mask_arena),
2200                        usage: wgpu::BufferUsages::VERTEX,
2201                    }),
2202            )
2203        };
2204        let composite_buf = if plan.composite_vertex_arena.is_empty() {
2205            None
2206        } else {
2207            Some(
2208                self.gpu
2209                    .device()
2210                    .create_buffer_init(&wgpu::util::BufferInitDescriptor {
2211                        label: Some("frame composite arena"),
2212                        contents: bytemuck::cast_slice(&plan.composite_vertex_arena),
2213                        usage: wgpu::BufferUsages::VERTEX,
2214                    }),
2215            )
2216        };
2217
2218        // Per-layer clip bindings: 1 (none) + 0 or 1 (shape).
2219        let per_layer: Vec<PerLayerBindings> = plan
2220            .layers
2221            .iter()
2222            .map(|pl| {
2223                let none_clip = self.create_clip_binding("clip none", pl.clip_params_none);
2224                let shape_clip = pl
2225                    .clip_params_shape
2226                    .map(|p| self.create_clip_binding("clip shape", p));
2227                PerLayerBindings {
2228                    none_clip,
2229                    shape_clip,
2230                }
2231            })
2232            .collect();
2233
2234        let needs_stencil_attachment = plan.needs_stencil_attachment;
2235
2236        let gpu_bufs = MainPassGpuBuffers {
2237            vertex_buf,
2238            rrect_buf,
2239            border_rrect_buf,
2240            shadow_buf,
2241            ring_progress_buf,
2242            stroke_buf,
2243            tex_buf,
2244            stencil_mask_buf,
2245            composite_buf,
2246            per_layer,
2247        };
2248
2249        let composite_table = if has_backdrop {
2250            let format = self.gpu.format();
2251            if self.effect_pipelines.is_none() {
2252                self.effect_pipelines = Some(EffectPipelines::new(self.gpu.device(), format));
2253            }
2254            let first_split = plan.backdrop_captures[0].split_planned_layer_idx;
2255            self.record_main_pass_segment(
2256                encoder,
2257                view,
2258                frame_texture,
2259                &plan,
2260                &gpu_bufs,
2261                0..first_split,
2262                clear_in_main,
2263                clear,
2264                needs_stencil_attachment,
2265                None,
2266                w,
2267                h,
2268                scale,
2269            );
2270
2271            let mut backdrop_table = BackdropTable::empty();
2272            for (i, cut) in plan.backdrop_captures.iter().enumerate() {
2273                let iso = plan
2274                    .planned_isolated
2275                    .iter()
2276                    .find(|p| p.id == cut.pass_id)
2277                    .expect("backdrop capture pass_id");
2278                let lease = lease_backdrop_slot(
2279                    &mut self.offscreen_pool,
2280                    self.gpu.device(),
2281                    cut.capture_rect_px,
2282                    format,
2283                );
2284                let px = lease.width as u64 * lease.height as u64;
2285                self.isolated_metrics.backdrop_capture_count += 1;
2286                self.isolated_metrics.backdrop_pixels_total += px;
2287
2288                copy_swapchain_region_to_offscreen(
2289                    self.gpu.device(),
2290                    encoder,
2291                    frame_texture.expect("backdrop texture validated before recording"),
2292                    cut.capture_rect_px,
2293                    &lease,
2294                    &self.offscreen_pool,
2295                    format,
2296                );
2297                let color_view = lease.color_view(&self.offscreen_pool);
2298                if iso.backdrop_blur_radius_px > 0.0 {
2299                    let effect = self.effect_pipelines.as_ref().expect("effect pipelines");
2300                    run_backdrop_blur(
2301                        self.gpu.device(),
2302                        encoder,
2303                        effect,
2304                        format,
2305                        color_view,
2306                        lease.width,
2307                        lease.height,
2308                        iso.backdrop_blur_radius_px,
2309                        cut.pass_id.0,
2310                    );
2311                    self.isolated_metrics.backdrop_blur_pass_count += 2;
2312                }
2313                let bind_group = {
2314                    let effect = self.effect_pipelines.as_ref().expect("effect pipelines");
2315                    self.gpu
2316                        .device()
2317                        .create_bind_group(&wgpu::BindGroupDescriptor {
2318                            label: Some("backdrop blur tex"),
2319                            layout: &self.gpu.pipelines().texture_bind_group_layout,
2320                            entries: &[
2321                                wgpu::BindGroupEntry {
2322                                    binding: 0,
2323                                    resource: wgpu::BindingResource::TextureView(color_view),
2324                                },
2325                                wgpu::BindGroupEntry {
2326                                    binding: 1,
2327                                    resource: wgpu::BindingResource::Sampler(&effect.sampler),
2328                                },
2329                            ],
2330                        })
2331                };
2332                backdrop_table.insert(cut.pass_id.0, lease, bind_group);
2333                self.frame_leases.push(lease);
2334
2335                if let Some(next) = plan.backdrop_captures.get(i + 1) {
2336                    self.record_main_pass_segment(
2337                        encoder,
2338                        view,
2339                        frame_texture,
2340                        &plan,
2341                        &gpu_bufs,
2342                        cut.split_planned_layer_idx..next.split_planned_layer_idx,
2343                        false,
2344                        clear,
2345                        needs_stencil_attachment,
2346                        None,
2347                        w,
2348                        h,
2349                        scale,
2350                    );
2351                }
2352            }
2353
2354            let saved_backdrop = (
2355                self.isolated_metrics.backdrop_capture_count,
2356                self.isolated_metrics.backdrop_pixels_total,
2357                self.isolated_metrics.backdrop_blur_pass_count,
2358            );
2359            let table = self.execute_isolated_passes(
2360                encoder,
2361                &plan,
2362                layers,
2363                &prepared,
2364                scale,
2365                stencil_supported,
2366                Some(&backdrop_table),
2367            );
2368            self.isolated_metrics.backdrop_capture_count = saved_backdrop.0;
2369            self.isolated_metrics.backdrop_pixels_total = saved_backdrop.1;
2370            self.isolated_metrics.backdrop_blur_pass_count = saved_backdrop.2;
2371
2372            let last_split = plan
2373                .backdrop_captures
2374                .last()
2375                .expect("backdrop_captures non-empty")
2376                .split_planned_layer_idx;
2377            self.record_main_pass_segment(
2378                encoder,
2379                view,
2380                frame_texture,
2381                &plan,
2382                &gpu_bufs,
2383                last_split..plan.layers.len(),
2384                false,
2385                clear,
2386                needs_stencil_attachment,
2387                Some(&table),
2388                w,
2389                h,
2390                scale,
2391            );
2392            table
2393        } else {
2394            let table = self.execute_isolated_passes(
2395                encoder,
2396                &plan,
2397                layers,
2398                &prepared,
2399                scale,
2400                stencil_supported,
2401                None,
2402            );
2403            self.record_main_pass_segment(
2404                encoder,
2405                view,
2406                frame_texture,
2407                &plan,
2408                &gpu_bufs,
2409                0..plan.layers.len(),
2410                clear_in_main,
2411                clear,
2412                needs_stencil_attachment,
2413                Some(&table),
2414                w,
2415                h,
2416                scale,
2417            );
2418            table
2419        };
2420
2421        self.finish_isolated_frame_metrics(&budget);
2422        self.offscreen_pool
2423            .debug_assert_leased_count(self.frame_leases.len());
2424        let _ = composite_table;
2425        self.offscreen_pool.end_frame();
2426        let atlas_stats = self.text_atlas.stats();
2427        self.text_atlas.finish_frame();
2428        record_text_atlas_frame(atlas_stats);
2429    }
2430
2431    #[allow(clippy::too_many_arguments)]
2432    /// Records a contiguous main-layer range, splitting around shader blends.
2433    ///
2434    /// The first segment clears only when `clear_load` is true; subsequent
2435    /// segments load preserved color. Each destination-dependent blend closes
2436    /// the render pass, copies the current destination, runs its composite, and
2437    /// then resumes ordinary planned layers.
2438    fn record_main_pass_segment(
2439        &mut self,
2440        encoder: &mut wgpu::CommandEncoder,
2441        view: &wgpu::TextureView,
2442        frame_texture: Option<&wgpu::Texture>,
2443        plan: &FrameRenderPlan,
2444        gpu_bufs: &MainPassGpuBuffers,
2445        layer_range: std::ops::Range<usize>,
2446        clear_load: bool,
2447        clear: Color,
2448        needs_stencil_attachment: bool,
2449        composite_table: Option<&IsolatedCompositeTable>,
2450        w: f32,
2451        h: f32,
2452        scale: Scale,
2453    ) {
2454        if layer_range.is_empty() {
2455            return;
2456        }
2457        let frame_has_stencil = plan.needs_stencil_attachment;
2458        let format = self.gpu.format();
2459        let mut use_clear = clear_load;
2460        let mut i = layer_range.start;
2461
2462        while i < layer_range.end {
2463            if let Some(comp) = find_shader_blend_composite(plan, i) {
2464                let depth =
2465                    stencil_depth_attachment(&self.stencil_target, needs_stencil_attachment);
2466                let load = if use_clear {
2467                    wgpu::LoadOp::Clear(wgpu::Color {
2468                        r: clear.r as f64,
2469                        g: clear.g as f64,
2470                        b: clear.b as f64,
2471                        a: clear.a as f64,
2472                    })
2473                } else {
2474                    wgpu::LoadOp::Load
2475                };
2476                {
2477                    let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2478                        label: Some("frame main pre-blend"),
2479                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2480                            view,
2481                            resolve_target: None,
2482                            ops: wgpu::Operations {
2483                                load,
2484                                store: wgpu::StoreOp::Store,
2485                            },
2486                        })],
2487                        depth_stencil_attachment: depth.clone(),
2488                        timestamp_writes: None,
2489                        occlusion_query_set: None,
2490                    });
2491                    record_planned_layer(
2492                        &mut rpass,
2493                        self.gpu.pipelines(),
2494                        &self.text_atlas,
2495                        &self.icon_cache,
2496                        &gpu_bufs.per_layer[i],
2497                        &plan.layers[i],
2498                        &plan.batches,
2499                        gpu_bufs.vertex_buf.as_ref(),
2500                        gpu_bufs.rrect_buf.as_ref(),
2501                        gpu_bufs.border_rrect_buf.as_ref(),
2502                        gpu_bufs.shadow_buf.as_ref(),
2503                        gpu_bufs.ring_progress_buf.as_ref(),
2504                        gpu_bufs.stroke_buf.as_ref(),
2505                        gpu_bufs.tex_buf.as_ref(),
2506                        gpu_bufs.stencil_mask_buf.as_ref(),
2507                        gpu_bufs.composite_buf.as_ref(),
2508                        composite_table,
2509                        w,
2510                        h,
2511                        scale,
2512                        frame_has_stencil,
2513                        true,
2514                    );
2515                }
2516                use_clear = false;
2517                self.draw_shader_blend_composite(
2518                    encoder,
2519                    view,
2520                    frame_texture.expect("blend texture validated before recording"),
2521                    comp,
2522                    composite_table,
2523                    gpu_bufs.composite_buf.as_ref(),
2524                    &gpu_bufs.per_layer[i],
2525                    format,
2526                );
2527                i += 1;
2528            } else {
2529                let run_start = i;
2530                while i < layer_range.end && find_shader_blend_composite(plan, i).is_none() {
2531                    i += 1;
2532                }
2533                let depth =
2534                    stencil_depth_attachment(&self.stencil_target, needs_stencil_attachment);
2535                let load = if use_clear {
2536                    wgpu::LoadOp::Clear(wgpu::Color {
2537                        r: clear.r as f64,
2538                        g: clear.g as f64,
2539                        b: clear.b as f64,
2540                        a: clear.a as f64,
2541                    })
2542                } else {
2543                    wgpu::LoadOp::Load
2544                };
2545                let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2546                    label: Some("frame main segment"),
2547                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2548                        view,
2549                        resolve_target: None,
2550                        ops: wgpu::Operations {
2551                            load,
2552                            store: wgpu::StoreOp::Store,
2553                        },
2554                    })],
2555                    depth_stencil_attachment: depth.clone(),
2556                    timestamp_writes: None,
2557                    occlusion_query_set: None,
2558                });
2559                use_clear = false;
2560                for layer_idx in run_start..i {
2561                    record_planned_layer(
2562                        &mut rpass,
2563                        self.gpu.pipelines(),
2564                        &self.text_atlas,
2565                        &self.icon_cache,
2566                        &gpu_bufs.per_layer[layer_idx],
2567                        &plan.layers[layer_idx],
2568                        &plan.batches,
2569                        gpu_bufs.vertex_buf.as_ref(),
2570                        gpu_bufs.rrect_buf.as_ref(),
2571                        gpu_bufs.border_rrect_buf.as_ref(),
2572                        gpu_bufs.shadow_buf.as_ref(),
2573                        gpu_bufs.ring_progress_buf.as_ref(),
2574                        gpu_bufs.stroke_buf.as_ref(),
2575                        gpu_bufs.tex_buf.as_ref(),
2576                        gpu_bufs.stencil_mask_buf.as_ref(),
2577                        gpu_bufs.composite_buf.as_ref(),
2578                        composite_table,
2579                        w,
2580                        h,
2581                        scale,
2582                        frame_has_stencil,
2583                        false,
2584                    );
2585                }
2586                drop(rpass);
2587            }
2588        }
2589    }
2590
2591    #[allow(clippy::too_many_arguments)]
2592    /// Captures destination color and records one non-normal shader composite.
2593    ///
2594    /// Missing planned vertices or foreground bind groups make the operation a
2595    /// no-op. Lazily compiled blend pipelines and captured leases remain valid
2596    /// until the frame ends.
2597    fn draw_shader_blend_composite(
2598        &mut self,
2599        encoder: &mut wgpu::CommandEncoder,
2600        view: &wgpu::TextureView,
2601        frame_texture: &wgpu::Texture,
2602        comp: &PlannedIsolatedComposite,
2603        composite_table: Option<&IsolatedCompositeTable>,
2604        composite_buf: Option<&wgpu::Buffer>,
2605        _bindings: &PerLayerBindings,
2606        format: wgpu::TextureFormat,
2607    ) {
2608        let Some(composite_buf) = composite_buf else {
2609            return;
2610        };
2611        let Some(table) = composite_table else {
2612            return;
2613        };
2614        let Some(fg_bg) = table.get(comp.pass_id.0) else {
2615            return;
2616        };
2617        let capture_rect = comp.dst_capture_rect_px.unwrap_or(comp.dest_rect_px);
2618        let device = self.gpu.device();
2619
2620        if self.composite_blend_pipelines.is_none() {
2621            self.composite_blend_pipelines = Some(CompositeBlendPipelines::new(device, format));
2622        }
2623        let blend_pipes = self
2624            .composite_blend_pipelines
2625            .as_ref()
2626            .expect("composite blend pipelines");
2627
2628        let lease = lease_backdrop_slot(&mut self.offscreen_pool, device, capture_rect, format);
2629        copy_swapchain_region_to_offscreen(
2630            device,
2631            encoder,
2632            frame_texture,
2633            capture_rect,
2634            &lease,
2635            &self.offscreen_pool,
2636            format,
2637        );
2638        self.frame_leases.push(lease);
2639        self.isolated_metrics.blend_capture_count += 1;
2640        self.isolated_metrics.blend_composite_count += 1;
2641
2642        let bg_view = lease.color_view(&self.offscreen_pool);
2643        draw_composite_blend(
2644            device,
2645            encoder,
2646            blend_pipes,
2647            fg_bg,
2648            bg_view,
2649            comp,
2650            composite_buf,
2651            view,
2652            None,
2653        );
2654    }
2655
2656    #[allow(clippy::too_many_arguments)]
2657    /// Executes isolated passes in child-before-parent topological order.
2658    ///
2659    /// Each pass leases a pooled color/stencil surface, optionally seeds it with
2660    /// backdrop or nested child color, records local content, runs effects, and
2661    /// installs a composite bind group. Leases are retained in `frame_leases`
2662    /// until after main-pass composition.
2663    fn execute_isolated_passes(
2664        &mut self,
2665        encoder: &mut wgpu::CommandEncoder,
2666        plan: &FrameRenderPlan,
2667        layers: &[LayerPass<'_>],
2668        prepared: &PreparedResources,
2669        scale: Scale,
2670        stencil_supported: bool,
2671        backdrop_table: Option<&BackdropTable>,
2672    ) -> IsolatedCompositeTable {
2673        if plan.planned_isolated.is_empty() {
2674            self.isolated_metrics.isolated_pass_count = 0;
2675            return IsolatedCompositeTable::empty();
2676        }
2677
2678        self.isolated_metrics = IsolatedFrameMetrics::default();
2679
2680        let format = self.gpu.format();
2681        if self.effect_pipelines.is_none() {
2682            self.effect_pipelines = Some(EffectPipelines::new(self.gpu.device(), format));
2683        }
2684        let device = self.gpu.device();
2685        let main_pipelines = self.gpu.pipelines();
2686        let effect = self.effect_pipelines.as_ref().expect("effect pipelines");
2687        let pool = &mut self.offscreen_pool;
2688
2689        let mut table = IsolatedCompositeTable::empty();
2690        let order = crate::isolated_plan::topo_sort_isolated_passes(&plan.planned_isolated);
2691
2692        for idx in order {
2693            let iso = &plan.planned_isolated[idx];
2694            let layer = &layers[iso.source_layer_idx];
2695            let sub = FrameRenderPlan::build_isolated_subplan(
2696                layer,
2697                prepared,
2698                iso,
2699                scale,
2700                stencil_supported,
2701            );
2702
2703            let key = PoolKey::color(
2704                iso.local_size_px[0],
2705                iso.local_size_px[1],
2706                iso.needs_stencil,
2707            );
2708            let lease = pool.lease(device, key, format);
2709            self.frame_leases.push(lease);
2710            let pixel_count = lease.width as u64 * lease.height as u64;
2711            self.isolated_metrics.offscreen_pixels_rendered += pixel_count;
2712            if iso.needs_stencil {
2713                self.isolated_metrics.stencil_offscreen_count += 1;
2714            }
2715
2716            let has_children = !iso.child_pass_ids.is_empty();
2717            let has_backdrop = iso.needs_backdrop_capture
2718                && backdrop_table.is_some_and(|t| t.get(iso.id.0).is_some());
2719            if has_children || has_backdrop {
2720                clear_isolated_color_target(encoder, &lease, pool, iso.clear_color);
2721            }
2722            if has_backdrop {
2723                blit_backdrop_texture(
2724                    encoder,
2725                    device,
2726                    main_pipelines,
2727                    &lease,
2728                    pool,
2729                    iso,
2730                    backdrop_table.expect("backdrop table"),
2731                );
2732            }
2733            if has_children {
2734                blit_child_isolated_textures(
2735                    encoder,
2736                    device,
2737                    main_pipelines,
2738                    &lease,
2739                    pool,
2740                    iso,
2741                    plan,
2742                    &table,
2743                );
2744            }
2745
2746            record_isolated_content_pass(
2747                encoder,
2748                device,
2749                main_pipelines,
2750                &self.text_atlas,
2751                &self.icon_cache,
2752                &sub,
2753                &lease,
2754                pool,
2755                iso.clear_color,
2756                iso.id.0,
2757                has_children || has_backdrop,
2758            );
2759
2760            let color_view = lease.color_view(pool);
2761            run_effect_chain(
2762                device,
2763                encoder,
2764                effect,
2765                format,
2766                color_view,
2767                lease.width,
2768                lease.height,
2769                &iso.effects,
2770                iso.id.0,
2771            );
2772
2773            let has_blur = iso.effects.effects.iter().any(|e| {
2774                matches!(
2775                    e,
2776                    crate::isolated_plan::IsolatedEffect::Blur { radius_px } if *radius_px > 0.0
2777                )
2778            });
2779            if has_blur {
2780                self.isolated_metrics.blur_pass_count += 2;
2781                self.isolated_metrics.blur_pixels_total += pixel_count;
2782            }
2783
2784            let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2785                label: Some("isolated composite tex"),
2786                layout: &main_pipelines.texture_bind_group_layout,
2787                entries: &[
2788                    wgpu::BindGroupEntry {
2789                        binding: 0,
2790                        resource: wgpu::BindingResource::TextureView(color_view),
2791                    },
2792                    wgpu::BindGroupEntry {
2793                        binding: 1,
2794                        resource: wgpu::BindingResource::Sampler(&effect.sampler),
2795                    },
2796                ],
2797            });
2798            table.bind_groups.insert(iso.id.0, bind_group);
2799            self.isolated_metrics.isolated_pass_count += 1;
2800        }
2801
2802        self.isolated_metrics.pool_reuse_hits = pool.reuse_hits;
2803        self.isolated_metrics.pool_allocs = pool.allocs;
2804        self.isolated_metrics.offscreen_peak_bytes = pool.peak_bytes();
2805
2806        table
2807    }
2808
2809    /// Copies planner downgrade counts and emits benchmark/debug frame metrics.
2810    fn finish_isolated_frame_metrics(&mut self, budget: &IsolatedBudgetPolicy) {
2811        self.isolated_metrics.downgrades = budget.downgrades;
2812        log_isolated_frame_metrics(self.isolated_metrics, self.bench_scenario.as_deref());
2813    }
2814}
2815
2816/// Emits isolated metrics to the benchmark sink and optional stderr diagnostics.
2817///
2818/// `None` scenarios are encoded as an empty string. Benchmark emission and GPU
2819/// debug logging are independently controlled by their environment switches.
2820fn log_isolated_frame_metrics(m: IsolatedFrameMetrics, scenario: Option<&str>) {
2821    let scenario = scenario.unwrap_or("").to_string();
2822    if ailloli_ui_bench::bench_enabled() {
2823        ailloli_ui_bench::record(ailloli_ui_bench::Event::IsolatedCompositorFrame {
2824            ts_ms: now_ms(),
2825            scenario,
2826            isolated_pass_count: m.isolated_pass_count,
2827            isolated_pixels_total: m.offscreen_pixels_rendered,
2828            blur_pixels_total: m.blur_pixels_total,
2829            offscreen_peak_bytes: m.offscreen_peak_bytes,
2830            pool_reuse_hits: m.pool_reuse_hits,
2831            pool_allocs: m.pool_allocs,
2832            pool_reuse_ratio: m.pool_reuse_ratio(),
2833            blur_pass_count: m.blur_pass_count,
2834            stencil_offscreen_count: m.stencil_offscreen_count,
2835            downgrade_count: m.downgrade_count(),
2836            downgrade_blur_clamped: m.downgrades.blur_radius_clamped,
2837            downgrade_surface_clamped: m.downgrades.surface_px_clamped,
2838            downgrade_bytes_skipped: m.downgrades.bytes_budget_skipped,
2839            backdrop_capture_count: m.backdrop_capture_count,
2840            backdrop_pixels_total: m.backdrop_pixels_total,
2841            backdrop_blur_pass_count: m.backdrop_blur_pass_count,
2842            downgrade_backdrop_skipped: m.downgrades.backdrop_budget_skipped,
2843            blend_capture_count: m.blend_capture_count,
2844            blend_composite_count: m.blend_composite_count,
2845            downgrade_blend_skipped: m.downgrades.blend_capture_budget_skipped,
2846        });
2847    }
2848
2849    if !crate::pipeline_cache::gpu_debug_enabled() {
2850        return;
2851    }
2852    eprintln!(
2853        "ailloli_ui_render_wgpu: isolated metrics passes={} pixels={} blur_pixels={} peak_bytes={} pool_hits={} pool_allocs={} reuse_ratio={:.3} blur_passes={} stencil_offscreen={} downgrades={}",
2854        m.isolated_pass_count,
2855        m.offscreen_pixels_rendered,
2856        m.blur_pixels_total,
2857        m.offscreen_peak_bytes,
2858        m.pool_reuse_hits,
2859        m.pool_allocs,
2860        m.pool_reuse_ratio(),
2861        m.blur_pass_count,
2862        m.stencil_offscreen_count,
2863        m.downgrade_count(),
2864    );
2865}
2866
2867/// Clears an isolated lease to `clear` while preserving no prior color.
2868///
2869/// The temporary render pass ends when this function returns.
2870fn clear_isolated_color_target(
2871    encoder: &mut wgpu::CommandEncoder,
2872    lease: &crate::offscreen_pool::LeasedOffscreen,
2873    pool: &crate::offscreen_pool::OffscreenSurfacePool,
2874    clear: Color,
2875) {
2876    clear_color_target(encoder, lease.color_view(pool), clear);
2877}
2878
2879/// Explicitly clears before any UI work, including an empty or backdrop-only frame.
2880fn clear_color_target(
2881    encoder: &mut wgpu::CommandEncoder,
2882    color_view: &wgpu::TextureView,
2883    clear: Color,
2884) {
2885    let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2886        label: Some("clear render target"),
2887        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2888            view: color_view,
2889            resolve_target: None,
2890            ops: wgpu::Operations {
2891                load: wgpu::LoadOp::Clear(wgpu::Color {
2892                    r: clear.r as f64,
2893                    g: clear.g as f64,
2894                    b: clear.b as f64,
2895                    a: clear.a as f64,
2896                }),
2897                store: wgpu::StoreOp::Store,
2898            },
2899        })],
2900        depth_stencil_attachment: None,
2901        timestamp_writes: None,
2902        occlusion_query_set: None,
2903    });
2904}
2905
2906#[allow(clippy::too_many_arguments)]
2907/// Blits a captured backdrop region into local isolated-pass coordinates.
2908///
2909/// Missing capture geometry, bind groups, or empty generated geometry makes the
2910/// operation a no-op. Existing isolated color is loaded and preserved.
2911fn blit_backdrop_texture(
2912    encoder: &mut wgpu::CommandEncoder,
2913    device: &wgpu::Device,
2914    pipelines: &crate::pipeline_cache::PipelineCache,
2915    lease: &crate::offscreen_pool::LeasedOffscreen,
2916    pool: &crate::offscreen_pool::OffscreenSurfacePool,
2917    iso: &crate::isolated_plan::PlannedIsolatedPass,
2918    backdrop_table: &BackdropTable,
2919) {
2920    use crate::cmd_bounds::push_composite_quad_local;
2921    let Some(capture_rect) = iso.backdrop_capture_rect_px else {
2922        return;
2923    };
2924    let Some(tex_bg) = backdrop_table.get(iso.id.0) else {
2925        return;
2926    };
2927
2928    let local_surface = [lease.width as f32, lease.height as f32];
2929    let [ox, oy] = iso.content_origin_px;
2930    let local = ailloli_ui_core::Rect::new(
2931        capture_rect.x - ox,
2932        capture_rect.y - oy,
2933        capture_rect.w,
2934        capture_rect.h,
2935    );
2936    let mut verts = Vec::new();
2937    let range = push_composite_quad_local(&mut verts, local_surface, local, [1.0, 1.0, 1.0, 1.0]);
2938    if verts.is_empty() {
2939        return;
2940    }
2941
2942    let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2943        label: Some("backdrop blit verts"),
2944        contents: bytemuck::cast_slice(&verts),
2945        usage: wgpu::BufferUsages::VERTEX,
2946    });
2947    let none_clip = create_clip_binding(
2948        device,
2949        pipelines,
2950        "backdrop iso clip none",
2951        ClipParamsGpu::none(),
2952    );
2953    let color_view = lease.color_view(pool);
2954    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2955        label: Some("blit backdrop into isolated"),
2956        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2957            view: color_view,
2958            resolve_target: None,
2959            ops: wgpu::Operations {
2960                load: wgpu::LoadOp::Load,
2961                store: wgpu::StoreOp::Store,
2962            },
2963        })],
2964        depth_stencil_attachment: None,
2965        timestamp_writes: None,
2966        occlusion_query_set: None,
2967    });
2968    pass.set_pipeline(&pipelines.textured);
2969    pass.set_bind_group(0, &none_clip.bind_group, &[]);
2970    pass.set_bind_group(1, tex_bg, &[]);
2971    pass.set_vertex_buffer(0, vbuf.slice(..));
2972    pass.draw(range, 0..1);
2973}
2974
2975#[allow(clippy::too_many_arguments)]
2976/// Composites already-rendered child isolation textures into their parent.
2977///
2978/// Children are drawn in the parent's declared order. Opacity is clamped to
2979/// `0.0..=1.0`; missing child plans or bind groups are skipped.
2980fn blit_child_isolated_textures(
2981    encoder: &mut wgpu::CommandEncoder,
2982    device: &wgpu::Device,
2983    pipelines: &crate::pipeline_cache::PipelineCache,
2984    parent_lease: &crate::offscreen_pool::LeasedOffscreen,
2985    pool: &crate::offscreen_pool::OffscreenSurfacePool,
2986    parent_iso: &crate::isolated_plan::PlannedIsolatedPass,
2987    plan: &FrameRenderPlan,
2988    table: &IsolatedCompositeTable,
2989) {
2990    use crate::cmd_bounds::push_composite_quad_local;
2991    use crate::vertices::TexVertex;
2992
2993    let local_surface = [parent_lease.width as f32, parent_lease.height as f32];
2994    let [ox, oy] = parent_iso.content_origin_px;
2995    let mut verts: Vec<TexVertex> = Vec::new();
2996    let mut draws: Vec<(OffscreenPassId, std::ops::Range<u32>)> = Vec::new();
2997
2998    for child_id in &parent_iso.child_pass_ids {
2999        let Some(child_iso) = plan.planned_isolated.iter().find(|p| p.id == *child_id) else {
3000            continue;
3001        };
3002        let dest = child_iso.composite.dest_rect_px;
3003        let local = ailloli_ui_core::Rect::new(dest.x - ox, dest.y - oy, dest.w, dest.h);
3004        let tint = [1.0, 1.0, 1.0, child_iso.composite.opacity.clamp(0.0, 1.0)];
3005        let range = push_composite_quad_local(&mut verts, local_surface, local, tint);
3006        draws.push((*child_id, range));
3007    }
3008
3009    if verts.is_empty() {
3010        return;
3011    }
3012
3013    let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3014        label: Some("nested isolated child blit verts"),
3015        contents: bytemuck::cast_slice(&verts),
3016        usage: wgpu::BufferUsages::VERTEX,
3017    });
3018
3019    let none_clip = create_clip_binding(
3020        device,
3021        pipelines,
3022        "nested iso clip none",
3023        ClipParamsGpu::none(),
3024    );
3025    let color_view = parent_lease.color_view(pool);
3026    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3027        label: Some("blit nested isolated children"),
3028        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3029            view: color_view,
3030            resolve_target: None,
3031            ops: wgpu::Operations {
3032                load: wgpu::LoadOp::Load,
3033                store: wgpu::StoreOp::Store,
3034            },
3035        })],
3036        depth_stencil_attachment: None,
3037        timestamp_writes: None,
3038        occlusion_query_set: None,
3039    });
3040
3041    for (child_id, range) in draws {
3042        let Some(tex_bg) = table.get(child_id.0) else {
3043            continue;
3044        };
3045        let byte_start = range.start as u64 * std::mem::size_of::<TexVertex>() as u64;
3046        let byte_end = range.end as u64 * std::mem::size_of::<TexVertex>() as u64;
3047        pass.set_pipeline(&pipelines.textured);
3048        pass.set_bind_group(0, &none_clip.bind_group, &[]);
3049        pass.set_bind_group(1, tex_bg, &[]);
3050        pass.set_vertex_buffer(0, vbuf.slice(byte_start..byte_end));
3051        pass.draw(range.clone(), 0..1);
3052    }
3053}
3054
3055#[allow(clippy::too_many_arguments)]
3056/// Uploads a local frame plan and records it into one isolated target.
3057///
3058/// Empty arenas allocate no buffers. `preserve_color` loads backdrop/child
3059/// color; otherwise the target is cleared. Stencil is attached only when the
3060/// local plan requests it and the lease contains a stencil view.
3061fn record_isolated_content_pass(
3062    encoder: &mut wgpu::CommandEncoder,
3063    device: &wgpu::Device,
3064    pipelines: &crate::pipeline_cache::PipelineCache,
3065    text_atlas: &TextAtlas,
3066    icon_cache: &IconCache,
3067    plan: &FrameRenderPlan,
3068    lease: &crate::offscreen_pool::LeasedOffscreen,
3069    pool: &crate::offscreen_pool::OffscreenSurfacePool,
3070    clear: Color,
3071    pass_id: u16,
3072    preserve_color: bool,
3073) {
3074    let color_view = lease.color_view(pool);
3075    let w = lease.width as f32;
3076    let h = lease.height as f32;
3077
3078    let vertex_buf = if plan.vertex_arena.is_empty() {
3079        None
3080    } else {
3081        Some(
3082            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3083                label: Some("isolated vertex arena"),
3084                contents: bytemuck::cast_slice(&plan.vertex_arena),
3085                usage: wgpu::BufferUsages::VERTEX,
3086            }),
3087        )
3088    };
3089    let rrect_buf = if plan.rrect_vertex_arena.is_empty() {
3090        None
3091    } else {
3092        Some(
3093            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3094                label: Some("isolated rrect arena"),
3095                contents: bytemuck::cast_slice(&plan.rrect_vertex_arena),
3096                usage: wgpu::BufferUsages::VERTEX,
3097            }),
3098        )
3099    };
3100    let border_rrect_buf = if plan.border_vertex_arena.is_empty() {
3101        None
3102    } else {
3103        Some(
3104            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3105                label: Some("isolated border rrect arena"),
3106                contents: bytemuck::cast_slice(&plan.border_vertex_arena),
3107                usage: wgpu::BufferUsages::VERTEX,
3108            }),
3109        )
3110    };
3111    let shadow_buf = if plan.shadow_vertex_arena.is_empty() {
3112        None
3113    } else {
3114        Some(
3115            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3116                label: Some("isolated box shadow arena"),
3117                contents: bytemuck::cast_slice(&plan.shadow_vertex_arena),
3118                usage: wgpu::BufferUsages::VERTEX,
3119            }),
3120        )
3121    };
3122    let ring_progress_buf = if plan.ring_progress_vertex_arena.is_empty() {
3123        None
3124    } else {
3125        Some(
3126            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3127                label: Some("isolated ring progress arena"),
3128                contents: bytemuck::cast_slice(&plan.ring_progress_vertex_arena),
3129                usage: wgpu::BufferUsages::VERTEX,
3130            }),
3131        )
3132    };
3133    let stroke_buf = if plan.stroke_vertex_arena.is_empty() {
3134        None
3135    } else {
3136        Some(
3137            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3138                label: Some("isolated stroke arena"),
3139                contents: bytemuck::cast_slice(&plan.stroke_vertex_arena),
3140                usage: wgpu::BufferUsages::VERTEX,
3141            }),
3142        )
3143    };
3144    let tex_buf = if plan.tex_vertex_arena.is_empty() {
3145        None
3146    } else {
3147        Some(
3148            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3149                label: Some("isolated tex arena"),
3150                contents: bytemuck::cast_slice(&plan.tex_vertex_arena),
3151                usage: wgpu::BufferUsages::VERTEX,
3152            }),
3153        )
3154    };
3155    let stencil_mask_buf = if plan.stencil_mask_arena.is_empty() {
3156        None
3157    } else {
3158        Some(
3159            device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3160                label: Some("isolated stencil mask arena"),
3161                contents: bytemuck::cast_slice(&plan.stencil_mask_arena),
3162                usage: wgpu::BufferUsages::VERTEX,
3163            }),
3164        )
3165    };
3166
3167    let per_layer: Vec<PerLayerBindings> = plan
3168        .layers
3169        .iter()
3170        .map(|pl| {
3171            let none = ClipParamsGpu::none();
3172            let none_clip = create_clip_binding(device, pipelines, "iso clip none", none);
3173            let shape_clip = pl
3174                .clip_params_shape
3175                .map(|p| create_clip_binding(device, pipelines, "iso clip shape", p));
3176            PerLayerBindings {
3177                none_clip,
3178                shape_clip,
3179            }
3180        })
3181        .collect();
3182
3183    let depth_stencil_attachment = if plan.needs_stencil_attachment {
3184        lease
3185            .stencil_view(pool)
3186            .map(|view| wgpu::RenderPassDepthStencilAttachment {
3187                view,
3188                depth_ops: None,
3189                stencil_ops: Some(wgpu::Operations {
3190                    load: wgpu::LoadOp::Clear(0),
3191                    store: wgpu::StoreOp::Store,
3192                }),
3193            })
3194    } else {
3195        None
3196    };
3197
3198    let color_load = if preserve_color {
3199        wgpu::LoadOp::Load
3200    } else {
3201        wgpu::LoadOp::Clear(wgpu::Color {
3202            r: clear.r as f64,
3203            g: clear.g as f64,
3204            b: clear.b as f64,
3205            a: clear.a as f64,
3206        })
3207    };
3208
3209    let pass_label = format!("isolated pass {pass_id}");
3210    let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3211        label: Some(&pass_label),
3212        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3213            view: color_view,
3214            resolve_target: None,
3215            ops: wgpu::Operations {
3216                load: color_load,
3217                store: wgpu::StoreOp::Store,
3218            },
3219        })],
3220        depth_stencil_attachment,
3221        timestamp_writes: None,
3222        occlusion_query_set: None,
3223    });
3224
3225    let frame_has_stencil = plan.needs_stencil_attachment;
3226    for (i, pl) in plan.layers.iter().enumerate() {
3227        record_planned_layer(
3228            &mut rpass,
3229            pipelines,
3230            text_atlas,
3231            icon_cache,
3232            &per_layer[i],
3233            pl,
3234            &plan.batches,
3235            vertex_buf.as_ref(),
3236            rrect_buf.as_ref(),
3237            border_rrect_buf.as_ref(),
3238            shadow_buf.as_ref(),
3239            ring_progress_buf.as_ref(),
3240            stroke_buf.as_ref(),
3241            tex_buf.as_ref(),
3242            stencil_mask_buf.as_ref(),
3243            None,
3244            None,
3245            w,
3246            h,
3247            Scale::new(1.0),
3248            frame_has_stencil,
3249            false,
3250        );
3251    }
3252}
3253
3254/// Uploads a clip uniform through explicit device/pipeline references.
3255///
3256/// This free-function variant supports isolated passes without borrowing the
3257/// whole renderer.
3258fn create_clip_binding(
3259    device: &wgpu::Device,
3260    pipelines: &crate::pipeline_cache::PipelineCache,
3261    label: &'static str,
3262    params: ClipParamsGpu,
3263) -> ClipBinding {
3264    let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3265        label: Some(label),
3266        contents: bytemuck::bytes_of(&params),
3267        usage: wgpu::BufferUsages::UNIFORM,
3268    });
3269    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3270        label: Some(label),
3271        layout: &pipelines.clip_bind_group_layout,
3272        entries: &[wgpu::BindGroupEntry {
3273            binding: 0,
3274            resource: buffer.as_entire_binding(),
3275        }],
3276    });
3277    ClipBinding {
3278        _buffer: buffer,
3279        bind_group,
3280    }
3281}
3282
3283/// Finds the destination-dependent non-normal composite in a planned layer.
3284///
3285/// Normal blends and composites not requiring destination capture return
3286/// `None`; planning guarantees at most one matching composite per synthetic
3287/// layer.
3288fn find_shader_blend_composite(
3289    plan: &FrameRenderPlan,
3290    layer_idx: usize,
3291) -> Option<&PlannedIsolatedComposite> {
3292    let pl = &plan.layers[layer_idx];
3293    for batch_idx in pl.batch_range.clone() {
3294        if let PlannedBatch::IsolatedComposite(comp) = &plan.batches[batch_idx] {
3295            if comp.needs_dst_capture && comp.blend_mode != BlendMode::Normal {
3296                return Some(comp);
3297            }
3298        }
3299    }
3300    None
3301}
3302
3303/// Records one `PlannedLayer` (scissor + stencil mask + batches) into the
3304/// already-open `wgpu::RenderPass`. Free function so it can borrow individual
3305/// `Renderer` fields without conflicting with the `&mut self` held by the
3306/// pass.
3307#[allow(clippy::too_many_arguments)]
3308fn record_planned_layer<'a>(
3309    rpass: &mut wgpu::RenderPass<'a>,
3310    pipelines: &'a crate::pipeline_cache::PipelineCache,
3311    text_atlas: &'a TextAtlas,
3312    icon_cache: &'a IconCache,
3313    bindings: &'a PerLayerBindings,
3314    pl: &PlannedLayer,
3315    batches: &[crate::frame_plan::PlannedBatch],
3316    vertex_buf: Option<&'a wgpu::Buffer>,
3317    rrect_buf: Option<&'a wgpu::Buffer>,
3318    border_rrect_buf: Option<&'a wgpu::Buffer>,
3319    shadow_buf: Option<&'a wgpu::Buffer>,
3320    ring_progress_buf: Option<&'a wgpu::Buffer>,
3321    stroke_buf: Option<&'a wgpu::Buffer>,
3322    tex_buf: Option<&'a wgpu::Buffer>,
3323    stencil_mask_buf: Option<&'a wgpu::Buffer>,
3324    composite_buf: Option<&'a wgpu::Buffer>,
3325    composite_table: Option<&'a IsolatedCompositeTable>,
3326    w: f32,
3327    h: f32,
3328    scale: Scale,
3329    frame_has_stencil_attachment: bool,
3330    skip_shader_blend_composite: bool,
3331) {
3332    // (3) Scissor reset: always, full-screen if `pl.scissor.is_none()`.
3333    apply_layer_scissor(rpass, w, h, scale, pl.scissor);
3334
3335    let use_stencil = pl.clip_mode == ClipRenderMode::Stencil;
3336
3337    // (4) Optional stencil-mask draw for this layer.
3338    if use_stencil {
3339        if let (Some(stencil_ref), Some(mask_range), Some(mask_buf)) = (
3340            pl.stencil_ref,
3341            pl.stencil_mask_range.clone(),
3342            stencil_mask_buf,
3343        ) {
3344            rpass.set_pipeline(&pipelines.rounded_rect_stencil_mask);
3345            rpass.set_bind_group(0, &bindings.none_clip.bind_group, &[]);
3346            rpass.set_stencil_reference(stencil_ref);
3347            rpass.set_vertex_buffer(0, mask_buf.slice(..));
3348            rpass.draw(mask_range, 0..1);
3349        }
3350    }
3351
3352    let stencil_ref = pl.stencil_ref.unwrap_or(0);
3353
3354    // (5) Iterate the layer's batches.
3355    for batch_idx in pl.batch_range.clone() {
3356        let batch = &batches[batch_idx];
3357
3358        if let PlannedBatch::IsolatedComposite(comp) = batch {
3359            if skip_shader_blend_composite
3360                || (comp.needs_dst_capture && comp.blend_mode != BlendMode::Normal)
3361            {
3362                continue;
3363            }
3364            let Some(composite_buf) = composite_buf else {
3365                continue;
3366            };
3367            let Some(table) = composite_table else {
3368                continue;
3369            };
3370            let Some(tex_bg) = table.get(comp.pass_id.0) else {
3371                continue;
3372            };
3373            rpass.set_pipeline(&pipelines.textured);
3374            rpass.set_bind_group(0, &bindings.none_clip.bind_group, &[]);
3375            rpass.set_bind_group(1, tex_bg, &[]);
3376            rpass.set_stencil_reference(0);
3377            rpass.set_vertex_buffer(0, composite_buf.slice(..));
3378            rpass.draw(comp.vertex_range.clone(), 0..1);
3379            continue;
3380        }
3381
3382        let PlannedBatch::Primitives {
3383            pipeline,
3384            clip_bind,
3385            texture,
3386            vertex_range,
3387        } = batch
3388        else {
3389            continue;
3390        };
3391
3392        // single-pass compositing: if the frame has a stencil attachment but this layer is
3393        // not in stencil mode, the wgpu validation requires that the pipeline
3394        // declares a compatible depth_stencil state. Use the passthrough
3395        // variants in that case (compare=Always, no write).
3396        let pipe = match (*pipeline, use_stencil, frame_has_stencil_attachment) {
3397            (PipelineKind::Rect, true, _) => &pipelines.rect_stencil,
3398            (PipelineKind::Rect, false, true) => &pipelines.rect_passthrough_stencil,
3399            (PipelineKind::Rect, false, false) => &pipelines.rect,
3400            (PipelineKind::RRect, true, _) => &pipelines.rounded_rect_stencil,
3401            (PipelineKind::RRect, false, true) => &pipelines.rounded_rect_passthrough_stencil,
3402            (PipelineKind::RRect, false, false) => &pipelines.rounded_rect,
3403            (PipelineKind::BorderRRect, true, _) => &pipelines.border_rounded_rect_stencil,
3404            (PipelineKind::BorderRRect, false, true) => {
3405                &pipelines.border_rounded_rect_passthrough_stencil
3406            }
3407            (PipelineKind::BorderRRect, false, false) => &pipelines.border_rounded_rect,
3408            (PipelineKind::BoxShadow, true, _) => &pipelines.box_shadow_stencil,
3409            (PipelineKind::BoxShadow, false, true) => &pipelines.box_shadow_passthrough_stencil,
3410            (PipelineKind::BoxShadow, false, false) => &pipelines.box_shadow,
3411            (PipelineKind::RingProgress, true, _) => &pipelines.ring_progress_stencil,
3412            (PipelineKind::RingProgress, false, true) => {
3413                &pipelines.ring_progress_passthrough_stencil
3414            }
3415            (PipelineKind::RingProgress, false, false) => &pipelines.ring_progress,
3416            // Stroked polylines skip the hard stencil Equal test: thin triangle strips
3417            // can miss the 8-bit stencil mask written by the rounded-rect mask pass
3418            // while solid rects still pass. Scissor + optional shader clip (Shape bind)
3419            // keep window-root corners clean.
3420            (PipelineKind::Stroke, true, _) => &pipelines.stroke_passthrough_stencil,
3421            (PipelineKind::Stroke, false, true) => &pipelines.stroke_passthrough_stencil,
3422            (PipelineKind::Stroke, false, false) => &pipelines.stroke,
3423            (PipelineKind::Textured, true, _) => &pipelines.textured_stencil,
3424            (PipelineKind::Textured, false, true) => &pipelines.textured_passthrough_stencil,
3425            (PipelineKind::Textured, false, false) => &pipelines.textured,
3426        };
3427
3428        let clip_bg = match clip_bind {
3429            ClipBindKind::None => &bindings.none_clip.bind_group,
3430            ClipBindKind::Shape => bindings
3431                .shape_clip
3432                .as_ref()
3433                .map(|c| &c.bind_group)
3434                .unwrap_or(&bindings.none_clip.bind_group),
3435        };
3436
3437        let arena_buf = match pipeline {
3438            PipelineKind::Rect => vertex_buf,
3439            PipelineKind::RRect => rrect_buf,
3440            PipelineKind::BorderRRect => border_rrect_buf,
3441            PipelineKind::BoxShadow => shadow_buf,
3442            PipelineKind::RingProgress => ring_progress_buf,
3443            PipelineKind::Stroke => stroke_buf,
3444            PipelineKind::Textured => tex_buf,
3445        };
3446        let Some(arena_buf) = arena_buf else {
3447            continue;
3448        };
3449
3450        rpass.set_pipeline(pipe);
3451        rpass.set_bind_group(0, clip_bg, &[]);
3452
3453        if matches!(pipeline, PipelineKind::Textured) {
3454            match texture {
3455                TextureBindKind::TextPage(p) => {
3456                    rpass.set_bind_group(1, text_atlas.page_bind_group(*p), &[]);
3457                }
3458                TextureBindKind::IconPage(key) => {
3459                    let Some(gpu_icon) = icon_cache.get(key) else {
3460                        continue;
3461                    };
3462                    rpass.set_bind_group(1, &gpu_icon.bind_group, &[]);
3463                }
3464                TextureBindKind::None => {
3465                    debug_assert!(false, "Textured batch without TextureBindKind");
3466                    continue;
3467                }
3468            }
3469        }
3470
3471        rpass.set_stencil_reference(stencil_ref);
3472        rpass.set_vertex_buffer(0, arena_buf.slice(..));
3473        rpass.draw(vertex_range.clone(), 0..1);
3474    }
3475}
3476
3477// multilayer compositing legacy `render_layer_pass` body removed in single-pass compositing.
3478//
3479// The single-pass implementation lives in `Renderer::record_single_pass`
3480// (open one `wgpu::RenderPass`, iterate `FrameRenderPlan::layers`) and the
3481// per-layer record logic lives in the free function `record_planned_layer`
3482// above.
3483//
3484// Removed at this site:
3485//   - `Renderer::render_layer_pass(...)`: replaced by `record_single_pass` +
3486//     `record_planned_layer`;
3487//   - `RenderBatch` / `BatchClipKey` / `batch_clip_key` / `push_render_batch`:
3488//     replaced by `FrameRenderPlan::PlannedBatch` + intra-layer fusion in
3489//     `frame_plan::push_planned_batch`;
3490//   - `mod render_batch_tests`: replaced by `frame_plan::tests::*`
3491//     (CPU-pure, no GPU required);
3492//   - `Renderer.stencil_frame: StencilFrameState`: replaced by per-frame
3493//     `stencil_ref` assignment inside `FrameRenderPlan::build_cpu`.