facett-core/src/render/gpu/readback.rs
//! **THE one GPU→CPU readback** — map a buffer, or copy a texture region out and map
//! that, with the row-padding arithmetic done once.
//!
//! Seventeen hand-rolled `map_async` call sites existed across the workspace when this
//! was written and none of them shared a line (GFX_V2 item 8 named it as LAW #5 debt it
//! could not pay, because live agents held the files). Every one repeats the same four
//! steps and the same two traps:
//!
//! 1. `copy_texture_to_buffer` demands `bytes_per_row` aligned to
//! [`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`] (256), so the staging buffer is *padded*
//! and a naive `&data[..w*h*bpp]` reads the wrong texels on any width that is not a
//! multiple of `256 / bytes_per_texel`. A 640-wide RGBA8 row is 2560 B — already
//! aligned — which is exactly why this bug hides: it only appears at, say, 500 px.
//! 2. `map_async`'s callback never fires unless the device is polled, and the poll must
//! happen *after* the submit. Getting that order wrong deadlocks rather than fails.
//!
//! So the padding strip and the poll ordering live here, once, and callers get plain
//! unpadded bytes back.
/// Map `buffer` (which must carry [`wgpu::BufferUsages::MAP_READ`]) and copy its whole
/// contents out as bytes. Unmaps before returning, so the buffer is reusable.
///
/// This is the raw four-step dance — submit-then-poll-then-recv-then-copy — and it is
/// the only place in the tree that spells it.
#[must_use]
pub fn map_read_all(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
let slice = buffer.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// The callback only runs while the device is polled, and only after the work that
// fills the buffer has been submitted. Caller's submit must already have happened.
device.poll(wgpu::PollType::wait_indefinitely()).ok();
rx.recv().ok();
let data = slice.get_mapped_range().expect("wgpu: get_mapped_range on a mapped buffer");
let out = data.to_vec();
drop(data);
buffer.unmap();
out
}
/// Copy `len` bytes from `src` (needs [`wgpu::BufferUsages::COPY_SRC`]) at `offset`
/// into a fresh staging buffer and read them back.
///
/// Returns empty when the range is empty or runs past the end of `src`.
#[must_use]
pub fn read_buffer_range(
device: &wgpu::Device,
queue: &wgpu::Queue,
src: &wgpu::Buffer,
offset: u64,
len: u64,
) -> Vec<u8> {
if len == 0 || offset.saturating_add(len) > src.size() {
return Vec::new();
}
// `copy_buffer_to_buffer` requires both size and offsets to be 4-aligned.
let padded = len.div_ceil(wgpu::COPY_BUFFER_ALIGNMENT) * wgpu::COPY_BUFFER_ALIGNMENT;
if offset.saturating_add(padded) > src.size() {
return Vec::new();
}
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("l0_readback_buffer"),
size: padded,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("l0_readback_buffer_enc"),
});
enc.copy_buffer_to_buffer(src, offset, &staging, 0, padded);
queue.submit(Some(enc.finish()));
let mut out = map_read_all(device, &staging);
out.truncate(len as usize);
out
}
/// Copy the `w × h` texel region at `(x, y)` of `tex` out and return it as
/// **unpadded** bytes, `w * bytes_per_texel` per row, `h` rows.
///
/// `tex` needs [`wgpu::TextureUsages::COPY_SRC`]. The region is clamped to the texture;
/// a region entirely outside it returns empty. `bytes_per_texel` is the caller's, not
/// derived from the format, because a depth/stencil aspect has no single answer — pass
/// the size of the aspect you are copying.
#[must_use]
#[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
pub fn read_texture_region(
device: &wgpu::Device,
queue: &wgpu::Queue,
tex: &wgpu::Texture,
bytes_per_texel: u32,
x: u32,
y: u32,
w: u32,
h: u32,
) -> Vec<u8> {
let (tw, th) = (tex.width(), tex.height());
if x >= tw || y >= th || w == 0 || h == 0 || bytes_per_texel == 0 {
return Vec::new();
}
let w = w.min(tw - x);
let h = h.min(th - y);
let unpadded = w * bytes_per_texel;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("l0_readback_texture"),
size: u64::from(padded) * u64::from(h),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("l0_readback_texture_enc"),
});
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: tex,
mip_level: 0,
origin: wgpu::Origin3d { x, y, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
queue.submit(Some(enc.finish()));
let data = map_read_all(device, &staging);
strip_row_padding(&data, unpadded as usize, padded as usize, h as usize)
}
/// Drop the per-row alignment padding a `copy_texture_to_buffer` inserts: keep the
/// first `unpadded` bytes of each `padded`-byte row, for `rows` rows.
///
/// Pure, so the arithmetic that silently corrupts a non-256-aligned readback is
/// testable without a GPU. Short input is truncated rather than panicking.
#[must_use]
pub fn strip_row_padding(data: &[u8], unpadded: usize, padded: usize, rows: usize) -> Vec<u8> {
if unpadded == 0 || padded < unpadded {
return Vec::new();
}
let mut out = Vec::with_capacity(unpadded * rows);
for row in 0..rows {
let base = row * padded;
let end = base + unpadded;
if end > data.len() {
break;
}
out.extend_from_slice(&data[base..end]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The trap this helper exists for: a width whose row is NOT 256-aligned. 5 RGBA8
/// texels = 20 B, padded to 256 — so a reader that ignores the padding takes 20 B
/// of row 0 and then 236 B of *padding* as rows 1-2.
#[test]
fn row_padding_is_stripped_and_the_naive_read_would_be_wrong() {
let (w, bpp, rows) = (5usize, 4usize, 3usize);
let unpadded = w * bpp; // 20
let padded = 256usize;
// Row r is filled with the byte value (r+1)*10; padding is 0xEE.
let mut raw = vec![0xEEu8; padded * rows];
for r in 0..rows {
for b in 0..unpadded {
raw[r * padded + b] = ((r + 1) * 10) as u8;
}
}
let out = strip_row_padding(&raw, unpadded, padded, rows);
assert_eq!(out.len(), unpadded * rows, "one unpadded row per row");
assert!(out.iter().all(|&b| b != 0xEE), "no padding byte survives the strip");
for r in 0..rows {
let row = &out[r * unpadded..(r + 1) * unpadded];
assert!(row.iter().all(|&b| b == ((r + 1) * 10) as u8), "row {r} is its own bytes");
}
// And the naive contiguous read is genuinely wrong — this is what the helper buys.
let naive = &raw[..unpadded * rows];
assert_ne!(naive, &out[..], "the naive read differs, i.e. the padding is load-bearing");
}
/// A row that IS aligned must pass through byte-identical, so the helper is not
/// quietly reshaping the common case.
#[test]
fn an_already_aligned_row_passes_through_unchanged() {
let (unpadded, rows) = (256usize, 4usize);
let raw: Vec<u8> = (0..unpadded * rows).map(|i| (i % 251) as u8).collect();
assert_eq!(strip_row_padding(&raw, unpadded, unpadded, rows), raw);
}
#[test]
fn degenerate_shapes_return_empty_rather_than_panicking() {
assert!(strip_row_padding(&[1, 2, 3], 0, 256, 1).is_empty(), "zero-width");
assert!(strip_row_padding(&[1, 2, 3], 8, 4, 1).is_empty(), "padded < unpadded is nonsense");
assert!(strip_row_padding(&[1, 2], 8, 8, 1).is_empty(), "short input truncates");
}
}