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}