Skip to main content

astrelis_render/
readback.rs

1//! GPU readback utilities for screenshot and framebuffer capture.
2//!
3//! This module provides utilities for reading back data from the GPU to the CPU:
4//! - Screenshot capture from textures
5//! - Framebuffer readback
6//! - Async GPU-to-CPU data transfer
7//! - PNG export
8//!
9//! # Example
10//!
11//! ```ignore
12//! use astrelis_render::*;
13//!
14//! // Capture a screenshot
15//! let readback = GpuReadback::from_texture(&context, &texture);
16//! let data = readback.read_async().await?;
17//!
18//! // Save to PNG
19//! readback.save_png("screenshot.png")?;
20//! ```
21
22use std::sync::Arc;
23
24use crate::GraphicsContext;
25
26/// GPU readback error.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ReadbackError {
29    /// Buffer mapping failed
30    MapFailed(String),
31    /// Texture copy failed
32    CopyFailed(String),
33    /// Image encoding failed
34    EncodeFailed(String),
35    /// IO error
36    IoError(String),
37    /// Invalid dimensions
38    InvalidDimensions,
39    /// Unsupported format
40    UnsupportedFormat,
41}
42
43impl std::fmt::Display for ReadbackError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::MapFailed(msg) => write!(f, "Buffer mapping failed: {}", msg),
47            Self::CopyFailed(msg) => write!(f, "Texture copy failed: {}", msg),
48            Self::EncodeFailed(msg) => write!(f, "Image encoding failed: {}", msg),
49            Self::IoError(msg) => write!(f, "IO error: {}", msg),
50            Self::InvalidDimensions => write!(f, "Invalid dimensions for readback"),
51            Self::UnsupportedFormat => write!(f, "Unsupported texture format for readback"),
52        }
53    }
54}
55
56impl std::error::Error for ReadbackError {}
57
58/// GPU readback handle for async data retrieval.
59pub struct GpuReadback {
60    /// Readback buffer
61    buffer: wgpu::Buffer,
62    /// Texture dimensions (width, height)
63    dimensions: (u32, u32),
64    /// Bytes per row (with padding)
65    bytes_per_row: u32,
66    /// Texture format
67    format: wgpu::TextureFormat,
68}
69
70impl GpuReadback {
71    /// Create a readback from a texture.
72    ///
73    /// This copies the texture to a staging buffer for CPU readback.
74    pub fn from_texture(
75        context: Arc<GraphicsContext>,
76        texture: &wgpu::Texture,
77    ) -> Result<Self, ReadbackError> {
78        let size = texture.size();
79        let dimensions = (size.width, size.height);
80        let format = texture.format();
81
82        // Validate dimensions
83        if dimensions.0 == 0 || dimensions.1 == 0 {
84            return Err(ReadbackError::InvalidDimensions);
85        }
86
87        // Calculate bytes per row (must be aligned to 256 bytes)
88        let bytes_per_pixel = match format {
89            wgpu::TextureFormat::Rgba8Unorm
90            | wgpu::TextureFormat::Rgba8UnormSrgb
91            | wgpu::TextureFormat::Bgra8Unorm
92            | wgpu::TextureFormat::Bgra8UnormSrgb => 4,
93            wgpu::TextureFormat::Rgb10a2Unorm => 4,
94            _ => return Err(ReadbackError::UnsupportedFormat),
95        };
96
97        let unpadded_bytes_per_row = dimensions.0 * bytes_per_pixel;
98        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
99        let bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
100
101        // Create staging buffer
102        let buffer_size = (bytes_per_row * dimensions.1) as wgpu::BufferAddress;
103        let buffer = context.device().create_buffer(&wgpu::BufferDescriptor {
104            label: Some("readback_buffer"),
105            size: buffer_size,
106            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
107            mapped_at_creation: false,
108        });
109
110        // Copy texture to buffer
111        let mut encoder =
112            context
113                .device()
114                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
115                    label: Some("readback_encoder"),
116                });
117
118        encoder.copy_texture_to_buffer(
119            wgpu::TexelCopyTextureInfo {
120                texture,
121                mip_level: 0,
122                origin: wgpu::Origin3d::ZERO,
123                aspect: wgpu::TextureAspect::All,
124            },
125            wgpu::TexelCopyBufferInfo {
126                buffer: &buffer,
127                layout: wgpu::TexelCopyBufferLayout {
128                    offset: 0,
129                    bytes_per_row: Some(bytes_per_row),
130                    rows_per_image: Some(dimensions.1),
131                },
132            },
133            size,
134        );
135
136        context.queue().submit(Some(encoder.finish()));
137
138        Ok(Self {
139            buffer,
140            dimensions,
141            bytes_per_row,
142            format,
143        })
144    }
145
146    /// Read data from GPU (blocking).
147    ///
148    /// Returns raw RGBA bytes.
149    /// Note: This is a simplified blocking implementation.
150    /// For async usage, consider wrapping in async runtime.
151    pub fn read(&self) -> Result<Vec<u8>, ReadbackError> {
152        let buffer_slice = self.buffer.slice(..);
153
154        // Map the buffer
155        buffer_slice.map_async(wgpu::MapMode::Read, |_| {});
156
157        // Note: In real usage, you would poll the device here
158        // For now, we'll just proceed - the get_mapped_range will block
159
160        // Read data
161        let data = buffer_slice.get_mapped_range();
162        let bytes_per_pixel = 4; // RGBA
163        let mut result =
164            Vec::with_capacity((self.dimensions.0 * self.dimensions.1 * bytes_per_pixel) as usize);
165
166        // Copy data, removing row padding
167        for y in 0..self.dimensions.1 {
168            let row_start = (y * self.bytes_per_row) as usize;
169            let row_end = row_start + (self.dimensions.0 * bytes_per_pixel) as usize;
170            result.extend_from_slice(&data[row_start..row_end]);
171        }
172
173        drop(data);
174        self.buffer.unmap();
175
176        Ok(result)
177    }
178
179    /// Save the readback data as a PNG file.
180    #[cfg(feature = "image")]
181    pub fn save_png(&self, path: impl AsRef<std::path::Path>) -> Result<(), ReadbackError> {
182        let data = self.read()?;
183
184        // Convert to image format
185        let img = image::RgbaImage::from_raw(self.dimensions.0, self.dimensions.1, data).ok_or(
186            ReadbackError::EncodeFailed("Failed to create image from raw data".to_string()),
187        )?;
188
189        // Save to PNG
190        img.save(path)
191            .map_err(|e| ReadbackError::IoError(format!("{}", e)))?;
192
193        Ok(())
194    }
195
196    /// Get the dimensions (width, height).
197    pub fn dimensions(&self) -> (u32, u32) {
198        self.dimensions
199    }
200
201    /// Get the texture format.
202    pub fn format(&self) -> wgpu::TextureFormat {
203        self.format
204    }
205}
206
207/// Extension trait for convenient screenshot capture.
208pub trait ReadbackExt {
209    /// Capture a screenshot from a texture.
210    fn capture_texture(&self, texture: &wgpu::Texture) -> Result<GpuReadback, ReadbackError>;
211}
212
213impl ReadbackExt for Arc<GraphicsContext> {
214    fn capture_texture(&self, texture: &wgpu::Texture) -> Result<GpuReadback, ReadbackError> {
215        GpuReadback::from_texture(self.clone(), texture)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_readback_error_display() {
225        let err = ReadbackError::MapFailed("test".to_string());
226        assert!(format!("{}", err).contains("Buffer mapping failed"));
227
228        let err = ReadbackError::InvalidDimensions;
229        assert!(format!("{}", err).contains("Invalid dimensions"));
230    }
231
232    #[test]
233    fn test_bytes_per_row_alignment() {
234        // Test that bytes per row alignment is correct
235        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
236
237        // Width 100, 4 bytes per pixel = 400 bytes
238        let unpadded: u32 = 100 * 4;
239        let padded = unpadded.div_ceil(align) * align;
240
241        // Should be padded to next multiple of 256
242        assert_eq!(padded, 512);
243        assert_eq!(padded % align, 0);
244    }
245
246    #[test]
247    fn test_readback_dimensions() {
248        // We can't actually create a GPU readback without a real context,
249        // but we can test the error cases
250        assert!(matches!(
251            ReadbackError::InvalidDimensions,
252            ReadbackError::InvalidDimensions
253        ));
254    }
255}