Skip to main content

cranpose_render_wgpu/
offscreen.rs

1use std::{
2    cell::OnceCell,
3    future::Future,
4    sync::atomic::{AtomicBool, Ordering},
5    task::{Context, Poll, Waker},
6};
7
8use crate::gpu_stats::FrameStats;
9
10/// Set once a device turns out unable to draw into the float format, which
11/// then no renderer in the process composites in.
12static FLOAT_COMPOSITION_UNSUPPORTED: AtomicBool = AtomicBool::new(false);
13
14pub(crate) fn composition_format() -> wgpu::TextureFormat {
15    static FORMAT: std::sync::OnceLock<wgpu::TextureFormat> = std::sync::OnceLock::new();
16    let preferred = *FORMAT.get_or_init(|| {
17        resolve_composition_format(
18            crate::debug_toggles::debug_toggle("CRANPOSE_COMPOSITION_8BIT").as_deref(),
19            cfg!(target_os = "android"),
20        )
21    });
22    if FLOAT_COMPOSITION_UNSUPPORTED.load(Ordering::Relaxed) {
23        wgpu::TextureFormat::Rgba8Unorm
24    } else {
25        preferred
26    }
27}
28
29/// The format a renderer on `device` composites in: the float format where
30/// the device can draw into it, eight bits where it cannot.
31///
32/// WebGPU, Vulkan, Metal and DirectX all draw into `Rgba16Float`. OpenGL ES
33/// and WebGL2 draw into it only through `EXT_color_buffer_float` or
34/// `EXT_color_buffer_half_float`, which some browsers and drivers leave out;
35/// every offscreen layer and effect pipeline would then fail validation and
36/// leave the window blank.
37pub(crate) fn settle_composition_format(
38    device: &wgpu::Device,
39    backend: wgpu::Backend,
40) -> wgpu::TextureFormat {
41    let format = composition_format();
42    if backend == wgpu::Backend::Gl
43        && format != wgpu::TextureFormat::Rgba8Unorm
44        && !renders_into(device, format)
45    {
46        log::warn!("[gpu-init] this device cannot draw into {format:?}; compositing in Rgba8Unorm");
47        FLOAT_COMPOSITION_UNSUPPORTED.store(true, Ordering::Relaxed);
48    }
49    composition_format()
50}
51
52/// Whether `device` accepts a texture of `format` to draw into and sample.
53pub(crate) fn renders_into(device: &wgpu::Device, format: wgpu::TextureFormat) -> bool {
54    let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
55    let _probe = create_2d_texture(
56        device,
57        format,
58        1,
59        1,
60        wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
61        Some("Composition format probe"),
62    );
63    let mut error = std::pin::pin!(scope.pop());
64    // A device wgpu validates itself answers at once; only a browser's
65    // WebGPU answers later, and WebGPU draws into every format asked here.
66    match error.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
67        Poll::Ready(error) => error.is_none(),
68        Poll::Pending => true,
69    }
70}
71
72fn resolve_composition_format(requested: Option<&str>, android: bool) -> wgpu::TextureFormat {
73    let eight_bit = match requested.map(str::trim) {
74        Some("1" | "true" | "yes") => true,
75        Some("0" | "false" | "no") => false,
76        _ => android,
77    };
78    if eight_bit {
79        wgpu::TextureFormat::Rgba8Unorm
80    } else {
81        wgpu::TextureFormat::Rgba16Float
82    }
83}
84
85pub(crate) fn create_2d_texture(
86    device: &wgpu::Device,
87    format: wgpu::TextureFormat,
88    width: u32,
89    height: u32,
90    usage: wgpu::TextureUsages,
91    label: Option<&str>,
92) -> wgpu::Texture {
93    device.create_texture(&wgpu::TextureDescriptor {
94        label,
95        size: wgpu::Extent3d {
96            width,
97            height,
98            depth_or_array_layers: 1,
99        },
100        mip_level_count: 1,
101        sample_count: 1,
102        dimension: wgpu::TextureDimension::D2,
103        format,
104        usage,
105        view_formats: &[],
106    })
107}
108
109pub(crate) struct OffscreenTarget {
110    pub view: wgpu::TextureView,
111    pub width: u32,
112    pub height: u32,
113    bytes_per_pixel: u64,
114    cached_bind_group: OnceCell<wgpu::BindGroup>,
115}
116
117impl OffscreenTarget {
118    pub(crate) fn new(
119        device: &wgpu::Device,
120        format: wgpu::TextureFormat,
121        width: u32,
122        height: u32,
123    ) -> Self {
124        Self::new_labeled(device, format, width, height, "Offscreen Target")
125    }
126
127    pub(crate) fn new_labeled(
128        device: &wgpu::Device,
129        format: wgpu::TextureFormat,
130        width: u32,
131        height: u32,
132        label: &'static str,
133    ) -> Self {
134        let texture = create_2d_texture(
135            device,
136            format,
137            width,
138            height,
139            wgpu::TextureUsages::RENDER_ATTACHMENT
140                | wgpu::TextureUsages::TEXTURE_BINDING
141                | wgpu::TextureUsages::COPY_SRC
142                | wgpu::TextureUsages::COPY_DST,
143            Some(label),
144        );
145        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
146        Self {
147            view,
148            width,
149            height,
150            bytes_per_pixel: crate::frame_graph::texture_format_bytes_per_pixel(format),
151            cached_bind_group: OnceCell::new(),
152        }
153    }
154
155    pub(crate) fn texture(&self) -> &wgpu::Texture {
156        self.view.texture()
157    }
158
159    pub(crate) fn format(&self) -> wgpu::TextureFormat {
160        self.texture().format()
161    }
162
163    fn matches_size(&self, width: u32, height: u32) -> bool {
164        self.width == width && self.height == height
165    }
166
167    pub fn get_or_create_bind_group(
168        &self,
169        device: &wgpu::Device,
170        layout: &wgpu::BindGroupLayout,
171        sampler: &wgpu::Sampler,
172    ) -> &wgpu::BindGroup {
173        self.cached_bind_group.get_or_init(|| {
174            device.create_bind_group(&wgpu::BindGroupDescriptor {
175                label: Some("Offscreen Texture Bind Group (cached)"),
176                layout,
177                entries: &[
178                    wgpu::BindGroupEntry {
179                        binding: 0,
180                        resource: wgpu::BindingResource::TextureView(&self.view),
181                    },
182                    wgpu::BindGroupEntry {
183                        binding: 1,
184                        resource: wgpu::BindingResource::Sampler(sampler),
185                    },
186                ],
187            })
188        })
189    }
190
191    /// Wraps a swapchain image as the frame's root target so the scene
192    /// renders into it directly, with no composition copy behind it.
193    pub(crate) fn from_surface(texture: wgpu::Texture, view: wgpu::TextureView) -> Self {
194        let width = texture.width();
195        let height = texture.height();
196        let format = texture.format().remove_srgb_suffix();
197        Self {
198            view,
199            width,
200            height,
201            bytes_per_pixel: crate::frame_graph::texture_format_bytes_per_pixel(format),
202            cached_bind_group: OnceCell::new(),
203        }
204    }
205}
206
207/// Bytes one pixel of the renderer's composition format occupies.
208pub fn composition_bytes_per_pixel() -> u64 {
209    crate::frame_graph::texture_format_bytes_per_pixel(composition_format())
210}
211
212pub(crate) struct OffscreenPool {
213    available: Vec<OffscreenTarget>,
214    format: wgpu::TextureFormat,
215    max_texture_dim: u32,
216}
217
218const MAX_POOLED_TARGETS: usize = 64;
219
220const MAX_POOLED_BYTES: u64 = 128 * 1024 * 1024;
221
222fn target_bytes(width: u32, height: u32, bytes_per_pixel: u64) -> u64 {
223    u64::from(width) * u64::from(height) * bytes_per_pixel
224}
225
226impl OffscreenPool {
227    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
228        Self {
229            available: Vec::new(),
230            format,
231            max_texture_dim: device.limits().max_texture_dimension_2d,
232        }
233    }
234
235    #[cfg(test)]
236    fn new_with_limit(format: wgpu::TextureFormat, max_texture_dim: u32) -> Self {
237        Self {
238            available: Vec::new(),
239            format,
240            max_texture_dim,
241        }
242    }
243
244    pub fn max_texture_dim(&self) -> u32 {
245        self.max_texture_dim
246    }
247
248    pub fn pool_size(&self) -> usize {
249        self.available.len()
250    }
251
252    pub fn estimated_bytes(&self) -> usize {
253        self.available
254            .iter()
255            .map(|t| {
256                (t.width as u64)
257                    .saturating_mul(t.height as u64)
258                    .saturating_mul(t.bytes_per_pixel) as usize
259            })
260            .sum()
261    }
262
263    pub fn acquire(
264        &mut self,
265        device: &wgpu::Device,
266        width: u32,
267        height: u32,
268        stats: Option<&FrameStats>,
269    ) -> OffscreenTarget {
270        let width = width.min(self.max_texture_dim).max(1);
271        let height = height.min(self.max_texture_dim).max(1);
272        if let Some(idx) = self
273            .available
274            .iter()
275            .position(|t| t.matches_size(width, height))
276        {
277            if let Some(s) = stats {
278                s.record_offscreen_acquire(width, height, self.format, false);
279            }
280            self.available.swap_remove(idx)
281        } else {
282            if let Some(s) = stats {
283                s.record_offscreen_acquire(width, height, self.format, true);
284            }
285            OffscreenTarget::new(device, self.format, width, height)
286        }
287    }
288
289    pub fn release(&mut self, target: OffscreenTarget) {
290        self.available.push(target);
291        while self.available.len() > MAX_POOLED_TARGETS
292            || self.pooled_bytes() > MAX_POOLED_BYTES && self.available.len() > 1
293        {
294            self.available.remove(0);
295        }
296    }
297
298    fn pooled_bytes(&self) -> u64 {
299        self.available
300            .iter()
301            .map(|t| target_bytes(t.width, t.height, self.bytes_per_pixel()))
302            .sum()
303    }
304
305    fn bytes_per_pixel(&self) -> u64 {
306        crate::frame_graph::texture_format_bytes_per_pixel(self.format)
307    }
308
309    pub fn texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
310        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
311            label: Some("Effect Texture Bind Group Layout"),
312            entries: &[
313                wgpu::BindGroupLayoutEntry {
314                    binding: 0,
315                    visibility: wgpu::ShaderStages::FRAGMENT,
316                    ty: wgpu::BindingType::Texture {
317                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
318                        view_dimension: wgpu::TextureViewDimension::D2,
319                        multisampled: false,
320                    },
321                    count: None,
322                },
323                wgpu::BindGroupLayoutEntry {
324                    binding: 1,
325                    visibility: wgpu::ShaderStages::FRAGMENT,
326                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
327                    count: None,
328                },
329            ],
330        })
331    }
332
333    pub fn uniform_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
334        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
335            label: Some("Effect Uniform Bind Group Layout"),
336            entries: &[wgpu::BindGroupLayoutEntry {
337                binding: 0,
338                visibility: wgpu::ShaderStages::FRAGMENT,
339                ty: wgpu::BindingType::Buffer {
340                    ty: wgpu::BufferBindingType::Uniform,
341                    has_dynamic_offset: true,
342                    min_binding_size: None,
343                },
344                count: None,
345            }],
346        })
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn android_composites_in_eight_bits_and_the_rest_in_float() {
356        assert_eq!(
357            resolve_composition_format(None, true),
358            wgpu::TextureFormat::Rgba8Unorm
359        );
360        assert_eq!(
361            resolve_composition_format(None, false),
362            wgpu::TextureFormat::Rgba16Float
363        );
364    }
365
366    #[test]
367    fn the_override_wins_in_both_directions_on_any_platform() {
368        assert_eq!(
369            resolve_composition_format(Some("0"), true),
370            wgpu::TextureFormat::Rgba16Float
371        );
372        assert_eq!(
373            resolve_composition_format(Some(" yes "), false),
374            wgpu::TextureFormat::Rgba8Unorm
375        );
376    }
377
378    #[test]
379    fn an_unparsable_override_falls_back_to_the_platform_default() {
380        assert_eq!(
381            resolve_composition_format(Some("half"), true),
382            wgpu::TextureFormat::Rgba8Unorm
383        );
384        assert_eq!(
385            resolve_composition_format(Some(""), false),
386            wgpu::TextureFormat::Rgba16Float
387        );
388    }
389
390    #[test]
391    fn a_device_is_asked_whether_it_can_draw_into_a_format() {
392        let (_lock, device, _queue) = crate::frame_graph::upload_test_device();
393        assert!(renders_into(&device, wgpu::TextureFormat::Rgba8Unorm));
394        assert!(
395            !renders_into(&device, wgpu::TextureFormat::Rgb9e5Ufloat),
396            "no device draws into a shared-exponent format"
397        );
398    }
399
400    #[test]
401    fn a_device_that_draws_into_the_float_format_keeps_it() {
402        let (_lock, device, _queue) = crate::frame_graph::upload_test_device();
403        let backend = device.adapter_info().backend;
404        let preferred = composition_format();
405        assert_eq!(settle_composition_format(&device, backend), preferred);
406    }
407
408    #[test]
409    fn a_frame_worth_of_small_surfaces_stays_pooled() {
410        let bytes: u64 = (0..20)
411            .map(|_| target_bytes(132, 132, composition_bytes_per_pixel()))
412            .sum();
413        assert!(
414            bytes < MAX_POOLED_BYTES,
415            "twenty control surfaces must fit the pool budget"
416        );
417        const _: () = assert!(20 < MAX_POOLED_TARGETS);
418    }
419
420    #[test]
421    fn the_byte_budget_bounds_full_screen_float_surfaces() {
422        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Rgba16Float, 4096);
423        let full_screen = target_bytes(1080, 2244, pool.bytes_per_pixel());
424        let held = MAX_POOLED_BYTES / full_screen;
425        assert!(
426            (2..=8).contains(&held),
427            "the budget should hold a few full-screen surfaces, not dozens: {held}"
428        );
429    }
430
431    #[test]
432    fn pool_starts_empty() {
433        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 8192);
434        assert!(pool.available.is_empty());
435        assert_eq!(pool.pool_size(), 0);
436    }
437
438    #[test]
439    fn max_texture_dimension_stored() {
440        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 2048);
441        assert_eq!(pool.max_texture_dim, 2048);
442
443        let pool = OffscreenPool::new_with_limit(wgpu::TextureFormat::Bgra8Unorm, 4096);
444        assert_eq!(pool.max_texture_dim, 4096);
445    }
446}