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}