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}