Skip to main content

tessera_ui/graphics/
scratch.rs

1use std::{ops::Deref, sync::Arc};
2
3use parking_lot::Mutex;
4
5/// How a scratch texture's size relates to the requested size.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum ScratchFit {
8    /// The texture has exactly the requested size, for attachments that must
9    /// match another target.
10    Exact,
11    /// The texture may be larger, so nearby sizes share pooled textures.
12    /// Address it through its actual size.
13    Approx,
14}
15
16/// Describes a pooled texture requested with
17/// [`GraphicsDevice::scratch_texture`](crate::GraphicsDevice::scratch_texture).
18#[derive(Clone, Copy, Debug)]
19pub struct ScratchTextureDescriptor {
20    /// Minimum width in texels.
21    pub width: u32,
22    /// Minimum height in texels.
23    pub height: u32,
24    /// Texture format.
25    pub format: wgpu::TextureFormat,
26    /// Required usages.
27    pub usage: wgpu::TextureUsages,
28    /// MSAA sample count.
29    pub sample_count: u32,
30    /// Size rounding.
31    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
67// Pooled textures unused for this many frames are released.
68const 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
94/// Rounds a dimension up so nearby sizes share pooled textures: small sizes
95/// round to 16, sizes up to 1024 to the next power of two, and larger sizes to
96/// the next half step between powers of two.
97fn 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
111/// A pooled texture. Work recorded after it is dropped may reuse it, so keep
112/// it until all work that uses its contents is recorded.
113///
114/// Returned textures share a 64 MiB idle-cache budget. Active leases are never
115/// evicted; oversized textures are released rather than cached when dropped.
116pub struct ScratchTexture {
117    entry: Option<Entry>,
118    pool: Arc<Mutex<ScratchPool>>,
119}
120
121impl ScratchTexture {
122    /// Returns the underlying texture.
123    pub fn texture(&self) -> &wgpu::Texture {
124        &self.entry().texture
125    }
126
127    /// Returns the actual size, which may exceed the requested size.
128    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    /// Leases a pooled texture for work recorded in the current frame.
159    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}