Skip to main content

cranpose_render_wgpu/
offscreen.rs

1//! Offscreen render target pool for effect layers.
2//!
3//! Provides reusable GPU textures that can be both rendered to (as a color
4//! attachment) and sampled from (as a texture binding). Used by blur and
5//! custom shader effects that need to capture a subtree's rendered output.
6
7use crate::gpu_stats::FrameStats;
8use std::cell::OnceCell;
9
10/// A GPU texture that can serve as both a render target and a texture source.
11pub(crate) struct OffscreenTarget {
12    // Texture kept alive for the view's lifetime; the view borrows from it implicitly.
13    texture: wgpu::Texture,
14    pub view: wgpu::TextureView,
15    pub width: u32,
16    pub height: u32,
17    /// Lazily-cached bind group for sampling this target as a texture.
18    /// Valid as long as the underlying texture is alive (i.e. while this target exists).
19    cached_bind_group: OnceCell<wgpu::BindGroup>,
20}
21
22impl OffscreenTarget {
23    pub(crate) fn new(
24        device: &wgpu::Device,
25        format: wgpu::TextureFormat,
26        width: u32,
27        height: u32,
28    ) -> Self {
29        Self::new_labeled(device, format, width, height, "Offscreen Target")
30    }
31
32    pub(crate) fn new_labeled(
33        device: &wgpu::Device,
34        format: wgpu::TextureFormat,
35        width: u32,
36        height: u32,
37        label: &'static str,
38    ) -> Self {
39        let texture = device.create_texture(&wgpu::TextureDescriptor {
40            label: Some(label),
41            size: wgpu::Extent3d {
42                width,
43                height,
44                depth_or_array_layers: 1,
45            },
46            mip_level_count: 1,
47            sample_count: 1,
48            dimension: wgpu::TextureDimension::D2,
49            format,
50            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
51                | wgpu::TextureUsages::TEXTURE_BINDING
52                | wgpu::TextureUsages::COPY_SRC
53                | wgpu::TextureUsages::COPY_DST,
54            view_formats: &[],
55        });
56        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
57        Self {
58            texture,
59            view,
60            width,
61            height,
62            cached_bind_group: OnceCell::new(),
63        }
64    }
65
66    /// Returns true if this target exactly matches the requested dimensions.
67    ///
68    /// Effects rely on a 1:1 mapping between render target texels and viewport
69    /// coordinates, so larger pooled textures are not considered compatible.
70    fn matches_size(&self, width: u32, height: u32) -> bool {
71        self.width == width && self.height == height
72    }
73
74    /// Get the cached texture bind group, creating it on first access.
75    ///
76    /// The bind group binds this target's texture view and the provided sampler
77    /// for use in effect fragment shaders. Since the underlying texture never
78    /// changes while this target is alive, the bind group is valid for reuse.
79    pub fn get_or_create_bind_group(
80        &self,
81        device: &wgpu::Device,
82        layout: &wgpu::BindGroupLayout,
83        sampler: &wgpu::Sampler,
84    ) -> &wgpu::BindGroup {
85        self.cached_bind_group.get_or_init(|| {
86            device.create_bind_group(&wgpu::BindGroupDescriptor {
87                label: Some("Offscreen Texture Bind Group (cached)"),
88                layout,
89                entries: &[
90                    wgpu::BindGroupEntry {
91                        binding: 0,
92                        resource: wgpu::BindingResource::TextureView(&self.view),
93                    },
94                    wgpu::BindGroupEntry {
95                        binding: 1,
96                        resource: wgpu::BindingResource::Sampler(sampler),
97                    },
98                ],
99            })
100        })
101    }
102
103    pub(crate) fn texture(&self) -> &wgpu::Texture {
104        &self.texture
105    }
106
107    pub(crate) fn from_readable_texture(
108        texture: &wgpu::Texture,
109        view: &wgpu::TextureView,
110    ) -> Option<Self> {
111        if !texture_supports_backdrop_reads(texture.usage()) {
112            return None;
113        }
114        Some(Self {
115            texture: texture.clone(),
116            view: view.clone(),
117            width: texture.width(),
118            height: texture.height(),
119            cached_bind_group: OnceCell::new(),
120        })
121    }
122}
123
124pub(crate) fn texture_supports_backdrop_reads(usage: wgpu::TextureUsages) -> bool {
125    usage.contains(wgpu::TextureUsages::COPY_SRC)
126        && usage.contains(wgpu::TextureUsages::TEXTURE_BINDING)
127}
128
129pub(crate) fn capture_root_target_reads() -> bool {
130    cranpose_core::env_flag!("CRANPOSE_CAPTURE_ROOT_TARGET_READS")
131}
132
133pub fn display_surface_usages(supported: wgpu::TextureUsages) -> wgpu::TextureUsages {
134    let mut usages = wgpu::TextureUsages::RENDER_ATTACHMENT;
135    for read in [
136        wgpu::TextureUsages::COPY_SRC,
137        wgpu::TextureUsages::TEXTURE_BINDING,
138    ] {
139        if supported.contains(read) {
140            usages |= read;
141        }
142    }
143    usages
144}
145
146/// Pool of reusable offscreen render targets.
147///
148/// Targets are returned to the pool after use and reused when a suitable size
149/// is available, avoiding per-frame GPU texture allocation. Capped to prevent
150/// unbounded GPU memory growth from accumulating targets of varying sizes.
151pub(crate) struct OffscreenPool {
152    available: Vec<OffscreenTarget>,
153    format: wgpu::TextureFormat,
154    max_texture_dim: u32,
155}
156
157/// Backstop on pooled targets, so a pathological frame cannot grow the pool
158/// without bound even when every target is small.
159const MAX_POOLED_TARGETS: usize = 64;
160
161/// Memory the pool may hold. A count-only cap cannot bound memory, because a
162/// target is anything from 132x132 to full screen; a byte budget can.
163///
164/// A screen of frosted controls asks for one surface per control every frame:
165/// a scrolling list on a 1080x2244 phone acquired thirteen, and a cap of
166/// sixteen targets kept the wrong ones, so twelve of the thirteen were created
167/// again on every frame. 64 MB holds a frame's worth of surfaces on that phone
168/// with room for the blur scratch.
169const MAX_POOLED_BYTES: u64 = 64 * 1024 * 1024;
170
171fn target_bytes(width: u32, height: u32) -> u64 {
172    u64::from(width) * u64::from(height) * 4
173}
174
175impl OffscreenPool {
176    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
177        Self {
178            available: Vec::new(),
179            format,
180            max_texture_dim: device.limits().max_texture_dimension_2d,
181        }
182    }
183
184    #[cfg(test)]
185    fn new_with_limit(format: wgpu::TextureFormat, max_texture_dim: u32) -> Self {
186        Self {
187            available: Vec::new(),
188            format,
189            max_texture_dim,
190        }
191    }
192
193    /// Maximum texture dimension supported by the GPU.
194    pub fn max_texture_dim(&self) -> u32 {
195        self.max_texture_dim
196    }
197
198    /// Number of targets currently in the pool.
199    pub fn pool_size(&self) -> usize {
200        self.available.len()
201    }
202
203    /// Approximate GPU memory held by pooled targets (bytes).
204    pub fn estimated_bytes(&self) -> usize {
205        self.available
206            .iter()
207            .map(|t| (t.width as usize) * (t.height as usize) * 4)
208            .sum()
209    }
210
211    /// Acquire an offscreen target for the given dimensions.
212    ///
213    /// Returns a pooled target when dimensions exactly match, otherwise creates
214    /// a new target for the requested size.
215    pub fn acquire(
216        &mut self,
217        device: &wgpu::Device,
218        width: u32,
219        height: u32,
220        stats: Option<&FrameStats>,
221    ) -> OffscreenTarget {
222        let width = width.min(self.max_texture_dim).max(1);
223        let height = height.min(self.max_texture_dim).max(1);
224        if let Some(idx) = self
225            .available
226            .iter()
227            .position(|t| t.matches_size(width, height))
228        {
229            if let Some(s) = stats {
230                s.record_offscreen_acquire(width, height, false);
231            }
232            self.available.swap_remove(idx)
233        } else {
234            if let Some(s) = stats {
235                s.record_offscreen_acquire(width, height, true);
236            }
237            OffscreenTarget::new(device, self.format, width, height)
238        }
239    }
240
241    /// Return a target to the pool for future reuse.
242    ///
243    /// The pool keeps the most recently returned targets, since those are the
244    /// sizes the next frame asks for, and drops the oldest ones once it is
245    /// over its budget.
246    pub fn release(&mut self, target: OffscreenTarget) {
247        self.available.push(target);
248        while self.available.len() > MAX_POOLED_TARGETS
249            || self.pooled_bytes() > MAX_POOLED_BYTES && self.available.len() > 1
250        {
251            self.available.remove(0);
252        }
253    }
254
255    fn pooled_bytes(&self) -> u64 {
256        self.available
257            .iter()
258            .map(|t| target_bytes(t.width, t.height))
259            .sum()
260    }
261
262    /// The bind group layout for sampling offscreen textures.
263    ///
264    /// Provides: `@group(N) @binding(0) var input_texture: texture_2d<f32>`
265    ///           `@group(N) @binding(1) var input_sampler: sampler`
266    pub fn texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
267        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
268            label: Some("Effect Texture Bind Group Layout"),
269            entries: &[
270                wgpu::BindGroupLayoutEntry {
271                    binding: 0,
272                    visibility: wgpu::ShaderStages::FRAGMENT,
273                    ty: wgpu::BindingType::Texture {
274                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
275                        view_dimension: wgpu::TextureViewDimension::D2,
276                        multisampled: false,
277                    },
278                    count: None,
279                },
280                wgpu::BindGroupLayoutEntry {
281                    binding: 1,
282                    visibility: wgpu::ShaderStages::FRAGMENT,
283                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
284                    count: None,
285                },
286            ],
287        })
288    }
289
290    /// The bind group layout for RuntimeShader uniforms.
291    ///
292    /// Provides: `@group(N) @binding(0) var<uniform> u: array<vec4<f32>, 64>`
293    pub fn uniform_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
294        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
295            label: Some("Effect Uniform Bind Group Layout"),
296            entries: &[wgpu::BindGroupLayoutEntry {
297                binding: 0,
298                visibility: wgpu::ShaderStages::FRAGMENT,
299                ty: wgpu::BindingType::Buffer {
300                    ty: wgpu::BufferBindingType::Uniform,
301                    has_dynamic_offset: false,
302                    min_binding_size: None,
303                },
304                count: None,
305            }],
306        })
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    /// A frame of frosted controls returns more surfaces than the old count
315    /// cap held, and the sizes it returns are the sizes the next frame asks
316    /// for.
317    #[test]
318    fn a_frame_worth_of_small_surfaces_stays_pooled() {
319        let bytes: u64 = (0..20).map(|_| target_bytes(132, 132)).sum();
320        assert!(
321            bytes < MAX_POOLED_BYTES,
322            "twenty control surfaces must fit the pool budget"
323        );
324        // `const_assert`-shaped on purpose: both sides are constants, so this
325        // is a compile-time claim about the budget, not a runtime check.
326        const _: () = assert!(20 < MAX_POOLED_TARGETS);
327    }
328
329    #[test]
330    fn the_budget_bounds_full_screen_surfaces() {
331        let full_screen = target_bytes(1080, 2244);
332        let held = MAX_POOLED_BYTES / full_screen;
333        assert!(
334            (2..=8).contains(&held),
335            "the budget should hold a few full-screen surfaces, not dozens: {held}"
336        );
337    }
338
339    #[test]
340    fn a_surface_asks_for_the_reads_the_adapter_offers() {
341        let all = wgpu::TextureUsages::RENDER_ATTACHMENT
342            | wgpu::TextureUsages::COPY_SRC
343            | wgpu::TextureUsages::TEXTURE_BINDING
344            | wgpu::TextureUsages::COPY_DST;
345        let asked = display_surface_usages(all);
346        assert!(asked.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
347        assert!(asked.contains(wgpu::TextureUsages::COPY_SRC));
348        assert!(asked.contains(wgpu::TextureUsages::TEXTURE_BINDING));
349        assert!(!asked.contains(wgpu::TextureUsages::COPY_DST));
350        assert!(texture_supports_backdrop_reads(asked));
351    }
352
353    #[test]
354    fn a_surface_that_offers_no_read_keeps_the_attachment_alone() {
355        let asked = display_surface_usages(wgpu::TextureUsages::RENDER_ATTACHMENT);
356        assert_eq!(asked, wgpu::TextureUsages::RENDER_ATTACHMENT);
357        assert!(!texture_supports_backdrop_reads(asked));
358    }
359
360    #[test]
361    fn one_read_alone_is_not_enough_for_a_backdrop() {
362        let copy_only = display_surface_usages(
363            wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
364        );
365        assert!(copy_only.contains(wgpu::TextureUsages::COPY_SRC));
366        assert!(!texture_supports_backdrop_reads(copy_only));
367        let sample_only = display_surface_usages(
368            wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
369        );
370        assert!(!texture_supports_backdrop_reads(sample_only));
371    }
372
373    #[test]
374    fn pool_starts_empty() {
375        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 8192);
376        assert!(pool.available.is_empty());
377        assert_eq!(pool.pool_size(), 0);
378    }
379
380    #[test]
381    fn max_texture_dimension_stored() {
382        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 2048);
383        assert_eq!(pool.max_texture_dim, 2048);
384
385        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 4096);
386        assert_eq!(pool.max_texture_dim, 4096);
387    }
388}