ailloli_ui_render_wgpu/
clip.rs

1//! GPU clip rendering mode selection (not part of `ailloli_ui_core`).
2
3use ailloli_ui_core::{ClipShape, Rect};
4use ailloli_ui_runtime::scene::{ClipEntry, ClipStackSnapshot};
5
6/// How a layer clip is applied on the GPU.
7///
8/// # Examples
9///
10/// ```
11/// use ailloli_ui_render_wgpu::ClipRenderMode;
12/// let mode = ClipRenderMode::Scissor;
13/// assert_eq!(mode, ClipRenderMode::Scissor);
14/// ```
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
16pub enum ClipRenderMode {
17    /// Axis-aligned scissor rect.
18    Scissor,
19    /// Per-fragment rounded-rect alpha in the shader.
20    ShaderMask,
21    /// Depth/stencil mask for large or window-root rounded clips.
22    Stencil,
23}
24
25/// GPU-ready interpretation of a non-destructive clip stack.
26///
27/// `scissor` is the rectangular intersection of the full stack. Rounded entries
28/// remain separate so window-root identity and nested masks are not discarded.
29///
30/// # Examples
31///
32/// ```
33/// use ailloli_ui_render_wgpu::resolve_clip_render_plan;
34/// use ailloli_ui_runtime::scene::ClipStackSnapshot;
35/// let clip = ClipStackSnapshot::from_entries(Vec::new());
36/// let plan = resolve_clip_render_plan(&clip, 0);
37/// assert!(plan.rounded_masks.is_empty());
38/// ```
39#[derive(Debug, Clone, PartialEq)]
40pub struct RenderClipPlan {
41    /// Rectangular clip intersection in logical coordinates, or none for an empty stack.
42    pub scissor: Option<Rect>,
43    /// All rounded entries in original stack order.
44    pub rounded_masks: Vec<ClipEntry>,
45    /// Window-root rounded entry when present, otherwise the first rounded entry.
46    pub primary_round_mask: Option<ClipEntry>,
47    /// GPU implementation selected for the primary rounded mask.
48    pub clip_mode: ClipRenderMode,
49}
50
51/// Above this draw-command count, prefer stencil over per-fragment shader masking.
52///
53/// The comparison is strict: four commands still use the shader for a non-root
54/// rounded clip, while five select stencil.
55///
56/// # Examples
57///
58/// ```
59/// use ailloli_ui_render_wgpu::clip::STENCIL_DRAW_CMD_THRESHOLD;
60/// assert_eq!(STENCIL_DRAW_CMD_THRESHOLD, 4);
61/// ```
62pub const STENCIL_DRAW_CMD_THRESHOLD: usize = 4;
63
64/// Shader AA band on stencil edges (on by default).
65/// Set `AILLOLI_UI_STENCIL_AA=0`; `OCTAVUI_STENCIL_AA` is a legacy fallback.
66/// The primary variable wins when both are set; only `0` and case-insensitive
67/// `false` disable the band.
68///
69/// # Examples
70///
71/// ```
72/// use ailloli_ui_render_wgpu::clip::stencil_aa_enabled;
73/// let enabled: bool = stencil_aa_enabled();
74/// let _ = enabled; // The environment controls the value.
75/// ```
76pub fn stencil_aa_enabled() -> bool {
77    !crate::env_control::falsey("AILLOLI_UI_STENCIL_AA", "OCTAVUI_STENCIL_AA")
78}
79
80/// Picks the GPU clip strategy for a layer.
81///
82/// `None` and rectangular clips always use scissor. A window-root rounded clip
83/// always uses stencil unless a force environment variable overrides it. For a
84/// non-root rounded clip, command counts above
85/// [`STENCIL_DRAW_CMD_THRESHOLD`] use stencil and smaller layers use a shader
86/// mask. If both force variables are true, the shader override wins.
87///
88/// # Examples
89///
90/// ```
91/// use ailloli_ui_render_wgpu::{choose_clip_render_mode, ClipRenderMode};
92/// assert_eq!(choose_clip_render_mode(None, false, 100), ClipRenderMode::Scissor);
93/// ```
94pub fn choose_clip_render_mode(
95    clip: Option<&ClipShape>,
96    is_window_root: bool,
97    draw_cmd_count: usize,
98) -> ClipRenderMode {
99    match clip {
100        None => ClipRenderMode::Scissor,
101        Some(ClipShape::Rect(_)) => ClipRenderMode::Scissor,
102        Some(ClipShape::RoundRect { .. }) => {
103            if crate::env_control::truthy(
104                "AILLOLI_UI_CLIP_FORCE_SHADER",
105                "OCTAVUI_CLIP_FORCE_SHADER",
106            ) {
107                return ClipRenderMode::ShaderMask;
108            }
109            if crate::env_control::truthy(
110                "AILLOLI_UI_CLIP_FORCE_STENCIL",
111                "OCTAVUI_CLIP_FORCE_STENCIL",
112            ) {
113                return ClipRenderMode::Stencil;
114            }
115            if is_window_root || draw_cmd_count > STENCIL_DRAW_CMD_THRESHOLD {
116                ClipRenderMode::Stencil
117            } else {
118                ClipRenderMode::ShaderMask
119            }
120        }
121    }
122}
123
124/// Resolves a stack of clips without fusing away root/window metadata.
125///
126/// `draw_cmd_count` influences only the rounded-mask strategy. The returned
127/// scissor remains in logical coordinates; conversion to physical pixels occurs
128/// at pass encoding.
129///
130/// # Examples
131///
132/// ```
133/// use ailloli_ui_core::{ClipShape, Rect};
134/// use ailloli_ui_render_wgpu::{resolve_clip_render_plan, ClipRenderMode};
135/// use ailloli_ui_runtime::scene::{ClipEntry, ClipStackSnapshot};
136/// let clip = ClipStackSnapshot::from_entries(vec![ClipEntry::new(
137///     ClipShape::Rect(Rect::new(0.0, 0.0, 20.0, 10.0)), false)]);
138/// let plan = resolve_clip_render_plan(&clip, 1);
139/// assert_eq!(plan.clip_mode, ClipRenderMode::Scissor);
140/// ```
141pub fn resolve_clip_render_plan(clip: &ClipStackSnapshot, draw_cmd_count: usize) -> RenderClipPlan {
142    let scissor = clip.scissor_rect();
143    let rounded_masks: Vec<ClipEntry> = clip
144        .entries()
145        .iter()
146        .copied()
147        .filter(|entry| matches!(entry.shape, ClipShape::RoundRect { .. }))
148        .collect();
149    let primary_round_mask = rounded_masks
150        .iter()
151        .copied()
152        .find(|entry| entry.is_window_root)
153        .or_else(|| rounded_masks.first().copied());
154    let clip_mode = primary_round_mask.map_or(ClipRenderMode::Scissor, |entry| {
155        choose_clip_render_mode(Some(&entry.shape), entry.is_window_root, draw_cmd_count)
156    });
157
158    RenderClipPlan {
159        scissor,
160        rounded_masks,
161        primary_round_mask,
162        clip_mode,
163    }
164}
165
166/// Uniform block for `clip_alpha` in WGSL (32 bytes, 16-byte aligned).
167///
168/// # Examples
169///
170/// ```
171/// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
172/// assert_eq!(std::mem::size_of::<ClipParamsGpu>(), 32);
173/// ```
174#[repr(C)]
175#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
176pub struct ClipParamsGpu {
177    /// Physical `[x, y, width, height]` bounding rectangle.
178    pub rect: [f32; 4],
179    /// Rounded-corner radius in physical pixels; zero for rectangles and no clip.
180    pub radius: f32,
181    /// Shader discriminator, one of `MODE_NONE`, `MODE_RECT`, or `MODE_ROUND`.
182    pub mode: u32,
183    /// Explicit alignment padding; always zero.
184    pub _pad: u32,
185    /// Struct tail padding (WGSL expects 32 bytes, not 28).
186    pub _struct_pad: u32,
187}
188
189const _: () = assert!(std::mem::size_of::<ClipParamsGpu>() == 32);
190
191impl ClipParamsGpu {
192    /// Shader mode for an inactive clip.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
198    /// assert_eq!(ClipParamsGpu::MODE_NONE, 0);
199    /// ```
200    pub const MODE_NONE: u32 = 0;
201    /// Shader mode for an axis-aligned rectangle.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
207    /// assert_eq!(ClipParamsGpu::MODE_RECT, 1);
208    /// ```
209    pub const MODE_RECT: u32 = 1;
210    /// Shader mode for a rounded rectangle.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
216    /// assert_eq!(ClipParamsGpu::MODE_ROUND, 2);
217    /// ```
218    pub const MODE_ROUND: u32 = 2;
219
220    /// Creates the zeroed inactive uniform block.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
226    /// let params = ClipParamsGpu::none();
227    /// assert_eq!((params.mode, params.radius), (ClipParamsGpu::MODE_NONE, 0.0));
228    /// ```
229    pub fn none() -> Self {
230        Self {
231            rect: [0.0; 4],
232            radius: 0.0,
233            mode: Self::MODE_NONE,
234            _pad: 0,
235            _struct_pad: 0,
236        }
237    }
238
239    /// Converts a logical clip shape to its physical-pixel shader parameters.
240    ///
241    /// Every coordinate and the rounded radius are multiplied by `dpr` without
242    /// clamping. Callers are responsible for a finite positive scale.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use ailloli_ui_core::{ClipShape, Rect};
248    /// use ailloli_ui_render_wgpu::clip::ClipParamsGpu;
249    /// let params = ClipParamsGpu::from_shape(
250    ///     &ClipShape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0)), 2.0);
251    /// assert_eq!(params.rect, [2.0, 4.0, 6.0, 8.0]);
252    /// assert_eq!(params.mode, ClipParamsGpu::MODE_RECT);
253    /// ```
254    pub fn from_shape(shape: &ClipShape, dpr: f32) -> Self {
255        let bbox = shape.bounding_rect();
256        let x = bbox.x * dpr;
257        let y = bbox.y * dpr;
258        let w = bbox.w * dpr;
259        let h = bbox.h * dpr;
260        match shape {
261            ClipShape::Rect(_) => Self {
262                rect: [x, y, w, h],
263                radius: 0.0,
264                mode: Self::MODE_RECT,
265                _pad: 0,
266                _struct_pad: 0,
267            },
268            ClipShape::RoundRect { radius, .. } => Self {
269                rect: [x, y, w, h],
270                radius: radius * dpr,
271                mode: Self::MODE_ROUND,
272                _pad: 0,
273                _struct_pad: 0,
274            },
275        }
276    }
277}
278
279/// Clip bounding box in physical pixels.
280///
281/// Coordinates are multiplied by `dpr` without clamping or rounding.
282///
283/// # Examples
284///
285/// ```
286/// use ailloli_ui_core::{ClipShape, Rect};
287/// use ailloli_ui_render_wgpu::clip::clip_bbox_physical;
288/// let shape = ClipShape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0));
289/// assert_eq!(clip_bbox_physical(&shape, 2.0), Rect::new(2.0, 4.0, 6.0, 8.0));
290/// ```
291pub fn clip_bbox_physical(shape: &ClipShape, dpr: f32) -> Rect {
292    let b = shape.bounding_rect();
293    Rect::new(b.x * dpr, b.y * dpr, b.w * dpr, b.h * dpr)
294}
295
296#[cfg(test)]
297/// Exercises clip-mode policy, environment overrides, stacks, and root clips.
298mod tests {
299    use super::*;
300    use std::sync::Mutex;
301
302    /// Serializes tests that mutate the process-wide clip override variables.
303    static CLIP_ENV_LOCK: Mutex<()> = Mutex::new(());
304
305    /// Installs temporary legacy clip overrides, runs `f`, then restores state.
306    fn with_clip_env(shader: Option<&str>, stencil: Option<&str>, f: impl FnOnce()) {
307        let _guard = CLIP_ENV_LOCK.lock().expect("clip env lock");
308        let old_shader = std::env::var("OCTAVUI_CLIP_FORCE_SHADER").ok();
309        let old_stencil = std::env::var("OCTAVUI_CLIP_FORCE_STENCIL").ok();
310
311        match shader {
312            Some(value) => std::env::set_var("OCTAVUI_CLIP_FORCE_SHADER", value),
313            None => std::env::remove_var("OCTAVUI_CLIP_FORCE_SHADER"),
314        }
315        match stencil {
316            Some(value) => std::env::set_var("OCTAVUI_CLIP_FORCE_STENCIL", value),
317            None => std::env::remove_var("OCTAVUI_CLIP_FORCE_STENCIL"),
318        }
319
320        f();
321
322        match old_shader {
323            Some(value) => std::env::set_var("OCTAVUI_CLIP_FORCE_SHADER", value),
324            None => std::env::remove_var("OCTAVUI_CLIP_FORCE_SHADER"),
325        }
326        match old_stencil {
327            Some(value) => std::env::set_var("OCTAVUI_CLIP_FORCE_STENCIL", value),
328            None => std::env::remove_var("OCTAVUI_CLIP_FORCE_STENCIL"),
329        }
330    }
331
332    #[test]
333    fn rect_uses_scissor() {
334        with_clip_env(None, None, || {
335            let r = Rect::new(0.0, 0.0, 10.0, 10.0);
336            assert_eq!(
337                choose_clip_render_mode(Some(&ClipShape::Rect(r)), false, 100),
338                ClipRenderMode::Scissor
339            );
340        });
341    }
342
343    #[test]
344    fn stacked_root_round_and_editor_rect_keep_scissor_and_stencil_mask() {
345        with_clip_env(None, None, || {
346            let root = ClipShape::RoundRect {
347                rect: Rect::new(0.0, 0.0, 100.0, 80.0),
348                radius: 12.0,
349            };
350            let editor = ClipShape::Rect(Rect::new(10.0, 10.0, 40.0, 20.0));
351            let clip = ClipStackSnapshot::from_entries(vec![
352                ClipEntry::new(root, true),
353                ClipEntry::new(editor, false),
354            ]);
355
356            let plan = resolve_clip_render_plan(&clip, 2);
357
358            assert_eq!(plan.scissor, Some(Rect::new(10.0, 10.0, 40.0, 20.0)));
359            assert_eq!(plan.rounded_masks, vec![ClipEntry::new(root, true)]);
360            assert_eq!(plan.primary_round_mask, Some(ClipEntry::new(root, true)));
361            assert_eq!(plan.clip_mode, ClipRenderMode::Stencil);
362        });
363    }
364
365    #[test]
366    fn rect_stack_resolves_to_scissor_only() {
367        with_clip_env(None, None, || {
368            let clip = ClipStackSnapshot::from_entries(vec![
369                ClipEntry::new(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), false),
370                ClipEntry::new(ClipShape::Rect(Rect::new(5.0, 2.0, 10.0, 4.0)), false),
371            ]);
372
373            let plan = resolve_clip_render_plan(&clip, 10);
374
375            assert_eq!(plan.scissor, Some(Rect::new(5.0, 2.0, 5.0, 4.0)));
376            assert!(plan.rounded_masks.is_empty());
377            assert_eq!(plan.primary_round_mask, None);
378            assert_eq!(plan.clip_mode, ClipRenderMode::Scissor);
379        });
380    }
381
382    #[test]
383    fn small_non_root_round_prefers_shader_mask() {
384        with_clip_env(None, None, || {
385            let round = ClipShape::RoundRect {
386                rect: Rect::new(0.0, 0.0, 40.0, 30.0),
387                radius: 6.0,
388            };
389            let clip = ClipStackSnapshot::from_entries(vec![ClipEntry::new(round, false)]);
390
391            let plan = resolve_clip_render_plan(&clip, 1);
392
393            assert_eq!(plan.scissor, Some(Rect::new(0.0, 0.0, 40.0, 30.0)));
394            assert_eq!(plan.primary_round_mask, Some(ClipEntry::new(round, false)));
395            assert_eq!(plan.clip_mode, ClipRenderMode::ShaderMask);
396        });
397    }
398
399    #[test]
400    fn none_and_rect_ignore_forced_round_modes() {
401        with_clip_env(Some("1"), Some("1"), || {
402            let r = Rect::new(0.0, 0.0, 10.0, 10.0);
403            assert_eq!(
404                choose_clip_render_mode(None, false, 100),
405                ClipRenderMode::Scissor
406            );
407            assert_eq!(
408                choose_clip_render_mode(Some(&ClipShape::Rect(r)), false, 100),
409                ClipRenderMode::Scissor
410            );
411        });
412    }
413
414    #[test]
415    fn window_root_uses_stencil() {
416        with_clip_env(None, None, || {
417            let r = Rect::new(0.0, 0.0, 100.0, 100.0);
418            assert_eq!(
419                choose_clip_render_mode(
420                    Some(&ClipShape::RoundRect {
421                        rect: r,
422                        radius: 14.0
423                    }),
424                    true,
425                    1
426                ),
427                ClipRenderMode::Stencil
428            );
429        });
430    }
431
432    #[test]
433    fn small_round_uses_shader() {
434        with_clip_env(None, None, || {
435            let r = Rect::new(0.0, 0.0, 50.0, 50.0);
436            assert_eq!(
437                choose_clip_render_mode(
438                    Some(&ClipShape::RoundRect {
439                        rect: r,
440                        radius: 8.0
441                    }),
442                    false,
443                    2
444                ),
445                ClipRenderMode::ShaderMask
446            );
447        });
448    }
449
450    #[test]
451    fn many_draws_use_stencil() {
452        with_clip_env(None, None, || {
453            let r = Rect::new(0.0, 0.0, 50.0, 50.0);
454            assert_eq!(
455                choose_clip_render_mode(
456                    Some(&ClipShape::RoundRect {
457                        rect: r,
458                        radius: 8.0
459                    }),
460                    false,
461                    10
462                ),
463                ClipRenderMode::Stencil
464            );
465        });
466    }
467
468    #[test]
469    fn forced_round_modes_apply_only_to_round_rect_with_shader_priority() {
470        let r = Rect::new(0.0, 0.0, 50.0, 50.0);
471        let clip = ClipShape::RoundRect {
472            rect: r,
473            radius: 8.0,
474        };
475
476        with_clip_env(None, Some("1"), || {
477            assert_eq!(
478                choose_clip_render_mode(Some(&clip), false, 1),
479                ClipRenderMode::Stencil
480            );
481        });
482        with_clip_env(Some("1"), Some("1"), || {
483            assert_eq!(
484                choose_clip_render_mode(Some(&clip), true, 100),
485                ClipRenderMode::ShaderMask
486            );
487        });
488    }
489}