Skip to content

Commit 4440e6b

Browse files
committed
feat: headless capture — public headless Window, pipelined pixel readback, fast read_pixels
Off-screen video capture improvements, in three parts: - read_pixels 18x faster: convert BGRA->RGB from cached memory (mapped readback memory is uncached, ~10 MB/s scalar reads), reuse the staging buffer across calls, and wait on the copy's submission index instead of polling the whole device. - Window::new_headless_with_setup(width, height, setup): a full-featured Window backed by no OS window and no swapchain (same headless canvas as OffscreenSurface, but exposing the whole Window API — custom renderers, ray tracer, snap* readbacks). Never throttled by display vsync; works without a display server. - snap_begin()/snap_finish(): pipelined readback that enqueues the texture->buffer copy and map without waiting, so frame N's copy is collected after frame N+1 renders instead of stalling the GPU pipeline each frame. read_pixels() is refactored onto the same begin/finish path with unchanged behavior. Motivation: a windowed capture loop (render_frame + blocking snap) is vsync-locked — the blocking readback defeats swapchain frame pipelining, so every iteration eats ~2 vblanks (~30 fps at 60 Hz) regardless of GPU load. Headless + pipelined readback took a downstream nexus3d capture loop from 33 to 92 fps at 640x480 (2.7 fps before the read_pixels fix). Supersedes dimforge#397 (included here).
1 parent 95dbcfa commit 4440e6b

5 files changed

Lines changed: 162 additions & 22 deletions

File tree

src/context/context.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ impl Context {
278278
self.queue.submit(command_buffers);
279279
}
280280

281+
/// Submits command buffers to the GPU queue, returning the submission
282+
/// index so callers can wait for exactly this submission (rather than the
283+
/// whole device).
284+
pub fn submit_indexed<I: IntoIterator<Item = wgpu::CommandBuffer>>(
285+
&self,
286+
command_buffers: I,
287+
) -> wgpu::SubmissionIndex {
288+
self.queue.submit(command_buffers)
289+
}
290+
281291
/// Writes texture data to the GPU.
282292
///
283293
/// # Arguments

src/window/canvas.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,16 @@ impl Canvas {
232232
pub fn read_pixels(&self, out: &mut Vec<u8>, x: usize, y: usize, width: usize, height: usize) {
233233
self.canvas.read_pixels(out, x, y, width, height)
234234
}
235+
236+
/// Starts a non-blocking readback of the readback texture; complete it
237+
/// with [`Self::finish_read_pixels`]. See `WgpuCanvas::begin_read_pixels`.
238+
pub fn begin_read_pixels(&self, x: usize, y: usize, width: usize, height: usize) {
239+
self.canvas.begin_read_pixels(x, y, width, height)
240+
}
241+
242+
/// Completes a readback started by [`Self::begin_read_pixels`], returning
243+
/// the captured `(width, height)`, or `None` when none is in flight.
244+
pub fn finish_read_pixels(&self, out: &mut Vec<u8>) -> Option<(u32, u32)> {
245+
self.canvas.finish_read_pixels(out)
246+
}
235247
}

src/window/screenshot.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,29 @@ impl Window {
7272
let img = img_opt.expect("Buffer created from window was not big enough for image.");
7373
imageops::flip_vertical(&img)
7474
}
75+
76+
/// Starts a non-blocking capture of the last rendered frame.
77+
///
78+
/// This enqueues the GPU→CPU copy of the framebuffer but does not wait for
79+
/// it; collect the pixels with [`Self::snap_finish`], typically after
80+
/// rendering the *next* frame, so the copy overlaps with useful GPU work
81+
/// instead of stalling the pipeline the way the blocking [`Self::snap`]
82+
/// does. One capture can be in flight at a time; a second `snap_begin`
83+
/// completes and discards the previous one.
84+
pub fn snap_begin(&self) {
85+
let (width, height) = self.canvas.size();
86+
self.canvas
87+
.begin_read_pixels(0, 0, width as usize, height as usize);
88+
}
89+
90+
/// Completes a capture started by [`Self::snap_begin`], returning the
91+
/// frame as an image (top-left origin, like [`Self::snap_image`]), or
92+
/// `None` when no capture is in flight.
93+
pub fn snap_finish(&self) -> Option<ImageBuffer<Rgb<u8>, Vec<u8>>> {
94+
let mut buf = Vec::new();
95+
let (width, height) = self.canvas.finish_read_pixels(&mut buf)?;
96+
let img = ImageBuffer::from_vec(width, height, buf)
97+
.expect("readback buffer was not big enough for image");
98+
Some(imageops::flip_vertical(&img))
99+
}
75100
}

