frust_gpu/pool.rs
1//! A recycling pool for the short-lived textures a frame renders into:
2//! [`TexturePool`], the [`PooledTexture`] it hands out, and the
3//! [`TextureAllocator`] seam that keeps both host-testable.
4//!
5//! An intermediate render target — a scratch surface a pass draws into and a
6//! later pass consumes — is allocated and dropped every frame if nothing
7//! recycles it. That is exactly the cost the hybrid renderer's own TODO
8//! names ("we currently allocate a new strips buffer for each render pass"),
9//! and it is worst during a resize storm: dragging a window edge produces a
10//! new surface size every frame, so a pool keyed on the exact requested
11//! extent allocates a fresh texture per frame and parks the previous one
12//! forever.
13//!
14//! Two decisions make the pool survive that storm.
15//!
16//! - **Quantization.** A request's extent is rounded up to the next multiple
17//! of [`SIZE_QUANTUM`] (256 px) *before* it becomes a key, so a drag
18//! through 4096 distinct widths lands on a couple of dozen keys instead.
19//! The caller gets a texture at least as large as it asked for and renders
20//! into the sub-rect it actually wants ([`PooledTexture::requested_size`]);
21//! Impeller's coverage-size quantization is the same trade — a bounded
22//! amount of slack memory in exchange for reuse across a size that never
23//! stops changing.
24//! - **Aging with slack.** A free entry survives
25//! [`DEFAULT_MAX_UNUSED_FRAMES`] (60) frames of disuse before
26//! [`TexturePool::age`] drops it. `frust-render`'s compositor scratch ages
27//! out after 2 frames, which is right for one full-surface texture whose
28//! size is pinned to the surface, and far too aggressive here: a resize
29//! drag revisits a quantized size seconds later, and a two-frame window
30//! would evict every entry between visits and turn the pool back into a
31//! plain allocator. Grow-and-never-shrink is the other failure mode, so the
32//! window is finite and tunable ([`TexturePool::with_max_unused_frames`]).
33//!
34//! # Recycled contents are never loaded
35//!
36//! A pooled texture's prior contents belong to whichever pass used it last,
37//! so a pass targeting one must CLEAR, never LOAD — that is what
38//! [`PooledTexture::color_attachment`] builds, and it is a correctness rule
39//! before it is a bandwidth one. Pairing `LoadOp::Clear` with
40//! `StoreOp::Discard` is also the precondition for
41//! `wgpu::TextureUsages::TRANSIENT_ATTACHMENT`, which [`effective_usage`]
42//! adds on an adapter that reports [`TierCaps::transient_saves_memory`] — a
43//! write-only attachment can then live in tile memory and may never get
44//! backing storage at all. `TRANSIENT_ATTACHMENT` is incompatible with every
45//! other usage, so it is applied only to a texture whose whole usage set is
46//! `RENDER_ATTACHMENT`; anything sampled or copied out afterwards keeps the
47//! clear/discard policy and no flag.
48//!
49//! # No GPU in the loop
50//!
51//! Allocation goes through [`TextureAllocator`] rather than a `wgpu::Device`
52//! borrow, so the pool's keying, reuse, aging and statistics are exercised
53//! against a counting fake with no device — the same pure-decision /
54//! platform-lookup split [`crate::caps::TierCaps::fake`] gives adapter
55//! capabilities. `wgpu::Device` implements the trait, and it is the only
56//! implementation the engine itself ever passes.
57
58use std::collections::HashMap;
59
60use crate::caps::TierCaps;
61use crate::texture::{ColorAttachment, Texture, TextureDesc};
62
63/// The multiple every pooled texture's width and height is rounded up to
64/// before it is used as a pool key.
65///
66/// 256 px is Impeller's coverage quantum: coarse enough that a resize drag
67/// collapses onto a handful of keys, fine enough that the slack area stays a
68/// small fraction of a real surface (a 5120x2880 request grows to
69/// 5120x3072 — 6.7% extra).
70pub const SIZE_QUANTUM: u32 = 256;
71
72/// How many frames a free entry may go unused before [`TexturePool::age`]
73/// drops it: roughly a second of frames at 60 Hz, so a resize drag that
74/// revisits a quantized size still finds it parked.
75pub const DEFAULT_MAX_UNUSED_FRAMES: u64 = 60;
76
77/// Creates the texture/view pair a [`TexturePool`] hands out.
78///
79/// Exists so the pool takes an allocation *capability* instead of a
80/// `wgpu::Device`: a host test implements it with a counter and plain
81/// stand-in values, which is what makes "how many textures did 400 resizes
82/// actually allocate?" answerable with no GPU. `wgpu::Device` is the only
83/// production implementation.
84pub trait TextureAllocator {
85 /// The allocated texture — `wgpu::Texture` in production.
86 type Texture;
87 /// A full-extent view of [`Self::Texture`] — `wgpu::TextureView` in
88 /// production.
89 type View;
90
91 /// Creates a texture matching `desc` plus a full-extent view of it.
92 ///
93 /// Named `allocate_texture` rather than `create_texture` so it never
94 /// shadows `wgpu::Device`'s inherent method of that name.
95 fn allocate_texture(&self, desc: &TextureDesc) -> (Self::Texture, Self::View);
96}
97
98impl TextureAllocator for wgpu::Device {
99 type Texture = wgpu::Texture;
100 type View = wgpu::TextureView;
101
102 fn allocate_texture(&self, desc: &TextureDesc) -> (wgpu::Texture, wgpu::TextureView) {
103 let texture = self.create_texture(&wgpu::TextureDescriptor {
104 label: desc.label.as_deref(),
105 size: wgpu::Extent3d {
106 width: desc.width,
107 height: desc.height,
108 depth_or_array_layers: 1,
109 },
110 mip_level_count: 1,
111 sample_count: desc.sample_count(),
112 dimension: wgpu::TextureDimension::D2,
113 format: desc.format,
114 usage: desc.usage,
115 view_formats: &[],
116 });
117 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
118 (texture, view)
119 }
120}
121
122/// The configuration a pooled texture is interchangeable within: two
123/// requests that produce the same key may share one texture, and two that do
124/// not never can.
125///
126/// `width`/`height` are the **quantized** extent ([`quantize_extent`]), and
127/// `usage` is the **effective** usage ([`effective_usage`]) rather than the
128/// requested one, because those are what the pooled texture was actually
129/// created with.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
131pub struct PoolKey {
132 /// Quantized width in texels.
133 pub width: u32,
134 /// Quantized height in texels.
135 pub height: u32,
136 /// The texture's pixel format.
137 pub format: wgpu::TextureFormat,
138 /// The usage the texture was created with.
139 pub usage: wgpu::TextureUsages,
140 /// The texture's sample count. Always 1 today ([`TextureDesc`] exposes
141 /// no way to ask for more), and part of the key anyway so a multisampled
142 /// target could never alias a single-sampled one.
143 pub sample_count: u32,
144}
145
146/// A texture checked out of a [`TexturePool`], the key it returns under, and
147/// the frame clock it ages against.
148///
149/// Held by value while in use — the pool retains only free entries — so a
150/// caller that forgets to [`TexturePool::release`] it simply drops it, at
151/// worst losing the reuse rather than leaking a slot.
152#[derive(Clone, Debug)]
153pub struct PooledTexture<T = wgpu::Texture, V = wgpu::TextureView> {
154 texture: Texture<T, V>,
155 key: PoolKey,
156 last_used_frame: u64,
157 requested: (u32, u32),
158}
159
160impl<T, V> PooledTexture<T, V> {
161 /// The pooled texture itself, carrying the [`TextureDesc`] it was
162 /// created from (the *quantized* extent) and its
163 /// [`crate::texture::SceneTextureId`].
164 pub fn texture(&self) -> &Texture<T, V> {
165 &self.texture
166 }
167
168 /// The texture's full-extent view.
169 pub fn view(&self) -> &V {
170 self.texture.view()
171 }
172
173 /// The quantized `(width, height)` this texture was allocated at — at
174 /// least [`Self::requested_size`] in both axes.
175 pub fn size(&self) -> (u32, u32) {
176 self.texture.size()
177 }
178
179 /// The `(width, height)` the current holder asked for, which is what it
180 /// should render into: the allocation is quantized up, so the rest of
181 /// the texture is slack the caller must not treat as part of its image.
182 pub fn requested_size(&self) -> (u32, u32) {
183 self.requested
184 }
185
186 /// The configuration this entry returns to the pool under.
187 pub fn key(&self) -> PoolKey {
188 self.key
189 }
190
191 /// The frame this entry was last acquired for — the clock
192 /// [`TexturePool::age`] measures disuse against.
193 pub fn last_used_frame(&self) -> u64 {
194 self.last_used_frame
195 }
196}
197
198impl PooledTexture<wgpu::Texture, wgpu::TextureView> {
199 /// A color attachment targeting this texture, clearing to `clear` and
200 /// discarding at pass end.
201 ///
202 /// The ops are not a parameter on purpose. A recycled texture holds
203 /// whatever its previous holder left there, so loading it would sample
204 /// another pass's image; and `StoreOp::Discard` is required outright once
205 /// [`effective_usage`] has added
206 /// `wgpu::TextureUsages::TRANSIENT_ATTACHMENT`. A caller that needs its
207 /// own contents back across passes wants a texture it owns, not a pooled
208 /// intermediate.
209 pub fn color_attachment(&self, clear: wgpu::Color) -> ColorAttachment<'_> {
210 ColorAttachment {
211 view: self.texture.view(),
212 load: wgpu::LoadOp::Clear(clear),
213 store: wgpu::StoreOp::Discard,
214 }
215 }
216}
217
218/// A [`TexturePool`]'s counters, as of the [`TexturePool::stats`] call.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
220pub struct PoolStats {
221 /// Textures the pool has ever asked the allocator for. The number a
222 /// resize-storm test holds against a budget.
223 pub created: u64,
224 /// Acquires satisfied from a free entry instead of an allocation.
225 pub reused: u64,
226 /// Entries dropped by [`TexturePool::age`].
227 pub evicted: u64,
228 /// Entries acquired and not yet released.
229 pub in_use: u64,
230 /// Entries currently parked and reusable.
231 pub free: usize,
232 /// Distinct [`PoolKey`]s with at least one parked entry.
233 pub keys: usize,
234}
235
236/// Recycles the intermediate textures a frame renders into, keyed by
237/// quantized configuration and aged by frame clock.
238///
239/// Generic over the texture (`T`) and view (`V`) types for the same reason
240/// [`Texture`] is: the pool's own behavior is exercised on a host with plain
241/// stand-in values. The engine always uses the default
242/// `TexturePool<wgpu::Texture, wgpu::TextureView>`.
243#[derive(Debug)]
244pub struct TexturePool<T = wgpu::Texture, V = wgpu::TextureView> {
245 free: HashMap<PoolKey, Vec<PooledTexture<T, V>>>,
246 max_dimension: u32,
247 transient_saves_memory: bool,
248 max_unused_frames: u64,
249 created: u64,
250 reused: u64,
251 evicted: u64,
252 in_use: u64,
253}
254
255impl<T, V> TexturePool<T, V> {
256 /// An empty pool configured from an adapter's capabilities: quantized
257 /// extents are capped at [`TierCaps::max_texture_dimension_2d`] and
258 /// `wgpu::TextureUsages::TRANSIENT_ATTACHMENT` is applied only where
259 /// [`TierCaps::transient_saves_memory`] says it buys something.
260 pub fn new(caps: &TierCaps) -> Self {
261 Self::with_max_unused_frames(caps, DEFAULT_MAX_UNUSED_FRAMES)
262 }
263
264 /// [`Self::new`] with an explicit keep-alive window, for a host that
265 /// knows its own memory budget is tighter (or its resize storms longer)
266 /// than the [`DEFAULT_MAX_UNUSED_FRAMES`] default.
267 pub fn with_max_unused_frames(caps: &TierCaps, max_unused_frames: u64) -> Self {
268 Self {
269 free: HashMap::new(),
270 max_dimension: caps.max_texture_dimension_2d,
271 transient_saves_memory: caps.transient_saves_memory,
272 max_unused_frames,
273 created: 0,
274 reused: 0,
275 evicted: 0,
276 in_use: 0,
277 }
278 }
279
280 /// The [`PoolKey`] `desc` resolves to: quantized extent, effective
281 /// usage, format and sample count.
282 ///
283 /// Public because a caller sizing a cache or asserting that two requests
284 /// share a texture should ask the pool rather than re-deriving the
285 /// quantization rule.
286 pub fn key_for(&self, desc: &TextureDesc) -> PoolKey {
287 let (width, height) = quantize_extent(desc.width, desc.height, self.max_dimension);
288 PoolKey {
289 width,
290 height,
291 format: desc.format,
292 usage: effective_usage(desc.usage, self.transient_saves_memory),
293 sample_count: desc.sample_count(),
294 }
295 }
296
297 /// Hands out a texture for `desc`, reusing a parked entry of the same
298 /// configuration when there is one and allocating otherwise.
299 ///
300 /// `frame` is the caller's monotonically increasing frame counter; it is
301 /// stamped on the entry and is what [`Self::age`] measures disuse
302 /// against. The returned texture is at least `desc`'s extent and usually
303 /// larger — see [`PooledTexture::requested_size`].
304 pub fn acquire<A>(
305 &mut self,
306 allocator: &A,
307 desc: &TextureDesc,
308 frame: u64,
309 ) -> PooledTexture<T, V>
310 where
311 A: TextureAllocator<Texture = T, View = V>,
312 {
313 let key = self.key_for(desc);
314 let requested = (desc.width, desc.height);
315 self.in_use += 1;
316
317 if let Some(mut entry) = self.free.get_mut(&key).and_then(Vec::pop) {
318 if self.free.get(&key).is_some_and(Vec::is_empty) {
319 self.free.remove(&key);
320 }
321 entry.last_used_frame = frame;
322 entry.requested = requested;
323 self.reused += 1;
324 return entry;
325 }
326
327 let pooled_desc = TextureDesc {
328 width: key.width,
329 height: key.height,
330 format: key.format,
331 usage: key.usage,
332 label: desc.label.clone(),
333 };
334 let (texture, view) = allocator.allocate_texture(&pooled_desc);
335 self.created += 1;
336 PooledTexture {
337 texture: Texture::new(texture, view, pooled_desc),
338 key,
339 last_used_frame: frame,
340 requested,
341 }
342 }
343
344 /// Parks `texture` for reuse under the key it was acquired with.
345 ///
346 /// Its [`PooledTexture::last_used_frame`] stamp is left as the acquiring
347 /// frame, so the keep-alive window is measured from when the entry was
348 /// last *needed*, not from when the holder happened to give it back.
349 pub fn release(&mut self, texture: PooledTexture<T, V>) {
350 self.in_use = self.in_use.saturating_sub(1);
351 self.free.entry(texture.key).or_default().push(texture);
352 }
353
354 /// Advances the pool to `frame` and drops every parked entry unused for
355 /// more than the keep-alive window.
356 ///
357 /// Call once per frame, including frames that acquired nothing — those
358 /// are the frames entries age on. Entries still checked out are
359 /// untouched: the pool does not hold them.
360 pub fn age(&mut self, frame: u64) {
361 let max_unused = self.max_unused_frames;
362 let mut evicted = 0u64;
363 self.free.retain(|_, entries| {
364 let before = entries.len();
365 entries.retain(|entry| !entry_expired(entry.last_used_frame, frame, max_unused));
366 evicted += (before - entries.len()) as u64;
367 !entries.is_empty()
368 });
369 self.evicted += evicted;
370 }
371
372 /// The pool's counters right now.
373 pub fn stats(&self) -> PoolStats {
374 PoolStats {
375 created: self.created,
376 reused: self.reused,
377 evicted: self.evicted,
378 in_use: self.in_use,
379 free: self.free.values().map(Vec::len).sum(),
380 keys: self.free.len(),
381 }
382 }
383
384 /// The keep-alive window in frames, as configured.
385 pub fn max_unused_frames(&self) -> u64 {
386 self.max_unused_frames
387 }
388}
389
390/// Rounds `width` and `height` up to the next multiple of [`SIZE_QUANTUM`],
391/// never past `max_dimension`.
392///
393/// The clamp keeps quantization from pushing a request that already sits
394/// just under the adapter's ceiling over it. A dimension that is *itself*
395/// above `max_dimension` is passed through unchanged rather than silently
396/// shrunk, so the device reports the real error instead of the pool handing
397/// back a texture that is not the size anyone asked for. A zero extent is
398/// likewise passed through — it is a caller bug, and the device names it.
399pub fn quantize_extent(width: u32, height: u32, max_dimension: u32) -> (u32, u32) {
400 (
401 quantize_dimension(width, max_dimension),
402 quantize_dimension(height, max_dimension),
403 )
404}
405
406fn quantize_dimension(value: u32, max_dimension: u32) -> u32 {
407 value
408 .checked_next_multiple_of(SIZE_QUANTUM)
409 .unwrap_or(value)
410 .min(max_dimension)
411 .max(value)
412}
413
414/// The usage a pooled texture requested as `usage` is actually created with.
415///
416/// Adds `wgpu::TextureUsages::TRANSIENT_ATTACHMENT` — which lets a driver
417/// keep the texture in tile memory and possibly never back it with real
418/// storage — on an adapter that reports it saves memory, and only for a
419/// texture whose entire usage set is `RENDER_ATTACHMENT`. That restriction is
420/// `wgpu`'s own: `TRANSIENT_ATTACHMENT` requires `RENDER_ATTACHMENT` and is
421/// incompatible with every other usage, which lines up exactly with "an
422/// intermediate nothing reads back". Anything sampled, copied or stored keeps
423/// its usage unchanged and relies on the clear/discard ops alone.
424pub fn effective_usage(
425 usage: wgpu::TextureUsages,
426 transient_saves_memory: bool,
427) -> wgpu::TextureUsages {
428 if transient_saves_memory && usage == wgpu::TextureUsages::RENDER_ATTACHMENT {
429 usage | wgpu::TextureUsages::TRANSIENT_ATTACHMENT
430 } else {
431 usage
432 }
433}
434
435/// Whether an entry last used at `last_used` is stale at `frame`.
436///
437/// The boundary is inclusive of `frame - max_unused` (that entry survives),
438/// the same shape `frust-render`'s compositor ages its scratch texture by —
439/// only the window differs.
440fn entry_expired(last_used: u64, frame: u64, max_unused: u64) -> bool {
441 frame.saturating_sub(last_used) > max_unused
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::caps::DownlevelProfile;
448
449 #[test]
450 fn quantization_rounds_up_to_the_next_multiple() {
451 assert_eq!(quantize_dimension(1, 8192), 256);
452 assert_eq!(quantize_dimension(255, 8192), 256);
453 assert_eq!(quantize_dimension(256, 8192), 256);
454 assert_eq!(quantize_dimension(257, 8192), 512);
455 assert_eq!(quantize_dimension(800, 8192), 1024);
456 assert_eq!(quantize_dimension(2880, 8192), 3072);
457 assert_eq!(quantize_dimension(5120, 8192), 5120);
458 }
459
460 #[test]
461 fn quantization_never_exceeds_the_adapter_ceiling() {
462 // Rounding 2000 up would land on 2048, past a 2000-px ceiling.
463 assert_eq!(quantize_dimension(2000, 2000), 2000);
464 // A request already above the ceiling is passed through so the
465 // device reports it, not shrunk to something nobody asked for.
466 assert_eq!(quantize_dimension(4096, 2048), 4096);
467 }
468
469 #[test]
470 fn quantization_passes_a_zero_extent_through() {
471 assert_eq!(quantize_extent(0, 0, 8192), (0, 0));
472 }
473
474 #[test]
475 fn transient_is_added_only_for_a_write_only_attachment() {
476 let attachment = wgpu::TextureUsages::RENDER_ATTACHMENT;
477 assert_eq!(
478 effective_usage(attachment, true),
479 attachment | wgpu::TextureUsages::TRANSIENT_ATTACHMENT
480 );
481 assert_eq!(effective_usage(attachment, false), attachment);
482
483 // Sampled afterwards, so the contents ARE read back: no flag, on
484 // either adapter.
485 let sampled = attachment | wgpu::TextureUsages::TEXTURE_BINDING;
486 assert_eq!(effective_usage(sampled, true), sampled);
487 assert_eq!(effective_usage(sampled, false), sampled);
488
489 // Copied out, same reasoning.
490 let copied = attachment | wgpu::TextureUsages::COPY_SRC;
491 assert_eq!(effective_usage(copied, true), copied);
492 }
493
494 #[test]
495 fn expiry_boundary_matches_the_compositor_precedent() {
496 assert!(!entry_expired(7, 7, 60));
497 assert!(!entry_expired(7, 67, 60));
498 assert!(entry_expired(7, 68, 60));
499 // A frame clock that has not advanced past the stamp never expires.
500 assert!(!entry_expired(9, 3, 60));
501 }
502
503 #[test]
504 fn key_folds_quantization_and_transient_policy_together() {
505 let mut caps = TierCaps::fake(DownlevelProfile::Full);
506 caps.transient_saves_memory = true;
507 let pool: TexturePool<u32, u32> = TexturePool::new(&caps);
508
509 let desc = TextureDesc {
510 width: 900,
511 height: 700,
512 format: wgpu::TextureFormat::Rgba8Unorm,
513 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
514 label: None,
515 };
516 let key = pool.key_for(&desc);
517 assert_eq!((key.width, key.height), (1024, 768));
518 assert!(
519 key.usage
520 .contains(wgpu::TextureUsages::TRANSIENT_ATTACHMENT)
521 );
522 assert_eq!(key.sample_count, 1);
523 }
524
525 #[test]
526 fn stats_start_empty() {
527 let caps = TierCaps::fake(DownlevelProfile::Full);
528 let pool: TexturePool<u32, u32> = TexturePool::new(&caps);
529 assert_eq!(pool.stats(), PoolStats::default());
530 assert_eq!(pool.max_unused_frames(), DEFAULT_MAX_UNUSED_FRAMES);
531 }
532}