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}