Skip to main content

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}