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(¶ms),
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(¶ms),
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`.