Skip to main content

frust_engine/schedule/
pages.rs

1//! Intermediate pages: which of the two ping-pong groups one comes from, and
2//! how large it is asked for.
3//!
4//! A page is one pooled off-screen texture an isolated layer is rendered into
5//! before its parent samples it. The scheduler never allocates one — it only
6//! decides the group and the extent, and [`IntermediateTargets`] hands the
7//! texture out at execute time. Everything here is therefore a pure function
8//! over plain values, host-testable with no device in the loop.
9//!
10//! Two decisions live here.
11//!
12//! - **Group by parity.** Even-depth layers draw into the even group, odd-depth
13//!   ones into the odd group, so a chain of nested layers ping-pongs between
14//!   exactly two textures however deep it runs: the child's page is sampled by
15//!   the parent's pass and freed the moment that pass ends, and the grandchild
16//!   reuses it. Depth here is counted over *isolated* layers only, because an
17//!   inlined layer never occupies a page and so never consumes a level (see the
18//!   [`schedule`](super) module header). [`PageParity::Spill`] is the one group
19//!   this rule does not name: it is the single bounded page a regular layer
20//!   falls back on when both parity groups are held and no round could be cut
21//!   to hand one back, and the [`schedule`](super) header's *The spill page*
22//!   section is where the shapes that need it live.
23//! - **Coverage-sized, quantized, capped.** A page is sized to the layer's own
24//!   tile-aligned bounds rather than to the viewport, floored at
25//!   [`PageConfig::min_page_size`] and quantized to the substrate pool's key
26//!   grid so a resizing layer reuses one texture instead of churning a new one
27//!   per frame. It is capped at the smaller of [`PageConfig::max_page_size`]
28//!   and what the adapter will allocate; a layer larger than that is refused
29//!   by [`page_size`] with [`EngineError::IntermediateTextureTooLarge`] rather
30//!   than clipped to the cap, which would silently drop the layer's outer
31//!   pixels — or split into [bands](page_bands), which is the answer that
32//!   renders it.
33//!
34//! The default bounds mirror the reference renderer's own guidance for mobile:
35//! keeping intermediate textures small matters more than saving render passes,
36//! so the floor stays at 512 square rather than being raised to batch layers
37//! together.
38//!
39//! ## A third decision: filter-layer sizing
40//!
41//! [`filter_page_size`] answers the same question [`page_size`] does — the
42//! extent to acquire a page at — for a filter layer specifically, and differs
43//! in two ways a regular layer's page never has to.
44//!
45//! - **Padded, not just coverage-sized.** A decimated filter pass overdraws a
46//!   [`FILTER_ATLAS_PADDING`]-wide transparent border around the region it
47//!   writes (see that constant's own doc), and a page whose real allocation
48//!   landed *exactly* on the layer's extent — which the pool's quantization can
49//!   and does produce, whenever a layer's own size already sits on the quantum
50//!   grid — would have nowhere to put it. [`filter_page_size`] reserves the
51//!   room by growing the request by [`FILTER_ATLAS_PADDING`] twice per axis
52//!   *before* flooring, quantizing (E13) and capping, so the page's real extent
53//!   always has it regardless of where the quantum grid happens to land.
54//!
55//!   Twice per axis, not "on every side": a layer is rendered into its page at
56//!   the page's own origin, so the whole of the growth lands on the far side
57//!   and the near side has no margin at all. That asymmetry is exactly why the
58//!   kernels bound their taps against the source region themselves rather than
59//!   trusting a margin to exist (`sample_region_bilinear` in
60//!   `shaders/filters_blur.wgsl`); the room reserved here is what the far-side
61//!   overdraw needs, not a guarantee about what a tap reads.
62//! - **Two ceilings, not one.** [`page_size`] folds [`page_ceiling`]'s two
63//!   inputs into one refusal ([`EngineError::IntermediateTextureTooLarge`]
64//!   either way). A filter layer's padded request is checked against them
65//!   separately instead, mirroring the reference sparse-strips renderer's own
66//!   filter-layer sizing (`vello_hybrid`'s
67//!   `LayersConfig::required_intermediate_texture_size`,
68//!   `render/common.rs:139-191`): [`max_texture_size`] is what the adapter
69//!   can allocate at all, refused as [`EngineError::IntermediateTextureTooLarge`]
70//!   exactly as an over-ceiling regular layer is; [`PageConfig::max_page_size`]
71//!   is this pool's own configured budget, which the adapter could serve but
72//!   this engine has chosen not to ask for, refused as
73//!   [`EngineError::IntermediateTextureLimitReached`] — the same two-error
74//!   shape the reference's own `IntermediateTextureError::TooLarge`/
75//!   `LimitReached` pair draws, adapted from its one-texture-for-the-whole-
76//!   scene budget to this scheduler's per-layer pages.
77//!
78//! Both errors, like [`page_size`]'s own, are values rather than panics
79//! (E17), and the padded width/height are computed in `u32` so a `bounds`
80//! near the `u16` ceiling grows into headroom instead of wrapping.
81//!
82//! ## A fourth decision: a layer wider than any page
83//!
84//! A 5K desktop surface under one root opacity layer asks for an intermediate
85//! wider than the ceiling above — 5120 texels against a 4096 default — and
86//! refusing it freezes the surface for as long as the layer is recorded (see
87//! the [`schedule`](super) module header on what a refused frame costs). The
88//! reference sparse-strips renderer takes exactly that refusal: its
89//! `LayersConfig::required_intermediate_texture_size` answers
90//! `IntermediateTextureError::TooLarge` for a scene past the device limit
91//! (`vello_hybrid`'s `render/common.rs:147-161`), and a root-level blend asks
92//! it for the *whole scene's* size (`:181-183`), so a wide enough window has
93//! no intermediate it can be served from at all.
94//!
95//! [`page_bands`] is the answer instead: the layer is cut into full-height
96//! **column bands** no wider than the ceiling, each an ordinary page rendered
97//! at its own origin and composited back at its own rectangle (E14). Three
98//! properties make that a page decision rather than a new kind of target.
99//!
100//! - **Columns only.** A band spans the layer's whole height, so a layer
101//!   *taller* than the ceiling is still refused. That is deliberate rather
102//!   than pending: bands multiply passes over the layer's own draw list, and
103//!   splitting one axis is what covers a display — which is wide before it is
104//!   tall — at one pass per band instead of one per tile.
105//! - **Evenly split, so the bands share one page.** The band count is what the
106//!   ceiling forces (`width.div_ceil(ceiling)`), but the width is then divided
107//!   *evenly* across that many bands rather than filling each to the ceiling
108//!   and leaving a narrow tail. Every band therefore asks for one extent, which
109//!   quantizes to one substrate-pool key (E13), so a banded layer costs the
110//!   pool a single texture reused band after band instead of one wide entry
111//!   plus an odd-sized tail — and its peak intermediate memory is the even
112//!   band's, not the ceiling's.
113//! - **Bounded.** [`MAX_PAGE_BANDS`] bands is where a split stops being cheaper
114//!   than a refusal, and past it the layer is refused exactly as an
115//!   over-ceiling one always was.
116//!
117//! The tiling this module computes is only half of what makes the split
118//! invisible. The scheduler replays the layer's whole draw list into every
119//! band, so a band renders what one page would have *only* while each band's
120//! contents are clipped to [`PageBand::bounds`] — a strip left of the band's
121//! own column contributing no instance rather than a clamped one. That clip
122//! lives at instance emission in the renderer (`PageWindow`), which is the one
123//! place a strip's geometry, its alpha columns and the band's own width are all
124//! known; a rectangle that tiles is what this module owes it.
125//!
126//! What this module does not do is decide *when* a layer is banded: that call
127//! belongs to the scheduler (`schedule::band_rounds`), reached only for a
128//! *regular* layer that has not already been handed a page ahead of time (a
129//! cut ancestor reserves one this way) and whose own accumulated ops hold no
130//! composite of a nested isolated child — a band replays the layer's own
131//! draws once per band, and replaying a child's composite would read a page
132//! a later band has already reused. Every other over-ceiling shape, and a
133//! layer taller than the ceiling on any axis, still refuses the frame exactly
134//! as [`page_size`] always has.
135
136use frust_gpu::TierCaps;
137use vello_common::geometry::RectU16;
138
139use crate::error::EngineError;
140use crate::filters::blur::FILTER_ATLAS_PADDING;
141use crate::gpu::targets::max_texture_size;
142
143/// Smallest page the scheduler asks for, per axis.
144///
145/// A conservative floor: large enough that a small layer does not allocate a
146/// texture of its own on every frame it resizes, small enough that a page is
147/// never a memory decision on a mobile GPU.
148pub const DEFAULT_MIN_PAGE_SIZE: u32 = 512;
149
150/// Largest page the scheduler asks for, per axis, before the adapter's own
151/// ceiling is applied.
152pub const DEFAULT_MAX_PAGE_SIZE: u32 = 4096;
153
154/// The most column bands one layer is split into by [`page_bands`].
155///
156/// Eight bands at the default ceiling span 32768 device pixels — half the
157/// widest device grid the strip pipeline can address at all (`u16`
158/// coordinates, E18) and several times any surface a shell configures. The
159/// count is bounded because each band costs a render pass over the layer's own
160/// draw list: an unbounded split would turn one pathological layer into an
161/// unbounded number of passes, which is a worse answer than the refusal it
162/// replaced.
163pub const MAX_PAGE_BANDS: usize = 8;
164
165/// Which texture group a page comes from: one of the two ping-pong groups, or
166/// the single spill page beside them.
167///
168/// The scheduler keeps at most one page live per group, so a group *is* a page
169/// identity for the shapes it serves; a schedule that would need two pages of
170/// the same group is escalated rather than given a second index.
171///
172/// The name is the pair's: [`Even`](Self::Even) and [`Odd`](Self::Odd) are what
173/// a layer's own depth parity names, and they carry every page of every chain
174/// and every fan. [`Spill`](Self::Spill) is not a parity and is never derived
175/// from a depth — it is the bounded third page the scheduler falls back on for
176/// the one shape the pair cannot hold, kept in this enum rather than beside it
177/// because what the renderer needs from all three is the same thing: an index
178/// naming which live page a round writes, samples and hands back.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum PageParity {
181    /// The group even-depth layers render into.
182    Even,
183    /// The group odd-depth layers render into.
184    Odd,
185    /// The one spill page, taken only when both parity groups are held and no
186    /// open round could be cut to hand one back.
187    ///
188    /// One page, never a group of its own: a second layer wanting it while it
189    /// is held is refused, which is what keeps a frame's live intermediates at
190    /// [`MAX_LIVE_PAGES`](super::MAX_LIVE_PAGES). It is released exactly as a
191    /// parity page is — by the round whose composite sampled it — and only a
192    /// regular layer is ever handed one (see [`schedule`](super)'s *The spill
193    /// page*).
194    Spill,
195}
196
197impl PageParity {
198    /// The group a layer at `depth` renders into.
199    ///
200    /// Depths are one-based (the outermost isolated layer is depth 1), matching
201    /// the recorder's own numbering, so the outermost layer takes the odd group
202    /// and the surface it composites onto is not a page at all. Never answers
203    /// [`Spill`](Self::Spill): the spill page is a fallback the scheduler
204    /// reaches for explicitly, not a group any depth prefers.
205    #[must_use]
206    pub const fn from_depth(depth: usize) -> Self {
207        if depth.is_multiple_of(2) {
208            Self::Even
209        } else {
210            Self::Odd
211        }
212    }
213
214    /// The other of the ping-pong pair.
215    ///
216    /// [`Spill`](Self::Spill) is not one of the pair and so has no other: it
217    /// answers itself, which keeps this total without inventing a third
218    /// ping-pong partner. Nothing asks: the one caller that ping-pongs between
219    /// two groups of its own is a filter layer's pass sequence, and a filter
220    /// layer is never handed the spill page.
221    #[must_use]
222    pub const fn opposite(self) -> Self {
223        match self {
224            Self::Even => Self::Odd,
225            Self::Odd => Self::Even,
226            Self::Spill => Self::Spill,
227        }
228    }
229
230    /// The group's index: `0` for even, `1` for odd, `2` for the spill page.
231    ///
232    /// This is what indexes the renderer's live-page slots, so the values are
233    /// dense and stay inside [`MAX_LIVE_PAGES`](super::MAX_LIVE_PAGES).
234    #[must_use]
235    pub const fn index(self) -> usize {
236        match self {
237            Self::Even => 0,
238            Self::Odd => 1,
239            Self::Spill => 2,
240        }
241    }
242}
243
244/// The extent a page is acquired at, in texels.
245///
246/// Already floored, capped and quantized — this is what
247/// [`IntermediateTargets::acquire`](crate::gpu::targets::IntermediateTargets::acquire)
248/// is called with, not the layer's own bounds.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct PageSize {
251    /// Width in texels.
252    pub width: u32,
253    /// Height in texels.
254    pub height: u32,
255}
256
257/// One full-height column of a layer, and the page it renders into.
258///
259/// A band is an ordinary page in every respect but its width: its contents are
260/// rendered at the page's own origin (so every strip in it is offset by
261/// `-(bounds.x0, bounds.y0)`, exactly as an unbanded layer's are) and it
262/// composites back at [`bounds`](Self::bounds) in the parent's own
263/// coordinates. A layer that fits one page is one band covering the whole of
264/// it, so a caller has no second shape to handle.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct PageBand {
267    /// The band's own tile-aligned device-space rectangle — a full-height
268    /// column of the layer's bounds, what the band composites at, and what its
269    /// contents are clipped to on the way into the page (see the module
270    /// header's *A fourth decision*).
271    pub bounds: RectU16,
272    /// The extent this band's page is acquired at.
273    ///
274    /// One value for every band of a layer, the narrower last one included:
275    /// the bands are split evenly and sized from the widest of them, so they
276    /// share one substrate-pool key and reuse one texture (E13).
277    pub size: PageSize,
278}
279
280/// The bounds page sizing works between.
281///
282/// Separate from the adapter's capabilities because both halves are policy: the
283/// floor trades memory for fewer allocations, the ceiling trades scenes the
284/// scheduler will serve for a bound on peak memory. The adapter's own limit is
285/// applied on top of the ceiling and can only lower it.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub struct PageConfig {
288    /// Smallest page to ask for, per axis.
289    pub min_page_size: u32,
290    /// Largest page to ask for, per axis, before the adapter's ceiling.
291    pub max_page_size: u32,
292}
293
294impl Default for PageConfig {
295    fn default() -> Self {
296        Self {
297            min_page_size: DEFAULT_MIN_PAGE_SIZE,
298            max_page_size: DEFAULT_MAX_PAGE_SIZE,
299        }
300    }
301}
302
303/// The largest page `config` will ask `caps`' adapter for, per axis.
304///
305/// The smaller of the configured ceiling and what the engine's intermediate
306/// pool will allocate at all, so a page can never be sized past the extent the
307/// pool would refuse.
308#[must_use]
309pub fn page_ceiling(config: &PageConfig, caps: &TierCaps) -> u32 {
310    config.max_page_size.min(max_texture_size(caps))
311}
312
313/// The extent to acquire a page holding `bounds` at.
314///
315/// `bounds` is the layer's tile-aligned device-space rectangle; the layer is
316/// rendered into the page at its origin, so only the extent matters here.
317///
318/// # Errors
319///
320/// [`EngineError::IntermediateTextureTooLarge`] when either axis of `bounds`
321/// exceeds [`page_ceiling`]. Shrinking such a layer to the ceiling would drop
322/// its outer pixels without saying so, which is why the frame is refused (and
323/// skipped by the caller) instead; [`page_bands`] is the answer that renders
324/// an over-wide layer rather than refusing it, and this function is the
325/// single-page sizing it falls back on for a band.
326pub fn page_size(
327    bounds: RectU16,
328    config: &PageConfig,
329    caps: &TierCaps,
330) -> Result<PageSize, EngineError> {
331    let ceiling = page_ceiling(config, caps);
332    let width = u32::from(bounds.width());
333    let height = u32::from(bounds.height());
334
335    if width > ceiling || height > ceiling {
336        return Err(EngineError::IntermediateTextureTooLarge);
337    }
338
339    // The floor is clamped to the ceiling first: a configuration whose floor
340    // sits above what the adapter allows must not turn every layer into an
341    // over-ceiling request.
342    let floor = config.min_page_size.min(ceiling);
343    let (width, height) =
344        frust_gpu::pool::quantize_extent(width.max(floor), height.max(floor), ceiling);
345
346    Ok(PageSize { width, height })
347}
348
349/// The column bands `bounds` renders as, and the page extent each is acquired
350/// at.
351///
352/// One band holding the whole layer whenever it fits a single page — which is
353/// every layer a phone or a laptop surface records — and otherwise the
354/// narrowest even split of its width that stays inside [`page_ceiling`]. The
355/// bands tile `bounds` exactly: they abut, none overlaps, and their union is
356/// the layer's own rectangle, so compositing them in order paints precisely
357/// what one page would have (E14) — provided each band's contents are clipped
358/// to its own [`PageBand::bounds`], which is the renderer's half of the same
359/// property and not something this function can enforce. See the module
360/// header's *A fourth decision* section for why the split is by column, why it
361/// is even rather than greedy, and what still has to happen for a banded layer
362/// to reach a device.
363///
364/// # Errors
365///
366/// [`EngineError::IntermediateTextureTooLarge`] when the layer is *taller*
367/// than [`page_ceiling`] — bands are columns, so height has no split to be
368/// served by — and when its width would need more than [`MAX_PAGE_BANDS`]
369/// bands. Both are the refusal [`page_size`] gives an over-ceiling layer, kept
370/// for the cases banding does not reach rather than replaced by a partial
371/// answer, and neither is a panic (E17).
372pub fn page_bands(
373    bounds: RectU16,
374    config: &PageConfig,
375    caps: &TierCaps,
376) -> Result<Vec<PageBand>, EngineError> {
377    let ceiling = page_ceiling(config, caps);
378    let width = u32::from(bounds.width());
379
380    if u32::from(bounds.height()) > ceiling {
381        return Err(EngineError::IntermediateTextureTooLarge);
382    }
383    if width <= ceiling {
384        return Ok(vec![PageBand {
385            bounds,
386            size: page_size(bounds, config, caps)?,
387        }]);
388    }
389
390    // `width > ceiling >= 0` here, so the ceiling is at least one and the
391    // division below is well defined however a caller configured this pool.
392    let count = width.div_ceil(ceiling.max(1));
393    if count > u32::try_from(MAX_PAGE_BANDS).unwrap_or(u32::MAX) {
394        return Err(EngineError::IntermediateTextureTooLarge);
395    }
396
397    // Even rather than greedy: `count` bands of this width cover the layer and
398    // none of them exceeds the ceiling, and sizing every page from the widest
399    // one gives the whole split a single pool key.
400    let band_width = width.div_ceil(count);
401    let size = page_size(
402        RectU16::new(
403            0,
404            0,
405            u16::try_from(band_width).unwrap_or(u16::MAX),
406            bounds.height(),
407        ),
408        config,
409        caps,
410    )?;
411
412    let end = u32::from(bounds.x1);
413    let mut bands = Vec::with_capacity(count as usize);
414    let mut x0 = u32::from(bounds.x0);
415    while x0 < end {
416        let x1 = end.min(x0.saturating_add(band_width));
417        bands.push(PageBand {
418            bounds: RectU16::new(
419                u16::try_from(x0).unwrap_or(u16::MAX),
420                bounds.y0,
421                u16::try_from(x1).unwrap_or(u16::MAX),
422                bounds.y1,
423            ),
424            size,
425        });
426        x0 = x1;
427    }
428
429    Ok(bands)
430}
431
432/// The extent to acquire a filter layer's page at.
433///
434/// `bounds` is the layer's own tile-aligned device-space rectangle — already
435/// grown by the filter's own visual spread (a blur's 3σ, a drop shadow's
436/// offset plus its own blur), the same `bounds` [`page_size`] would take for
437/// a regular layer. This function grows it by [`FILTER_ATLAS_PADDING`] twice
438/// per axis before flooring, quantizing (E13) and capping, and checks the
439/// padded request against two ceilings rather than one — see the module
440/// header's *A third decision* section for why both differences exist, and for
441/// why the growth is not a margin "on every side".
442///
443/// # Errors
444///
445/// [`EngineError::IntermediateTextureTooLarge`] when the padded extent
446/// exceeds [`max_texture_size`] on either axis — this adapter will never
447/// allocate a page that large, the same refusal an over-ceiling regular layer
448/// gets from [`page_size`]. [`EngineError::IntermediateTextureLimitReached`]
449/// when the padded extent stays inside that hard ceiling but still exceeds
450/// [`PageConfig::max_page_size`] — the adapter could serve it, but this
451/// pool's own configured budget does not. Neither is a panic (E17).
452pub fn filter_page_size(
453    bounds: RectU16,
454    config: &PageConfig,
455    caps: &TierCaps,
456) -> Result<PageSize, EngineError> {
457    let device_ceiling = max_texture_size(caps);
458    let budget_ceiling = config.max_page_size;
459
460    // `u32` throughout: `bounds`' axes are `u16`, so even a `bounds` at the
461    // `u16` ceiling grows into `u32` headroom under the padding below rather
462    // than wrapping.
463    let padding = u32::from(FILTER_ATLAS_PADDING).saturating_mul(2);
464    let width = u32::from(bounds.width()).saturating_add(padding);
465    let height = u32::from(bounds.height()).saturating_add(padding);
466
467    if width > device_ceiling || height > device_ceiling {
468        return Err(EngineError::IntermediateTextureTooLarge);
469    }
470    if width > budget_ceiling || height > budget_ceiling {
471        return Err(EngineError::IntermediateTextureLimitReached);
472    }
473
474    // The floor is clamped to the tighter of the two ceilings first, exactly
475    // as `page_size` clamps it to `page_ceiling`: a configuration whose floor
476    // sits above what either ceiling allows must not turn every filter layer
477    // into a refused request.
478    let ceiling = budget_ceiling.min(device_ceiling);
479    let floor = config.min_page_size.min(ceiling);
480    let (width, height) =
481        frust_gpu::pool::quantize_extent(width.max(floor), height.max(floor), ceiling);
482
483    Ok(PageSize { width, height })
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use frust_gpu::DownlevelProfile;
490
491    fn caps() -> TierCaps {
492        TierCaps::fake(DownlevelProfile::Full)
493    }
494
495    #[test]
496    fn parity_alternates_down_a_chain_and_starts_odd_at_the_outermost_layer() {
497        assert_eq!(PageParity::from_depth(1), PageParity::Odd);
498        assert_eq!(PageParity::from_depth(2), PageParity::Even);
499        assert_eq!(PageParity::from_depth(3), PageParity::Odd);
500        assert_eq!(PageParity::from_depth(4), PageParity::Even);
501
502        assert_eq!(PageParity::Even.opposite(), PageParity::Odd);
503        assert_eq!(PageParity::Odd.opposite(), PageParity::Even);
504        assert_eq!(PageParity::Even.index(), 0);
505        assert_eq!(PageParity::Odd.index(), 1);
506    }
507
508    #[test]
509    fn the_spill_page_is_no_depths_group_and_indexes_past_the_ping_pong_pair() {
510        // A depth never names it — it is reached by falling back, not by
511        // preferring — and its index is the third live-page slot, which is
512        // what keeps the renderer's own array indexable by this value alone.
513        for depth in 0..=16 {
514            assert_ne!(PageParity::from_depth(depth), PageParity::Spill);
515        }
516        assert_eq!(PageParity::Spill.index(), 2);
517        assert!(PageParity::Spill.index() < crate::schedule::MAX_LIVE_PAGES);
518        assert_eq!(
519            PageParity::Spill.opposite(),
520            PageParity::Spill,
521            "the spill page is not one of the ping-pong pair, so it has no other"
522        );
523    }
524
525    #[test]
526    fn a_small_layer_is_floored_at_the_minimum_page_size() {
527        let size = page_size(RectU16::new(0, 0, 12, 8), &PageConfig::default(), &caps())
528            .expect("a 12x8 layer is far inside the ceiling");
529        assert_eq!(
530            size,
531            PageSize {
532                width: DEFAULT_MIN_PAGE_SIZE,
533                height: DEFAULT_MIN_PAGE_SIZE
534            }
535        );
536    }
537
538    #[test]
539    fn a_larger_layer_is_quantized_up_to_the_pool_grid() {
540        let size = page_size(
541            RectU16::new(0, 0, 900, 700),
542            &PageConfig::default(),
543            &caps(),
544        )
545        .expect("a 900x700 layer is inside the ceiling");
546        // The substrate pool keys on a 256-texel grid, so both axes round up to
547        // it rather than to the request.
548        assert_eq!(
549            size,
550            PageSize {
551                width: 1024,
552                height: 768
553            }
554        );
555    }
556
557    #[test]
558    fn a_layer_past_the_ceiling_is_refused_rather_than_shrunk() {
559        let config = PageConfig {
560            min_page_size: DEFAULT_MIN_PAGE_SIZE,
561            max_page_size: 1024,
562        };
563        let ceiling = page_ceiling(&config, &caps());
564        assert_eq!(ceiling, 1024);
565
566        assert!(matches!(
567            page_size(RectU16::new(0, 0, 1025, 16), &config, &caps()),
568            Err(EngineError::IntermediateTextureTooLarge)
569        ));
570        assert!(matches!(
571            page_size(RectU16::new(0, 0, 16, 1025), &config, &caps()),
572            Err(EngineError::IntermediateTextureTooLarge)
573        ));
574        assert!(page_size(RectU16::new(0, 0, 1024, 1024), &config, &caps()).is_ok());
575    }
576
577    #[test]
578    fn a_floor_above_the_ceiling_still_produces_a_page_inside_it() {
579        let config = PageConfig {
580            min_page_size: 4096,
581            max_page_size: 512,
582        };
583        let size = page_size(RectU16::new(0, 0, 8, 8), &config, &caps())
584            .expect("the floor is clamped to the ceiling, not applied over it");
585        assert_eq!(
586            size,
587            PageSize {
588                width: 512,
589                height: 512
590            }
591        );
592    }
593
594    #[test]
595    fn the_adapter_limit_can_only_lower_the_configured_ceiling() {
596        let mut caps = caps();
597        caps.max_texture_dimension_2d = 2048;
598        let config = PageConfig::default();
599        assert_eq!(page_ceiling(&config, &caps), 2048);
600
601        caps.max_texture_dimension_2d = 16384;
602        assert_eq!(page_ceiling(&config, &caps), DEFAULT_MAX_PAGE_SIZE);
603    }
604
605    /// The bands cover `bounds` exactly: they start at its left edge, abut
606    /// with no gap and no overlap, end at its right edge, and every one of
607    /// them spans its full height.
608    fn assert_tiles(bands: &[PageBand], bounds: RectU16) {
609        let mut x = bounds.x0;
610        for band in bands {
611            assert_eq!(band.bounds.x0, x, "bands abut with no gap and no overlap");
612            assert!(band.bounds.x1 > band.bounds.x0, "no band is degenerate");
613            assert_eq!(band.bounds.y0, bounds.y0, "a band spans the full height");
614            assert_eq!(band.bounds.y1, bounds.y1, "a band spans the full height");
615            x = band.bounds.x1;
616        }
617        assert_eq!(x, bounds.x1, "the bands end exactly at the layer's edge");
618    }
619
620    #[test]
621    fn a_layer_that_fits_one_page_is_a_single_band_holding_all_of_it() {
622        let bounds = RectU16::new(0, 0, 900, 700);
623        let config = PageConfig::default();
624        let bands = page_bands(bounds, &config, &caps()).expect("900x700 fits one page");
625
626        assert_eq!(bands.len(), 1);
627        assert_eq!(bands[0].bounds, bounds);
628        assert_eq!(
629            bands[0].size,
630            page_size(bounds, &config, &caps()).expect("the same layer sizes as one page"),
631            "an unbanded layer's band is sized exactly as `page_size` sizes it"
632        );
633        assert_tiles(&bands, bounds);
634    }
635
636    #[test]
637    fn a_5k_layer_splits_into_two_equal_bands_sharing_one_page_extent() {
638        // The desktop case: a 5120x2880 surface under one root opacity layer,
639        // 1024 texels past the default 4096 ceiling.
640        let bounds = RectU16::new(0, 0, 5120, 2880);
641        let bands =
642            page_bands(bounds, &PageConfig::default(), &caps()).expect("a 5K layer is banded");
643
644        assert_eq!(bands.len(), 2, "5120 needs two bands under a 4096 ceiling");
645        assert_tiles(&bands, bounds);
646        assert_eq!(bands[0].bounds, RectU16::new(0, 0, 2560, 2880));
647        assert_eq!(bands[1].bounds, RectU16::new(2560, 0, 5120, 2880));
648
649        // Split evenly rather than greedily, so both bands quantize to one
650        // pool key — 2560 is already on the 256 grid, 2880 rounds up to 3072.
651        assert_eq!(
652            bands[0].size,
653            PageSize {
654                width: 2560,
655                height: 3072
656            }
657        );
658        assert_eq!(
659            bands[0].size, bands[1].size,
660            "every band of a layer asks the pool for one extent"
661        );
662    }
663
664    #[test]
665    fn an_uneven_width_gives_a_narrower_last_band_at_the_same_page_extent() {
666        // Three bands under a 1024 ceiling, and 2500 does not divide by three:
667        // the first two take 834 and the last one 832.
668        let config = PageConfig {
669            min_page_size: 256,
670            max_page_size: 1024,
671        };
672        let bounds = RectU16::new(0, 0, 2500, 600);
673        let bands = page_bands(bounds, &config, &caps()).expect("2500 needs three bands");
674
675        assert_eq!(bands.len(), 3);
676        assert_tiles(&bands, bounds);
677        assert_eq!(bands[0].bounds.width(), 834);
678        assert_eq!(bands[1].bounds.width(), 834);
679        assert_eq!(bands[2].bounds.width(), 832, "the last band takes the rest");
680        assert!(
681            bands
682                .iter()
683                .all(|band| band.size == bands[0].size && band.size.width <= config.max_page_size),
684            "the short band is still sized from the widest one, and none exceeds the ceiling"
685        );
686    }
687
688    #[test]
689    fn bands_are_offset_by_the_layers_own_origin_rather_than_starting_at_zero() {
690        let config = PageConfig {
691            min_page_size: 256,
692            max_page_size: 512,
693        };
694        let bounds = RectU16::new(100, 40, 1100, 300);
695        let bands = page_bands(bounds, &config, &caps()).expect("a 1000-wide layer needs two");
696
697        assert_eq!(bands.len(), 2);
698        assert_tiles(&bands, bounds);
699        assert_eq!(bands[0].bounds, RectU16::new(100, 40, 600, 300));
700        assert_eq!(bands[1].bounds, RectU16::new(600, 40, 1100, 300));
701    }
702
703    #[test]
704    fn a_layer_taller_than_the_ceiling_is_still_refused_because_bands_are_columns() {
705        let config = PageConfig {
706            min_page_size: 256,
707            max_page_size: 1024,
708        };
709
710        assert!(page_bands(RectU16::new(0, 0, 512, 1024), &config, &caps()).is_ok());
711        assert!(matches!(
712            page_bands(RectU16::new(0, 0, 512, 1025), &config, &caps()),
713            Err(EngineError::IntermediateTextureTooLarge)
714        ));
715        // Width past the ceiling is served; height past it is not, and a layer
716        // over on both axes takes the height refusal.
717        assert!(matches!(
718            page_bands(RectU16::new(0, 0, 4096, 1025), &config, &caps()),
719            Err(EngineError::IntermediateTextureTooLarge)
720        ));
721    }
722
723    #[test]
724    fn a_width_needing_more_than_the_band_bound_is_refused_rather_than_split_further() {
725        let config = PageConfig {
726            min_page_size: 256,
727            max_page_size: 1024,
728        };
729        let ceiling = page_ceiling(&config, &caps());
730        let at_bound = ceiling * MAX_PAGE_BANDS as u32;
731
732        let bands = page_bands(RectU16::new(0, 0, at_bound as u16, 64), &config, &caps())
733            .expect("exactly the bound is served");
734        assert_eq!(bands.len(), MAX_PAGE_BANDS);
735
736        assert!(matches!(
737            page_bands(
738                RectU16::new(0, 0, (at_bound + 1) as u16, 64),
739                &config,
740                &caps()
741            ),
742            Err(EngineError::IntermediateTextureTooLarge)
743        ));
744    }
745
746    #[test]
747    fn every_band_of_a_layer_is_a_page_the_pool_would_accept() {
748        // Whatever the split, no band may ask for an extent `page_size` itself
749        // would refuse — that is what makes a band an ordinary page.
750        let config = PageConfig::default();
751        let caps = caps();
752        let ceiling = page_ceiling(&config, &caps);
753
754        for width in [4097_u32, 5120, 6000, 8192, 12288, 32768] {
755            let bounds = RectU16::new(0, 0, width as u16, 2880);
756            let bands = page_bands(bounds, &config, &caps).expect("inside the band bound");
757            assert_tiles(&bands, bounds);
758            for band in &bands {
759                assert!(band.bounds.width() as u32 <= ceiling);
760                assert_eq!(
761                    band.size,
762                    page_size(
763                        RectU16::new(0, 0, bands[0].bounds.width(), bounds.height()),
764                        &config,
765                        &caps
766                    )
767                    .expect("a band is inside the ceiling by construction")
768                );
769            }
770        }
771    }
772
773    #[test]
774    fn a_small_filter_layer_is_padded_then_still_floored_at_the_minimum_page_size() {
775        // 12x8 plus padding on every side (24x20) is still far inside the
776        // floor, exactly as the unpadded case is for `page_size`.
777        let size = filter_page_size(RectU16::new(0, 0, 12, 8), &PageConfig::default(), &caps())
778            .expect("a padded 24x20 request is far inside the ceiling");
779        assert_eq!(
780            size,
781            PageSize {
782                width: DEFAULT_MIN_PAGE_SIZE,
783                height: DEFAULT_MIN_PAGE_SIZE
784            }
785        );
786    }
787
788    #[test]
789    fn filter_padding_can_push_a_borderline_layer_into_the_next_quantum() {
790        // Unpadded, 1013 rounds up to 1024 and 700 to 768 (`page_size`'s own
791        // grid); the extra 12 texels of padding on each axis is what carries
792        // the request past 1024 into the next 256-wide bucket.
793        let size = filter_page_size(
794            RectU16::new(0, 0, 1013, 700),
795            &PageConfig::default(),
796            &caps(),
797        )
798        .expect("1025x712 padded is inside the default ceiling");
799        assert_eq!(
800            size,
801            PageSize {
802                width: 1280,
803                height: 768
804            }
805        );
806    }
807
808    #[test]
809    fn a_filter_layer_whose_padding_carries_it_past_the_device_ceiling_is_too_large() {
810        // A budget at least as large as the device ceiling isolates the
811        // device-capability refusal from the pool-budget one below.
812        let config = PageConfig {
813            min_page_size: DEFAULT_MIN_PAGE_SIZE,
814            max_page_size: 8192,
815        };
816        let caps = caps();
817        let device_ceiling = max_texture_size(&caps);
818        assert_eq!(device_ceiling, 8192);
819
820        // Exactly at the ceiling once padded: still served.
821        let at_ceiling = device_ceiling - u32::from(FILTER_ATLAS_PADDING) * 2;
822        assert!(
823            filter_page_size(RectU16::new(0, 0, at_ceiling as u16, 16), &config, &caps).is_ok()
824        );
825
826        // One texel past it once padded — the padding is what pushes this
827        // request over, not the bounds alone.
828        let over_ceiling = at_ceiling + 1;
829        assert!(matches!(
830            filter_page_size(RectU16::new(0, 0, over_ceiling as u16, 16), &config, &caps),
831            Err(EngineError::IntermediateTextureTooLarge)
832        ));
833        assert!(matches!(
834            filter_page_size(RectU16::new(0, 0, 16, over_ceiling as u16), &config, &caps),
835            Err(EngineError::IntermediateTextureTooLarge)
836        ));
837    }
838
839    #[test]
840    fn a_filter_layer_inside_the_device_ceiling_but_past_the_pool_budget_is_limit_reached() {
841        // The default budget (4096) sits well under the default caps' device
842        // ceiling (8192), so a request that overflows only the former is
843        // distinguishable from one that overflows the latter.
844        let config = PageConfig::default();
845        let caps = caps();
846        assert!(config.max_page_size < max_texture_size(&caps));
847
848        let over_budget = config.max_page_size - u32::from(FILTER_ATLAS_PADDING) * 2 + 1;
849        assert!(matches!(
850            filter_page_size(RectU16::new(0, 0, over_budget as u16, 16), &config, &caps),
851            Err(EngineError::IntermediateTextureLimitReached)
852        ));
853        assert!(matches!(
854            filter_page_size(RectU16::new(0, 0, 16, over_budget as u16), &config, &caps),
855            Err(EngineError::IntermediateTextureLimitReached)
856        ));
857    }
858
859    #[test]
860    fn filter_page_size_never_overflows_padding_a_bounds_near_the_u16_ceiling() {
861        // `bounds` is `u16`-addressed, so its axes can sit right at 65535;
862        // the padded width/height are computed in `u32`, so this refuses as
863        // an ordinary over-ceiling request rather than wrapping or panicking.
864        let size = filter_page_size(
865            RectU16::new(0, 0, u16::MAX, u16::MAX),
866            &PageConfig::default(),
867            &caps(),
868        );
869        assert!(matches!(
870            size,
871            Err(EngineError::IntermediateTextureTooLarge)
872        ));
873    }
874}