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}