Skip to main content

miniscreenshot_wgpu/
lib.rs

1//! Screenshot integration for the [`wgpu`] graphics API.
2//!
3//! This crate re-exports the `wgpu` crate (ensuring version consistency
4//! across the workspace) and provides [`capture_texture`], a synchronous
5//! utility for reading a GPU texture back to CPU memory and converting it
6//! into a [`Screenshot`].
7//!
8//! # Re-export
9//!
10//! ```rust,no_run
11//! use miniscreenshot_wgpu::wgpu;
12//! ```
13//!
14//! # Feature selection
15//!
16//! Enable exactly one compatibility feature to select the `wgpu` major
17//! version:
18//!
19//! - `wgpu-28`
20//! - `wgpu-29`
21//!
22//! # How it works
23//!
24//! 1. A staging `Buffer` is created with `COPY_DST | MAP_READ` usage.
25//! 2. A `copy_texture_to_buffer` command is encoded and submitted.
26//! 3. The device is polled to completion (blocking).
27//! 4. The staging buffer is mapped, row padding is stripped, and the pixel
28//!    data is converted to RGBA8 if necessary.
29
30#[cfg(all(feature = "wgpu-28", feature = "wgpu-29"))]
31compile_error!("features `wgpu-28` and `wgpu-29` are mutually exclusive; enable exactly one");
32#[cfg(not(any(feature = "wgpu-28", feature = "wgpu-29")))]
33compile_error!("one of `wgpu-28` or `wgpu-29` must be enabled for miniscreenshot-wgpu");
34
35/// Re-export of the `wgpu` crate.
36///
37/// Depending on `miniscreenshot-wgpu` instead of `wgpu` directly guarantees
38/// version compatibility across the workspace.
39#[cfg(feature = "wgpu-28")]
40pub use wgpu_28 as wgpu;
41#[cfg(feature = "wgpu-29")]
42pub use wgpu_29 as wgpu;
43
44pub use miniscreenshot::{Capture, CaptureError, Screenshot};
45
46// ── Public API ────────────────────────────────────────────────────────────────
47
48/// Errors that can occur while capturing a GPU texture.
49#[derive(Debug)]
50pub enum WgpuCaptureError {
51    /// The texture format is not yet supported.
52    ///
53    /// Supported formats: `Rgba8Unorm`, `Rgba8UnormSrgb`, `Bgra8Unorm`,
54    /// `Bgra8UnormSrgb`.
55    UnsupportedFormat(wgpu::TextureFormat),
56
57    /// The staging buffer could not be mapped.
58    MapFailed(wgpu::BufferAsyncError),
59
60    /// The device poll failed.
61    PollFailed(wgpu::PollError),
62}
63
64impl std::fmt::Display for WgpuCaptureError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::UnsupportedFormat(fmt) => {
68                write!(f, "unsupported texture format for screenshot: {fmt:?}")
69            }
70            Self::MapFailed(e) => write!(f, "staging buffer map failed: {e}"),
71            Self::PollFailed(e) => write!(f, "device poll failed: {e}"),
72        }
73    }
74}
75
76impl std::error::Error for WgpuCaptureError {
77    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78        match self {
79            Self::MapFailed(e) => Some(e),
80            Self::PollFailed(e) => Some(e),
81            _ => None,
82        }
83    }
84}
85
86impl From<WgpuCaptureError> for CaptureError {
87    fn from(e: WgpuCaptureError) -> Self {
88        match e {
89            WgpuCaptureError::UnsupportedFormat(fmt) => CaptureError::new(
90                miniscreenshot::CaptureErrorKind::Unsupported,
91                format!("unsupported texture format: {fmt:?}"),
92            )
93            .with_source(WgpuCaptureError::UnsupportedFormat(fmt)),
94            WgpuCaptureError::MapFailed(e) => CaptureError::new(
95                miniscreenshot::CaptureErrorKind::Backend,
96                format!("staging buffer map failed: {e}"),
97            )
98            .with_source(WgpuCaptureError::MapFailed(e)),
99            WgpuCaptureError::PollFailed(e) => CaptureError::new(
100                miniscreenshot::CaptureErrorKind::Backend,
101                format!("device poll failed: {e}"),
102            )
103            .with_source(WgpuCaptureError::PollFailed(e)),
104        }
105    }
106}
107
108/// Borrowed view over a wgpu [`Texture`](wgpu::Texture) that implements [`Capture`].
109///
110/// # Example
111///
112/// ```rust,ignore
113/// use miniscreenshot::Capture;
114/// use miniscreenshot_wgpu::WgpuCapture;
115///
116/// let mut cap = WgpuCapture::new(&device, &queue, &texture);
117/// let shot = cap.capture()?;
118/// ```
119pub struct WgpuCapture<'a> {
120    device: &'a wgpu::Device,
121    queue: &'a wgpu::Queue,
122    texture: &'a wgpu::Texture,
123}
124
125impl<'a> WgpuCapture<'a> {
126    /// Create a new capture helper.
127    pub fn new(
128        device: &'a wgpu::Device,
129        queue: &'a wgpu::Queue,
130        texture: &'a wgpu::Texture,
131    ) -> Self {
132        Self {
133            device,
134            queue,
135            texture,
136        }
137    }
138}
139
140impl Capture for WgpuCapture<'_> {
141    type Error = CaptureError;
142
143    fn capture(&mut self) -> Result<Screenshot, CaptureError> {
144        capture(self.device, self.queue, self.texture).map_err(CaptureError::from)
145    }
146}
147
148/// Capture a screenshot from a wgpu [`Texture`](wgpu::Texture) synchronously.
149///
150/// The texture must have been created with [`wgpu::TextureUsages::COPY_SRC`].
151///
152/// # Supported texture formats
153///
154/// | Format | Behaviour |
155/// |--------|-----------|
156/// | `Rgba8Unorm` / `Rgba8UnormSrgb` | Used directly |
157/// | `Bgra8Unorm` / `Bgra8UnormSrgb` | Channels reordered to RGBA |
158///
159/// All other formats return [`WgpuCaptureError::UnsupportedFormat`].
160///
161/// # Blocking behaviour
162///
163/// This function calls [`wgpu::Device::poll`] with [`wgpu::Maintain::Wait`],
164/// which blocks the current thread until the GPU work is complete.
165pub fn capture(
166    device: &wgpu::Device,
167    queue: &wgpu::Queue,
168    texture: &wgpu::Texture,
169) -> Result<Screenshot, WgpuCaptureError> {
170    let size = texture.size();
171    let width = size.width;
172    let height = size.height;
173    let format = texture.format();
174
175    // Determine whether channel swapping (BGRA → RGBA) is needed.
176    let is_bgra = match format {
177        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => false,
178        wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => true,
179        _ => return Err(WgpuCaptureError::UnsupportedFormat(format)),
180    };
181
182    let bytes_per_row = padded_bytes_per_row(width);
183    let buffer_size = u64::from(bytes_per_row) * u64::from(height);
184
185    // Create a staging buffer on the CPU side.
186    let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
187        label: Some("miniscreenshot_staging_buffer"),
188        size: buffer_size,
189        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
190        mapped_at_creation: false,
191    });
192
193    // Encode the GPU→CPU copy.
194    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
195        label: Some("miniscreenshot_encoder"),
196    });
197    encoder.copy_texture_to_buffer(
198        texture.as_image_copy(),
199        wgpu::TexelCopyBufferInfo {
200            buffer: &staging_buffer,
201            layout: wgpu::TexelCopyBufferLayout {
202                offset: 0,
203                bytes_per_row: Some(bytes_per_row),
204                rows_per_image: Some(height),
205            },
206        },
207        size,
208    );
209    queue.submit(std::iter::once(encoder.finish()));
210
211    // Map the buffer and wait for completion.
212    let buffer_slice = staging_buffer.slice(..);
213    let (tx, rx) = std::sync::mpsc::channel();
214    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
215        let _ = tx.send(result);
216    });
217    device
218        .poll(wgpu::PollType::wait_indefinitely())
219        .map_err(WgpuCaptureError::PollFailed)?;
220    rx.recv()
221        .expect("map_async callback channel closed unexpectedly")
222        .map_err(WgpuCaptureError::MapFailed)?;
223
224    // Strip row padding and optionally swap BGRA → RGBA.
225    let mapped = buffer_slice.get_mapped_range();
226    let raw: &[u8] = &mapped;
227    let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
228    for row_idx in 0..height as usize {
229        let row_start = row_idx * bytes_per_row as usize;
230        let row_end = row_start + width as usize * 4;
231        let row = &raw[row_start..row_end];
232        if is_bgra {
233            for pixel in row.chunks_exact(4) {
234                rgba.push(pixel[2]); // R  ← was B
235                rgba.push(pixel[1]); // G
236                rgba.push(pixel[0]); // B  ← was R
237                rgba.push(pixel[3]); // A
238            }
239        } else {
240            rgba.extend_from_slice(row);
241        }
242    }
243    drop(mapped);
244    staging_buffer.unmap();
245
246    Ok(Screenshot::from_rgba(width, height, rgba))
247}
248
249// ── Helpers ───────────────────────────────────────────────────────────────────
250
251/// Round `width * 4` (bytes per row in RGBA8) up to the next multiple of
252/// [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`] (256 bytes).
253fn padded_bytes_per_row(width: u32) -> u32 {
254    let unpadded = width * 4;
255    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
256    unpadded.div_ceil(align) * align
257}
258
259#[cfg(test)]
260mod tests {
261    use super::padded_bytes_per_row;
262
263    #[test]
264    fn padding_aligns_to_256() {
265        // 1 pixel → 4 bytes → padded to 256
266        assert_eq!(padded_bytes_per_row(1), 256);
267        // 64 pixels → 256 bytes → already aligned
268        assert_eq!(padded_bytes_per_row(64), 256);
269        // 65 pixels → 260 bytes → padded to 512
270        assert_eq!(padded_bytes_per_row(65), 512);
271        // 128 pixels → 512 bytes → already aligned
272        assert_eq!(padded_bytes_per_row(128), 512);
273    }
274}