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}