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}