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}