Skip to main content

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}