use std::path::Path;
#[derive(Debug)]
pub enum CaptureError {
UnsupportedFormat(wgpu::TextureFormat),
NotCopyable,
MapFailed,
Write(String),
}
impl std::fmt::Display for CaptureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedFormat(fmt) => {
write!(f, "capture: unsupported surface format {fmt:?} (need 8-bit BGRA/RGBA)")
}
Self::NotCopyable => write!(
f,
"capture: the texture lacks COPY_SRC usage — the surface must be configured with it"
),
Self::MapFailed => write!(f, "capture: mapping the readback buffer failed"),
Self::Write(e) => write!(f, "capture: writing the file failed: {e}"),
}
}
}
impl std::error::Error for CaptureError {}
fn channel_order(format: wgpu::TextureFormat) -> Option<bool> {
use wgpu::TextureFormat::*;
match format {
Bgra8Unorm | Bgra8UnormSrgb => Some(true),
Rgba8Unorm | Rgba8UnormSrgb => Some(false),
_ => None,
}
}
pub fn texture_to_png(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
path: &Path,
) -> Result<(), CaptureError> {
let format = texture.format();
let swap_rb = channel_order(format).ok_or(CaptureError::UnsupportedFormat(format))?;
if !texture.usage().contains(wgpu::TextureUsages::COPY_SRC) {
return Err(CaptureError::NotCopyable);
}
let width = texture.width();
let height = texture.height();
let unpadded_row = width * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded_row = unpadded_row.div_ceil(align) * align;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("capture-readback"),
size: u64::from(padded_row) * u64::from(height),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("capture") });
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_row),
rows_per_image: Some(height),
},
},
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
);
queue.submit(std::iter::once(encoder.finish()));
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
match rx.recv() {
Ok(Ok(())) => {}
_ => return Err(CaptureError::MapFailed),
}
let mut pixels = Vec::with_capacity((unpadded_row * height) as usize);
{
let mapped = slice.get_mapped_range();
for row in 0..height as usize {
let start = row * padded_row as usize;
let row_bytes = &mapped[start..start + unpadded_row as usize];
if swap_rb {
for px in row_bytes.chunks_exact(4) {
pixels.extend_from_slice(&[px[2], px[1], px[0], px[3]]);
}
} else {
pixels.extend_from_slice(row_bytes);
}
}
}
staging.unmap();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| CaptureError::Write(e.to_string()))?;
}
}
image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| CaptureError::Write("pixel buffer size did not match the image".into()))?
.save(path)
.map_err(|e| CaptureError::Write(e.to_string()))
}
#[derive(Debug, Clone)]
pub struct CaptureRequest {
pub path: std::path::PathBuf,
pub frame: u64,
pub exit_after: bool,
}
impl CaptureRequest {
pub fn from_env() -> Option<Self> {
let path = std::env::var_os("GIZMO_SCREENSHOT")?;
let frame = std::env::var("GIZMO_SCREENSHOT_FRAME")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(90);
let exit_after = std::env::var("GIZMO_SCREENSHOT_EXIT").is_ok_and(|v| v == "1");
Some(Self { path: path.into(), frame, exit_after })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bgra_swaps_rgba_does_not_and_everything_else_is_refused() {
use wgpu::TextureFormat::*;
assert_eq!(channel_order(Bgra8UnormSrgb), Some(true));
assert_eq!(channel_order(Bgra8Unorm), Some(true));
assert_eq!(channel_order(Rgba8UnormSrgb), Some(false));
assert_eq!(channel_order(Rgba8Unorm), Some(false));
assert_eq!(channel_order(Rgba16Float), None, "HDR formats need tone mapping, not a memcpy");
assert_eq!(channel_order(Depth32Float), None);
}
#[test]
fn row_padding_rounds_up_to_256_only_when_needed() {
let padded = |w: u32| (w * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * 256;
assert_eq!(padded(1600), 6400, "1600*4 is already a multiple of 256");
assert_eq!(padded(64), 256);
assert_eq!(padded(1590), 6400, "6360 rounds up to 6400");
assert_eq!(padded(1), 256);
assert_eq!(padded(1920), 7680);
}
#[test]
fn no_env_var_means_no_capture() {
temp_env_unset("GIZMO_SCREENSHOT");
assert!(CaptureRequest::from_env().is_none());
}
fn temp_env_unset(key: &str) {
unsafe { std::env::remove_var(key) };
}
}