src/window/wgpu_canvas.rs

Lines changed: 105 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ enum PendingEvent {
104104
},
105105
}
106106

107+
/// A GPU→CPU pixel readback still in flight (see `WgpuCanvas::begin_read_pixels`).
108+
struct PendingSnap {
109+
buffer: wgpu::Buffer,
110+
rx: std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
111+
submission: wgpu::SubmissionIndex,
112+
width: usize,
113+
height: usize,
114+
padded_bytes_per_row: usize,
115+
}
116+
107117
/// A unified canvas based on wgpu that works on both native and web platforms.
108118
#[allow(dead_code)]
109119
pub struct WgpuCanvas {
@@ -126,6 +136,11 @@ pub struct WgpuCanvas {
126136
sample_count: u32,
127137
/// Texture for reading back pixels (for screenshots)
128138
readback_texture: wgpu::Texture,
139+
/// Staging buffer reused across `read_pixels` calls, grown on demand, so
140+
/// per-frame capture doesn't allocate (and free) a GPU buffer every call.
141+
screenshot_staging: RefCell<Option<wgpu::Buffer>>,
142+
/// Readback started by `begin_read_pixels`, completed by `finish_read_pixels`.
143+
snap_pending: RefCell<Option<PendingSnap>>,
129144
/// Pending events from web callbacks (WASM only)
130145
#[cfg(target_arch = "wasm32")]
131146
pending_events: Rc<RefCell<Vec<WindowEvent>>>,
@@ -665,6 +680,8 @@ impl WgpuCanvas {
665680
msaa_view,
666681
sample_count,
667682
readback_texture,
683+
screenshot_staging: RefCell::new(None),
684+
snap_pending: RefCell::new(None),
668685
#[cfg(target_arch = "wasm32")]
669686
pending_events,
670687
#[cfg(target_arch = "wasm32")]
@@ -775,6 +792,8 @@ impl WgpuCanvas {
775792
msaa_view,
776793
sample_count,
777794
readback_texture,
795+
screenshot_staging: RefCell::new(None),
796+
snap_pending: RefCell::new(None),
778797
#[cfg(target_arch = "wasm32")]
779798
pending_events: Rc::new(RefCell::new(Vec::new())),
780799
#[cfg(target_arch = "wasm32")]
@@ -1310,6 +1329,25 @@ impl WgpuCanvas {
13101329
/// Reads pixels from the readback texture into the provided buffer.
13111330
/// Returns RGB data (3 bytes per pixel).
13121331
pub fn read_pixels(&self, out: &mut Vec<u8>, x: usize, y: usize, width: usize, height: usize) {
1332+
self.begin_read_pixels(x, y, width, height);
1333+
self.finish_read_pixels(out);
1334+
}
1335+
1336+
/// Starts an asynchronous GPU→CPU readback of the readback texture and
1337+
/// returns immediately: it enqueues the texture→buffer copy and the buffer
1338+
/// map, but never waits on the GPU. Complete it — typically one frame
1339+
/// later, once the GPU has long finished the copy — with
1340+
/// [`Self::finish_read_pixels`]. Pipelining capture this way hides the
1341+
/// full CPU↔GPU sync that a blocking [`Self::read_pixels`] pays every
1342+
/// frame.
1343+
///
1344+
/// A second `begin` before the previous readback was finished completes
1345+
/// and discards the previous one first (one readback in flight at a time).
1346+
pub fn begin_read_pixels(&self, x: usize, y: usize, width: usize, height: usize) {
1347+
if self.snap_pending.borrow().is_some() {
1348+
self.finish_read_pixels(&mut Vec::new());
1349+
}
1350+
13131351
let ctxt = Context::get();
13141352

13151353
// Calculate buffer size with alignment
@@ -1320,13 +1358,19 @@ impl WgpuCanvas {
13201358
let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
13211359
let buffer_size = padded_bytes_per_row * height;
13221360

1323-
// Create staging buffer
1324-
let staging_buffer = ctxt.create_buffer(&wgpu::BufferDescriptor {
1325-
label: Some("screenshot_staging_buffer"),
1326-
size: buffer_size as u64,
1327-
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1328-
mapped_at_creation: false,
1329-
});
1361+
// Reuse the cached staging buffer when it is large enough; (re)create
1362+
// it otherwise. Capturing every frame (video export) then allocates
1363+
// exactly once instead of once per call.
1364+
let mut staging_slot = self.screenshot_staging.borrow_mut();
1365+
let staging_buffer = match staging_slot.take() {
1366+
Some(buffer) if buffer.size() >= buffer_size as u64 => buffer,
1367+
_ => ctxt.create_buffer(&wgpu::BufferDescriptor {
1368+
label: Some("screenshot_staging_buffer"),
1369+
size: buffer_size as u64,
1370+
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1371+
mapped_at_creation: false,
1372+
}),
1373+
};
13301374

13311375
// Copy from readback texture to staging buffer
13321376
let mut encoder = ctxt.create_command_encoder(Some("screenshot_copy_encoder"));
@@ -1357,20 +1401,55 @@ impl WgpuCanvas {
13571401
},
13581402
);
13591403

1360-
ctxt.submit(std::iter::once(encoder.finish()));
1404+
let submission = ctxt.submit_indexed(std::iter::once(encoder.finish()));
13611405

1362-
// Map the buffer and read the data
1406+
// Queue the map; completion is observed in `finish_read_pixels`.
13631407
let buffer_slice = staging_buffer.slice(..);
13641408
let (tx, rx) = std::sync::mpsc::channel();
13651409
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
13661410
tx.send(result).unwrap();
13671411
});
13681412

1369-
// Wait for the GPU to finish
1370-
let _ = ctxt.device.poll(wgpu::PollType::wait_indefinitely());
1413+
*self.snap_pending.borrow_mut() = Some(PendingSnap {
1414+
buffer: staging_buffer,
1415+
rx,
1416+
submission,
1417+
width,
1418+
height,
1419+
padded_bytes_per_row,
1420+
});
1421+
}
1422+
1423+
/// Completes a readback started by [`Self::begin_read_pixels`], filling
1424+
/// `out` with RGB data (3 bytes per pixel, rows bottom-to-top like
1425+
/// [`Self::read_pixels`]) and returning the captured `(width, height)`.
1426+
/// Returns `None` (and leaves `out` untouched) when no readback is in
1427+
/// flight. Blocks only until the copy's submission completes — a no-op
1428+
/// when a frame of GPU work has been submitted since the `begin`.
1429+
pub fn finish_read_pixels(&self, out: &mut Vec<u8>) -> Option<(u32, u32)> {
1430+
let PendingSnap {
1431+
buffer: staging_buffer,
1432+
rx,
1433+
submission,
1434+
width,
1435+
height,
1436+
padded_bytes_per_row,
1437+
} = self.snap_pending.borrow_mut().take()?;
1438+
let ctxt = Context::get();
1439+
1440+
// Wait only for the copy submission (and, transitively, the work it
1441+
// depends on) instead of polling the device indefinitely.
1442+
let _ = ctxt.device.poll(wgpu::PollType::Wait {
1443+
submission_index: Some(submission),
1444+
timeout: None,
1445+
});
13711446
rx.recv().unwrap().unwrap();
13721447

1448+
let bytes_per_pixel = 4; // RGBA or BGRA
1449+
let unpadded_bytes_per_row = width * bytes_per_pixel;
1450+
13731451
// Read the data
1452+
let buffer_slice = staging_buffer.slice(..);
13741453
let data = buffer_slice.get_mapped_range();
13751454

13761455
// Convert from BGRA/RGBA to RGB and handle row padding
@@ -1384,27 +1463,31 @@ impl WgpuCanvas {
13841463
);
13851464

13861465
// wgpu has origin at top-left, but we want bottom-left origin for OpenGL compatibility
1387-
// So we read rows in reverse order
1466+
// So we read rows in reverse order.
1467+
//
1468+
// The mapped range is uncached (write-combined) memory: scalar reads
1469+
// from it run at ~10 MB/s. memcpy each row into a cached local buffer
1470+
// first, then convert — orders of magnitude faster than indexing the
1471+
// mapped slice per byte.
1472+
let mut row_buf = vec![0u8; unpadded_bytes_per_row];
13881473
for row in (0..height).rev() {
13891474
let row_start = row * padded_bytes_per_row;
1390-
for col in 0..width {
1391-
let pixel_start = row_start + col * bytes_per_pixel;
1475+
row_buf.copy_from_slice(&data[row_start..row_start + unpadded_bytes_per_row]);
1476+
for px in row_buf.chunks_exact(bytes_per_pixel) {
13921477
if is_bgra {
1393-
// BGRA -> RGB
1394-
out.push(data[pixel_start + 2]); // R
1395-
out.push(data[pixel_start + 1]); // G
1396-
out.push(data[pixel_start]); // B
1478+
out.extend_from_slice(&[px[2], px[1], px[0]]);
13971479
} else {
1398-
// RGBA -> RGB
1399-
out.push(data[pixel_start]); // R
1400-
out.push(data[pixel_start + 1]); // G
1401-
out.push(data[pixel_start + 2]); // B
1480+
out.extend_from_slice(&[px[0], px[1], px[2]]);
14021481
}
14031482
}
14041483
}
14051484

14061485
drop(data);
14071486
staging_buffer.unmap();
1487+
// Return the buffer to the cache so the next `begin_read_pixels`
1488+
// (or blocking `read_pixels`) reuses it instead of allocating.
1489+
*self.screenshot_staging.borrow_mut() = Some(staging_buffer);
1490+
Some((width as u32, height as u32))
14081491
}
14091492

14101493
/// Gets the depth texture view for rendering.

src/window/window.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,16 @@ impl Window {
10501050
usr_window
10511051
}
10521052

1053+
/// Creates a headless window with custom setup options: a full-featured
1054+
/// [`Window`] backed by no OS window and no swapchain, rendering straight
1055+
/// into an off-screen texture. Unlike [`OffscreenSurface`](crate::window::OffscreenSurface)
1056+
/// this exposes the whole `Window` API (custom renderers, ray tracer,
1057+
/// `snap*` readbacks, …), for callers that drive the render loop
1058+
/// themselves and never present to a display.
1059+
pub async fn new_headless_with_setup(width: u32, height: u32, setup: CanvasSetup) -> Window {
1060+
Self::do_new_headless(width, height, Some(setup)).await
1061+
}
1062+
10531063
/// Creates a headless window: a render target backed by no actual window,
10541064
/// for off-screen rendering. Powers [`OffscreenSurface`](crate::window::OffscreenSurface).
10551065
pub(super) async fn do_new_headless(

0 commit comments

Comments
 (0)