1use std::{ops::Deref, sync::Arc};
2
3use parking_lot::Mutex;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum ScratchFit {
8 Exact,
11 Approx,
14}
15
16#[derive(Clone, Copy, Debug)]
19pub struct ScratchTextureDescriptor {
20 pub width: u32,
22 pub height: u32,
24 pub format: wgpu::TextureFormat,
26 pub usage: wgpu::TextureUsages,
28 pub sample_count: u32,
30 pub fit: ScratchFit,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35struct Key {
36 width: u32,
37 height: u32,
38 format: wgpu::TextureFormat,
39 usage: wgpu::TextureUsages,
40 sample_count: u32,
41}
42
43impl Key {
44 fn bytes(self) -> u64 {
45 let (width, height) = self.format.block_dimensions();
46 u64::from(self.width.div_ceil(width))
47 * u64::from(self.height.div_ceil(height))
48 * u64::from(self.format.block_copy_size(None).unwrap_or(8))
49 * u64::from(self.sample_count)
50 }
51}
52
53#[derive(Debug)]
54struct Entry {
55 key: Key,
56 texture: wgpu::Texture,
57 view: wgpu::TextureView,
58 last_used: u64,
59}
60
61#[derive(Debug, Default)]
62pub(crate) struct ScratchPool {
63 free: Vec<Entry>,
64 frame: u64,
65}
66
67const RETAINED_FRAMES: u64 = 2;
69const CACHE_BYTES: u64 = 64 << 20;
70
71impl ScratchPool {
72 pub(crate) fn begin_frame(&mut self) {
73 self.frame = self.frame.wrapping_add(1);
74 let frame = self.frame;
75 self.free
76 .retain(|entry| frame.wrapping_sub(entry.last_used) <= RETAINED_FRAMES);
77 }
78
79 fn trim(&mut self, budget: u64) {
80 let mut bytes: u64 = self.free.iter().map(|entry| entry.key.bytes()).sum();
81 while bytes > budget {
82 let oldest = self
83 .free
84 .iter()
85 .enumerate()
86 .max_by_key(|(_, entry)| self.frame.wrapping_sub(entry.last_used))
87 .map(|(index, _)| index)
88 .expect("nonempty scratch cache exceeds budget");
89 bytes -= self.free.swap_remove(oldest).key.bytes();
90 }
91 }
92}
93
94fn approx_dimension(value: u32) -> u32 {
98 const MIN: u32 = 16;
99 const POW2_LIMIT: u32 = 1024;
100 if value <= MIN {
101 return MIN;
102 }
103 let ceil = value.checked_next_power_of_two().unwrap_or(value);
104 if value <= POW2_LIMIT || value == ceil {
105 return ceil;
106 }
107 let mid = (ceil >> 1) + (ceil >> 2);
108 if value <= mid { mid } else { ceil }
109}
110
111pub struct ScratchTexture {
117 entry: Option<Entry>,
118 pool: Arc<Mutex<ScratchPool>>,
119}
120
121impl ScratchTexture {
122 pub fn texture(&self) -> &wgpu::Texture {
124 &self.entry().texture
125 }
126
127 pub fn size(&self) -> (u32, u32) {
129 let key = self.entry().key;
130 (key.width, key.height)
131 }
132
133 fn entry(&self) -> &Entry {
134 self.entry.as_ref().expect("scratch texture is leased")
135 }
136}
137
138impl Deref for ScratchTexture {
139 type Target = wgpu::TextureView;
140
141 fn deref(&self) -> &Self::Target {
142 &self.entry().view
143 }
144}
145
146impl Drop for ScratchTexture {
147 fn drop(&mut self) {
148 if let Some(mut entry) = self.entry.take() {
149 let mut pool = self.pool.lock();
150 entry.last_used = pool.frame;
151 pool.free.push(entry);
152 pool.trim(CACHE_BYTES);
153 }
154 }
155}
156
157impl crate::GraphicsDevice {
158 pub fn scratch_texture(&self, descriptor: ScratchTextureDescriptor) -> ScratchTexture {
160 let limit = self.device.limits().max_texture_dimension_2d;
161 assert!(
162 descriptor.width <= limit && descriptor.height <= limit,
163 "scratch texture exceeds the device dimension limit"
164 );
165 let (width, height) = match descriptor.fit {
166 ScratchFit::Exact => (descriptor.width.max(1), descriptor.height.max(1)),
167 ScratchFit::Approx => (
168 approx_dimension(descriptor.width).min(limit),
169 approx_dimension(descriptor.height).min(limit),
170 ),
171 };
172 let key = Key {
173 width,
174 height,
175 format: descriptor.format,
176 usage: descriptor.usage,
177 sample_count: descriptor.sample_count,
178 };
179 let reused = {
180 let mut pool = self.scratch.lock();
181 pool.free
182 .iter()
183 .position(|entry| entry.key == key)
184 .map(|index| pool.free.swap_remove(index))
185 };
186 let entry = reused.unwrap_or_else(|| {
187 let texture = self.create_texture(
188 "scratch_textures",
189 &wgpu::TextureDescriptor {
190 label: Some("Scratch Texture"),
191 size: wgpu::Extent3d {
192 width,
193 height,
194 depth_or_array_layers: 1,
195 },
196 mip_level_count: 1,
197 sample_count: key.sample_count,
198 dimension: wgpu::TextureDimension::D2,
199 format: key.format,
200 usage: key.usage,
201 view_formats: &[],
202 },
203 );
204 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
205 Entry {
206 key,
207 texture,
208 view,
209 last_used: 0,
210 }
211 });
212 ScratchTexture {
213 entry: Some(entry),
214 pool: Arc::clone(&self.scratch),
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::approx_dimension;
222
223 #[test]
224 fn approximate_sizes_share_buckets_without_wasting_large_targets() {
225 assert_eq!(approx_dimension(1), 16);
226 assert_eq!(approx_dimension(17), 32);
227 assert_eq!(approx_dimension(1000), 1024);
228 assert_eq!(approx_dimension(1025), 1536);
229 assert_eq!(approx_dimension(1536), 1536);
230 assert_eq!(approx_dimension(1537), 2048);
231 assert_eq!(approx_dimension(2048), 2048);
232 }
233
234 #[cfg(all(feature = "headless", not(target_family = "wasm")))]
235 #[test]
236 #[ignore = "requires a native GPU adapter"]
237 fn leases_reuse_buckets_without_aliasing_and_budget_evicts_only_idle_textures() {
238 use super::*;
239
240 let session = crate::RuntimeSession::new();
241 let _scope = session.enter();
242 let core = pollster::block_on(crate::renderer::core::RenderCore::new_offscreen(
243 winit::dpi::PhysicalSize::new(1, 1),
244 wgpu::TextureFormat::Rgba8Unorm,
245 1,
246 crate::renderer::DEFAULT_MAX_NON_SAMPLER_BINDINGS,
247 ));
248 let graphics = core.resources().graphics;
249 let descriptor = ScratchTextureDescriptor {
250 width: 17,
251 height: 17,
252 format: wgpu::TextureFormat::Rgba8Unorm,
253 usage: wgpu::TextureUsages::TEXTURE_BINDING,
254 sample_count: 1,
255 fit: ScratchFit::Approx,
256 };
257 let first = graphics.scratch_texture(descriptor);
258 let identity = first.texture().clone();
259 let active = graphics.scratch_texture(descriptor);
260 assert_ne!(identity, *active.texture(), "active leases cannot alias");
261 assert_eq!(first.size(), (32, 32));
262 drop(first);
263 let reused = graphics.scratch_texture(ScratchTextureDescriptor {
264 width: 18,
265 ..descriptor
266 });
267 assert_eq!(identity, *reused.texture(), "nearby sizes share a bucket");
268 drop(reused);
269 graphics.scratch.lock().trim(0);
270 let replacement = graphics.scratch_texture(descriptor);
271 assert_ne!(
272 identity,
273 *replacement.texture(),
274 "trim evicts idle textures"
275 );
276 assert_ne!(
277 active.texture(),
278 replacement.texture(),
279 "trim cannot evict an active lease"
280 );
281 let allocation = graphics.diagnostics();
282 drop(replacement);
283 for _ in 0..=RETAINED_FRAMES {
284 graphics.begin_frame();
285 }
286 let _expired = graphics.scratch_texture(descriptor);
287 assert_eq!(
288 graphics
289 .diagnostics()
290 .since(&allocation)
291 .group("scratch_textures")
292 .texture_allocations,
293 1,
294 "unused buckets expire without requiring another allocation first"
295 );
296 }
297}