Skip to main content

frust_gpu/
headless.rs

1//! An offscreen render target with no swapchain and no `wgpu::Surface`:
2//! [`HeadlessTarget`], for tests and tooling that need to render through this
3//! crate's own seams ([`crate::encoder::CommandBuffer`]) with no window.
4//!
5//! `HeadlessTarget` requests `RENDER_ATTACHMENT | COPY_SRC` and deliberately
6//! **not** `STORAGE_BINDING` — this crate renders through ordinary render
7//! passes, not compute, so a downlevel (WebGL2/GLES3.0) target never needs a
8//! usage flag that profile does not offer. [`HeadlessTarget::read_back`]
9//! strips wgpu's mandatory `copy_texture_to_buffer` row padding so an
10//! arbitrary width — not just one whose row happens to already be aligned —
11//! reads back exactly; the padding/stripping arithmetic here is written to be
12//! liftable into a shared helper once `frust-render`'s own headless module
13//! needs the identical calculation.
14
15use wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as COPY_ROW_ALIGNMENT;
16
17/// An offscreen render target: one `wgpu::Texture` plus its full-extent view,
18/// created with `RENDER_ATTACHMENT | COPY_SRC` usage and no `STORAGE_BINDING`.
19pub struct HeadlessTarget {
20    width: u32,
21    height: u32,
22    format: wgpu::TextureFormat,
23    texture: wgpu::Texture,
24    view: wgpu::TextureView,
25}
26
27impl HeadlessTarget {
28    /// Creates a `width` x `height` render target in `format`.
29    pub fn new(
30        device: &wgpu::Device,
31        width: u32,
32        height: u32,
33        format: wgpu::TextureFormat,
34    ) -> Self {
35        let texture = device.create_texture(&wgpu::TextureDescriptor {
36            label: Some("frust-gpu headless target"),
37            size: wgpu::Extent3d {
38                width,
39                height,
40                depth_or_array_layers: 1,
41            },
42            mip_level_count: 1,
43            sample_count: 1,
44            dimension: wgpu::TextureDimension::D2,
45            format,
46            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
47            view_formats: &[],
48        });
49        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
50        Self {
51            width,
52            height,
53            format,
54            texture,
55            view,
56        }
57    }
58
59    /// Target width in texels.
60    pub fn width(&self) -> u32 {
61        self.width
62    }
63
64    /// Target height in texels.
65    pub fn height(&self) -> u32 {
66        self.height
67    }
68
69    /// The target's pixel format.
70    pub fn format(&self) -> wgpu::TextureFormat {
71        self.format
72    }
73
74    /// The full-extent view a render pass attaches to.
75    pub fn view(&self) -> &wgpu::TextureView {
76        &self.view
77    }
78
79    /// The underlying texture, e.g. as a `copy_texture_to_buffer` source.
80    pub fn texture(&self) -> &wgpu::Texture {
81        &self.texture
82    }
83
84    /// Copies the target into a mappable buffer and returns its rows with
85    /// wgpu's row padding removed — tightly packed, `width * height *
86    /// bytes-per-texel` bytes, top-to-bottom, left-to-right.
87    ///
88    /// # Panics
89    ///
90    /// Panics if [`Self::format`] has no defined block size (a compressed or
91    /// planar format an offscreen render target never uses), if the device
92    /// poll fails, or if the buffer map fails — none of which is a condition
93    /// a headless test or tool should try to recover from.
94    pub fn read_back(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> {
95        let bytes_per_pixel = self
96            .format
97            .block_copy_size(None)
98            .expect("a headless render target's format must have a defined block size");
99        let bytes_per_row = padded_bytes_per_row(self.width, bytes_per_pixel);
100        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
101            label: Some("frust-gpu headless readback"),
102            size: u64::from(bytes_per_row) * u64::from(self.height),
103            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
104            mapped_at_creation: false,
105        });
106        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
107            label: Some("frust-gpu headless readback copy"),
108        });
109        encoder.copy_texture_to_buffer(
110            wgpu::TexelCopyTextureInfo {
111                texture: &self.texture,
112                mip_level: 0,
113                origin: wgpu::Origin3d::ZERO,
114                aspect: wgpu::TextureAspect::All,
115            },
116            wgpu::TexelCopyBufferInfo {
117                buffer: &buffer,
118                layout: wgpu::TexelCopyBufferLayout {
119                    offset: 0,
120                    bytes_per_row: Some(bytes_per_row),
121                    rows_per_image: Some(self.height),
122                },
123            },
124            wgpu::Extent3d {
125                width: self.width,
126                height: self.height,
127                depth_or_array_layers: 1,
128            },
129        );
130        queue.submit([encoder.finish()]);
131
132        let slice = buffer.slice(..);
133        let (tx, rx) = std::sync::mpsc::channel();
134        slice.map_async(wgpu::MapMode::Read, move |res| {
135            let _ = tx.send(res);
136        });
137        device
138            .poll(wgpu::PollType::wait_indefinitely())
139            .expect("frust-gpu headless: device poll for readback map must succeed");
140        rx.recv()
141            .expect("frust-gpu headless: readback map channel closed before a result arrived")
142            .expect("frust-gpu headless: readback buffer map failed");
143
144        let mapped = slice
145            .get_mapped_range()
146            .expect("frust-gpu headless: the readback buffer is mapped after map_async succeeded");
147        let pixels = strip_row_padding(&mapped, self.width, self.height, bytes_per_pixel);
148        drop(mapped);
149        buffer.unmap();
150        pixels
151    }
152}
153
154/// The `bytes_per_row` a `copy_texture_to_buffer` of a `width`-texel row of
155/// `bytes_per_pixel`-byte texels must use: the tight row length rounded up to
156/// wgpu's mandatory [`COPY_ROW_ALIGNMENT`].
157fn padded_bytes_per_row(width: u32, bytes_per_pixel: u32) -> u32 {
158    (width * bytes_per_pixel).next_multiple_of(COPY_ROW_ALIGNMENT)
159}
160
161/// Copies the leading `width * bytes_per_pixel` bytes out of each padded row
162/// of `padded`, producing tightly packed rows.
163fn strip_row_padding(padded: &[u8], width: u32, height: u32, bytes_per_pixel: u32) -> Vec<u8> {
164    let row = width as usize * bytes_per_pixel as usize;
165    let stride = padded_bytes_per_row(width, bytes_per_pixel) as usize;
166    let mut out = Vec::with_capacity(row * height as usize);
167    for y in 0..height as usize {
168        let start = y * stride;
169        out.extend_from_slice(&padded[start..start + row]);
170    }
171    out
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// RGBA8/BGRA8 (this crate's headless target formats): 4 bytes/texel.
179    const RGBA8_BPP: u32 = 4;
180
181    #[test]
182    fn padded_row_is_the_tight_row_when_already_aligned() {
183        assert_eq!(padded_bytes_per_row(64, RGBA8_BPP), 256);
184        assert_eq!(padded_bytes_per_row(128, RGBA8_BPP), 512);
185        assert_eq!(padded_bytes_per_row(256, RGBA8_BPP), 1024);
186    }
187
188    #[test]
189    fn padded_row_rounds_an_unaligned_width_up_to_the_alignment() {
190        assert_eq!(padded_bytes_per_row(1, RGBA8_BPP), 256);
191        assert_eq!(padded_bytes_per_row(63, RGBA8_BPP), 256);
192        assert_eq!(padded_bytes_per_row(65, RGBA8_BPP), 512);
193        assert_eq!(padded_bytes_per_row(97, RGBA8_BPP), 512);
194        assert_eq!(padded_bytes_per_row(129, RGBA8_BPP), 768);
195        for width in 1..600u32 {
196            let padded = padded_bytes_per_row(width, RGBA8_BPP);
197            assert!(
198                padded >= width * RGBA8_BPP,
199                "padding must never truncate a row"
200            );
201            assert_eq!(padded % COPY_ROW_ALIGNMENT, 0, "width {width}");
202            assert!(
203                padded - width * RGBA8_BPP < COPY_ROW_ALIGNMENT,
204                "padding must be minimal, width {width}"
205            );
206        }
207    }
208
209    #[test]
210    fn stripping_padding_keeps_every_rows_own_pixels() {
211        // 3-texel rows (12 bytes) padded to 256: each row is tagged with its
212        // own index so a stride slip is visible rather than plausible.
213        let width = 3;
214        let height = 4;
215        let stride = padded_bytes_per_row(width, RGBA8_BPP) as usize;
216        let mut padded = vec![0xEE_u8; stride * height as usize];
217        for y in 0..height as usize {
218            for byte in 0..(width as usize * RGBA8_BPP as usize) {
219                padded[y * stride + byte] = (y * 16 + byte) as u8;
220            }
221        }
222        let stripped = strip_row_padding(&padded, width, height, RGBA8_BPP);
223        assert_eq!(stripped.len(), (width * height * RGBA8_BPP) as usize);
224        for y in 0..height as usize {
225            for byte in 0..(width as usize * RGBA8_BPP as usize) {
226                assert_eq!(
227                    stripped[y * width as usize * RGBA8_BPP as usize + byte],
228                    (y * 16 + byte) as u8,
229                    "row {y} byte {byte}"
230                );
231            }
232        }
233        assert!(
234            !stripped.contains(&0xEE),
235            "no padding byte may survive the strip"
236        );
237    }
238
239    #[test]
240    fn stripping_an_already_aligned_width_is_a_plain_copy() {
241        let width = 64;
242        let height = 2;
243        let padded: Vec<u8> = (0..(width * height * RGBA8_BPP))
244            .map(|i| (i % 251) as u8)
245            .collect();
246        assert_eq!(strip_row_padding(&padded, width, height, RGBA8_BPP), padded);
247    }
248}