Skip to main content

rlx_core/render/
capture_api.rs

1//! The capture API — `Renderer`'s headless, off-hot-path entry points
2//! (Plan 0013), carved out of `render/mod.rs` by Plan 0061 Phase 3.
3//!
4//! **Dev-tooling API, with one exception at the bottom of the file.** Every
5//! `capture_*` entry point here is driven by the `shot` example and
6//! `core/tests/`, never by the standalone's frame loop and never from behind the
7//! C ABI: each blocks on a GPU readback, so calling one from a *display* loop is
8//! a stutter by construction. The frame tap (Plan 0115) blocks on the same
9//! readback and is nonetheless a live path — a headless source has no present
10//! deadline to miss, only throughput to hold, and its readback is what bounds
11//! the run's memory. It lives here because it shares the offscreen machinery,
12//! not because it shares the caller.
13//!
14//! It is a second `impl Renderer` block rather than a separate type, because the
15//! methods are public API whose paths must not move — `Renderer::capture_preset`
16//! is spelled the same before and after this split.
17
18// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
19// hygiene guard). These paths are off the frame loop, but they share
20// `Renderer`'s state and the pragma travels with the code, not with the file.
21#![deny(
22    clippy::unwrap_used,
23    clippy::expect_used,
24    clippy::indexing_slicing,
25    clippy::panic,
26    clippy::unreachable
27)]
28
29// A continuation of one `impl` block that was split across two files, so it
30// needs the same names `render/mod.rs` has in scope. Enumerating them would be a
31// list to keep in sync with a file whose whole purpose is to be the other half
32// of this one.
33use super::*;
34
35/// What one [`Renderer::capture_audio_after_warmup`] run produced.
36///
37/// The two fields beside the images exist so a caller can check *what the run
38/// did* rather than infer it from a stopwatch (Plan 0084 Phase 3): `analysis`
39/// is the analyzer state the run walked through, and `rendered` is how much of
40/// it reached a rasterizer.
41pub struct AudioCapture {
42    /// The requested frames, in `at_frames` order.
43    pub images: Vec<CaptureImage>,
44    /// One published [`AnalysisFrame`] per hop, in hop order. Independent of
45    /// whether the hop was rendered — which is the property that makes feeding
46    /// warm-up hops without pixels safe, and is asserted rather than argued
47    /// (`core/tests/suite/capture_advance.rs`).
48    pub analysis: Vec<AnalysisFrame>,
49    /// How many frames were rasterized. Zero when every hop is a warm-up hop.
50    pub rendered: usize,
51}
52
53impl Renderer {
54    /// Advance the scene clock one step and capture that single frame into an
55    /// offscreen texture, returning tight RGBA (Plan 0013). Off the hot path —
56    /// blocks on GPU readback; never call it from a live loop.
57    pub fn capture_frame(&mut self, frame: &AnalysisFrame) -> Result<CaptureImage, RenderError> {
58        self.time += scenes::FALLBACK_DT;
59        self.capture_at_clock(frame)
60    }
61
62    /// Draw the active preset for `frame` at the **current** clock into a fresh
63    /// offscreen texture and read it back. Does not advance the clock, so
64    /// callers that already stepped it share this. The whole path (clear → draw
65    /// → copy → map) is deterministic for a given `(preset, frame, clock)`.
66    fn capture_at_clock(&mut self, frame: &AnalysisFrame) -> Result<CaptureImage, RenderError> {
67        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
68        let format = self.ctx.surface_format();
69        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
70        let (buffer, padded_bpr) = capture::create_readback(&self.ctx.device, width, height);
71        let mut encoder = self
72            .ctx
73            .device
74            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
75                label: Some("rlx-capture-frame"),
76            });
77        // The preview intermediate, when one is open, sits between the draw and
78        // the destination here exactly as it does on the present path — same
79        // clear, same draw, same `copy_texture_to_texture`. That is what lets
80        // the intermediate's one real claim — that a frame routed through it is
81        // byte-identical to one drawn straight at the target — be asserted with
82        // no window, in `core/tests/suite/console_preview.rs`.
83        let preview = self.preview.take_target();
84        capture::record_clear(&mut encoder, preview.as_ref().map_or(&view, |p| &p.view));
85        let _ = self.draw_frame(
86            frame,
87            &mut encoder,
88            preview.as_ref().map_or(&view, |p| &p.view),
89            (width, height),
90            scenes::FALLBACK_DT,
91            SaltMode::Pinned,
92        );
93        if let Some(p) = preview.as_ref() {
94            p.record_copy_to(&mut encoder, &texture);
95        }
96        self.preview.restore_target(preview);
97        // The preview readback advances on **every** frame drawn through the
98        // intermediate, which is this path as much as the present path: the two
99        // record the same clear, draw and copy, and stating the rule once is
100        // what lets the readback's own claims be asserted with no window
101        // (`core/tests/suite/console_preview.rs`). It changes nothing about the image
102        // returned below — it is an extra copy out of the intermediate, not a
103        // change to what was drawn into it.
104        let recorded = self.preview.step_readback(&self.ctx.device, &mut encoder);
105        capture::record_copy(&mut encoder, &texture, &buffer, padded_bpr, width, height);
106        self.ctx.queue.submit(std::iter::once(encoder.finish()));
107        if recorded {
108            self.preview.arm_readback();
109        }
110
111        #[cfg(feature = "text")]
112        self.text_layer.end_frame();
113
114        capture::read_back(&self.ctx.device, &buffer, width, height, padded_bpr)
115    }
116
117    /// Capture preset `name` after advancing it `frames` steps from a fixed
118    /// initial state, driven by a single constant `frame` (Plan 0013). A **pure
119    /// function** of `(name, frame, frames)`: the scenes are rebuilt so any
120    /// stateful system (e.g. the seeded swarm particles) starts from its
121    /// deterministic seed, and the scene clock resets to `0.0`, so the result is
122    /// independent of any earlier capture. Errors if `name` is not in the
123    /// roster. `frames` is treated as at least 1.
124    pub fn capture_preset(
125        &mut self,
126        name: &str,
127        frame: &AnalysisFrame,
128        frames: u32,
129    ) -> Result<CaptureImage, RenderError> {
130        self.reset_for_capture(name)?;
131
132        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
133        let format = self.ctx.surface_format();
134        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
135
136        // Warm the scene through the first frames-1 steps (state advances, pixels
137        // discarded); then capture the final frame.
138        let n = frames.max(1);
139        for _ in 1..n {
140            self.time += scenes::FALLBACK_DT;
141            self.step_offscreen(frame, &view, width, height, scenes::FALLBACK_DT);
142        }
143        self.time += scenes::FALLBACK_DT;
144
145        let (buffer, padded_bpr) = capture::create_readback(&self.ctx.device, width, height);
146        let mut encoder = self
147            .ctx
148            .device
149            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
150                label: Some("rlx-capture-preset"),
151            });
152        capture::record_clear(&mut encoder, &view);
153        let _ = self.draw_frame(
154            frame,
155            &mut encoder,
156            &view,
157            (width, height),
158            scenes::FALLBACK_DT,
159            SaltMode::Pinned,
160        );
161        capture::record_copy(&mut encoder, &texture, &buffer, padded_bpr, width, height);
162        self.ctx.queue.submit(std::iter::once(encoder.finish()));
163
164        #[cfg(feature = "text")]
165        self.text_layer.end_frame();
166
167        capture::read_back(&self.ctx.device, &buffer, width, height, padded_bpr)
168    }
169
170    /// Capture preset `name` across a **time-varying** stimulus (Plan 0037):
171    /// one rendered frame per entry of `stimulus`, read back in order, so the
172    /// returned images are the response *while it changes* rather than after it
173    /// settles.
174    ///
175    /// This is the primitive [`capture_preset`](Self::capture_preset) cannot be:
176    /// holding one frame for every step converges every smoother before the
177    /// pixels are read, which makes the result identical for any `[smoothing]`
178    /// constant (ADR-0039). `capture_preset` is left exactly as it was — four
179    /// suites and `--report` consume it — and this is its sibling, sharing the
180    /// same `reset_for_capture` seed so both are pure
181    /// functions of their arguments.
182    ///
183    /// The clock advances one `FALLBACK_DT` per entry, so
184    /// index `i` is second `i * dt` of the response. An empty `stimulus` yields
185    /// no images. Errors if `name` is not in the roster.
186    ///
187    /// **Off the hot path, and more so than its sibling** — it blocks on a GPU
188    /// readback *per frame*, not once per call. The target and readback buffer
189    /// are allocated up front rather than per frame, because building GPU
190    /// resources mid-sequence perturbs what the feedback stages resolve to on the
191    /// DX12 software adapter (a WARP reading, unverified on lavapipe as of
192    /// 2026-10-06).
193    pub fn capture_preset_over(
194        &mut self,
195        name: &str,
196        stimulus: &[AnalysisFrame],
197    ) -> Result<Vec<CaptureImage>, RenderError> {
198        self.reset_for_capture(name)?;
199
200        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
201        let format = self.ctx.surface_format();
202        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
203        let (buffer, padded_bpr) = capture::create_readback(&self.ctx.device, width, height);
204
205        let mut images = Vec::with_capacity(stimulus.len());
206        for frame in stimulus {
207            self.time += scenes::FALLBACK_DT;
208            let mut encoder =
209                self.ctx
210                    .device
211                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
212                        label: Some("rlx-capture-over"),
213                    });
214            capture::record_clear(&mut encoder, &view);
215            let _ = self.draw_frame(
216                frame,
217                &mut encoder,
218                &view,
219                (width, height),
220                scenes::FALLBACK_DT,
221                SaltMode::Pinned,
222            );
223            capture::record_copy(&mut encoder, &texture, &buffer, padded_bpr, width, height);
224            self.ctx.queue.submit(std::iter::once(encoder.finish()));
225
226            #[cfg(feature = "text")]
227            self.text_layer.end_frame();
228
229            images.push(capture::read_back(
230                &self.ctx.device,
231                &buffer,
232                width,
233                height,
234                padded_bpr,
235            )?);
236        }
237        Ok(images)
238    }
239
240    /// Advance preset `name` under a single constant `frame` and read back only
241    /// the frames named in `at_frames` (Plan 0085 Phase 1) — the **long-run**
242    /// primitive, and the one a horizon needs.
243    ///
244    /// Its two siblings cannot serve a run of tens of thousands of frames:
245    /// [`capture_preset`](Self::capture_preset) reseeds from scratch on every
246    /// call, so sampling *k* points costs `O(k·N)` renders, and
247    /// [`capture_preset_over`](Self::capture_preset_over) reads back *every*
248    /// frame, so a ten-minute run at 720p would materialize ~36,000 images. This
249    /// renders `N` frames once and holds `at_frames.len()` of them.
250    ///
251    /// Frame numbering matches [`capture_audio`](Self::capture_audio): frame 0
252    /// is the first advanced frame, so `at_frames = [n - 1]` returns exactly what
253    /// `capture_preset(name, frame, n)` returns — asserted in
254    /// `core/tests/suite/capture_advance.rs` rather than argued, because it is the
255    /// property that lets a horizon's rows be compared with every other capture
256    /// this repo takes.
257    ///
258    /// Deterministic on the same terms as its siblings: scenes are rebuilt to
259    /// their seed, the clock resets to `0.0`, and the step is a fixed
260    /// `FALLBACK_DT` — so a row at index *k* does not
261    /// depend on how far the run was asked to go. Images come back in
262    /// `at_frames` order; a repeated index yields the same frame twice rather
263    /// than rendering it twice. An empty `at_frames` renders nothing.
264    ///
265    /// **Off the hot path** — it blocks on a GPU readback per requested frame.
266    /// The readback buffer is built **once, at the first requested frame**, and
267    /// reused for every later one. Both halves of that matter on the DX12
268    /// software adapter, where building GPU resources mid-sequence perturbs what
269    /// the feedback stages resolve to (the hazard
270    /// [`capture_preset_over`](Self::capture_preset_over) documents, and a
271    /// horizon is precisely a long feedback sequence): reusing it means the
272    /// perturbation happens once rather than per sample, and doing it at the
273    /// first sample rather than up front is what puts the allocation at the same
274    /// point in the sequence [`capture_preset`](Self::capture_preset) puts it —
275    /// which is what makes the two agree pixel-for-pixel on WARP as well as on
276    /// hardware. It also stays independent of the horizon requested, since the
277    /// first sample sits at the same frame index however long the run is. (The
278    /// perturbation and the agreement are WARP readings, unverified on lavapipe as
279    /// of 2026-10-06.)
280    pub fn capture_preset_at(
281        &mut self,
282        name: &str,
283        frame: &AnalysisFrame,
284        at_frames: &[u32],
285    ) -> Result<Vec<CaptureImage>, RenderError> {
286        self.reset_for_capture(name)?;
287        let Some(&last) = at_frames.iter().max() else {
288            return Ok(Vec::new());
289        };
290
291        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
292        let format = self.ctx.surface_format();
293        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
294        let mut readback: Option<(wgpu::Buffer, u32)> = None;
295
296        let mut captured: Vec<(u32, CaptureImage)> = Vec::with_capacity(at_frames.len());
297        for index in 0..=last {
298            self.time += scenes::FALLBACK_DT;
299            if !at_frames.contains(&index) {
300                self.step_offscreen(frame, &view, width, height, scenes::FALLBACK_DT);
301                continue;
302            }
303            let slot = readback
304                .get_or_insert_with(|| capture::create_readback(&self.ctx.device, width, height));
305            let (buffer, padded_bpr) = (&slot.0, slot.1);
306            let mut encoder =
307                self.ctx
308                    .device
309                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
310                        label: Some("rlx-capture-at"),
311                    });
312            capture::record_clear(&mut encoder, &view);
313            let _ = self.draw_frame(
314                frame,
315                &mut encoder,
316                &view,
317                (width, height),
318                scenes::FALLBACK_DT,
319                SaltMode::Pinned,
320            );
321            capture::record_copy(&mut encoder, &texture, buffer, padded_bpr, width, height);
322            self.ctx.queue.submit(std::iter::once(encoder.finish()));
323
324            #[cfg(feature = "text")]
325            self.text_layer.end_frame();
326
327            let img = capture::read_back(&self.ctx.device, buffer, width, height, padded_bpr)?;
328            captured.push((index, img));
329        }
330
331        at_frames
332            .iter()
333            .map(|idx| {
334                captured
335                    .iter()
336                    .find(|(i, _)| i == idx)
337                    .map(|(_, img)| img.clone())
338                    .ok_or(RenderError::CaptureReadback)
339            })
340            .collect()
341    }
342
343    /// Advance preset `name` one frame per entry of `stimulus` and read back only
344    /// the frames named in `at_frames` — the sibling that is
345    /// [`capture_preset_over`](Self::capture_preset_over) in what it renders and
346    /// [`capture_preset_at`](Self::capture_preset_at) in what it reads back.
347    ///
348    /// A long time-varying run needs both halves: a held frame never re-fires
349    /// an edge-triggered binding such as voxel's `reseed`, and reading back
350    /// every frame of a 900-frame run pays 900 readbacks to keep two.
351    ///
352    /// Frame `i` is the frame `capture_preset_over(name, stimulus)` returns at
353    /// index `i`, byte for byte — asserted in `core/tests/suite/capture_advance.rs`.
354    /// Rendering stops at the largest requested index, so `stimulus` entries
355    /// past it are never drawn. Images come back in `at_frames` order; a
356    /// repeated index yields the same frame twice. An empty `at_frames` renders
357    /// nothing, and an index at or past `stimulus.len()` is
358    /// [`RenderError::CaptureReadback`], since that frame is never drawn.
359    ///
360    /// **Off the hot path** — it blocks on a GPU readback per requested frame.
361    /// The readback buffer follows `capture_preset_at`'s rule: built once, at the
362    /// first requested frame, and reused for every later one.
363    pub fn capture_preset_over_at(
364        &mut self,
365        name: &str,
366        stimulus: &[AnalysisFrame],
367        at_frames: &[usize],
368    ) -> Result<Vec<CaptureImage>, RenderError> {
369        self.reset_for_capture(name)?;
370        let Some(&last) = at_frames.iter().max() else {
371            return Ok(Vec::new());
372        };
373
374        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
375        let format = self.ctx.surface_format();
376        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
377        let mut readback: Option<(wgpu::Buffer, u32)> = None;
378
379        let mut captured: Vec<(usize, CaptureImage)> = Vec::with_capacity(at_frames.len());
380        for (index, frame) in stimulus.iter().enumerate().take(last.saturating_add(1)) {
381            self.time += scenes::FALLBACK_DT;
382            if !at_frames.contains(&index) {
383                self.step_offscreen(frame, &view, width, height, scenes::FALLBACK_DT);
384                continue;
385            }
386            let slot = readback
387                .get_or_insert_with(|| capture::create_readback(&self.ctx.device, width, height));
388            let (buffer, padded_bpr) = (&slot.0, slot.1);
389            let mut encoder =
390                self.ctx
391                    .device
392                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
393                        label: Some("rlx-capture-over-at"),
394                    });
395            capture::record_clear(&mut encoder, &view);
396            let _ = self.draw_frame(
397                frame,
398                &mut encoder,
399                &view,
400                (width, height),
401                scenes::FALLBACK_DT,
402                SaltMode::Pinned,
403            );
404            capture::record_copy(&mut encoder, &texture, buffer, padded_bpr, width, height);
405            self.ctx.queue.submit(std::iter::once(encoder.finish()));
406
407            #[cfg(feature = "text")]
408            self.text_layer.end_frame();
409
410            let img = capture::read_back(&self.ctx.device, buffer, width, height, padded_bpr)?;
411            captured.push((index, img));
412        }
413
414        at_frames
415            .iter()
416            .map(|idx| {
417                captured
418                    .iter()
419                    .find(|(i, _)| i == idx)
420                    .map(|(_, img)| img.clone())
421                    .ok_or(RenderError::CaptureReadback)
422            })
423            .collect()
424    }
425
426    /// Render preset `name` for `frames` frames at an **injected** `dt`, handing
427    /// each frame to `sink` the moment it is read back (Plan 0101 / ADR-0114) —
428    /// the **streaming** primitive, and the one an offline video render needs.
429    ///
430    /// Its three siblings all return a `Vec<CaptureImage>`, which is exactly what
431    /// a video render cannot afford: a 1080p frame is 8.29 MB, so a four-minute
432    /// track at 60 fps is 119 GB of retained images. Nothing is retained here —
433    /// the frame is handed to `sink` and dropped, so the resident set of a
434    /// 14,400-frame render is the same as a 100-frame one.
435    ///
436    /// It is also the only capture entry point whose step is **not** the fixed
437    /// `FALLBACK_DT`. A render at `--fps 30` advances the
438    /// scene by 1/30 s a frame, or the visuals would run at half speed against
439    /// their own soundtrack; that `dt` is the caller's, exactly as it is for the
440    /// live frontend (ADR-0013). At 60 fps `dt` *is* `FALLBACK_DT`, which is what
441    /// makes a rendered frame comparable with every other capture this repo takes.
442    ///
443    /// `analysis` supplies the [`AnalysisFrame`] for each frame index. The audio
444    /// hop clock and the frame clock are different clocks and only the caller
445    /// knows the mapping between them, so this deliberately does not walk PCM —
446    /// unlike [`capture_audio`](Self::capture_audio), which welds one rendered
447    /// frame to one analysis hop.
448    ///
449    /// Deterministic on the same terms as its siblings: scenes rebuilt to their
450    /// seed, the clock reset to `0.0`, the salt pinned. Given a deterministic
451    /// `analysis` the whole run is a pure function of `(name, frames, dt)`.
452    ///
453    /// **Off the hot path** — it blocks on a GPU readback every frame, which is
454    /// also what bounds its memory: `read_back` polls, so each frame's submission
455    /// is retired before the next is encoded (the retention Plan 0099 measured).
456    /// The target and the readback buffer are built **once** and reused, so a
457    /// long run allocates no GPU resources mid-sequence.
458    ///
459    /// A `sink` error stops the run and comes back as
460    /// [`RenderError::Sink`] carrying the consumer's own
461    /// message.
462    ///
463    /// A `dt` that is not finite and positive is replaced by `FALLBACK_DT` for the
464    /// whole run (`sanitize_frame_dt`, ADR-0191).
465    pub fn capture_stream(
466        &mut self,
467        name: &str,
468        frames: u32,
469        dt: f32,
470        analysis: &mut dyn FnMut(u32) -> AnalysisFrame,
471        sink: &mut dyn FnMut(u32, &CaptureImage) -> Result<(), String>,
472    ) -> Result<(), RenderError> {
473        let dt = super::sanitize_frame_dt(dt);
474        self.reset_for_capture(name)?;
475
476        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
477        let format = self.ctx.surface_format();
478        let (texture, view) = capture::create_target(&self.ctx.device, format, width, height);
479        let (buffer, padded_bpr) = capture::create_readback(&self.ctx.device, width, height);
480
481        for index in 0..frames {
482            let frame = analysis(index);
483            self.time += dt;
484            let mut encoder =
485                self.ctx
486                    .device
487                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
488                        label: Some("rlx-capture-stream"),
489                    });
490            capture::record_clear(&mut encoder, &view);
491            let _ = self.draw_frame(
492                &frame,
493                &mut encoder,
494                &view,
495                (width, height),
496                dt,
497                SaltMode::Pinned,
498            );
499            capture::record_copy(&mut encoder, &texture, &buffer, padded_bpr, width, height);
500            self.ctx.queue.submit(std::iter::once(encoder.finish()));
501
502            #[cfg(feature = "text")]
503            self.text_layer.end_frame();
504
505            let img = capture::read_back(&self.ctx.device, &buffer, width, height, padded_bpr)?;
506            sink(index, &img).map_err(RenderError::Sink)?;
507        }
508        Ok(())
509    }
510
511    /// Select `name` and reset every stateful system to its deterministic seed —
512    /// the shared preamble of [`capture_preset`](Self::capture_preset) and
513    /// [`capture_preset_over`](Self::capture_preset_over), so both are pure
514    /// functions of their arguments and neither inherits an earlier capture's
515    /// history.
516    fn reset_for_capture(&mut self, name: &str) -> Result<(), RenderError> {
517        if !self.select_preset_by_name_now(name) {
518            return Err(RenderError::UnknownPreset(name.to_string()));
519        }
520        self.scenes =
521            scenes::create_all(&self.ctx.device, COMPOSITE_FORMAT, &self.tier, self.budget);
522        self.cancel_transition();
523        self.side.reset_resources();
524        self.tonemap.reset_resources();
525        self.ink.reset_resources();
526        self.blend.reset_resources();
527        self.time = 0.0;
528        // The rebuilt scenes are fresh — re-apply the active preset's structural
529        // config (ADR-0007) so a line scene captures with its geometry built.
530        self.configure_active_scene();
531        Ok(())
532    }
533
534    /// Drive preset `name` with **real audio through the real analyzer** and
535    /// capture the frames at `at_frames` (Plan 0013). The PCM is fed hop-by-hop
536    /// into a fresh [`Analyzer`](crate::dsp::Analyzer) (format validated at the
537    /// intake boundary — the source-agnostic rule); each produced
538    /// [`AnalysisFrame`] drives one rendered frame, so `at_frames` indexes the
539    /// hop sequence (frame 0 is the first hop). Deterministic: scenes are rebuilt
540    /// to their seed and the clock resets to 0, exactly like
541    /// [`capture_preset`](Self::capture_preset).
542    ///
543    /// This is in-memory PCM only — no file, decoder, or OS audio-source code,
544    /// just like a frontend pushing samples. Returned images are in `at_frames`
545    /// order; an index past the audio length is an error.
546    pub fn capture_audio(
547        &mut self,
548        name: &str,
549        pcm: &[f32],
550        format: AudioFormat,
551        at_frames: &[u32],
552    ) -> Result<Vec<CaptureImage>, RenderError> {
553        Ok(self
554            .capture_audio_after_warmup(name, pcm, format, at_frames, 0)?
555            .images)
556    }
557
558    /// [`capture_audio`](Self::capture_audio), with the first `warmup_hops` hops
559    /// **advanced but not rasterized** (Plan 0084 Phase 3).
560    ///
561    /// A warm-up hop still pushes its samples, still publishes its
562    /// [`AnalysisFrame`], and still advances the scene clock by one
563    /// `FALLBACK_DT` — the hop happened, it just did not
564    /// draw. What it skips is the render pass, which is why a caller that only
565    /// needs the analyzer warm (`core/tests/reactivity.rs` drives
566    /// `WARMUP_HOPS` of them per capture, at silence, and reads none of them
567    /// back) stops paying a full rasterization per hop to reach a DSP state that
568    /// needs no pixels.
569    ///
570    /// **This does not warm GPU-side scene state.** Analysis is a pure function
571    /// of its window and the render pass never touches the analyzer, so the
572    /// published frames are bit-for-bit what they would have been — but a scene
573    /// that *integrates* on the GPU (particles, trails, reaction-diffusion) has
574    /// that many fewer steps behind it at the first rendered hop. Time-driven
575    /// scenes are unaffected, since the clock advances either way.
576    ///
577    /// An `at_frames` entry inside the warm-up span was never rendered and is an
578    /// error, the same one an index past the audio length gives.
579    pub fn capture_audio_after_warmup(
580        &mut self,
581        name: &str,
582        pcm: &[f32],
583        format: AudioFormat,
584        at_frames: &[u32],
585        warmup_hops: usize,
586    ) -> Result<AudioCapture, RenderError> {
587        if !self.select_preset_by_name_now(name) {
588            return Err(RenderError::UnknownPreset(name.to_string()));
589        }
590        let mut analyzer = crate::dsp::Analyzer::new(format).map_err(RenderError::AudioFormat)?;
591
592        self.scenes =
593            scenes::create_all(&self.ctx.device, COMPOSITE_FORMAT, &self.tier, self.budget);
594        self.cancel_transition();
595        self.side.reset_resources();
596        self.tonemap.reset_resources();
597        self.ink.reset_resources();
598        self.blend.reset_resources();
599        self.time = 0.0;
600        self.configure_active_scene();
601
602        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
603        let target_format = self.ctx.surface_format();
604        let (texture, view) =
605            capture::create_target(&self.ctx.device, target_format, width, height);
606
607        let hop_samples = crate::dsp::HOP_SIZE * format.channels as usize;
608        let mut captured: Vec<(u32, CaptureImage)> = Vec::with_capacity(at_frames.len());
609        let mut published: Vec<AnalysisFrame> = Vec::new();
610        let mut rendered = 0usize;
611
612        for (index, hop) in pcm.chunks(hop_samples).enumerate() {
613            let frame_index = index as u32;
614            analyzer.push_interleaved(hop);
615            let analysis = analyzer.take_frame();
616            self.time += scenes::FALLBACK_DT;
617            published.push(analysis);
618
619            if index < warmup_hops {
620                continue;
621            }
622            rendered += 1;
623
624            let wanted = at_frames.contains(&frame_index)
625                && !captured.iter().any(|(i, _)| *i == frame_index);
626            if wanted {
627                let (buffer, padded_bpr) =
628                    capture::create_readback(&self.ctx.device, width, height);
629                let mut encoder =
630                    self.ctx
631                        .device
632                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
633                            label: Some("rlx-capture-audio"),
634                        });
635                capture::record_clear(&mut encoder, &view);
636                let _ = self.draw_frame(
637                    &analysis,
638                    &mut encoder,
639                    &view,
640                    (width, height),
641                    scenes::FALLBACK_DT,
642                    SaltMode::Pinned,
643                );
644                capture::record_copy(&mut encoder, &texture, &buffer, padded_bpr, width, height);
645                self.ctx.queue.submit(std::iter::once(encoder.finish()));
646                #[cfg(feature = "text")]
647                self.text_layer.end_frame();
648                let img = capture::read_back(&self.ctx.device, &buffer, width, height, padded_bpr)?;
649                captured.push((frame_index, img));
650            } else {
651                self.step_offscreen(&analysis, &view, width, height, scenes::FALLBACK_DT);
652            }
653        }
654
655        let images = at_frames
656            .iter()
657            .map(|idx| {
658                captured
659                    .iter()
660                    .find(|(i, _)| i == idx)
661                    .map(|(_, img)| img.clone())
662                    .ok_or(RenderError::CaptureReadback)
663            })
664            .collect::<Result<Vec<_>, _>>()?;
665
666        Ok(AudioCapture {
667            images,
668            analysis: published,
669            rendered,
670        })
671    }
672
673    /// Draw one frame into `view` and submit it — advancing scene state without
674    /// reading anything back. The warm-up step [`capture_preset`] uses to reach
675    /// frame `N`.
676    ///
677    /// **It polls, and that is what bounds the memory of a long run** (Plan
678    /// 0099). Nothing else here reads anything back, so before this line the
679    /// only `device.poll` in the whole capture path was
680    /// [`capture::read_back`]'s — meaning wgpu got no opportunity to retire a
681    /// completed submission between two *sampled* frames. A horizon at the
682    /// default 30 s interval is 1,800 consecutive unpolled submits, and the
683    /// retention is per **pass**, not per pixel: measured over one such stretch
684    /// on the Windows dev box (hardware adapter, debug, 96x96), a
685    /// reaction-diffusion world — 12 simulation sub-steps plus a present, 13
686    /// passes a frame — retained **950 KB per frame** against a captured frame
687    /// of 36 KB, while single-pass worlds retained ~30 KB. That is what made
688    /// the ceiling look like a property of the RD family: every world grew, RD
689    /// grew ~32x faster and hit the allocator first, at ~4.4 GB.
690    fn step_offscreen(
691        &mut self,
692        frame: &AnalysisFrame,
693        view: &wgpu::TextureView,
694        width: u32,
695        height: u32,
696        dt: f32,
697    ) {
698        let mut encoder = self
699            .ctx
700            .device
701            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
702                label: Some("rlx-capture-step"),
703            });
704        capture::record_clear(&mut encoder, view);
705        let _ = self.draw_frame(
706            frame,
707            &mut encoder,
708            view,
709            (width, height),
710            dt,
711            SaltMode::Pinned,
712        );
713        self.ctx.queue.submit(std::iter::once(encoder.finish()));
714
715        // Retire this submission's resources. `wait_indefinitely` rather than a
716        // non-blocking `Poll`, and that was **measured, not assumed**: a
717        // `PollType::Poll` here took the same 3,600-frame stretch from
718        // 3,668 MB to 3,188 MB and no further, because a headless loop submits
719        // far faster than the GPU drains and a non-blocking poll finds almost
720        // nothing complete to retire. Waiting is what makes the retention
721        // per-frame instead of per-run.
722        //
723        // It is the same `poll(Wait)` `capture::read_back` already performs at
724        // every sampled frame, so this path pays what the sampled path always
725        // paid — and this whole file is off the hot path by construction (see
726        // the module docs); nothing in the frame loop or behind the C ABI
727        // reaches it.
728        //
729        // The result is discarded for the same reason `draw_frame`'s is above —
730        // this returns nothing, and a poll that fails means the device is gone,
731        // which the next `read_back` reports as `CaptureReadback` rather than
732        // letting the run pass silently.
733        let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
734
735        #[cfg(feature = "text")]
736        self.text_layer.end_frame();
737    }
738}
739
740// ---------------------------------------------------------------------------
741// The sustained frame tap (Plan 0115 Phase 2)
742// ---------------------------------------------------------------------------
743
744impl Renderer {
745    /// Open a [`FrameTap`] sized to this renderer's configured target — the one
746    /// GPU allocation a tapped run makes, so the per-frame path makes none.
747    ///
748    /// The tap is fixed at the size it is built with. A [`resize`](Renderer::resize)
749    /// underneath a live tap leaves the two disagreeing, and the tap wins: it is
750    /// what [`render_tapped`](Self::render_tapped) draws and copies against.
751    /// Reopen after a resize to follow it.
752    ///
753    /// Infallible: `RenderContext` floors both dimensions at 1 where the size
754    /// enters, so there is nothing left to reject here.
755    pub fn open_tap(&self) -> FrameTap {
756        FrameTap::new(
757            &self.ctx.device,
758            &self.ctx.queue,
759            self.ctx.surface_format(),
760            self.ctx.config.width,
761            self.ctx.config.height,
762        )
763    }
764
765    /// Advance the scene clock by `dt` real seconds, draw the active preset for
766    /// `frame` through the same `draw_frame` the window presents through, and
767    /// hand back the **previous** call's frame out of `tap`.
768    ///
769    /// `dt` is **per call**, which is the difference between this and
770    /// [`capture_stream`](Self::capture_stream)'s one fixed step: a caller that
771    /// falls behind the wall clock yields fewer, correctly-timed frames rather
772    /// than a picture running slow against the music.
773    ///
774    /// Draws under `SaltMode::Live`, because a tap is a live render path and
775    /// not a capture: a preset declaring `seed = "random"` (ADR-0051) must vary
776    /// per launch here exactly as it does in the window. Both salts are equal for
777    /// every preset that declares anything else, which is why a tapped frame and
778    /// a [`capture_frame`](Self::capture_frame) of the same preset at the same
779    /// clock are still byte-identical (`core/tests/suite/frame_tap.rs`).
780    ///
781    /// # One frame in flight, and `Ok(None)` is the ordinary first answer
782    ///
783    /// This frame's submission carries the copy out of the tap's texture, the
784    /// *previous* frame's map is taken before it is drawn, and the caller sees
785    /// frame `N` while frame `N+1` is on the GPU. So the first call after
786    /// [`open_tap`](Self::open_tap) yields `Ok(None)`, and **every later call
787    /// yields a frame**.
788    ///
789    /// **It waits only when the GPU is behind.** A previous map that has landed
790    /// is taken without blocking; one that has not is waited for before this
791    /// frame is submitted, so at most one frame is ever queued and a caller
792    /// that outruns the GPU is held to the GPU's rate. [`FrameTap`] carries the
793    /// cycle and says why the wait is the loop's only backpressure. The same
794    /// bound keeps a long run's memory per-frame, the retention Plan 0099
795    /// measured.
796    ///
797    /// A `dt` that is not finite and positive is replaced by one nominal step
798    /// before the clock sees it (`sanitize_frame_dt`, ADR-0191).
799    ///
800    /// **Feeds the diagnostics frame clock**, as [`render`](Self::render) does,
801    /// because a tap is the whole of a windowless run's live output and
802    /// [`metrics`](Self::metrics) is what that run reports. Counted per frame
803    /// **drawn** rather than per frame handed back, so the rate is the tap's
804    /// own throughput and does not read as halved on the pipeline's first
805    /// call. No capture entry point feeds it: their frames are drawn offline
806    /// and would make a rate of nothing.
807    ///
808    /// **Times every pass it encodes**, where the tap has a timer — the query
809    /// set is armed around the draw, resolved into the same submission, and
810    /// read back on the same poll that takes the pixels, so a tapped run costs
811    /// one extra buffer copy and no extra wait (ADR-0245).
812    pub fn render_tapped(
813        &mut self,
814        tap: &mut FrameTap,
815        frame: &AnalysisFrame,
816        dt: f32,
817    ) -> Result<Option<CaptureImage>, RenderError> {
818        let dt = super::sanitize_frame_dt(dt);
819        let (width, height) = (tap.width, tap.height);
820        self.time += dt;
821        // Taken first: the buffer cannot be recorded into while it is mapped,
822        // and this is also where a caller ahead of the GPU waits for it. It
823        // collects the previous frame's pass timings too, while the timer
824        // still holds that frame's labels.
825        let image = tap.take_previous(&self.ctx.device);
826
827        let mut encoder = self
828            .ctx
829            .device
830            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
831                label: Some("rlx-frame-tap"),
832            });
833        // Armed for the whole encode and taken back before the submit, so no
834        // pass encoded outside this window can write into the query set.
835        gpu::arm_pass_timer(tap.timer.take());
836        capture::record_clear(&mut encoder, &tap.view);
837        let draw_calls = self.draw_frame(
838            frame,
839            &mut encoder,
840            &tap.view,
841            (width, height),
842            dt,
843            SaltMode::Live,
844        );
845        capture::record_copy(
846            &mut encoder,
847            &tap.texture,
848            &tap.buffer,
849            tap.padded_bpr,
850            width,
851            height,
852        );
853        tap.timer = gpu::disarm_pass_timer(&mut encoder);
854        self.ctx.queue.submit(std::iter::once(encoder.finish()));
855        tap.arm();
856        if let Some(timer) = tap.timer.as_mut() {
857            timer.map();
858        }
859
860        #[cfg(feature = "text")]
861        self.text_layer.end_frame();
862
863        self.diag.set_draw_calls(draw_calls);
864        self.diag.record_frame();
865        Ok(image)
866    }
867
868    /// **Wait** for the frame `tap` still holds and hand it back, drawing
869    /// nothing.
870    ///
871    /// [`render_tapped`](Self::render_tapped) keeps one frame in flight, so a
872    /// run that stops asking for frames leaves one behind. This is how a
873    /// **bounded** run collects it, and how a test takes exactly one frame per
874    /// call: `render_tapped` then this, and the pipeline is empty again.
875    ///
876    /// `None` when nothing is in flight — an unused tap, or one already
877    /// drained.
878    ///
879    /// A live loop has no use for it: `render_tapped` already waits when the
880    /// GPU is behind, and calling this per frame would wait even when it is not.
881    pub fn drain_tap(&mut self, tap: &mut FrameTap) -> Option<CaptureImage> {
882        tap.drain(&self.ctx.device)
883    }
884}