Skip to main content

rlx_core/render/
preview_readback.rs

1//! A non-blocking readback of the preview intermediate, consumed one frame late.
2//!
3//! The operator console already draws the show into an intermediate and copies
4//! it out (ADR-0143). A studio wants those same pixels on the CPU — and the one
5//! thing it must not cost is a **stall in the display loop**, which answers to a
6//! present deadline the headless tap does not have.
7//!
8//! ## One frame in flight, and nothing waits
9//!
10//! Each cycle is three steps across two frames:
11//!
12//! 1. **Record** — frame *N*'s encoder gets a scaling blit from the intermediate
13//!    into the fixed-size tap and a `copy_texture_to_buffer` out of that tap into
14//!    this buffer, both riding the submission the frame makes anyway.
15//! 2. **Arm** — after that submission, `map_async` is asked for the buffer.
16//! 3. **Consume** — frame *N+1* polls **without waiting**
17//!    ([`wgpu::PollType::Poll`]) and takes the mapping if it has landed. If it
18//!    has not, this frame yields nothing and the buffer stays in flight; the
19//!    display loop does not notice.
20//!
21//! So a consumer sees frame *N* while frame *N+1* is being drawn. That is the
22//! whole latency, and it is the price of never blocking.
23//!
24//! **The buffer cannot be re-recorded while it is mapped**, which is why the
25//! consume step precedes the record step within a frame and why a map that has
26//! not landed skips the record: a second copy into a mapped buffer is a
27//! validation error, not a dropped frame.
28//!
29//! ## Why not the frame tap's readback
30//!
31//! `capture.rs`'s `read_back` waits indefinitely, deliberately: a headless loop
32//! outruns the GPU and the wait is what paces it. That is the correct policy
33//! there and the wrong one here, and the two are not merged for exactly that
34//! reason — `core/tests/console_preview.rs` holds `render/` to no indefinite
35//! wait outside the two capture files.
36
37// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
38// hygiene guard).
39#![deny(
40    clippy::unwrap_used,
41    clippy::expect_used,
42    clippy::indexing_slicing,
43    clippy::panic,
44    clippy::unreachable
45)]
46
47// A continuation of one module split across several files, so it needs the
48// names `render/mod.rs` has in scope.
49use super::*;
50
51use std::sync::mpsc::{Receiver, TryRecvError};
52
53/// The fixed-size tap, the staging buffer, and the state of the map in flight.
54pub(super) struct PreviewReadback {
55    /// The tap the buffer copies out of. Owned here rather than beside the
56    /// intermediate because its whole purpose is to give this readback one
57    /// geometry for the life of the run (ADR-0187).
58    pub(super) tap: preview::PreviewTap,
59    buffer: wgpu::Buffer,
60    width: u32,
61    height: u32,
62    /// `width * 4` rounded up to wgpu's 256-byte row alignment. The mapped
63    /// range carries this stride and the frame handed out does not.
64    padded_bpr: u32,
65    /// The armed map's result channel, `None` when the buffer is free to record
66    /// into.
67    armed: Option<Receiver<Result<(), wgpu::BufferAsyncError>>>,
68}
69
70impl PreviewReadback {
71    /// Build a readback that yields `width`x`height` frames at `format`.
72    ///
73    /// Neither follows the intermediate: the tap is built here and stays, so a
74    /// renderer resize changes what the blit reads and nothing about what this
75    /// hands out.
76    pub(super) fn new(
77        device: &wgpu::Device,
78        format: wgpu::TextureFormat,
79        width: u32,
80        height: u32,
81    ) -> Self {
82        let tap = preview::PreviewTap::new(device, format, width, height);
83        let (width, height) = tap.size();
84        let (buffer, padded_bpr) = capture::create_readback(device, width, height);
85        Self {
86            tap,
87            buffer,
88            width,
89            height,
90            padded_bpr,
91            armed: None,
92        }
93    }
94
95    /// The size of the frames this yields.
96    pub(super) fn size(&self) -> (u32, u32) {
97        (self.width, self.height)
98    }
99
100    /// Take the frame the **previous** submission's map produced, if it has
101    /// landed.
102    ///
103    /// Polls without waiting. A map still in flight yields `None` and leaves the
104    /// buffer armed; the next frame asks again. Call this before
105    /// [`record`](Self::record) within a frame — the buffer cannot be copied
106    /// into while it is mapped.
107    pub(super) fn consume(&mut self, device: &wgpu::Device) -> Option<CaptureImage> {
108        let armed = self.armed.as_ref()?;
109        // The one poll on this path, and it is the non-blocking kind. A `Wait`
110        // here is the defect the whole module exists to avoid.
111        let _ = device.poll(wgpu::PollType::Poll);
112        match armed.try_recv() {
113            Ok(Ok(())) => {}
114            // Still in flight. Not an error and not a dropped frame — the buffer
115            // stays armed and the next frame asks again.
116            Err(TryRecvError::Empty) => return None,
117            // Mapping failed, or the callback was dropped without firing. Either
118            // way this cycle is over: disarm so the next frame records afresh.
119            Ok(Err(_)) | Err(TryRecvError::Disconnected) => {
120                self.armed = None;
121                return None;
122            }
123        }
124        self.armed = None;
125        let slice = self.buffer.slice(..);
126        let image = slice.get_mapped_range().ok().map(|mapped| CaptureImage {
127            width: self.width,
128            height: self.height,
129            rgba: capture::unpad_rows(&mapped, self.width, self.height, self.padded_bpr),
130        });
131        // Unmapped whether or not the range was readable: a buffer left mapped
132        // is one this readback can never record into again.
133        self.buffer.unmap();
134        image
135    }
136
137    /// Fill the tap from `preview` and record the copy out of it, if the buffer
138    /// is free.
139    ///
140    /// Two recorded steps rather than one: the blit scales and letterboxes the
141    /// show into the tap's fixed shape, and the copy that follows reads a
142    /// texture whose extent has not moved since this readback was opened.
143    ///
144    /// Returns whether it recorded, which the caller needs: [`arm`](Self::arm)
145    /// must be called after the submission if and only if this did.
146    pub(super) fn record(
147        &mut self,
148        device: &wgpu::Device,
149        encoder: &mut wgpu::CommandEncoder,
150        preview: &preview::PreviewTarget,
151    ) -> bool {
152        if self.armed.is_some() || !self.tap.record_fill_from(device, encoder, preview) {
153            return false;
154        }
155        capture::record_copy(
156            encoder,
157            self.tap.texture(),
158            &self.buffer,
159            self.padded_bpr,
160            self.width,
161            self.height,
162        );
163        true
164    }
165
166    /// Ask for the mapping, after the submission carrying the recorded copy.
167    ///
168    /// The callback only sends; every decision is taken on the display thread
169    /// when it next polls, so nothing wgpu calls back into does work.
170    pub(super) fn arm(&mut self) {
171        let (tx, rx) = std::sync::mpsc::channel();
172        self.buffer
173            .slice(..)
174            .map_async(wgpu::MapMode::Read, move |res| {
175                let _ = tx.send(res);
176            });
177        self.armed = Some(rx);
178    }
179}
180
181/// The renderer-facing half: opening and closing the readback, and the two steps
182/// a frame drawn through the intermediate performs.
183impl Renderer {
184    /// Open a non-blocking readback of the preview, yielding `width`x`height`
185    /// frames.
186    ///
187    /// Requires an open preview — the blit that fills the readback's tap samples
188    /// that intermediate and has nothing to read without one. The **size is the
189    /// caller's** and is answered for the life of the readback: a renderer
190    /// resize rebuilds the intermediate under the blit and moves nothing here
191    /// (ADR-0187).
192    ///
193    /// Calling it again replaces the readback, which is how a caller changes the
194    /// size it asked for.
195    ///
196    /// Refused when the frames would come out at a format no consumer can be
197    /// told the order of, so the announcement that follows can always be true
198    /// (ADR-0187).
199    pub fn open_preview_readback(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
200        let Some(preview) = self.preview.as_ref() else {
201            return Err(RenderError::CaptureReadback);
202        };
203        let format = preview.format();
204        if PixelOrder::of(format).is_none() {
205            return Err(RenderError::UnnameablePixelOrder(format));
206        }
207        self.preview_readback = Some(PreviewReadback::new(
208            &self.ctx.device,
209            format,
210            width,
211            height,
212        ));
213        Ok(())
214    }
215
216    /// Close the readback and free its staging buffer. A closed readback yields
217    /// nothing and costs the frame one `Option` test.
218    pub fn close_preview_readback(&mut self) {
219        self.preview_readback = None;
220    }
221
222    /// The size of the frames the readback yields, or `None` when it is closed.
223    ///
224    /// **The size the caller asked for, and not the output's.** The frames are a
225    /// scaled, letterboxed copy of the intermediate rather than an exact one, so
226    /// this is a fixed property of the open readback: it answers the same pair
227    /// across every resize, and a consumer told it once never has to be told
228    /// again.
229    pub fn preview_readback_size(&self) -> Option<(u32, u32)> {
230        self.preview_readback.as_ref().map(PreviewReadback::size)
231    }
232
233    /// Take the frame the readback produced, if one has landed.
234    ///
235    /// A frame is available at most every other call in the steady state, and
236    /// `None` means "not yet" rather than "never": the caller sends what it gets
237    /// and does not wait.
238    pub fn take_preview_frame(&mut self) -> Option<CaptureImage> {
239        self.preview_frame.take()
240    }
241
242    /// The consume-then-record half, before the frame's submission.
243    ///
244    /// Returns whether a copy was recorded, which decides whether
245    /// [`arm_preview_readback`](Self::arm_preview_readback) runs after it.
246    pub(super) fn step_preview_readback(&mut self, encoder: &mut wgpu::CommandEncoder) -> bool {
247        let Self {
248            ctx,
249            preview,
250            preview_readback,
251            preview_frame,
252            ..
253        } = self;
254        let (Some(readback), Some(preview)) = (preview_readback.as_mut(), preview.as_ref()) else {
255            return false;
256        };
257        // Consumed first: the buffer cannot be recorded into while it is mapped,
258        // so this frame's copy is only possible once the previous one has been
259        // taken. A frame the caller never collected is replaced rather than
260        // queued — the newest picture is the one a preview wants.
261        if let Some(image) = readback.consume(&ctx.device) {
262            *preview_frame = Some(image);
263        }
264        readback.record(&ctx.device, encoder, preview)
265    }
266
267    /// Ask for the mapping, after the submission that carried the copy.
268    pub(super) fn arm_preview_readback(&mut self) {
269        if let Some(readback) = self.preview_readback.as_mut() {
270            readback.arm();
271        }
272    }
273}