ailloli_ui_render_wgpu/isolated_budget.rs
1//! isolated compositor hardening: budgets and downgrade policy for isolated offscreen compositing.
2
3use ailloli_ui_core::Rect;
4
5use crate::isolated_plan::{IsolatedEffect, IsolatedEffectChain};
6
7/// Per-frame offscreen budgets (defaults suitable for 1080p-class UIs).
8///
9/// Byte estimates assume four bytes per color pixel and another four bytes per
10/// pixel when stencil is required. Limits are independent: satisfying one does
11/// not bypass the pass-count, depth, or capture limits.
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_render_wgpu::IsolatedBudgetConfig;
17/// let config = IsolatedBudgetConfig::default();
18/// assert_eq!(config.max_isolated_passes_per_frame, 8);
19/// assert_eq!(config.max_offscreen_surface_px, 1920 * 1080);
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct IsolatedBudgetConfig {
23 /// Maximum `width * height` for one isolated surface (physical pixels).
24 pub max_offscreen_surface_px: u64,
25 /// Maximum estimated bytes for all offscreen slots in one frame.
26 pub max_offscreen_bytes_per_frame: u64,
27 /// Largest content-blur radius, in physical pixels.
28 pub max_blur_radius_px: f32,
29 /// Maximum number of isolated passes scheduled in one frame.
30 pub max_isolated_passes_per_frame: u32,
31 /// Maximum isolated nesting depth (0 = root only). nested isolated compositor.
32 pub max_isolated_nesting_depth: u8,
33 /// Max backdrop snapshots per frame (backdrop filter).
34 pub max_backdrop_captures_per_frame: u32,
35 /// Largest backdrop-blur radius, in physical pixels.
36 pub max_backdrop_blur_radius_px: f32,
37 /// Max dst captures for shader blend compositing per frame (blend modes).
38 pub max_blend_captures_per_frame: u32,
39}
40
41impl Default for IsolatedBudgetConfig {
42 fn default() -> Self {
43 Self {
44 max_offscreen_surface_px: 1920 * 1080,
45 max_offscreen_bytes_per_frame: 64 * 1024 * 1024,
46 max_blur_radius_px: 64.0,
47 max_isolated_passes_per_frame: 8,
48 max_isolated_nesting_depth: 3,
49 max_backdrop_captures_per_frame: 4,
50 max_backdrop_blur_radius_px: 64.0,
51 max_blend_captures_per_frame: 4,
52 }
53 }
54}
55
56/// Why an isolated pass was clamped, skipped, or rejected.
57///
58/// # Examples
59///
60/// ```
61/// use ailloli_ui_render_wgpu::IsolatedDowngradeReason;
62/// let reason = IsolatedDowngradeReason::BytesBudgetSkipped;
63/// assert_eq!(reason, IsolatedDowngradeReason::BytesBudgetSkipped);
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum IsolatedDowngradeReason {
67 /// A content blur exceeded `max_blur_radius_px`.
68 BlurRadiusClamped,
69 /// Bounds were scaled to satisfy `max_offscreen_surface_px`.
70 SurfacePxClamped,
71 /// A pass would exceed the aggregate byte limit.
72 BytesBudgetSkipped,
73 /// Bounds could not produce a valid offscreen allocation.
74 OversizedBounds,
75 /// A backdrop blur exceeded `max_backdrop_blur_radius_px`.
76 BackdropRadiusClamped,
77 /// A backdrop snapshot exceeded its count or byte budget.
78 BackdropBudgetSkipped,
79 /// A destination snapshot exceeded its per-frame count budget.
80 BlendCaptureBudgetSkipped,
81}
82
83/// Per-reason downgrade counters for the current frame.
84///
85/// Counters use `u32` and are reset for each planned frame.
86///
87/// # Examples
88///
89/// ```
90/// use ailloli_ui_render_wgpu::IsolatedDowngradeCounts;
91/// let counts = IsolatedDowngradeCounts::default();
92/// assert_eq!(counts.total(), 0);
93/// ```
94#[derive(Debug, Default, Clone, Copy)]
95pub struct IsolatedDowngradeCounts {
96 /// Number of content-blur clamps.
97 pub blur_radius_clamped: u32,
98 /// Number of surface-area clamps.
99 pub surface_px_clamped: u32,
100 /// Number of byte-budget pass skips.
101 pub bytes_budget_skipped: u32,
102 /// Number of invalid or oversized bounds rejections.
103 pub oversized_bounds: u32,
104 /// Number of backdrop-radius clamps.
105 pub backdrop_radius_clamped: u32,
106 /// Number of backdrop-capture skips.
107 pub backdrop_budget_skipped: u32,
108 /// Number of blend destination-capture skips.
109 pub blend_capture_budget_skipped: u32,
110}
111
112impl IsolatedDowngradeCounts {
113 /// Sums all downgrade counters.
114 ///
115 /// # Panics
116 ///
117 /// Debug builds panic if the sum exceeds `u32::MAX`; normal planner limits
118 /// keep the counters far below that bound.
119 ///
120 /// # Examples
121 ///
122 /// ```
123 /// use ailloli_ui_render_wgpu::IsolatedDowngradeCounts;
124 /// let counts = IsolatedDowngradeCounts { blur_radius_clamped: 2, ..Default::default() };
125 /// assert_eq!(counts.total(), 2);
126 /// ```
127 pub fn total(&self) -> u32 {
128 self.blur_radius_clamped
129 + self.surface_px_clamped
130 + self.bytes_budget_skipped
131 + self.oversized_bounds
132 + self.backdrop_radius_clamped
133 + self.backdrop_budget_skipped
134 + self.blend_capture_budget_skipped
135 }
136
137 /// Increments the counter associated with `reason` by one.
138 ///
139 /// # Panics
140 ///
141 /// Debug builds panic if that `u32` counter is already `u32::MAX`.
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use ailloli_ui_render_wgpu::{IsolatedDowngradeCounts, IsolatedDowngradeReason};
147 /// let mut counts = IsolatedDowngradeCounts::default();
148 /// counts.record(IsolatedDowngradeReason::SurfacePxClamped);
149 /// assert_eq!(counts.surface_px_clamped, 1);
150 /// ```
151 pub fn record(&mut self, reason: IsolatedDowngradeReason) {
152 match reason {
153 IsolatedDowngradeReason::BlurRadiusClamped => self.blur_radius_clamped += 1,
154 IsolatedDowngradeReason::SurfacePxClamped => self.surface_px_clamped += 1,
155 IsolatedDowngradeReason::BytesBudgetSkipped => self.bytes_budget_skipped += 1,
156 IsolatedDowngradeReason::OversizedBounds => self.oversized_bounds += 1,
157 IsolatedDowngradeReason::BackdropRadiusClamped => self.backdrop_radius_clamped += 1,
158 IsolatedDowngradeReason::BackdropBudgetSkipped => self.backdrop_budget_skipped += 1,
159 IsolatedDowngradeReason::BlendCaptureBudgetSkipped => {
160 self.blend_capture_budget_skipped += 1
161 }
162 }
163 }
164}
165
166/// Mutable per-frame budget state during CPU planning.
167///
168/// Construct one policy, call [`Self::reset_frame`] before each frame, and
169/// record every scheduled allocation so later decisions see accumulated usage.
170///
171/// # Examples
172///
173/// ```
174/// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
175/// let policy = IsolatedBudgetPolicy::with_defaults();
176/// assert_eq!(policy.frame_bytes_acc, 0);
177/// ```
178#[derive(Debug, Clone)]
179pub struct IsolatedBudgetPolicy {
180 /// Immutable limits used by subsequent decisions; callers may replace them between frames.
181 pub config: IsolatedBudgetConfig,
182 /// Estimated offscreen bytes scheduled in the current frame.
183 pub frame_bytes_acc: u64,
184 /// Isolated passes scheduled in the current frame.
185 pub isolated_pass_count: u32,
186 /// Clamp and skip counters for the current frame.
187 pub downgrades: IsolatedDowngradeCounts,
188 /// Backdrop snapshots scheduled in the current frame.
189 pub backdrop_capture_count: u32,
190 /// Blend destination snapshots scheduled in the current frame.
191 pub blend_capture_count: u32,
192}
193
194impl IsolatedBudgetPolicy {
195 /// Creates zeroed per-frame state with the supplied limits.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use ailloli_ui_render_wgpu::{IsolatedBudgetConfig, IsolatedBudgetPolicy};
201 /// let policy = IsolatedBudgetPolicy::new(IsolatedBudgetConfig::default());
202 /// assert_eq!(policy.isolated_pass_count, 0);
203 /// ```
204 pub fn new(config: IsolatedBudgetConfig) -> Self {
205 Self {
206 config,
207 frame_bytes_acc: 0,
208 isolated_pass_count: 0,
209 downgrades: IsolatedDowngradeCounts::default(),
210 backdrop_capture_count: 0,
211 blend_capture_count: 0,
212 }
213 }
214
215 /// Creates zeroed per-frame state with [`IsolatedBudgetConfig::default`].
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
221 /// assert!(IsolatedBudgetPolicy::with_defaults().can_schedule_pass());
222 /// ```
223 pub fn with_defaults() -> Self {
224 Self::new(IsolatedBudgetConfig::default())
225 }
226
227 /// Clears accumulated bytes, counts, and downgrades without changing limits.
228 ///
229 /// # Examples
230 ///
231 /// ```
232 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
233 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
234 /// policy.record_pass_scheduled(10, 10, false);
235 /// policy.reset_frame();
236 /// assert_eq!((policy.frame_bytes_acc, policy.isolated_pass_count), (0, 0));
237 /// ```
238 pub fn reset_frame(&mut self) {
239 self.frame_bytes_acc = 0;
240 self.isolated_pass_count = 0;
241 self.downgrades = IsolatedDowngradeCounts::default();
242 self.backdrop_capture_count = 0;
243 self.blend_capture_count = 0;
244 }
245
246 /// Returns `true` if another isolated pass may be scheduled.
247 ///
248 /// This checks only the pass-count limit; callers must check area, byte, and
249 /// nesting limits separately.
250 ///
251 /// # Examples
252 ///
253 /// ```
254 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
255 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
256 /// policy.config.max_isolated_passes_per_frame = 0;
257 /// assert!(!policy.can_schedule_pass());
258 /// ```
259 pub fn can_schedule_pass(&self) -> bool {
260 self.isolated_pass_count < self.config.max_isolated_passes_per_frame
261 }
262
263 /// Estimated bytes for one pass (RGBA color + optional stencil copy).
264 ///
265 /// # Panics
266 ///
267 /// Debug builds panic if `width * height * 4` exceeds `u64`.
268 ///
269 /// # Examples
270 ///
271 /// ```
272 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
273 /// let policy = IsolatedBudgetPolicy::with_defaults();
274 /// assert_eq!(policy.estimate_pass_bytes(10, 5, false), 200);
275 /// assert_eq!(policy.estimate_pass_bytes(10, 5, true), 400);
276 /// ```
277 pub fn estimate_pass_bytes(&self, width: u32, height: u32, needs_stencil: bool) -> u64 {
278 let color = width as u64 * height as u64 * 4;
279 let stencil = if needs_stencil {
280 width as u64 * height as u64 * 4
281 } else {
282 0
283 };
284 color + stencil
285 }
286
287 /// Whether scheduling this pass would exceed the frame byte budget.
288 ///
289 /// Equality with the limit is allowed; only a strictly larger total is
290 /// rejected.
291 ///
292 /// # Panics
293 ///
294 /// Debug builds panic if the accumulated and estimated byte counts overflow.
295 ///
296 /// # Examples
297 ///
298 /// ```
299 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
300 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
301 /// policy.config.max_offscreen_bytes_per_frame = 399;
302 /// assert!(policy.would_exceed_bytes(10, 10, false));
303 /// ```
304 pub fn would_exceed_bytes(&self, width: u32, height: u32, needs_stencil: bool) -> bool {
305 let next = self.frame_bytes_acc + self.estimate_pass_bytes(width, height, needs_stencil);
306 next > self.config.max_offscreen_bytes_per_frame
307 }
308
309 /// Records one scheduled pass and its estimated allocation.
310 ///
311 /// This method does not enforce limits; call it only after all checks pass.
312 ///
313 /// # Panics
314 ///
315 /// Debug builds panic on byte or pass-count overflow.
316 ///
317 /// # Examples
318 ///
319 /// ```
320 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
321 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
322 /// policy.record_pass_scheduled(10, 10, false);
323 /// assert_eq!((policy.frame_bytes_acc, policy.isolated_pass_count), (400, 1));
324 /// ```
325 pub fn record_pass_scheduled(&mut self, width: u32, height: u32, needs_stencil: bool) {
326 self.frame_bytes_acc += self.estimate_pass_bytes(width, height, needs_stencil);
327 self.isolated_pass_count += 1;
328 }
329
330 /// Clamp blur radii in the effect chain; returns whether any radius was reduced.
331 ///
332 /// Negative radii and NaN are not greater than the limit and remain
333 /// unchanged. At most one downgrade is recorded per chain.
334 ///
335 /// # Examples
336 ///
337 /// ```
338 /// use ailloli_ui_render_wgpu::{IsolatedBudgetPolicy, IsolatedEffect, IsolatedEffectChain};
339 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
340 /// policy.config.max_blur_radius_px = 4.0;
341 /// let mut chain = IsolatedEffectChain { effects: vec![IsolatedEffect::Blur { radius_px: 8.0 }] };
342 /// assert!(policy.clamp_blur_chain(&mut chain));
343 /// assert_eq!(chain.effects[0], IsolatedEffect::Blur { radius_px: 4.0 });
344 /// ```
345 pub fn clamp_blur_chain(&mut self, chain: &mut IsolatedEffectChain) -> bool {
346 let max = self.config.max_blur_radius_px;
347 let mut changed = false;
348 for e in &mut chain.effects {
349 if let IsolatedEffect::Blur { radius_px } = e {
350 if *radius_px > max {
351 *radius_px = max;
352 changed = true;
353 }
354 }
355 }
356 if changed {
357 self.downgrades
358 .record(IsolatedDowngradeReason::BlurRadiusClamped);
359 }
360 changed
361 }
362
363 /// Scale `bounds` down (centered) so `w*h <= max_offscreen_surface_px`.
364 ///
365 /// Zero, negative, and NaN areas are returned unchanged. A clamped rectangle
366 /// preserves the original center and keeps both dimensions at least one.
367 ///
368 /// # Examples
369 ///
370 /// ```
371 /// use ailloli_ui_core::Rect;
372 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
373 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
374 /// policy.config.max_offscreen_surface_px = 100;
375 /// let bounds = policy.clamp_surface_bounds(Rect::new(0.0, 0.0, 20.0, 20.0));
376 /// assert!(bounds.w * bounds.h <= 100.01);
377 /// ```
378 pub fn clamp_surface_bounds(&mut self, bounds: Rect) -> Rect {
379 let px = (bounds.w * bounds.h).max(0.0) as u64;
380 let max_px = self.config.max_offscreen_surface_px;
381 if px <= max_px || px == 0 {
382 return bounds;
383 }
384 let scale = (max_px as f64 / px as f64).sqrt();
385 let nw = (bounds.w * scale as f32).max(1.0);
386 let nh = (bounds.h * scale as f32).max(1.0);
387 let cx = bounds.x + bounds.w * 0.5;
388 let cy = bounds.y + bounds.h * 0.5;
389 self.downgrades
390 .record(IsolatedDowngradeReason::SurfacePxClamped);
391 Rect::new(cx - nw * 0.5, cy - nh * 0.5, nw, nh)
392 }
393
394 /// Records that a pass was skipped by the aggregate byte budget.
395 ///
396 /// # Examples
397 ///
398 /// ```
399 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
400 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
401 /// policy.record_bytes_skip();
402 /// assert_eq!(policy.downgrades.bytes_budget_skipped, 1);
403 /// ```
404 pub fn record_bytes_skip(&mut self) {
405 self.downgrades
406 .record(IsolatedDowngradeReason::BytesBudgetSkipped);
407 }
408
409 /// Returns `true` when `depth` is within the configured nesting limit.
410 ///
411 /// The comparison is exclusive: a limit of `3` admits depths `0`, `1`, and
412 /// `2`; a limit of zero admits none.
413 ///
414 /// # Examples
415 ///
416 /// ```
417 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
418 /// let policy = IsolatedBudgetPolicy::with_defaults();
419 /// assert!(policy.nesting_depth_ok(2));
420 /// assert!(!policy.nesting_depth_ok(3));
421 /// ```
422 pub fn nesting_depth_ok(&self, depth: u8) -> bool {
423 depth < self.config.max_isolated_nesting_depth
424 }
425
426 /// Returns whether another backdrop snapshot fits the count limit.
427 ///
428 /// Byte limits must be checked separately.
429 ///
430 /// # Examples
431 ///
432 /// ```
433 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
434 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
435 /// policy.config.max_backdrop_captures_per_frame = 0;
436 /// assert!(!policy.can_schedule_backdrop());
437 /// ```
438 pub fn can_schedule_backdrop(&self) -> bool {
439 self.backdrop_capture_count < self.config.max_backdrop_captures_per_frame
440 }
441
442 /// Clamps a backdrop blur radius to the physical-pixel limit.
443 ///
444 /// Negative values and NaN remain unchanged. A strict clamp records one
445 /// downgrade.
446 ///
447 /// # Examples
448 ///
449 /// ```
450 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
451 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
452 /// policy.config.max_backdrop_blur_radius_px = 6.0;
453 /// assert_eq!(policy.clamp_backdrop_radius(9.0), 6.0);
454 /// ```
455 pub fn clamp_backdrop_radius(&mut self, radius: f32) -> f32 {
456 let max = self.config.max_backdrop_blur_radius_px;
457 if radius > max {
458 self.downgrades
459 .record(IsolatedDowngradeReason::BackdropRadiusClamped);
460 max
461 } else {
462 radius
463 }
464 }
465
466 /// Records one backdrop snapshot as a four-byte-per-pixel allocation.
467 ///
468 /// This method does not enforce limits.
469 ///
470 /// # Examples
471 ///
472 /// ```
473 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
474 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
475 /// policy.record_backdrop_scheduled(2, 3);
476 /// assert_eq!((policy.frame_bytes_acc, policy.backdrop_capture_count), (24, 1));
477 /// ```
478 pub fn record_backdrop_scheduled(&mut self, width: u32, height: u32) {
479 self.frame_bytes_acc += self.estimate_pass_bytes(width, height, false);
480 self.backdrop_capture_count += 1;
481 }
482
483 /// Records a backdrop-capture budget skip.
484 ///
485 /// # Examples
486 ///
487 /// ```
488 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
489 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
490 /// policy.record_backdrop_skip();
491 /// assert_eq!(policy.downgrades.backdrop_budget_skipped, 1);
492 /// ```
493 pub fn record_backdrop_skip(&mut self) {
494 self.downgrades
495 .record(IsolatedDowngradeReason::BackdropBudgetSkipped);
496 }
497
498 /// Returns whether another blend destination snapshot fits the count limit.
499 ///
500 /// # Examples
501 ///
502 /// ```
503 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
504 /// let policy = IsolatedBudgetPolicy::with_defaults();
505 /// assert!(policy.can_schedule_blend());
506 /// ```
507 pub fn can_schedule_blend(&self) -> bool {
508 self.blend_capture_count < self.config.max_blend_captures_per_frame
509 }
510
511 /// Records one blend destination snapshot as four bytes per pixel.
512 ///
513 /// # Examples
514 ///
515 /// ```
516 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
517 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
518 /// policy.record_blend_scheduled(4, 4);
519 /// assert_eq!((policy.frame_bytes_acc, policy.blend_capture_count), (64, 1));
520 /// ```
521 pub fn record_blend_scheduled(&mut self, width: u32, height: u32) {
522 self.frame_bytes_acc += self.estimate_pass_bytes(width, height, false);
523 self.blend_capture_count += 1;
524 }
525
526 /// Records a blend destination-capture budget skip.
527 ///
528 /// # Examples
529 ///
530 /// ```
531 /// use ailloli_ui_render_wgpu::IsolatedBudgetPolicy;
532 /// let mut policy = IsolatedBudgetPolicy::with_defaults();
533 /// policy.record_blend_skip();
534 /// assert_eq!(policy.downgrades.blend_capture_budget_skipped, 1);
535 /// ```
536 pub fn record_blend_skip(&mut self) {
537 self.downgrades
538 .record(IsolatedDowngradeReason::BlendCaptureBudgetSkipped);
539 }
540}
541
542#[cfg(test)]
543/// Verifies blur and offscreen-surface budget clamping.
544mod tests {
545 use super::*;
546
547 #[test]
548 fn clamp_surface_scales_down() {
549 let mut policy = IsolatedBudgetPolicy::with_defaults();
550 policy.config.max_offscreen_surface_px = 100 * 100;
551 let r = Rect::new(0.0, 0.0, 200.0, 200.0);
552 let c = policy.clamp_surface_bounds(r);
553 assert!((c.w * c.h) as u64 <= 100 * 100);
554 assert_eq!(policy.downgrades.surface_px_clamped, 1);
555 }
556
557 #[test]
558 fn clamp_blur_radius() {
559 let mut policy = IsolatedBudgetPolicy::with_defaults();
560 policy.config.max_blur_radius_px = 8.0;
561 let mut chain = IsolatedEffectChain {
562 effects: vec![IsolatedEffect::Blur { radius_px: 32.0 }],
563 };
564 policy.clamp_blur_chain(&mut chain);
565 assert_eq!(chain.effects[0], IsolatedEffect::Blur { radius_px: 8.0 });
566 }
567}