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