Skip to main content

frust_engine/gpu/
targets.rs

1//! The per-renderer pool the engine draws its off-screen intermediates from.
2//!
3//! A layer, a filter input, a scratch copy — every target that is not the
4//! frame's own surface is a short-lived texture that a later pass in the same
5//! frame samples and nothing outlives the frame. Allocating one per frame is
6//! the cost `frust_gpu::TexturePool` exists to remove, so the engine holds one
7//! pool per renderer (one per surface, since a renderer is per surface) and
8//! takes its intermediates from there.
9//!
10//! Two engine-level decisions sit on top of the substrate pool.
11//!
12//! - **A ceiling of [`MAX_INTERMEDIATE_DIMENSION`].** The adapter's own
13//!   `max_texture_dimension_2d` can be far larger than anything worth
14//!   allocating as a transient: a single 16384-square `Rgba8Unorm` intermediate
15//!   is a gigabyte. [`max_texture_size`] therefore takes the smaller of the
16//!   adapter's ceiling and 8192, which is the largest intermediate the engine
17//!   will ask a driver for.
18//! - **An over-ceiling request is a value, not an error.** A layer larger than
19//!   that ceiling is answered with [`IntermediateTexture::TooLarge`] carrying
20//!   the extent that was refused, so a caller sees exactly what it asked for
21//!   and skips the layer rather than taking a device error mid-frame. A caller
22//!   that instead splits the layer into
23//!   [bands](crate::schedule::pages::page_bands) never reaches this arm: every
24//!   band is sized inside the same ceiling, and all of a layer's bands share
25//!   one extent, so a banded layer draws one texture out of this pool and
26//!   reuses it band after band.
27//!
28//! Like the substrate pool this type is generic over the texture and view
29//! types, so its keying, reuse and aging are exercised against a counting fake
30//! with no GPU in the loop. The engine always uses the default
31//! `IntermediateTargets<wgpu::Texture, wgpu::TextureView>`.
32//!
33//! ## The one off-screen target this module does not hand out
34//!
35//! [`atlas_layer_config`] describes a pass whose colour attachment is one
36//! layer of the glyph/image atlas array — a target [`crate::gpu::atlas`] owns
37//! outright and deliberately keeps out of the pool (its module doc gives the
38//! reason: residency across frames is the atlas's whole purpose). The
39//! *allocation* therefore belongs there; the *viewport* decision belongs here,
40//! beside [`IntermediateTargets::descriptor`], because it is the same decision
41//! this module already makes for every other target that is not the frame's own
42//! surface — what extent the vertex stage maps its NDC against, and which
43//! resource-texture widths the fragment stage reconstructs by shift.
44//!
45//! ## The filter pass's two extras
46//!
47//! A [filter](crate::filters) round renders between two pooled pages this
48//! module hands out, and needs two things beyond them: the *filter-data*
49//! texture holding the frame's parameter blocks
50//! ([`filter_data_texture_descriptor`], sized by
51//! [`filter_data_texture_height`]) and the bilinear [`filter_sampler`] its
52//! kernels read the source page through — the engine's first and only sampler.
53//! Both are described here, beside the pages they are used with;
54//! [`crate::renderer::FilterResources`] owns the live resources.
55
56use frust_gpu::{PoolStats, PooledTexture, TextureAllocator, TextureDesc, TexturePool, TierCaps};
57
58use crate::filters::blur::GpuFilterData;
59
60use super::config::GpuConfig;
61use super::pipelines::INTERMEDIATE_FORMAT;
62
63/// The largest intermediate the engine allocates on any adapter, whatever its
64/// own `max_texture_dimension_2d` reports.
65///
66/// 8192 square is 256 MiB at [`INTERMEDIATE_FORMAT`] — already far past any
67/// real layer — and a transient that big is a memory decision rather than a
68/// capability one, which is why it is pinned here instead of taken from the
69/// adapter.
70pub const MAX_INTERMEDIATE_DIMENSION: u32 = 8192;
71
72/// How many of the most recent frames' parked entries a surface resize keeps
73/// (see [`IntermediateTargets::drop_parked`]).
74///
75/// Two: the frame that just ran and the one before it. One is not enough — a
76/// resize arrives *between* frames, so the page the last frame released is
77/// already a frame behind by the time the resize is handled, and a window
78/// being dragged would evict it on every step. Three or more starts holding
79/// pages across frames that stopped asking for them, which is what the pool's
80/// own aging is for.
81pub const RESIZE_KEEP_ALIVE_FRAMES: u64 = 2;
82
83/// The usage every intermediate is created with: drawn into by a strip pass,
84/// then sampled by the pass that composites it.
85pub const INTERMEDIATE_USAGE: wgpu::TextureUsages = wgpu::TextureUsages::RENDER_ATTACHMENT
86    .union(wgpu::TextureUsages::TEXTURE_BINDING)
87    .union(wgpu::TextureUsages::COPY_SRC);
88
89/// The largest intermediate extent the engine will request on `caps`' adapter.
90#[must_use]
91pub fn max_texture_size(caps: &TierCaps) -> u32 {
92    caps.max_texture_dimension_2d
93        .min(MAX_INTERMEDIATE_DIMENSION)
94}
95
96/// The viewport uniform a strip pass whose colour attachment is one atlas
97/// array layer draws with.
98///
99/// `page` is the layer's own extent in texels — the atlas page size, not the
100/// frame's — because the vertex stage maps a strip's pixel coordinates into NDC
101/// against the *attachment* it writes, and an atlas layer is neither the
102/// surface's extent nor a pooled page's. Getting this wrong does not fail
103/// validation: it silently scales every glyph by the ratio of the two extents.
104///
105/// `alphas_tex_width` is the coverage texture the replayed strips sample;
106/// `encoded_paints_tex_width` is the encoded-paint texture, which an atlas pass
107/// only ever binds as a stand-in — a replayed glyph outline paints solid, and a
108/// solid instance carries its colour in its own payload rather than indexing a
109/// record. Both must be powers of two, for
110/// [`tex_width_bits`](super::config::tex_width_bits)' reason.
111///
112/// No strip offset and no NDC negation: an atlas page holds its glyphs at the
113/// slot coordinates the allocator handed out, in the same y-down space every
114/// other engine target uses.
115#[must_use]
116pub fn atlas_layer_config(
117    page: (u32, u32),
118    alphas_tex_width: u32,
119    encoded_paints_tex_width: u32,
120) -> GpuConfig {
121    GpuConfig::new(page.0, page.1, alphas_tex_width, encoded_paints_tex_width)
122}
123
124/// Whether an atlas page of `page` texels can be a render attachment on
125/// `caps`' adapter.
126///
127/// The atlas is not pooled, so it never passes through
128/// [`IntermediateTargets::acquire`]'s own ceiling check — but a page is still a
129/// texture a driver has to accept, and the budgets
130/// [`crate::cache::images::AtlasBudget`] hands out are chosen without the
131/// adapter in view. This is the check a caller makes once, at the point it
132/// decides a page size, rather than discovering the refusal as a device error
133/// on the first frame that misses a glyph.
134///
135/// The adapter's own `max_texture_dimension_2d` is the bound, not
136/// [`MAX_INTERMEDIATE_DIMENSION`]: that ceiling is a transient-memory decision
137/// about targets allocated per frame, and an atlas page is allocated once and
138/// lives for the renderer.
139#[must_use]
140pub fn atlas_page_fits(caps: &TierCaps, page: (u32, u32)) -> bool {
141    page.0 > 0
142        && page.1 > 0
143        && page.0 <= caps.max_texture_dimension_2d
144        && page.1 <= caps.max_texture_dimension_2d
145}
146
147/// Texels per row of the filter-data texture.
148///
149/// Sixteen [`super::RESOURCE_TEXTURE_FORMAT`] texels is exactly 256 bytes,
150/// which is `wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` — the narrowest row an upload
151/// may legally have, and so the cheapest whole-texture write a frame with one
152/// filter can issue. A filter's parameter block is
153/// [`GpuFilterData::SIZE_TEXELS`] wide and blocks are packed back to back, so a
154/// block may straddle a row boundary; the fragment stage addresses one by a
155/// flat texel index and reconstructs the coordinate by division, so it never
156/// notices (see `load_filter_texel` in `shaders/filter.wgsl`).
157pub const FILTER_DATA_TEXTURE_WIDTH: u32 = 16;
158
159const _: () = assert!(
160    FILTER_DATA_TEXTURE_WIDTH * super::TEXEL_BYTES == wgpu::COPY_BYTES_PER_ROW_ALIGNMENT,
161    "a filter-data row is exactly one copy alignment unit, which is what makes a one-row upload \
162     legal"
163);
164
165/// The filter-data texture's descriptor at `height` rows.
166///
167/// A resource texture like the alpha and encoded-paint ones: `Rgba32Uint`,
168/// sampled by the fragment stage with `textureLoad` and written by a queue
169/// upload, never a render attachment. Unlike those two it is
170/// [`FILTER_DATA_TEXTURE_WIDTH`] texels wide rather than the adapter's resource
171/// dimension — a frame's filters are counted in ones, not in thousands, and a
172/// row of the adapter's width would make the smallest possible upload 64 KiB.
173#[must_use]
174pub fn filter_data_texture_descriptor(height: u32) -> wgpu::TextureDescriptor<'static> {
175    super::resource_texture_descriptor(
176        "frust-engine filter data texture",
177        FILTER_DATA_TEXTURE_WIDTH,
178        height,
179    )
180}
181
182/// The filter-data texture height that holds `blocks` parameter blocks, or
183/// `None` for a block count past [`MAX_INTERMEDIATE_DIMENSION`] rows.
184///
185/// The refusal is a value rather than an error for the reason every ceiling in
186/// this module is: the caller skips what does not fit rather than taking a
187/// device error mid-frame. It is also unreachable in practice —
188/// [`MAX_INTERMEDIATE_DIMENSION`] rows hold over forty thousand filters, and a
189/// frame records filters in ones.
190#[must_use]
191pub fn filter_data_texture_height(blocks: usize) -> Option<u32> {
192    let texels = u32::try_from(blocks)
193        .ok()?
194        .checked_mul(GpuFilterData::SIZE_TEXELS)?;
195    let height = texels
196        .div_ceil(FILTER_DATA_TEXTURE_WIDTH)
197        .max(super::MIN_RESOURCE_TEXTURE_HEIGHT);
198    (height <= MAX_INTERMEDIATE_DIMENSION).then_some(height)
199}
200
201/// The engine's one sampler, which the blur kernels read their source page
202/// through.
203///
204/// Bilinear, and that is the whole reason it exists: every other engine
205/// pipeline reads its textures with `textureLoad` at integer coordinates, while
206/// the blur kernels sample at *fractional* offsets so a decimation costs four
207/// samples instead of sixteen and a convolution tap one instead of two (see
208/// [`crate::filters::blur`]'s bilinear kernel).
209///
210/// Clamped rather than bordered on every axis: `wgpu`'s transparent-black
211/// border address mode needs an adapter feature this tier never requests. The
212/// address mode is not what makes a tap past the region transparent, and could
213/// not be — a filter layer's region sits at its page's own *origin*, so on the
214/// near side a clamp replicates the region's own edge texel instead of leaving
215/// the texture at all. Every kernel therefore bounds its own taps against the
216/// source region (`sample_region_bilinear` in `shaders/filters_blur.wgsl`,
217/// `drop_shadow_load_checked` in `shaders/filters_drop_shadow.wgsl`), which is
218/// what makes this sampler's behaviour outside that region unobservable rather
219/// than merely harmless. No mip chain, because a pooled page has exactly one
220/// level.
221#[must_use]
222pub fn filter_sampler(device: &wgpu::Device) -> wgpu::Sampler {
223    device.create_sampler(&wgpu::SamplerDescriptor {
224        label: Some("frust-engine filter sampler"),
225        address_mode_u: wgpu::AddressMode::ClampToEdge,
226        address_mode_v: wgpu::AddressMode::ClampToEdge,
227        address_mode_w: wgpu::AddressMode::ClampToEdge,
228        mag_filter: wgpu::FilterMode::Linear,
229        min_filter: wgpu::FilterMode::Linear,
230        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
231        ..Default::default()
232    })
233}
234
235/// The result of asking for an intermediate: a pooled texture, or the extent
236/// that was refused.
237#[derive(Debug)]
238pub enum IntermediateTexture<T = wgpu::Texture, V = wgpu::TextureView> {
239    /// A texture at least the requested extent. Its allocation is quantized
240    /// up, so render into `PooledTexture::requested_size`, not `size`.
241    Texture(PooledTexture<T, V>),
242    /// The request exceeded [`IntermediateTargets::max_texture_size`] on at
243    /// least one axis and nothing was allocated.
244    ///
245    /// A layer too wide for one page has an answer that renders it —
246    /// [`page_bands`](crate::schedule::pages::page_bands) — so reaching this
247    /// arm means the extent was asked for whole rather than by band.
248    TooLarge {
249        /// Requested width in texels.
250        width: u32,
251        /// Requested height in texels.
252        height: u32,
253        /// The per-axis ceiling that refused it.
254        max: u32,
255    },
256}
257
258impl<T, V> IntermediateTexture<T, V> {
259    /// The pooled texture, or `None` for a refused request.
260    #[must_use]
261    pub fn texture(&self) -> Option<&PooledTexture<T, V>> {
262        match self {
263            Self::Texture(texture) => Some(texture),
264            Self::TooLarge { .. } => None,
265        }
266    }
267
268    /// Whether the request was refused for exceeding the ceiling.
269    #[must_use]
270    pub fn is_too_large(&self) -> bool {
271        matches!(self, Self::TooLarge { .. })
272    }
273}
274
275/// The engine's pool of off-screen intermediates, with its own frame clock.
276///
277/// The clock is what the substrate pool ages entries against; it advances once
278/// per [`Self::end_frame`], including frames that acquired nothing — those are
279/// the frames a parked entry ages on.
280#[derive(Debug)]
281pub struct IntermediateTargets<T = wgpu::Texture, V = wgpu::TextureView> {
282    pool: TexturePool<T, V>,
283    max_texture_size: u32,
284    frame: u64,
285}
286
287impl<T, V> IntermediateTargets<T, V> {
288    /// A pool sized for `caps`' adapter.
289    #[must_use]
290    pub fn new(caps: &TierCaps) -> Self {
291        Self {
292            pool: TexturePool::new(caps),
293            max_texture_size: max_texture_size(caps),
294            frame: 0,
295        }
296    }
297
298    /// The largest intermediate this pool will allocate, per axis.
299    #[must_use]
300    pub fn max_texture_size(&self) -> u32 {
301        self.max_texture_size
302    }
303
304    /// The frame clock parked entries age against.
305    #[must_use]
306    pub fn frame(&self) -> u64 {
307        self.frame
308    }
309
310    /// The underlying pool's counters.
311    #[must_use]
312    pub fn stats(&self) -> PoolStats {
313        self.pool.stats()
314    }
315
316    /// The descriptor an intermediate of `width` x `height` is requested with,
317    /// before the pool quantizes it.
318    #[must_use]
319    pub fn descriptor(width: u32, height: u32, label: &str) -> TextureDesc {
320        TextureDesc {
321            width,
322            height,
323            format: INTERMEDIATE_FORMAT,
324            usage: INTERMEDIATE_USAGE,
325            label: Some(label.to_string()),
326        }
327    }
328
329    /// Hands out an intermediate of at least `width` x `height`, or refuses it
330    /// for exceeding [`Self::max_texture_size`].
331    ///
332    /// The check is on the *requested* extent rather than the quantized one:
333    /// the substrate pool clamps its quantization to the adapter ceiling
334    /// already, so a request inside the engine's ceiling can never quantize
335    /// past the adapter's.
336    pub fn acquire<A>(
337        &mut self,
338        allocator: &A,
339        width: u32,
340        height: u32,
341        label: &str,
342    ) -> IntermediateTexture<T, V>
343    where
344        A: TextureAllocator<Texture = T, View = V>,
345    {
346        if width > self.max_texture_size || height > self.max_texture_size {
347            return IntermediateTexture::TooLarge {
348                width,
349                height,
350                max: self.max_texture_size,
351            };
352        }
353
354        let desc = Self::descriptor(width, height, label);
355        IntermediateTexture::Texture(self.pool.acquire(allocator, &desc, self.frame))
356    }
357
358    /// Parks an intermediate for reuse.
359    pub fn release(&mut self, texture: PooledTexture<T, V>) {
360        self.pool.release(texture);
361    }
362
363    /// Advances the frame clock and ages parked entries out of the pool.
364    ///
365    /// Call once per frame, whether or not the frame acquired anything.
366    pub fn end_frame(&mut self) {
367        self.frame = self.frame.saturating_add(1);
368        self.pool.age(self.frame);
369    }
370
371    /// Drops the parked entries the last [`RESIZE_KEEP_ALIVE_FRAMES`] frames
372    /// did not use, keeping the pool itself and its counters.
373    ///
374    /// This is what a surface resize calls. A page keyed on the *old* surface
375    /// extent is dead weight the moment the surface changes size, and holding
376    /// it for the pool's full keep-alive window is waste — so a resize
377    /// collapses that window to the frames immediately behind it instead of
378    /// waiting a second for the ordinary aging to reach them.
379    ///
380    /// It collapses the window rather than emptying the pool, and the
381    /// difference is the whole point. Not every page is keyed on the surface:
382    /// a fading card, a dismissing sheet, a menu at its own size all ask for
383    /// the same page extent whatever the window does, and a window edge being
384    /// dragged produces a resize *per frame*. Dropping everything on each of
385    /// them would evict that page between every pair of frames that wants it
386    /// and turn the pool back into a plain allocator for the whole drag —
387    /// exactly the failure the substrate pool's aging slack exists to prevent
388    /// (see `frust_gpu::pool`'s module header). Keeping what the frames right
389    /// behind this resize actually used is bounded by what one frame can hold
390    /// live at once and is precisely the set the next frame asks for again.
391    ///
392    /// Implemented by aging against a clock far enough ahead to expire
393    /// everything older, without moving this pool's own clock: the frame
394    /// counter still advances once per [`Self::end_frame`], so a resize does
395    /// not age unrelated entries by a second every time a window edge moves.
396    /// Entries still checked out are untouched — the pool does not hold those.
397    pub fn drop_parked(&mut self) {
398        // `TexturePool::age(f)` drops an entry when `f - last_used` exceeds the
399        // keep-alive window, so a horizon this far ahead expires exactly the
400        // entries last used before the retained frames.
401        let horizon = self
402            .frame
403            .saturating_add(self.pool.max_unused_frames())
404            .saturating_sub(RESIZE_KEEP_ALIVE_FRAMES.saturating_sub(1));
405        self.pool.age(horizon);
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::cell::Cell;
413
414    use frust_gpu::DownlevelProfile;
415
416    /// A [`TextureAllocator`] with no GPU behind it: the texture and view are
417    /// both the allocation's ordinal, so "how many textures did this actually
418    /// allocate?" is answerable with no device.
419    #[derive(Debug, Default)]
420    struct FakeAllocator {
421        allocations: Cell<u32>,
422    }
423
424    impl TextureAllocator for FakeAllocator {
425        type Texture = u32;
426        type View = u32;
427
428        fn allocate_texture(&self, _desc: &TextureDesc) -> (u32, u32) {
429            self.allocations.set(self.allocations.get() + 1);
430            (self.allocations.get(), self.allocations.get())
431        }
432    }
433
434    fn caps() -> TierCaps {
435        TierCaps::fake(DownlevelProfile::Full)
436    }
437
438    fn targets() -> IntermediateTargets<u32, u32> {
439        IntermediateTargets::new(&caps())
440    }
441
442    #[test]
443    fn the_ceiling_is_the_smaller_of_the_adapter_limit_and_the_engine_cap() {
444        let mut caps = caps();
445        caps.max_texture_dimension_2d = 16384;
446        assert_eq!(max_texture_size(&caps), MAX_INTERMEDIATE_DIMENSION);
447
448        caps.max_texture_dimension_2d = 4096;
449        assert_eq!(max_texture_size(&caps), 4096);
450    }
451
452    #[test]
453    fn an_intermediate_is_a_sampled_copyable_render_attachment() {
454        let desc = IntermediateTargets::<u32, u32>::descriptor(64, 32, "layer");
455        assert_eq!(desc.format, INTERMEDIATE_FORMAT);
456        assert!(desc.usage.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
457        assert!(desc.usage.contains(wgpu::TextureUsages::TEXTURE_BINDING));
458        assert_eq!(desc.sample_count(), 1);
459    }
460
461    #[test]
462    fn an_atlas_layer_pass_maps_its_ndc_against_the_page_not_the_frame() {
463        let config = atlas_layer_config((1024, 1024), 2048, 1);
464
465        assert_eq!(config.width, 1024);
466        assert_eq!(config.height, 1024);
467        assert_eq!(config.strip_offset_x, 0);
468        assert_eq!(config.strip_offset_y, 0);
469        assert_eq!(config.negate_ndc, 0);
470        assert_eq!(config.alphas_tex_width_bits, 11, "log2(2048)");
471        assert_eq!(
472            config.encoded_paints_tex_width_bits, 0,
473            "a 1-texel stand-in reconstructs as `1 << 0`"
474        );
475    }
476
477    #[test]
478    fn a_page_past_the_adapters_own_limit_does_not_fit() {
479        let mut caps = caps();
480        caps.max_texture_dimension_2d = 2048;
481
482        assert!(atlas_page_fits(&caps, (2048, 2048)));
483        assert!(!atlas_page_fits(&caps, (4096, 1024)));
484        assert!(!atlas_page_fits(&caps, (1024, 4096)));
485        // A degenerate page is not a page: nothing can be allocated in it, and
486        // a zero-extent attachment is a device error rather than an empty pass.
487        assert!(!atlas_page_fits(&caps, (0, 1024)));
488        assert!(!atlas_page_fits(&caps, (1024, 0)));
489    }
490
491    #[test]
492    fn the_atlas_pages_ceiling_is_the_adapters_rather_than_the_transient_cap() {
493        let mut caps = caps();
494        caps.max_texture_dimension_2d = 16384;
495
496        // The pool refuses this; the atlas does not, because a page is
497        // allocated once for the renderer rather than per frame.
498        const { assert!(MAX_INTERMEDIATE_DIMENSION < 16384) };
499        assert!(atlas_page_fits(&caps, (16384, 16384)));
500        assert_eq!(max_texture_size(&caps), MAX_INTERMEDIATE_DIMENSION);
501    }
502
503    #[test]
504    fn the_filter_data_texture_is_one_copy_aligned_row_per_five_and_a_third_filters() {
505        let desc = filter_data_texture_descriptor(1);
506        assert_eq!(desc.size.width, FILTER_DATA_TEXTURE_WIDTH);
507        assert_eq!(desc.size.height, 1);
508        assert_eq!(desc.format, crate::gpu::RESOURCE_TEXTURE_FORMAT);
509        assert!(desc.usage.contains(wgpu::TextureUsages::TEXTURE_BINDING));
510        assert!(desc.usage.contains(wgpu::TextureUsages::COPY_DST));
511        assert!(!desc.usage.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
512
513        // Three texels a block into a sixteen-texel row: five whole blocks fit
514        // on the first row and the sixth straddles onto the second, which the
515        // flat-index addressing makes a non-event.
516        assert_eq!(filter_data_texture_height(0), Some(1), "never zero-height");
517        assert_eq!(filter_data_texture_height(1), Some(1));
518        assert_eq!(filter_data_texture_height(5), Some(1));
519        assert_eq!(filter_data_texture_height(6), Some(2));
520        assert_eq!(filter_data_texture_height(11), Some(3));
521    }
522
523    #[test]
524    fn a_filter_count_past_the_transient_ceiling_is_a_value_rather_than_a_device_error() {
525        let per_row = (FILTER_DATA_TEXTURE_WIDTH / GpuFilterData::SIZE_TEXELS) as usize;
526        let at_ceiling = per_row * MAX_INTERMEDIATE_DIMENSION as usize;
527
528        assert!(filter_data_texture_height(at_ceiling).is_some());
529        assert_eq!(filter_data_texture_height(usize::MAX), None);
530        assert_eq!(
531            filter_data_texture_height(at_ceiling * 2),
532            None,
533            "a block count no texture could hold is refused rather than clamped onto one"
534        );
535    }
536
537    #[test]
538    fn an_oversized_request_is_refused_without_allocating() {
539        let allocator = FakeAllocator::default();
540        let mut targets = targets();
541        let max = targets.max_texture_size();
542
543        let refused = targets.acquire(&allocator, max + 1, 16, "huge layer");
544        assert!(refused.is_too_large());
545        assert!(refused.texture().is_none());
546        assert!(matches!(
547            refused,
548            IntermediateTexture::TooLarge { width, height, max: ceiling }
549                if width == max + 1 && height == 16 && ceiling == max
550        ));
551        assert_eq!(allocator.allocations.get(), 0);
552        assert_eq!(targets.stats().created, 0);
553
554        // The other axis is checked the same way.
555        assert!(
556            targets
557                .acquire(&allocator, 16, max + 1, "huge layer")
558                .is_too_large()
559        );
560        assert_eq!(allocator.allocations.get(), 0);
561    }
562
563    #[test]
564    fn a_released_intermediate_is_reused_rather_than_reallocated() {
565        let allocator = FakeAllocator::default();
566        let mut targets = targets();
567
568        let first = targets.acquire(&allocator, 300, 200, "layer");
569        let pooled = match first {
570            IntermediateTexture::Texture(texture) => texture,
571            IntermediateTexture::TooLarge { .. } => unreachable!("300x200 is inside the ceiling"),
572        };
573        // Quantized up to the pool's 256-px keys, with the request preserved.
574        assert_eq!(pooled.requested_size(), (300, 200));
575        assert_eq!(pooled.size(), (512, 256));
576        targets.release(pooled);
577
578        let second = targets.acquire(&allocator, 300, 200, "layer");
579        assert!(second.texture().is_some());
580        assert_eq!(allocator.allocations.get(), 1, "the second acquire reuses");
581        assert_eq!(targets.stats().reused, 1);
582    }
583
584    #[test]
585    fn a_parked_entry_ages_out_after_the_keep_alive_window() {
586        let allocator = FakeAllocator::default();
587        let mut targets = targets();
588
589        let pooled = targets
590            .acquire(&allocator, 256, 256, "layer")
591            .texture()
592            .is_some();
593        assert!(pooled);
594        let entry = match targets.acquire(&allocator, 256, 256, "layer") {
595            IntermediateTexture::Texture(texture) => texture,
596            IntermediateTexture::TooLarge { .. } => unreachable!("256x256 is inside the ceiling"),
597        };
598        targets.release(entry);
599        assert_eq!(targets.stats().free, 1);
600
601        // Frames that acquire nothing are the frames entries age on.
602        for _ in 0..60 {
603            targets.end_frame();
604        }
605        assert_eq!(
606            targets.stats().free,
607            1,
608            "still inside the keep-alive window"
609        );
610
611        targets.end_frame();
612        assert_eq!(targets.stats().free, 0);
613        assert_eq!(targets.stats().evicted, 1);
614        assert_eq!(targets.frame(), 61);
615    }
616
617    #[test]
618    fn a_resize_drops_the_parked_entries_the_recent_frames_did_not_use() {
619        let allocator = FakeAllocator::default();
620        let mut targets = targets();
621
622        // Three pages parked on this frame, then a few frames of nothing —
623        // the shape of a surface whose layers went away well before the drag
624        // started.
625        for size in [256_u32, 512, 768] {
626            match targets.acquire(&allocator, size, size, "layer") {
627                IntermediateTexture::Texture(texture) => targets.release(texture),
628                IntermediateTexture::TooLarge { .. } => unreachable!("inside the ceiling"),
629            }
630        }
631        assert_eq!(targets.stats().free, 3);
632        assert_eq!(targets.stats().keys, 3);
633        for _ in 0..RESIZE_KEEP_ALIVE_FRAMES {
634            targets.end_frame();
635        }
636
637        targets.drop_parked();
638
639        assert_eq!(targets.stats().free, 0);
640        assert_eq!(targets.stats().keys, 0);
641        assert_eq!(targets.stats().evicted, 3);
642        // Well inside the pool's own keep-alive window, so ordinary aging
643        // would not have reached them yet — the resize is what dropped them.
644        assert!(targets.frame() < targets.pool.max_unused_frames());
645        // The pool itself survives: a fresh acquire still works, and the
646        // lifetime counters are not reset by the eviction.
647        assert_eq!(targets.stats().created, 3);
648        assert!(
649            targets
650                .acquire(&allocator, 256, 256, "layer")
651                .texture()
652                .is_some()
653        );
654        assert_eq!(targets.stats().created, 4);
655    }
656
657    #[test]
658    fn a_resize_storm_reuses_the_page_a_fixed_size_layer_keeps_asking_for() {
659        let allocator = FakeAllocator::default();
660        let mut targets = targets();
661
662        // One layer at one size under a surface being resized every frame:
663        // resize, render, end frame, over and over. Its page never changes
664        // extent, so the pool must hand back the same texture every time
665        // rather than allocating one per resize (E13).
666        for _ in 0..200 {
667            targets.drop_parked();
668            match targets.acquire(&allocator, 640, 480, "layer") {
669                IntermediateTexture::Texture(texture) => targets.release(texture),
670                IntermediateTexture::TooLarge { .. } => unreachable!("inside the ceiling"),
671            }
672            targets.end_frame();
673        }
674
675        assert_eq!(
676            targets.stats().created,
677            1,
678            "a resize per frame must not turn the pool back into a plain allocator"
679        );
680        assert_eq!(targets.stats().reused, 199);
681        assert_eq!(targets.stats().evicted, 0);
682        assert_eq!(allocator.allocations.get(), 1);
683    }
684
685    #[test]
686    fn a_resize_does_not_age_the_pool_by_a_second_every_time_a_window_edge_moves() {
687        let allocator = FakeAllocator::default();
688        let mut targets = targets();
689
690        // A page parked on this frame, then a drag's worth of resizes with no
691        // frames between them: the clock is the frame counter's to advance, so
692        // none of these may age anything out on their own.
693        match targets.acquire(&allocator, 256, 256, "layer") {
694            IntermediateTexture::Texture(texture) => targets.release(texture),
695            IntermediateTexture::TooLarge { .. } => unreachable!("inside the ceiling"),
696        }
697        for _ in 0..100 {
698            targets.drop_parked();
699        }
700
701        assert_eq!(targets.frame(), 0, "a resize is not a frame");
702        assert_eq!(targets.stats().free, 1);
703        assert_eq!(targets.stats().evicted, 0);
704    }
705
706    #[test]
707    fn a_checked_out_intermediate_survives_a_drop_of_the_parked_entries() {
708        let allocator = FakeAllocator::default();
709        let mut targets = targets();
710
711        let held = match targets.acquire(&allocator, 256, 256, "layer") {
712            IntermediateTexture::Texture(texture) => texture,
713            IntermediateTexture::TooLarge { .. } => unreachable!("inside the ceiling"),
714        };
715        targets.drop_parked();
716        assert_eq!(targets.stats().evicted, 0, "nothing was parked to evict");
717        assert_eq!(targets.stats().in_use, 1);
718
719        // It returns to the pool as usual afterwards.
720        targets.release(held);
721        assert_eq!(targets.stats().free, 1);
722    }
723}