Skip to main content

engawa_wgpu/
pool.rs

1//! `TexturePool` — offscreen texture alloc/reuse keyed by
2//! `(size, format, usage)`.
3//!
4//! Subsumes the per-effect `ensure_offscreen` pattern (mado
5//! `render.rs::PostProcessPipeline::ensure_offscreen`): instead
6//! of each consumer hand-tracking `last_width`/`last_height` +
7//! `Option<wgpu::Texture>` per effect, the consumer leases a
8//! texture for the frame and releases it back. A resize is just
9//! a lease under a different key; entries for stale sizes stay
10//! in the free list until [`TexturePool::clear`] or the targeted
11//! [`TexturePool::retain`] — render loops MUST call one of them
12//! when their surface size changes, or every live-resize
13//! intermediate size strands a full texture set for the pool's
14//! lifetime.
15//!
16//! ## Lease discipline (tier-honest)
17//!
18//! [`TexturePool::lease`] returns a **move-only**
19//! [`TextureLease`] — the only handout the pool makes. A pooled
20//! texture is either in the free list OR inside exactly one
21//! live lease value (moved out on `lease`, moved back on
22//! [`TexturePool::release`]), so the pool can never hand the
23//! same texture to two callers simultaneously, and "use a
24//! texture you did not lease" has no API path.
25//!
26//! **Tier: only-mitigated at the wgpu-handle layer, API-shape
27//! enforced at the pool layer** — wgpu handles are internally
28//! reference-counted, so a caller CAN `.clone()` the inner
29//! `TextureView` out of a lease (e.g. into [`crate::BoundResource`],
30//! which is the intended dispatch path) and deliberately hold
31//! that clone past `release`. Nothing in Rust's type system
32//! revokes a cloned Arc-backed GPU handle, so use-after-release
33//! is not truly unrepresentable; it requires an explicit clone
34//! escape rather than being the default, which is the honest
35//! ceiling for wgpu's handle model.
36
37use std::collections::HashMap;
38
39use crate::dispatcher::BoundResource;
40
41/// Allocation key: textures are interchangeable iff size,
42/// format, and usage all match.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct TextureKey {
45    pub width: u32,
46    pub height: u32,
47    pub format: wgpu::TextureFormat,
48    pub usage: wgpu::TextureUsages,
49}
50
51impl TextureKey {
52    /// The canonical post-process offscreen shape: render into
53    /// it in one pass, sample it in the next
54    /// (`RENDER_ATTACHMENT | TEXTURE_BINDING`). Zero dimensions
55    /// are clamped to 1 — same guard mado's `ensure_offscreen`
56    /// carried for minimized windows.
57    #[must_use]
58    pub fn offscreen(width: u32, height: u32, format: wgpu::TextureFormat) -> Self {
59        Self {
60            width: width.max(1),
61            height: height.max(1),
62            format,
63            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
64                | wgpu::TextureUsages::TEXTURE_BINDING,
65        }
66    }
67}
68
69/// A leased pooled texture. Move-only — holding the lease IS
70/// the right to use the texture (see the module doc for the
71/// honest tier statement). Dropping a lease without
72/// [`TexturePool::release`] simply lets wgpu free the texture;
73/// safe, just forfeits reuse.
74#[derive(Debug)]
75pub struct TextureLease {
76    key: TextureKey,
77    texture: wgpu::Texture,
78    view: wgpu::TextureView,
79}
80
81impl TextureLease {
82    #[must_use]
83    pub fn key(&self) -> TextureKey {
84        self.key
85    }
86
87    #[must_use]
88    pub fn texture(&self) -> &wgpu::Texture {
89        &self.texture
90    }
91
92    #[must_use]
93    pub fn view(&self) -> &wgpu::TextureView {
94        &self.view
95    }
96
97    /// The dispatch-path bridge: a [`BoundResource::Texture`]
98    /// carrying a clone of the leased view, ready for
99    /// [`crate::BoundResources`].
100    #[must_use]
101    pub fn bound_resource(&self) -> BoundResource {
102        BoundResource::Texture {
103            view: self.view.clone(),
104            format: self.key.format,
105        }
106    }
107}
108
109/// Free-list pool of offscreen textures. One per consumer
110/// render loop; lease at frame start, release at frame end.
111#[derive(Debug, Default)]
112pub struct TexturePool {
113    free: HashMap<TextureKey, Vec<(wgpu::Texture, wgpu::TextureView)>>,
114}
115
116impl TexturePool {
117    #[must_use]
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Lease a texture matching `key` — reuses a free pooled
123    /// texture when one exists, otherwise allocates.
124    #[must_use]
125    pub fn lease(&mut self, device: &wgpu::Device, key: TextureKey) -> TextureLease {
126        if let Some((texture, view)) =
127            self.free.get_mut(&key).and_then(Vec::pop)
128        {
129            return TextureLease { key, texture, view };
130        }
131        let texture = device.create_texture(&wgpu::TextureDescriptor {
132            label: Some("engawa-wgpu pooled texture"),
133            size: wgpu::Extent3d {
134                width: key.width,
135                height: key.height,
136                depth_or_array_layers: 1,
137            },
138            mip_level_count: 1,
139            sample_count: 1,
140            dimension: wgpu::TextureDimension::D2,
141            format: key.format,
142            usage: key.usage,
143            view_formats: &[],
144        });
145        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
146        TextureLease { key, texture, view }
147    }
148
149    /// Return a leased texture to the free list for reuse.
150    pub fn release(&mut self, lease: TextureLease) {
151        self.free
152            .entry(lease.key)
153            .or_default()
154            .push((lease.texture, lease.view));
155    }
156
157    /// Total free (releasable) textures across all keys.
158    #[must_use]
159    pub fn free_count(&self) -> usize {
160        self.free.values().map(Vec::len).sum()
161    }
162
163    /// Drop every pooled texture (e.g. after a resize storm
164    /// left stale-size entries behind).
165    pub fn clear(&mut self) {
166        self.free.clear();
167    }
168
169    /// Keep only free-list buckets whose key satisfies `keep`;
170    /// everything else is dropped (wgpu frees the textures).
171    ///
172    /// The targeted eviction seam for the live-resize hazard the
173    /// module doc names: a consumer that renders at one resolution
174    /// per frame calls `retain(|k| k.width == w && k.height == h)`
175    /// when its surface size changes, so a macOS live-resize drag
176    /// (a distinct size nearly every frame) cannot strand full-window
177    /// texture sets for every intermediate size (M3 review
178    /// 2026-06-12 — mado leaked ~24 MB x 9 textures per visited
179    /// size with the 6-effect chain enabled). Covers DPI and format
180    /// churn too: the predicate sees the whole key.
181    pub fn retain(&mut self, mut keep: impl FnMut(&TextureKey) -> bool) {
182        self.free.retain(|key, _| keep(key));
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn offscreen_key_clamps_zero_dimensions_and_sets_postprocess_usage() {
192        let key = TextureKey::offscreen(0, 0, wgpu::TextureFormat::Rgba8UnormSrgb);
193        assert_eq!(key.width, 1);
194        assert_eq!(key.height, 1);
195        assert!(key.usage.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
196        assert!(key.usage.contains(wgpu::TextureUsages::TEXTURE_BINDING));
197    }
198
199    #[test]
200    fn keys_differ_by_any_axis() {
201        let base = TextureKey::offscreen(64, 64, wgpu::TextureFormat::Rgba8UnormSrgb);
202        let wider = TextureKey::offscreen(128, 64, wgpu::TextureFormat::Rgba8UnormSrgb);
203        let other_format =
204            TextureKey::offscreen(64, 64, wgpu::TextureFormat::Bgra8UnormSrgb);
205        assert_ne!(base, wider);
206        assert_ne!(base, other_format);
207        assert_eq!(
208            base,
209            TextureKey::offscreen(64, 64, wgpu::TextureFormat::Rgba8UnormSrgb)
210        );
211    }
212}