Skip to main content

cranpose_render_wgpu/
offscreen.rs

1use std::cell::OnceCell;
2
3use crate::gpu_stats::FrameStats;
4
5pub(crate) fn composition_format() -> wgpu::TextureFormat {
6    static FORMAT: std::sync::OnceLock<wgpu::TextureFormat> = std::sync::OnceLock::new();
7    *FORMAT.get_or_init(|| {
8        resolve_composition_format(
9            crate::debug_toggles::debug_toggle("CRANPOSE_COMPOSITION_8BIT").as_deref(),
10            cfg!(target_os = "android"),
11        )
12    })
13}
14
15fn resolve_composition_format(requested: Option<&str>, android: bool) -> wgpu::TextureFormat {
16    let eight_bit = match requested.map(str::trim) {
17        Some("1") | Some("true") | Some("yes") => true,
18        Some("0") | Some("false") | Some("no") => false,
19        _ => android,
20    };
21    if eight_bit {
22        wgpu::TextureFormat::Rgba8Unorm
23    } else {
24        wgpu::TextureFormat::Rgba16Float
25    }
26}
27
28pub(crate) fn create_2d_texture(
29    device: &wgpu::Device,
30    format: wgpu::TextureFormat,
31    width: u32,
32    height: u32,
33    usage: wgpu::TextureUsages,
34    label: Option<&str>,
35) -> wgpu::Texture {
36    device.create_texture(&wgpu::TextureDescriptor {
37        label,
38        size: wgpu::Extent3d {
39            width,
40            height,
41            depth_or_array_layers: 1,
42        },
43        mip_level_count: 1,
44        sample_count: 1,
45        dimension: wgpu::TextureDimension::D2,
46        format,
47        usage,
48        view_formats: &[],
49    })
50}
51
52pub(crate) struct OffscreenTarget {
53    pub view: wgpu::TextureView,
54    pub width: u32,
55    pub height: u32,
56    bytes_per_pixel: u64,
57    cached_bind_group: OnceCell<wgpu::BindGroup>,
58}
59
60impl OffscreenTarget {
61    pub(crate) fn new(
62        device: &wgpu::Device,
63        format: wgpu::TextureFormat,
64        width: u32,
65        height: u32,
66    ) -> Self {
67        Self::new_labeled(device, format, width, height, "Offscreen Target")
68    }
69
70    pub(crate) fn new_labeled(
71        device: &wgpu::Device,
72        format: wgpu::TextureFormat,
73        width: u32,
74        height: u32,
75        label: &'static str,
76    ) -> Self {
77        let texture = create_2d_texture(
78            device,
79            format,
80            width,
81            height,
82            wgpu::TextureUsages::RENDER_ATTACHMENT
83                | wgpu::TextureUsages::TEXTURE_BINDING
84                | wgpu::TextureUsages::COPY_SRC
85                | wgpu::TextureUsages::COPY_DST,
86            Some(label),
87        );
88        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
89        Self {
90            view,
91            width,
92            height,
93            bytes_per_pixel: crate::frame_graph::texture_format_bytes_per_pixel(format),
94            cached_bind_group: OnceCell::new(),
95        }
96    }
97
98    pub(crate) fn texture(&self) -> &wgpu::Texture {
99        self.view.texture()
100    }
101
102    pub(crate) fn format(&self) -> wgpu::TextureFormat {
103        self.texture().format()
104    }
105
106    fn matches_size(&self, width: u32, height: u32) -> bool {
107        self.width == width && self.height == height
108    }
109
110    pub fn get_or_create_bind_group(
111        &self,
112        device: &wgpu::Device,
113        layout: &wgpu::BindGroupLayout,
114        sampler: &wgpu::Sampler,
115    ) -> &wgpu::BindGroup {
116        self.cached_bind_group.get_or_init(|| {
117            device.create_bind_group(&wgpu::BindGroupDescriptor {
118                label: Some("Offscreen Texture Bind Group (cached)"),
119                layout,
120                entries: &[
121                    wgpu::BindGroupEntry {
122                        binding: 0,
123                        resource: wgpu::BindingResource::TextureView(&self.view),
124                    },
125                    wgpu::BindGroupEntry {
126                        binding: 1,
127                        resource: wgpu::BindingResource::Sampler(sampler),
128                    },
129                ],
130            })
131        })
132    }
133
134    /// Wraps a swapchain image as the frame's root target so the scene
135    /// renders into it directly, with no composition copy behind it.
136    pub(crate) fn from_surface(texture: wgpu::Texture, view: wgpu::TextureView) -> Self {
137        let width = texture.width();
138        let height = texture.height();
139        let format = texture.format().remove_srgb_suffix();
140        Self {
141            view,
142            width,
143            height,
144            bytes_per_pixel: crate::frame_graph::texture_format_bytes_per_pixel(format),
145            cached_bind_group: OnceCell::new(),
146        }
147    }
148}
149
150/// Bytes one pixel of the renderer's composition format occupies.
151pub fn composition_bytes_per_pixel() -> u64 {
152    crate::frame_graph::texture_format_bytes_per_pixel(composition_format())
153}
154
155pub(crate) struct OffscreenPool {
156    available: Vec<OffscreenTarget>,
157    format: wgpu::TextureFormat,
158    max_texture_dim: u32,
159}
160
161const MAX_POOLED_TARGETS: usize = 64;
162
163const MAX_POOLED_BYTES: u64 = 128 * 1024 * 1024;
164
165fn target_bytes(width: u32, height: u32, bytes_per_pixel: u64) -> u64 {
166    u64::from(width) * u64::from(height) * bytes_per_pixel
167}
168
169impl OffscreenPool {
170    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
171        Self {
172            available: Vec::new(),
173            format,
174            max_texture_dim: device.limits().max_texture_dimension_2d,
175        }
176    }
177
178    #[cfg(test)]
179    fn new_with_limit(format: wgpu::TextureFormat, max_texture_dim: u32) -> Self {
180        Self {
181            available: Vec::new(),
182            format,
183            max_texture_dim,
184        }
185    }
186
187    pub fn max_texture_dim(&self) -> u32 {
188        self.max_texture_dim
189    }
190
191    pub fn pool_size(&self) -> usize {
192        self.available.len()
193    }
194
195    pub fn estimated_bytes(&self) -> usize {
196        self.available
197            .iter()
198            .map(|t| {
199                (t.width as u64)
200                    .saturating_mul(t.height as u64)
201                    .saturating_mul(t.bytes_per_pixel) as usize
202            })
203            .sum()
204    }
205
206    pub fn acquire(
207        &mut self,
208        device: &wgpu::Device,
209        width: u32,
210        height: u32,
211        stats: Option<&FrameStats>,
212    ) -> OffscreenTarget {
213        let width = width.min(self.max_texture_dim).max(1);
214        let height = height.min(self.max_texture_dim).max(1);
215        if let Some(idx) = self
216            .available
217            .iter()
218            .position(|t| t.matches_size(width, height))
219        {
220            if let Some(s) = stats {
221                s.record_offscreen_acquire(width, height, self.format, false);
222            }
223            self.available.swap_remove(idx)
224        } else {
225            if let Some(s) = stats {
226                s.record_offscreen_acquire(width, height, self.format, true);
227            }
228            OffscreenTarget::new(device, self.format, width, height)
229        }
230    }
231
232    pub fn release(&mut self, target: OffscreenTarget) {
233        self.available.push(target);
234        while self.available.len() > MAX_POOLED_TARGETS
235            || self.pooled_bytes() > MAX_POOLED_BYTES && self.available.len() > 1
236        {
237            self.available.remove(0);
238        }
239    }
240
241    fn pooled_bytes(&self) -> u64 {
242        self.available
243            .iter()
244            .map(|t| target_bytes(t.width, t.height, self.bytes_per_pixel()))
245            .sum()
246    }
247
248    fn bytes_per_pixel(&self) -> u64 {
249        crate::frame_graph::texture_format_bytes_per_pixel(self.format)
250    }
251
252    pub fn texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
253        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
254            label: Some("Effect Texture Bind Group Layout"),
255            entries: &[
256                wgpu::BindGroupLayoutEntry {
257                    binding: 0,
258                    visibility: wgpu::ShaderStages::FRAGMENT,
259                    ty: wgpu::BindingType::Texture {
260                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
261                        view_dimension: wgpu::TextureViewDimension::D2,
262                        multisampled: false,
263                    },
264                    count: None,
265                },
266                wgpu::BindGroupLayoutEntry {
267                    binding: 1,
268                    visibility: wgpu::ShaderStages::FRAGMENT,
269                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
270                    count: None,
271                },
272            ],
273        })
274    }
275
276    pub fn uniform_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
277        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
278            label: Some("Effect Uniform Bind Group Layout"),
279            entries: &[wgpu::BindGroupLayoutEntry {
280                binding: 0,
281                visibility: wgpu::ShaderStages::FRAGMENT,
282                ty: wgpu::BindingType::Buffer {
283                    ty: wgpu::BufferBindingType::Uniform,
284                    has_dynamic_offset: true,
285                    min_binding_size: None,
286                },
287                count: None,
288            }],
289        })
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn android_composites_in_eight_bits_and_the_rest_in_float() {
299        assert_eq!(
300            resolve_composition_format(None, true),
301            wgpu::TextureFormat::Rgba8Unorm
302        );
303        assert_eq!(
304            resolve_composition_format(None, false),
305            wgpu::TextureFormat::Rgba16Float
306        );
307    }
308
309    #[test]
310    fn the_override_wins_in_both_directions_on_any_platform() {
311        assert_eq!(
312            resolve_composition_format(Some("0"), true),
313            wgpu::TextureFormat::Rgba16Float
314        );
315        assert_eq!(
316            resolve_composition_format(Some(" yes "), false),
317            wgpu::TextureFormat::Rgba8Unorm
318        );
319    }
320
321    #[test]
322    fn an_unparsable_override_falls_back_to_the_platform_default() {
323        assert_eq!(
324            resolve_composition_format(Some("half"), true),
325            wgpu::TextureFormat::Rgba8Unorm
326        );
327        assert_eq!(
328            resolve_composition_format(Some(""), false),
329            wgpu::TextureFormat::Rgba16Float
330        );
331    }
332
333    #[test]
334    fn a_frame_worth_of_small_surfaces_stays_pooled() {
335        let bytes: u64 = (0..20)
336            .map(|_| target_bytes(132, 132, composition_bytes_per_pixel()))
337            .sum();
338        assert!(
339            bytes < MAX_POOLED_BYTES,
340            "twenty control surfaces must fit the pool budget"
341        );
342        const _: () = assert!(20 < MAX_POOLED_TARGETS);
343    }
344
345    #[test]
346    fn the_byte_budget_bounds_full_screen_float_surfaces() {
347        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Rgba16Float, 4096);
348        let full_screen = target_bytes(1080, 2244, pool.bytes_per_pixel());
349        let held = MAX_POOLED_BYTES / full_screen;
350        assert!(
351            (2..=8).contains(&held),
352            "the budget should hold a few full-screen surfaces, not dozens: {held}"
353        );
354    }
355
356    #[test]
357    fn pool_starts_empty() {
358        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 8192);
359        assert!(pool.available.is_empty());
360        assert_eq!(pool.pool_size(), 0);
361    }
362
363    #[test]
364    fn max_texture_dimension_stored() {
365        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 2048);
366        assert_eq!(pool.max_texture_dim, 2048);
367
368        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 4096);
369        assert_eq!(pool.max_texture_dim, 4096);
370    }
371}