ailloli_ui_render_wgpu/
isolated_plan.rs

1//! isolated compositor: isolated offscreen pass planning (CPU pure).
2//! nested isolated compositor: parent/child DAG for nested isolated passes.
3
4use std::collections::HashMap;
5use std::ops::Range;
6
7use ailloli_ui_core::{Color, Rect};
8use ailloli_ui_runtime::{BlendMode, IsolatedEffects};
9
10/// Stable id for an offscreen pass / composite batch.
11///
12/// IDs are frame-local `u16` values assigned by CPU planning.
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_render_wgpu::OffscreenPassId;
18/// let id = OffscreenPassId(7);
19/// assert_eq!(id.0, 7);
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct OffscreenPassId(pub u16);
23
24/// Linear post-effect applied after content render, before composite.
25///
26/// # Examples
27///
28/// ```
29/// use ailloli_ui_render_wgpu::IsolatedEffect;
30/// let effect = IsolatedEffect::Blur { radius_px: 8.0 };
31/// assert!(matches!(effect, IsolatedEffect::Blur { radius_px: 8.0 }));
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum IsolatedEffect {
35    /// Multiplies the composited group alpha; `1.0` leaves it unchanged.
36    Opacity(f32),
37    /// Applies a blur whose radius is measured in physical pixels.
38    Blur {
39        /// Nonnegative physical-pixel radius after budget clamping.
40        radius_px: f32,
41    },
42}
43
44/// Ordered effects for one isolated pass.
45///
46/// The planner currently emits blur before opacity. An empty chain requires no
47/// post-processing, though the pass may still be needed for blending.
48///
49/// # Examples
50///
51/// ```
52/// use ailloli_ui_render_wgpu::{IsolatedEffect, IsolatedEffectChain};
53/// let chain = IsolatedEffectChain {
54///     effects: vec![IsolatedEffect::Opacity(0.5)],
55/// };
56/// assert!(!chain.is_empty());
57/// ```
58#[derive(Debug, Clone, Default)]
59pub struct IsolatedEffectChain {
60    /// Effects in GPU execution order.
61    pub effects: Vec<IsolatedEffect>,
62}
63
64impl IsolatedEffectChain {
65    /// Converts runtime effects into the renderer's executable linear chain.
66    ///
67    /// Blur is included only for a strictly positive radius. Opacity is
68    /// included below the `0.999` no-op threshold. Backdrop blur and blend mode
69    /// are planned separately.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ailloli_ui_render_wgpu::IsolatedEffectChain;
75    /// use ailloli_ui_runtime::IsolatedEffects;
76    /// let chain = IsolatedEffectChain::from_effects(&IsolatedEffects::default());
77    /// assert!(chain.is_empty());
78    /// ```
79    pub fn from_effects(e: &IsolatedEffects) -> Self {
80        let mut effects = Vec::new();
81        if e.blur_radius_px > 0.0 {
82            effects.push(IsolatedEffect::Blur {
83                radius_px: e.blur_radius_px,
84            });
85        }
86        if e.opacity < 0.999 {
87            effects.push(IsolatedEffect::Opacity(e.opacity));
88        }
89        Self { effects }
90    }
91
92    /// Returns whether no linear post-effect is scheduled.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use ailloli_ui_render_wgpu::IsolatedEffectChain;
98    /// assert!(IsolatedEffectChain::default().is_empty());
99    /// ```
100    pub fn is_empty(&self) -> bool {
101        self.effects.is_empty()
102    }
103}
104
105/// Parameters for compositing an offscreen result into the main pass.
106///
107/// # Examples
108///
109/// ```
110/// use ailloli_ui_core::Rect;
111/// use ailloli_ui_render_wgpu::CompositeParams;
112/// use ailloli_ui_runtime::BlendMode;
113/// let params = CompositeParams {
114///     dest_rect_px: Rect::new(0.0, 0.0, 32.0, 16.0),
115///     opacity: 0.75,
116///     blend_mode: BlendMode::Normal,
117/// };
118/// assert_eq!(params.opacity, 0.75);
119/// ```
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct CompositeParams {
122    /// Destination rectangle in physical framebuffer pixels.
123    pub dest_rect_px: Rect,
124    /// Group opacity multiplier; planning normally keeps it in `[0, 1]`.
125    pub opacity: f32,
126    /// Blend operation used when recombining with the destination.
127    pub blend_mode: BlendMode,
128}
129
130/// Full isolated pass descriptor produced by `FrameRenderPlan::build_cpu`.
131///
132/// Bounds and origins use physical pixels. `local_size_px` is the allocated
133/// integer extent after snapping and budget clamping.
134///
135/// # Examples
136///
137/// ```
138/// use ailloli_ui_render_wgpu::PlannedIsolatedPass;
139/// let _: usize = std::mem::size_of::<PlannedIsolatedPass>();
140/// ```
141#[derive(Debug, Clone)]
142pub struct PlannedIsolatedPass {
143    /// Frame-local pass identity.
144    pub id: OffscreenPassId,
145    /// Index of the source scene layer.
146    pub source_layer_idx: usize,
147    /// Tight command bounds before effect inflation, in physical pixels.
148    pub content_bounds_px: Rect,
149    /// Allocated and rendered bounds after effects and budget policy.
150    pub render_bounds_px: Rect,
151    /// Physical origin subtracted when vertices are made pass-local.
152    pub content_origin_px: [f32; 2],
153    /// Offscreen allocation width and height in physical pixels.
154    pub local_size_px: [u32; 2],
155    /// Whether rounded clipping requires a stencil attachment.
156    pub needs_stencil: bool,
157    /// Transparent or opaque clear color used before drawing content.
158    pub clear_color: Color,
159    /// Linear effects executed before compositing.
160    pub effects: IsolatedEffectChain,
161    /// Destination, opacity, and original blend contract.
162    pub composite: CompositeParams,
163    /// Parent offscreen pass (if nested).
164    pub parent_id: Option<OffscreenPassId>,
165    /// Child passes sampled into this pass before local content.
166    pub child_pass_ids: Vec<OffscreenPassId>,
167    /// Zero-based nesting depth; root isolated passes have depth zero.
168    pub isolated_depth: u8,
169    /// When true, composite into the main frame pass (root isolated only).
170    pub composites_to_main: bool,
171    /// Backdrop blur radius (physical px); root passes only in v1.
172    pub backdrop_blur_radius_px: f32,
173    /// Region to copy from the main framebuffer before this pass (if backdrop active).
174    pub backdrop_capture_rect_px: Option<Rect>,
175    /// Whether the main framebuffer must be copied before this pass executes.
176    pub needs_backdrop_capture: bool,
177    /// Effective blend for main-pass composite (after budget downgrade).
178    pub composite_blend_mode: BlendMode,
179    /// Whether non-normal blending requires a destination snapshot.
180    pub needs_blend_dst_capture: bool,
181    /// Physical main-frame region copied for destination-aware blending.
182    pub blend_capture_rect_px: Option<Rect>,
183}
184
185/// When to snapshot the swapchain for backdrop blur (after `split_planned_layer_idx` layers).
186///
187/// # Examples
188///
189/// ```
190/// use ailloli_ui_core::Rect;
191/// use ailloli_ui_render_wgpu::isolated_plan::BackdropCapturePoint;
192/// use ailloli_ui_render_wgpu::OffscreenPassId;
193/// let point = BackdropCapturePoint {
194///     pass_id: OffscreenPassId(2), source_layer_idx: 3,
195///     capture_rect_px: Rect::new(0.0, 0.0, 10.0, 10.0),
196///     split_planned_layer_idx: 4,
197/// };
198/// assert_eq!(point.split_planned_layer_idx, 4);
199/// ```
200#[derive(Debug, Clone)]
201pub struct BackdropCapturePoint {
202    /// Isolated pass that consumes the snapshot.
203    pub pass_id: OffscreenPassId,
204    /// Original scene-layer index of that pass.
205    pub source_layer_idx: usize,
206    /// Region copied from the main framebuffer, in physical pixels.
207    pub capture_rect_px: Rect,
208    /// Planned-layer index after which the copy occurs.
209    pub split_planned_layer_idx: usize,
210}
211
212/// Composite batch inserted into the main pass plan.
213///
214/// # Examples
215///
216/// ```
217/// use ailloli_ui_render_wgpu::isolated_plan::PlannedIsolatedComposite;
218/// let _: usize = std::mem::size_of::<PlannedIsolatedComposite>();
219/// ```
220#[derive(Debug, Clone, PartialEq)]
221pub struct PlannedIsolatedComposite {
222    /// Offscreen pass whose output is sampled.
223    pub pass_id: OffscreenPassId,
224    /// Destination in physical framebuffer pixels.
225    pub dest_rect_px: Rect,
226    /// Group opacity multiplier applied by the composite shader.
227    pub opacity: f32,
228    /// Effective blend operation after any budget downgrade.
229    pub blend_mode: BlendMode,
230    /// Capture swapchain dst for shader blend (root, non-Normal only).
231    pub needs_dst_capture: bool,
232    /// Physical destination region copied when destination-aware blending is active.
233    pub dst_capture_rect_px: Option<Rect>,
234    /// Range into [`crate::frame_plan::FrameRenderPlan::composite_vertex_arena`].
235    pub vertex_range: Range<u32>,
236}
237
238/// Topological order for isolated pass execution (children before parents).
239///
240/// Independent passes are ordered by `source_layer_idx`. Missing parent IDs are
241/// treated as external roots. A cycle violates the planner invariant and is
242/// caught by a debug assertion; release builds return the acyclic prefix.
243///
244/// # Examples
245///
246/// ```
247/// use ailloli_ui_render_wgpu::isolated_plan::topo_sort_isolated_passes;
248/// assert!(topo_sort_isolated_passes(&[]).is_empty());
249/// ```
250pub fn topo_sort_isolated_passes(passes: &[PlannedIsolatedPass]) -> Vec<usize> {
251    let n = passes.len();
252    if n == 0 {
253        return Vec::new();
254    }
255    let id_to_idx: HashMap<OffscreenPassId, usize> =
256        passes.iter().enumerate().map(|(i, p)| (p.id, i)).collect();
257    let mut indegree = vec![0usize; n];
258    for p in passes {
259        if let Some(pid) = p.parent_id {
260            if let Some(&parent_idx) = id_to_idx.get(&pid) {
261                indegree[parent_idx] += 1;
262            }
263        }
264    }
265    let mut order = Vec::with_capacity(n);
266    let mut ready: Vec<usize> = (0..n).filter(|&i| indegree[i] == 0).collect();
267    ready.sort_by_key(|&i| passes[i].source_layer_idx);
268    let mut head = 0usize;
269    while head < ready.len() {
270        let i = ready[head];
271        head += 1;
272        order.push(i);
273        if let Some(pid) = passes[i].parent_id {
274            if let Some(&parent_idx) = id_to_idx.get(&pid) {
275                indegree[parent_idx] = indegree[parent_idx].saturating_sub(1);
276                if indegree[parent_idx] == 0 {
277                    ready.push(parent_idx);
278                }
279            }
280        }
281        ready[head..].sort_by_key(|&idx| passes[idx].source_layer_idx);
282    }
283    debug_assert_eq!(
284        order.len(),
285        n,
286        "isolated pass DAG has a cycle or missing parent"
287    );
288    order
289}
290
291#[cfg(test)]
292/// Verifies deterministic child-before-parent ordering for isolated pass DAGs.
293mod tests {
294    use super::*;
295
296    /// Creates a minimal pass node with configurable DAG identity and parent.
297    fn stub_pass(id: u16, layer: usize, parent: Option<u16>, depth: u8) -> PlannedIsolatedPass {
298        PlannedIsolatedPass {
299            id: OffscreenPassId(id),
300            source_layer_idx: layer,
301            content_bounds_px: Rect::new(0.0, 0.0, 10.0, 10.0),
302            render_bounds_px: Rect::new(0.0, 0.0, 10.0, 10.0),
303            content_origin_px: [0.0, 0.0],
304            local_size_px: [10, 10],
305            needs_stencil: false,
306            clear_color: Color::new(0.0, 0.0, 0.0, 0.0),
307            effects: IsolatedEffectChain::default(),
308            composite: CompositeParams {
309                dest_rect_px: Rect::new(0.0, 0.0, 10.0, 10.0),
310                opacity: 1.0,
311                blend_mode: BlendMode::Normal,
312            },
313            composite_blend_mode: BlendMode::Normal,
314            needs_blend_dst_capture: false,
315            blend_capture_rect_px: None,
316            parent_id: parent.map(OffscreenPassId),
317            child_pass_ids: Vec::new(),
318            isolated_depth: depth,
319            composites_to_main: parent.is_none(),
320            backdrop_blur_radius_px: 0.0,
321            backdrop_capture_rect_px: None,
322            needs_backdrop_capture: false,
323        }
324    }
325
326    #[test]
327    fn topo_sort_child_before_parent() {
328        let passes = vec![stub_pass(0, 0, None, 0), stub_pass(1, 1, Some(0), 1)];
329        let order = topo_sort_isolated_passes(&passes);
330        assert_eq!(order, vec![1, 0]);
331    }
332
333    #[test]
334    fn topo_sort_siblings_before_parent() {
335        let mut parent = stub_pass(2, 2, None, 0);
336        parent.child_pass_ids = vec![OffscreenPassId(0), OffscreenPassId(1)];
337        let passes = vec![
338            stub_pass(0, 0, Some(2), 1),
339            stub_pass(1, 1, Some(2), 1),
340            parent,
341        ];
342        let order = topo_sort_isolated_passes(&passes);
343        assert!(
344            order.iter().position(|&i| i == 2).unwrap()
345                > order.iter().position(|&i| i == 0).unwrap()
346        );
347        assert!(
348            order.iter().position(|&i| i == 2).unwrap()
349                > order.iter().position(|&i| i == 1).unwrap()
350        );
351    }
352}