rlx_core/render/capture.rs
1//! Headless offscreen capture: draw into a texture with no window and read the
2//! pixels back as tight RGBA (Plan 0013). Dev/agent tooling over the native
3//! Rust API — no dependency, no present.
4//!
5//! **Not the hot path.** The readback blocks (`map_async` + `poll(Wait)`); it is
6//! only ever driven by capture/QA tooling, never wired into the live `render`
7//! loop (see CLAUDE.md real-time rules). The panic-denial pragma below is kept
8//! anyway so every file under `render/` satisfies the hygiene guard.
9
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18use super::RenderError;
19use crate::render::gpu;
20
21/// Bytes per pixel of [`HEADLESS_FORMAT`](super::context::HEADLESS_FORMAT).
22const BYTES_PER_PIXEL: u32 = 4;
23
24/// Bytes per pixel of [`COMPOSITE_FORMAT`](super::COMPOSITE_FORMAT) — four
25/// 16-bit halves. Only the linear readback below reads it, and that is test-only.
26#[cfg(test)]
27const LINEAR_BYTES_PER_PIXEL: u32 = 8;
28
29/// A captured frame: tight (row-unpadded) `Rgba8UnormSrgb` pixels, row-major
30/// top-to-bottom. `rgba.len() == width * height * 4`.
31#[derive(Clone)]
32pub struct CaptureImage {
33 /// Image width in pixels.
34 pub width: u32,
35 /// Image height in pixels.
36 pub height: u32,
37 /// `width * height * 4` bytes, RGBA8, no row padding.
38 pub rgba: Vec<u8>,
39}
40
41/// A `RENDER_ATTACHMENT | COPY_SRC` texture sized `width`×`height` plus a view,
42/// the offscreen draw target for one capture.
43pub(crate) fn create_target(
44 device: &wgpu::Device,
45 format: wgpu::TextureFormat,
46 width: u32,
47 height: u32,
48) -> (wgpu::Texture, wgpu::TextureView) {
49 let texture = device.create_texture(&wgpu::TextureDescriptor {
50 label: Some("rlx-capture-target"),
51 size: wgpu::Extent3d {
52 width,
53 height,
54 depth_or_array_layers: 1,
55 },
56 mip_level_count: 1,
57 sample_count: 1,
58 dimension: wgpu::TextureDimension::D2,
59 format,
60 // `COPY_DST` alongside the two it is drawn and read through, so this
61 // target can stand in for a swapchain image on the preview path — where
62 // the frame is drawn into an intermediate and reaches its destination by
63 // `copy_texture_to_texture`. Without it the capture paths could not
64 // exercise that copy at all, and the only instrument left for it would
65 // need a real window.
66 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
67 | wgpu::TextureUsages::COPY_SRC
68 | wgpu::TextureUsages::COPY_DST,
69 view_formats: &[],
70 });
71 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
72 (texture, view)
73}
74
75/// A `COPY_DST | MAP_READ` readback buffer sized for `height` rows padded to the
76/// 256-byte row alignment `copy_texture_to_buffer` requires; returns it with the
77/// padded bytes-per-row so [`read_back`] can strip the padding.
78pub(crate) fn create_readback(
79 device: &wgpu::Device,
80 width: u32,
81 height: u32,
82) -> (wgpu::Buffer, u32) {
83 let padded_bpr = padded_row_bytes(width);
84 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
85 label: Some("rlx-capture-readback"),
86 size: padded_bpr as u64 * height as u64,
87 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
88 mapped_at_creation: false,
89 });
90 (buffer, padded_bpr)
91}
92
93/// Clear the capture target to opaque black before the scene draws, so an empty
94/// or `Load`-op scene still yields defined, non-transparent pixels.
95pub(crate) fn record_clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
96 gpu::color_pass(
97 encoder,
98 "rlx-capture-clear",
99 view,
100 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
101 );
102}
103
104/// Record the texture→buffer copy honoring the padded row stride.
105pub(crate) fn record_copy(
106 encoder: &mut wgpu::CommandEncoder,
107 texture: &wgpu::Texture,
108 buffer: &wgpu::Buffer,
109 padded_bpr: u32,
110 width: u32,
111 height: u32,
112) {
113 encoder.copy_texture_to_buffer(
114 wgpu::TexelCopyTextureInfo {
115 texture,
116 mip_level: 0,
117 origin: wgpu::Origin3d::ZERO,
118 aspect: wgpu::TextureAspect::All,
119 },
120 wgpu::TexelCopyBufferInfo {
121 buffer,
122 layout: wgpu::TexelCopyBufferLayout {
123 offset: 0,
124 bytes_per_row: Some(padded_bpr),
125 rows_per_image: Some(height),
126 },
127 },
128 wgpu::Extent3d {
129 width,
130 height,
131 depth_or_array_layers: 1,
132 },
133 );
134}
135
136/// Map the readback buffer (blocking on `poll(Wait)`), strip the row padding,
137/// and return a tight [`CaptureImage`]. The caller must have already submitted
138/// the copy. Off the hot path by construction.
139pub(crate) fn read_back(
140 device: &wgpu::Device,
141 buffer: &wgpu::Buffer,
142 width: u32,
143 height: u32,
144 padded_bpr: u32,
145) -> Result<CaptureImage, RenderError> {
146 let slice = buffer.slice(..);
147 let (tx, rx) = std::sync::mpsc::channel();
148 slice.map_async(wgpu::MapMode::Read, move |res| {
149 let _ = tx.send(res);
150 });
151 device
152 .poll(wgpu::PollType::wait_indefinitely())
153 .map_err(|_| RenderError::CaptureReadback)?;
154 rx.recv()
155 .map_err(|_| RenderError::CaptureReadback)?
156 .map_err(|_| RenderError::CaptureReadback)?;
157
158 let rgba = {
159 let mapped = slice
160 .get_mapped_range()
161 .map_err(|_| RenderError::CaptureReadback)?;
162 unpad_rows(&mapped, width, height, padded_bpr)
163 };
164 buffer.unmap();
165
166 Ok(CaptureImage {
167 width,
168 height,
169 rgba,
170 })
171}
172
173/// `width * 4` rounded up to the 256-byte row alignment.
174fn padded_row_bytes(width: u32) -> u32 {
175 row_bytes(width, BYTES_PER_PIXEL)
176}
177
178/// `width * bytes_per_pixel` rounded up to the 256-byte row alignment
179/// `copy_texture_to_buffer` requires.
180fn row_bytes(width: u32, bytes_per_pixel: u32) -> u32 {
181 let unpadded = width * bytes_per_pixel;
182 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
183 unpadded.div_ceil(align) * align
184}
185
186// ---------------------------------------------------------------------------
187// Linear-light readback (Plan 0045 Phase 3)
188// ---------------------------------------------------------------------------
189
190/// A `COPY_DST | MAP_READ` buffer sized for a `Rgba16Float` texture of
191/// `width`×`height`; returns it with the padded bytes-per-row.
192#[cfg(test)]
193pub(crate) fn create_linear_readback(
194 device: &wgpu::Device,
195 width: u32,
196 height: u32,
197) -> (wgpu::Buffer, u32) {
198 let padded_bpr = row_bytes(width, LINEAR_BYTES_PER_PIXEL);
199 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
200 label: Some("rlx-capture-linear-readback"),
201 size: padded_bpr as u64 * height as u64,
202 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
203 mapped_at_creation: false,
204 });
205 (buffer, padded_bpr)
206}
207
208/// [`read_back`], for a `Rgba16Float` source: strips the row padding and decodes
209/// each half to `f32`, returning `width * height * 4` tight linear values
210/// row-major top-to-bottom.
211///
212/// The point is that these are **not clamped to 1.0** — this is the only way to
213/// observe the composite as light rather than as a picture, which is what Plan
214/// 0045 Phase 3's first done-when asks for. Test-only: nothing in the frame path
215/// reads a texture back (see the module docs on why the readback blocks).
216#[cfg(test)]
217pub(crate) fn read_back_linear(
218 device: &wgpu::Device,
219 buffer: &wgpu::Buffer,
220 width: u32,
221 height: u32,
222 padded_bpr: u32,
223) -> Result<Vec<f32>, RenderError> {
224 let slice = buffer.slice(..);
225 let (tx, rx) = std::sync::mpsc::channel();
226 slice.map_async(wgpu::MapMode::Read, move |res| {
227 let _ = tx.send(res);
228 });
229 device
230 .poll(wgpu::PollType::wait_indefinitely())
231 .map_err(|_| RenderError::CaptureReadback)?;
232 rx.recv()
233 .map_err(|_| RenderError::CaptureReadback)?
234 .map_err(|_| RenderError::CaptureReadback)?;
235
236 let rgba = {
237 let mapped = slice
238 .get_mapped_range()
239 .map_err(|_| RenderError::CaptureReadback)?;
240 let tight_bpr = (width * LINEAR_BYTES_PER_PIXEL) as usize;
241 let mut out = Vec::with_capacity(width as usize * height as usize * 4);
242 for row in mapped.chunks_exact(padded_bpr as usize) {
243 let Some(tight) = row.get(..tight_bpr) else {
244 continue; // a short final row — never expected
245 };
246 for half in tight.chunks_exact(2) {
247 let bits = u16::from_le_bytes([
248 half.first().copied().unwrap_or(0),
249 half.get(1).copied().unwrap_or(0),
250 ]);
251 out.push(f16_to_f32(bits));
252 }
253 }
254 out
255 };
256 buffer.unmap();
257
258 Ok(rgba)
259}
260
261/// Decode one IEEE-754 binary16 to `f32`. Twelve lines rather than a `half`
262/// dependency: it is test-only, and "every new crate is a cost" (CLAUDE.md).
263///
264/// Subnormals are handled by arithmetic (`mantissa * 2^-24`, exact in `f32`)
265/// rather than by renormalizing bit surgery, so there is no loop to bound.
266#[cfg(test)]
267fn f16_to_f32(bits: u16) -> f32 {
268 let sign = if bits & 0x8000 != 0 { -1.0f32 } else { 1.0 };
269 let exponent = u32::from((bits >> 10) & 0x1f);
270 let mantissa = u32::from(bits & 0x03ff);
271 match exponent {
272 // Zero or subnormal.
273 0 => sign * (mantissa as f32) * (1.0 / 16_777_216.0),
274 // Infinity or NaN.
275 0x1f => f32::from_bits(((bits as u32 & 0x8000) << 16) | 0x7f80_0000 | (mantissa << 13)),
276 // Normal: rebias the exponent from 15 to 127 and left-align the mantissa.
277 _ => f32::from_bits(
278 ((bits as u32 & 0x8000) << 16) | ((exponent + 127 - 15) << 23) | (mantissa << 13),
279 ),
280 }
281}
282
283/// Copy the tight `width*4` bytes out of each padded row into a contiguous
284/// buffer. A short final row (never expected) is skipped rather than panicking.
285pub(super) fn unpad_rows(padded: &[u8], width: u32, height: u32, padded_bpr: u32) -> Vec<u8> {
286 let tight_bpr = (width * BYTES_PER_PIXEL) as usize;
287 let mut out = Vec::with_capacity(tight_bpr * height as usize);
288 for row in padded.chunks_exact(padded_bpr as usize) {
289 if let Some(tight) = row.get(..tight_bpr) {
290 out.extend_from_slice(tight);
291 }
292 }
293 out
294}
295
296// ---------------------------------------------------------------------------
297// The sustained frame tap (Plan 0115 Phase 2)
298// ---------------------------------------------------------------------------
299
300/// A persistent offscreen target plus readback buffer, built once and reused for
301/// every frame of a sustained tap.
302///
303/// The difference from the rest of this file is lifetime, not stage. Every other
304/// capture entry point builds its target and buffer per call — right for QA
305/// tooling that takes one frame, and wrong for a source that takes 864,000 of
306/// them, where per-frame texture and buffer creation is GPU allocation inside
307/// the loop. `capture_stream` already reuses its pair, but only for the length
308/// of one fixed-`dt`, one-preset run it drives itself; this type hands that
309/// reuse to a caller who owns the loop.
310///
311/// **Sized at construction and never resized.** `record_copy`'s extent, the
312/// buffer's length and `padded_bpr` are all fixed against `width`×`height`, so a
313/// renderer that resizes underneath a live tap needs a new one — [`open_tap`]
314/// is the only thing that sets these.
315///
316/// [`open_tap`]: super::Renderer::open_tap
317pub struct FrameTap {
318 /// `RENDER_ATTACHMENT | COPY_SRC`, the offscreen the frame draws into.
319 pub(crate) texture: wgpu::Texture,
320 /// A view of `texture`, held rather than recreated per frame.
321 pub(crate) view: wgpu::TextureView,
322 /// `COPY_DST | MAP_READ`, sized `padded_bpr * height`.
323 pub(crate) buffer: wgpu::Buffer,
324 /// Row stride of `buffer`, padded to the 256-byte copy alignment.
325 pub(crate) padded_bpr: u32,
326 /// Pixel width the three resources above are sized against.
327 pub(crate) width: u32,
328 /// Pixel height the three resources above are sized against.
329 pub(crate) height: u32,
330}
331
332impl FrameTap {
333 /// Build the target, its view and the readback buffer in one step — the
334 /// whole of the tap's GPU allocation, paid here so the per-frame path pays
335 /// none.
336 pub(crate) fn new(
337 device: &wgpu::Device,
338 format: wgpu::TextureFormat,
339 width: u32,
340 height: u32,
341 ) -> Self {
342 let (texture, view) = create_target(device, format, width, height);
343 let (buffer, padded_bpr) = create_readback(device, width, height);
344 Self {
345 texture,
346 view,
347 buffer,
348 padded_bpr,
349 width,
350 height,
351 }
352 }
353
354 /// The pixel size every frame this tap yields will carry.
355 pub fn size(&self) -> (u32, u32) {
356 (self.width, self.height)
357 }
358}