rlx_core/render/mod.rs
1//! The render seam: take an [`AnalysisFrame`], drive the active preset's system,
2//! draw one frame.
3//!
4//! The render loop is driven by the frontend at display cadence and is fully
5//! decoupled from audio delivery — the ring buffer is the seam (CLAUDE.md).
6//! Cycling moves between loaded presets (ADR-0002); each preset names a built-in
7//! system and binds its parameters to expressions the renderer evaluates from
8//! the analysis frame plus the shared scene clock.
9
10// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every displayed
11// frame; a panic here is a visible crash mid-show.
12#![deny(
13 clippy::unwrap_used,
14 clippy::expect_used,
15 clippy::indexing_slicing,
16 clippy::panic,
17 clippy::unreachable
18)]
19
20// The four compositing stages stay crate-private to the outside world, but are
21// `pub(crate)` so `preset::schema` can read their global `PARAMS` vocabularies
22// for the load-time typo check (ADR-0020). `post` holds the two **per-preset**
23// stages that run after the scene, behind one trait in one fixed-order chain
24// (ADR-0031); the engine-wide passes stay outside it (ADR-0032) — `background`
25// is the pre-pass that owns the clear, `ink` the terminal tone remap.
26// The secondary present target (ADR-0143): a second surface on this same
27// device, carrying text the shell queues for it. Feature-gated with the text
28// layer it draws through — see the module docs.
29//
30// NOT `aux.rs`: `AUX` is a reserved DOS device name, so on Windows that path
31// cannot be opened by the compiler even though the file creates fine.
32#[cfg(feature = "text")]
33pub mod aux_target;
34pub(crate) mod background;
35pub(crate) mod bloom;
36pub mod camera;
37pub mod capture;
38// The `capture_*` entry points themselves — a continuation of `impl Renderer`
39// (Plan 0061 Phase 3). Private, because it adds no path of its own: every method
40// in it is reached as `Renderer::capture_*`, exactly as before the split.
41mod capture_api;
42pub mod context;
43pub mod feedback;
44// The program preview's intermediate and its letterbox geometry (ADR-0143).
45// NOT feature-gated with `aux_target`: the console that consumes a preview
46// draws text, but the intermediate is a render path, and the property that
47// matters about it is asserted on the headless capture path — which compiles
48// glyphon out.
49pub(crate) mod gpu;
50pub(crate) mod grid;
51// One shell-supplied picture over the frame, drawn in the text pass before the
52// text. Behind the text feature with the layer it composites with.
53#[cfg(feature = "text")]
54pub mod image_layer;
55pub(crate) mod ink;
56pub(crate) mod kaleidoscope;
57pub mod preview;
58mod preview_readback;
59// The `over`-join blend pass (ADR-0090 / Plan 0076 Phase 3) — driven by the
60// `PostChain`, whose walk knows the junction; nothing else reaches it.
61pub(crate) mod layer_blend;
62pub mod metrics;
63// The now-playing banner (ADR-0110). Deliberately **not** behind the `text`
64// feature: a build without it keeps the state and never asks for a layout, so
65// the plugin build turns the feature on without touching this module.
66pub mod now_playing;
67pub mod overlay;
68mod overlay_font;
69pub mod palette;
70// The interface's backdrops. The rectangle type is in every build; the pass
71// that draws them is behind `text`, inside the text layer that records it.
72pub mod panel;
73// `pub(crate)` for the same reason the stage modules are: the preset loader's
74// typo check unions every global vocabulary, and since ADR-0085 one of them —
75// `occlude` — belongs to the chain rather than to a stage inside it.
76pub(crate) mod post;
77pub mod scenes;
78#[cfg(feature = "text")]
79pub mod text;
80// The interface's look (ADR-0252). Not behind `text`: the diagnostics panel
81// and the banner read it in every build.
82pub mod theme;
83pub mod tier;
84pub(crate) mod tonemap;
85pub(crate) mod trails;
86mod transition;
87
88use crate::audio::AudioFormat;
89use crate::diag::{AnalysisMetrics, Diag, Metrics};
90use crate::dsp::AnalysisFrame;
91use crate::preset::{
92 Easing, Expr, HoldEdge, LATCH_CAP, Latch, Layer, LayerJoin, Preset, SystemKind, Variables,
93};
94#[cfg(feature = "text")]
95use aux_target::AuxTarget;
96#[cfg(feature = "text")]
97pub use aux_target::{AuxCounts, AuxPresentMode};
98use background::Background;
99pub use capture::{CaptureImage, FrameTap, PassCosts};
100pub use capture_api::AudioCapture;
101pub use context::{
102 AdapterChoice, AdapterDescription, RenderContext, RenderError, adapter_change_permitted,
103 list_adapters,
104};
105#[cfg(feature = "text")]
106use image_layer::ImageLayer;
107#[cfg(feature = "text")]
108pub use image_layer::{ImageRect, OverlayImage, OverlayImageError};
109use ink::Ink;
110use now_playing::NowPlaying;
111use overlay::Overlay;
112use palette::Palette;
113pub use panel::{Panel, PanelKind};
114use post::PostChain;
115use scenes::Scene;
116pub use scenes::lines::CapOverflow;
117#[cfg(feature = "text")]
118use text::TextLayer;
119#[cfg(feature = "text")]
120pub use text::{TextMeasure, TextRun, fit_width};
121pub use tier::{GridScale, REFERENCE_PX, Tier, TierConfig, attractor_budget};
122use tonemap::Tonemap;
123use transition::{Blend, DEFAULT_DURATION_SECS, Transition, TransitionKind};
124
125/// The format **every intermediate upstream of the tonemap** carries: linear
126/// light, unbounded above 1.0 (ADR-0046, Plan 0045 Phase 3).
127///
128/// The scene targets, both post stages, the transition blend's two sides and the
129/// tonemap's own input are all this — the surface format stops at the tonemap,
130/// which is where the frame becomes display-referred. Running those
131/// intermediates at the surface's 8 bits instead clips an additive accumulation
132/// per channel at each hand-off: the "additive ceiling" ADR-0046's Context
133/// catalogues, and the reason a bright-pass had nothing correct to bloom from.
134///
135/// `Rgba16Float` rather than 32-bit because it is the format
136/// [`PingPongField`](feedback::PingPongField) ships on (Plan 0014) — proven
137/// blendable and filterable on both backends — and because half
138/// the bandwidth matters on the floor tier (`tier::TierConfig::post_cap`).
139///
140/// Note the arithmetic did **not** change: an 8-bit *sRGB* target already blends
141/// in linear space, so what this buys is headroom above 1.0 and precision, not a
142/// different colour model.
143pub(crate) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
144
145/// Assumed bytes-per-pixel for the swapchain GPU-byte estimate (the common
146/// 8-bit RGBA/BGRA surface formats). An approximation, per ADR-0008.
147const SWAPCHAIN_BYTES_PER_PIXEL: u64 = 4;
148/// Fixed 2-image approximation for the swapchain GPU-byte estimate. wgpu exposes
149/// no real image count, so this stays a constant decoupled from the context's
150/// `desired_maximum_frame_latency` (also 2); the figure is a trend indicator,
151/// not an exact footprint (ADR-0008).
152const SWAPCHAIN_IMAGE_COUNT: u64 = 2;
153
154/// **The engine's whole transition policy** (ADR-0024: policy lives in code, not
155/// in a preset's `[transition]` table and not in an operator UI — both are
156/// deliberate follow-ups). Two constants, in one place, so tuning the show is a
157/// one-line edit that changes *every* switch path at once.
158///
159/// `None` rotates deterministically over [`TransitionKind::LIBRARY`], so a live
160/// show sees the whole library; `Some(kind)` pins every dissolve to one. The
161/// rotation counter is engine state, never a clock or an RNG, so a captured
162/// sequence of switches reproduces exactly (NFR §6).
163const TRANSITION_KIND: Option<TransitionKind> = None;
164/// How long every dissolve runs, in seconds. See [`TRANSITION_KIND`].
165const TRANSITION_DURATION_SECS: f32 = DEFAULT_DURATION_SECS;
166/// Smoothed frame-time ceiling, in milliseconds, under which a dissolve may run
167/// its outgoing side **live** as well (ADR-0024's adaptive governor). A named
168/// constant on purpose — this is the number to calibrate on a low-end rig.
169///
170/// Sized against 60 fps @ 1080p (NFR §1 = 16.7 ms) with slack, not under it: with
171/// vsync on, a machine keeping up reports the refresh interval no matter how much
172/// GPU headroom it has, so a stricter threshold would simply never upgrade on a
173/// 60 Hz display. ~55 fps is the "we are already struggling, do not double the
174/// composite" line. The real protection against *starting* an unaffordable
175/// dissolve is the latch: dual-live begins, the frame time rises past this within
176/// a few frames, and the rest of the dissolve falls back to the frozen side.
177const DUAL_LIVE_BUDGET_MS: f32 = 18.0;
178
179/// A caller's frame delta, made safe to accumulate: `dt` itself when it is
180/// finite and positive, [`scenes::FALLBACK_DT`] otherwise.
181///
182/// **The one place a frame delta is checked, and the nominal step is the
183/// engine's only answer to a degenerate one.** Everything below reads the value
184/// this returns and none of it re-checks — every `Scene::advance`, the
185/// composite's per-second decay, the transition's step, the MilkDrop runtime's
186/// envelopes, the cellular generation clock, the now-playing banner. A second
187/// finiteness guard on a `dt` anywhere in `core/src/` fails
188/// `core/tests/suite/hygiene.rs`, because a second guard is a second policy.
189///
190/// **Every `Renderer` entry that takes a caller's `dt` calls this as its first
191/// statement** and hands its result — never the raw value — to the scene clock,
192/// the now-playing banner and `draw_frame`. An entry that steps the clock by
193/// `FALLBACK_DT` itself takes no caller delta and has nothing to pass through
194/// here. A new entry that adds a caller's `dt` to `self.time` without calling
195/// this is invisible to every test: the hygiene suite can count guards, not
196/// missing calls.
197///
198/// A shell can hand over a `NaN` (a clock read across a device loss), a zero
199/// (two frames inside one timer tick) or a negative (a clock that jumped
200/// backwards), and the C ABI passes a plugin's delta through unexamined.
201///
202/// The trap it closes is one-way. `self.time` and `Phase::step` are `+=`
203/// accumulators with no other mutator on the live path, so one non-finite frame
204/// poisons them for the life of the process and nothing can ever clear it. The
205/// substitution is a nominal step rather than zero so a degenerate frame
206/// advances the animation instead of freezing it. ADR-0152, ADR-0191.
207pub(crate) fn sanitize_frame_dt(dt: f32) -> f32 {
208 if dt.is_finite() && dt > 0.0 {
209 dt
210 } else {
211 scenes::FALLBACK_DT
212 }
213}
214
215// The five concerns this file keeps out of the `Renderer` (Plan 0126 Phase 2).
216// `routing` answers where a name goes and which scene a system has, `roster` the
217// loaded presets and their per-binding frame state, `evaluate` one frame's bindings,
218// `composite` one side's encode, `tier_governor` the demotion path as an
219// `impl Renderer` continuation. What stays here is the `Renderer` itself.
220mod composite;
221mod evaluate;
222mod roster;
223mod routing;
224mod tier_governor;
225
226use composite::*;
227use evaluate::*;
228pub use roster::ParamError;
229use roster::*;
230use routing::*;
231
232/// How to build a headless [`Renderer`] for capture (Plan 0013).
233///
234/// Deliberately carries **no tier**: [`Renderer::new_headless`] is
235/// [`Tier::Floor`] by construction, which is what keeps every golden baseline
236/// byte-reproducible (ADR-0045). A capture at another tier goes through
237/// [`Renderer::new_headless_tiered`], where the choice is written at the call
238/// site and cannot be reached by forgetting a field.
239#[derive(Debug, Clone, Copy)]
240pub struct HeadlessOptions {
241 /// Offscreen render width in pixels.
242 pub width: u32,
243 /// Offscreen render height in pixels.
244 pub height: u32,
245 /// Force a fallback (software) adapter — WARP on DX12 — so captures
246 /// rasterize identically across machines. Tests want this on.
247 pub prefer_software: bool,
248}
249
250/// Construction-time options for an on-surface [`Renderer`] (Plan 0044).
251///
252/// One field today and a struct anyway, because the tier pin is the first of a
253/// family: a renderer's *construction* choices (quality, later backend or format
254/// preferences) are decided once and never per frame, and threading them as
255/// positional arguments is how the three constructors drift apart.
256#[derive(Debug, Clone, Default, PartialEq, Eq)]
257pub struct RendererOptions {
258 /// An explicit tier pin, or `None` for auto — which resolves [`Tier::Rich`]
259 /// and leaves the frame-time governor free to demote it once (ADR-0045). A
260 /// pin is honoured in both directions and never demotes.
261 pub tier: Option<Tier>,
262 /// Which graphics adapter the window's context asks for.
263 ///
264 /// [`AdapterChoice::Default`] is what the surface path asked for before this
265 /// field existed and stays the default here: an operator's `--gpu` is a
266 /// lever, not a new preference. Changing what an *unflagged* window selects
267 /// would re-base every frame-time figure this project has published, which
268 /// is a measurement question and not an argument-parsing one (ADR-0155).
269 ///
270 /// Carrying a [`AdapterChoice::Named`] `String` is why this struct is
271 /// `Clone` rather than `Copy`.
272 pub adapter: AdapterChoice,
273 /// Which of the tier's two attractor sample ceilings to resolve against
274 /// (ADR-0140). [`SampleBudget::Live`] by default, so every caller that does
275 /// not ask resolves exactly what it resolved before the choice existed.
276 pub budget: SampleBudget,
277 /// An explicit grid-scale pin, or `None` for auto (ADR-0245) — which a
278 /// window resolves from the tier and the adapter's class, and a headless
279 /// renderer resolves as [`GridScale::FULL`]. A pin wins on both paths and
280 /// survives a tier change and an adapter change.
281 pub grid_scale: Option<GridScale>,
282}
283
284impl RendererOptions {
285 /// Options pinning `tier` explicitly, on the default adapter.
286 pub fn pinned(tier: Tier) -> Self {
287 Self {
288 tier: Some(tier),
289 ..Self::default()
290 }
291 }
292
293 /// [`pinned`](Self::pinned), against the **offline** sample ceiling — for a
294 /// headless render, which has no present deadline to answer to.
295 pub fn pinned_offline(tier: Tier) -> Self {
296 Self {
297 tier: Some(tier),
298 budget: SampleBudget::Offline,
299 ..Self::default()
300 }
301 }
302}
303
304/// Which of a tier's two sample ceilings this renderer resolves against
305/// (ADR-0140).
306///
307/// The **law is the same either way** — a budget is
308/// `clamp(round(anchor * target_px / REFERENCE_PX), anchor, ceiling)` — and this
309/// picks the `ceiling`. It is a construction choice and never a per-frame one:
310/// the ceiling is also the **allocation**, so changing it means rebuilding the
311/// scene.
312#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
313pub enum SampleBudget {
314 /// Frame-time bound: a window, the plugin's host surface, and every capture
315 /// path — a still, a filmstrip, a report, the golden and sanity suites.
316 ///
317 /// **A capture takes this one even though it has no deadline either**, and
318 /// that is deliberate rather than an oversight: the offline ceiling is a
319 /// larger *allocation*, and `AttractorScene::seed`'s scatter is a function of
320 /// how many particles were asked for, so handing a capture path the offline
321 /// ceiling would move every committed baseline without moving a single
322 /// resolved count.
323 #[default]
324 Live,
325 /// Memory bound: `shot --render`, which walks a clip end to end with `dt`
326 /// injected and answers to no display.
327 Offline,
328}
329
330/// The internal grids a renderer resolves for its target, in texels, width then
331/// height ([`Renderer::internal_grids`], ADR-0245).
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub struct InternalGrids {
334 /// The post chain's grid — every active stage runs at this size.
335 pub post: (u32, u32),
336 /// The attractor's trail field, when it draws straight into the target.
337 pub trail: (u32, u32),
338}
339
340/// The channel order the four bytes of a pixel arrive in, for a consumer
341/// reading frames off a pipe or a buffer (ADR-0187).
342///
343/// **A closed set of two**, because a frame this engine hands out is 8 bits per
344/// channel in one of the two orders a swapchain negotiates. sRGB and linear
345/// variants of a format are the same order — the transfer function is what a
346/// consumer's own colour handling deals with and the byte layout is what it has
347/// to be told.
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub enum PixelOrder {
350 /// Red, green, blue, alpha — what a headless run always produces, and what
351 /// an `ImageData` on a canvas means by its bytes.
352 Rgba8,
353 /// Blue, green, red, alpha — what a swapchain commonly negotiates on
354 /// Windows, and the order a consumer assuming the other one paints with red
355 /// and blue swapped.
356 Bgra8,
357}
358
359impl PixelOrder {
360 /// The name a frame pipe's announcement carries.
361 pub fn as_str(self) -> &'static str {
362 match self {
363 PixelOrder::Rgba8 => "rgba8",
364 PixelOrder::Bgra8 => "bgra8",
365 }
366 }
367
368 /// Every name, for a consumer enumerating the closed set.
369 pub const ALL: [PixelOrder; 2] = [PixelOrder::Rgba8, PixelOrder::Bgra8];
370
371 /// The order `format` stores its channels in, or `None` where it is not an
372 /// 8-bit four-channel format and so has no order this vocabulary can name.
373 pub(crate) fn of(format: wgpu::TextureFormat) -> Option<Self> {
374 match format {
375 wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => {
376 Some(PixelOrder::Rgba8)
377 }
378 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
379 Some(PixelOrder::Bgra8)
380 }
381 _ => None,
382 }
383 }
384}
385
386/// Owns the GPU context, the built-in systems, and the loaded presets; renders
387/// one frame per call by evaluating the active preset into the active system.
388///
389/// Every entry point that accepts a caller's frame delta — `render`,
390/// `render_tapped`, `capture_stream` — replaces a degenerate one through
391/// `sanitize_frame_dt` before anything below sees it, and an entry added later
392/// owes the same call.
393pub struct Renderer {
394 ctx: RenderContext,
395 /// Which sample ceiling this renderer's scenes were built against
396 /// (ADR-0140). Read wherever the scenes are rebuilt — a tier change, a
397 /// capture reset — so a rebuild cannot silently swap the ceiling under a
398 /// run that is already producing frames.
399 budget: SampleBudget,
400 /// Every built-in scene, keyed by the system it drives (see [`SceneRoster`]).
401 scenes: SceneRoster,
402 /// The active preset's composite — its `bg_*` backdrop pre-pass (ADR-0018,
403 /// which owns the frame clear now that scenes `Load` instead of `Clear`) and
404 /// the per-preset [`PostChain`] its trails and kaleidoscope fold through
405 /// (ADR-0018 order, ADR-0031 seam). Each stage is individually skippable, so an
406 /// unbound preset renders straight to the chain's destination.
407 ///
408 /// While a dissolve runs this side stays on the **outgoing** preset — which is
409 /// what keeps its trail accumulating across the dissolve instead of restarting
410 /// — and [`incoming_side`](Self::incoming_side) carries the new one.
411 side: CompositeSide,
412 /// The incoming preset's composite while a dissolve runs, `None` otherwise.
413 /// Created at the switch site and promoted to [`side`](Self::side) at finalize,
414 /// so there is no frame where both or neither is the live one, and no GPU state
415 /// is ever shared between the two presets.
416 incoming_side: Option<CompositeSide>,
417 /// The terminal engine tone-remap (ADR-0028), outside the chain per ADR-0032:
418 /// it remaps the **one** finished frame, so it must run after the transition
419 /// blend of two per-preset composites. Skipped entirely at `ink_amount <= 0`,
420 /// which is every preset that does not opt in.
421 ink: Ink,
422 /// The exposure + tonemap pass (ADR-0046) — the frame's **linear/display
423 /// boundary**, between the transition blend and ink. Unlike every other pass
424 /// it never skips: it is the format seam, not a look, so an unbound preset
425 /// still runs it at `exposure = 1.0`.
426 tonemap: Tonemap,
427 /// The two-input cross-preset blend pass (Plan 0023 / ADR-0032), between the
428 /// chain and the tonemap. Holds no GPU resources between dissolves.
429 blend: Blend,
430 /// The in-flight dissolve, if any. `None` is the ordinary frame path — chain
431 /// straight into ink's input (or the surface), no blend encoded at all.
432 transition: Option<Transition>,
433 /// Dissolves started since the last explicit jump — the rotation position for
434 /// [`TRANSITION_KIND`]. A counter, not a clock or an RNG, so the sequence of
435 /// kinds a run produces is reproducible.
436 transitions_started: u32,
437 /// Loaded presets + the active index (pure selection state — see [`Roster`]).
438 roster: Roster,
439 /// Shared scene clock (seconds), advanced once per rendered frame by that
440 /// frame's delta — a caller's only after [`sanitize_frame_dt`], a capture's
441 /// fixed step otherwise. The single source for both an expression's `time`
442 /// and system animation.
443 time: f32,
444 /// Runtime diagnostics: rolling frame-time stats + overlay flags (Plan 0011).
445 diag: Diag,
446 /// The debug overlay pass, painted only while `diag.overlay_enabled()`.
447 overlay: Overlay,
448 /// On-canvas text seam (browse overlay / HUD), standalone-only via the
449 /// `text` feature (ADR-0009); absent from the plugin/default build.
450 #[cfg(feature = "text")]
451 text_layer: TextLayer,
452 /// The one picture a shell may set and queue over the frame, drawn in the
453 /// text pass before the text. Holds no GPU object until the first image is
454 /// set, so a shell that never sets one pays an `Option` test per frame.
455 #[cfg(feature = "text")]
456 image_layer: ImageLayer,
457 /// The secondary present target (ADR-0143), `None` until a shell attaches
458 /// one and again the moment it detaches. Holds a swapchain and a text atlas,
459 /// so the `None` case is the whole cost of the feature while unused.
460 #[cfg(feature = "text")]
461 aux: Option<AuxTarget>,
462 /// The program preview (ADR-0143): the intermediate, its readback and the
463 /// frame that readback produced, as **one** owner rather than three fields
464 /// a caller could move two of. Closed until a shell opens it, and closed it
465 /// allocates nothing and costs the frame one `Option` test.
466 preview: preview::PreviewService,
467 /// The now-playing banner (ADR-0110): a string a shell pushes in, plus the
468 /// `dt`-driven envelope that fades it. Present in every build — a plugin
469 /// build without the `text` feature holds the state and draws nothing.
470 now_playing: NowPlaying,
471 /// Segment-cap truncation from the active preset's last `configure`, if any
472 /// (ADR-0007: the cap is never a silent cut). Refreshed whenever the active
473 /// preset changes; the frontend surfaces it. `None` when geometry fit.
474 cap_overflow: Option<CapOverflow>,
475 /// Scratch for per-element binding evaluation (Plan 0034 Phase 4). Sized
476 /// **once, here at construction**, to the largest element count the loader
477 /// admits — so the per-frame path slices it and never allocates. A frame uses
478 /// the prefix its preset's `[spectrum] elements` asks for; every other system
479 /// uses an empty prefix, which is what makes their path unchanged.
480 series_scratch: Vec<f32>,
481 /// Scratch for per-vertex binding evaluation (Plan 0100 Phase 1). Sized
482 /// **once, here at construction**, to the largest mesh any tier may name
483 /// ([`MAX_MESH`](scenes::warp_mesh::MAX_MESH)), so the per-frame path slices
484 /// it and never allocates. Every system but the warp mesh uses an empty
485 /// prefix.
486 vertex_scratch: Vec<f32>,
487 /// The active preset's per-binding frame state: its easing envelopes
488 /// (ADR-0019) and its sample-and-holds (ADR-0180 rule 2). Reset on every
489 /// active-preset change and capture rebuild, and handed to
490 /// [`outgoing_state`](Self::outgoing_state) at a dissolve's roster flip.
491 param_state: BindingState,
492 /// Live per-name parameter overrides on the active preset (ADR-0176) — what a
493 /// control surface holds while a slider is being dragged, shadowing the
494 /// preset's own binding until it is cleared.
495 ///
496 /// Empty on every path but a driven one, and an empty bank costs the frame a
497 /// slice length check. Cleared on every active-preset change and on every
498 /// `set_presets`, so an override can never outlive the preset it named.
499 overrides: ParamOverrides,
500 /// The active preset's `[latch]` state (ADR-0137). Reset wherever
501 /// [`param_state`](Self::param_state) is, and handed to the outgoing
502 /// bank at the same roster flip — a latch mid-hold keeps reading through a
503 /// dual-live dissolve exactly as an eased param keeps easing.
504 ///
505 /// One bank per preset, not per surface: a latch is preset-level state and
506 /// its `[layer]` bindings read the same event the main scene does, so unlike
507 /// [`layer_state`](Self::layer_state) there is no second one.
508 latches: LatchBank,
509 /// The outgoing preset's `[latch]` state during a dual-live dissolve.
510 outgoing_latches: LatchBank,
511 /// The active preset's **layer** frame state (Plan 0076 Phase 1) — its own,
512 /// because layer bindings are indexed within the layer's `params` and would
513 /// collide with the main preset's indices in
514 /// [`param_state`](Self::param_state). Reset wherever that one is.
515 layer_state: BindingState,
516 /// The outgoing preset's layer frame state during a dual-live dissolve —
517 /// the layer counterpart of [`outgoing_state`](Self::outgoing_state),
518 /// handed over at the same roster flip.
519 outgoing_layer_state: BindingState,
520 /// The active quality tier's capacity values, resolved **once** here at
521 /// construction (ADR-0045). Read at construction and reconfigure time only —
522 /// never branched on per frame.
523 tier: TierConfig,
524 /// True when the tier was pinned explicitly rather than auto-resolved. A pin
525 /// is honoured in both directions (ADR-0045), so the governor never touches
526 /// one — which is also the escape hatch for a machine whose transient stall
527 /// cost it the rich tier.
528 tier_pinned: bool,
529 /// **The governor's one-way latch.** Set the single time the frame-time
530 /// governor demotes `Rich -> Floor`, and never cleared: there is no
531 /// auto-promotion by design, so a demoted session stays demoted and the
532 /// decision cannot oscillate. The same shape as the dual-live freeze latch
533 /// below, for the same reason.
534 ///
535 /// Also what the frontend reads to report the demotion, so a pinned floor and
536 /// a demoted floor are distinguishable — otherwise the demotion would be
537 /// silent, which ADR-0045 rules out.
538 tier_demoted: bool,
539 /// The grid-scale pin this renderer was built with, or last given
540 /// ([`RendererOptions::grid_scale`]). Kept because the scale is re-resolved
541 /// whenever the tier or the adapter moves, and a pin has to survive both; the
542 /// resolved value itself lives in [`tier`](Self::tier).
543 grid_scale_pin: Option<GridScale>,
544 /// The display's frame budget in seconds, set by the frontend from its
545 /// monitor's refresh rate ([`set_display_hz`](Self::set_display_hz)). Not read
546 /// from the platform here — a refresh rate is a shell concern, and `core`
547 /// stays source- and platform-agnostic.
548 frame_budget_secs: f32,
549 /// The **outgoing** preset's frame state during a dual-live dissolve. Moved
550 /// out of [`param_state`](Self::param_state) when the roster flips, so a
551 /// heavily-smoothed preset keeps easing through the dissolve instead of
552 /// snapping to raw values the moment it stops being active, and a held
553 /// binding keeps showing what it held rather than re-picking a figure on
554 /// the way out.
555 outgoing_state: BindingState,
556}
557
558/// `tier` with its grid scale resolved for `ctx` (ADR-0245): the pin when there
559/// is one, the table's row for this adapter's class on a window, and
560/// [`GridScale::FULL`] on a context with no surface.
561///
562/// The one call every path that takes a [`TierConfig`] goes through —
563/// construction, [`Renderer::set_tier`], the governor's demotion and an adapter
564/// switch — so no rebuild can hand the scenes a tier with the scale left at the
565/// constant's `1.0`.
566fn resolved_tier(ctx: &RenderContext, tier: TierConfig, pin: Option<GridScale>) -> TierConfig {
567 tier.with_grid_scale(tier::resolve_grid_scale(
568 pin,
569 tier.tier,
570 ctx.adapter_class(),
571 ctx.surface.is_some(),
572 ))
573}
574
575impl Renderer {
576 /// Everything a renderer is beyond its [`RenderContext`]: the scene roster,
577 /// the composite side, the engine-wide post passes, the overlay, and the
578 /// embedded default presets. **The one construction path** — the three public
579 /// constructors differ only in how they obtain the context, so a new field is
580 /// a one-place edit here rather than three.
581 fn from_context(ctx: RenderContext, opts: RendererOptions) -> Self {
582 // The one tier resolution in the engine (ADR-0045): a pin wins, and
583 // unpinned is `Rich` — the governor's job is to take that back, not to
584 // hedge it here. The grid scale resolves with it, from the same tier and
585 // this context's adapter (ADR-0245).
586 let tier = resolved_tier(
587 &ctx,
588 TierConfig::for_tier(opts.tier.unwrap_or(Tier::Rich)),
589 opts.grid_scale,
590 );
591 // Everything upstream of the tonemap is built against COMPOSITE_FORMAT,
592 // not the surface's (ADR-0046): the scenes, both composite sides and the
593 // blend all paint in linear light. Only the tonemap, ink, the overlay and
594 // the text layer write display-referred pixels.
595 let budget = opts.budget;
596 let scenes =
597 crate::render::scenes::create_all(&ctx.device, COMPOSITE_FORMAT, &tier, budget);
598 let side = CompositeSide::new(&ctx.device, COMPOSITE_FORMAT, &tier);
599 let blend = Blend::new(&ctx.device, COMPOSITE_FORMAT);
600 let tonemap = Tonemap::new(&ctx.device, ctx.surface_format());
601 let ink = Ink::new(&ctx.device, ctx.surface_format());
602 let overlay = Overlay::new(&ctx.device, ctx.surface_format());
603 #[cfg(feature = "text")]
604 let text_layer = TextLayer::new(&ctx.device, &ctx.queue, ctx.surface_format());
605 #[cfg(feature = "text")]
606 let surface_format = ctx.surface_format();
607 let mut renderer = Self {
608 ctx,
609 budget,
610 scenes,
611 side,
612 incoming_side: None,
613 ink,
614 tonemap,
615 blend,
616 transition: None,
617 transitions_started: 0,
618 roster: Roster::new(crate::preset::default_presets()),
619 time: 0.0,
620 diag: Diag::new(),
621 overlay,
622 #[cfg(feature = "text")]
623 text_layer,
624 #[cfg(feature = "text")]
625 image_layer: ImageLayer::new(surface_format),
626 #[cfg(feature = "text")]
627 aux: None,
628 preview: preview::PreviewService::closed(),
629 now_playing: NowPlaying::default(),
630 cap_overflow: None,
631 series_scratch: vec![0.0; scenes::lines::spectrum::MAX_ELEMENTS],
632 vertex_scratch: vec![0.0; scenes::warp_mesh::vertex_count(scenes::warp_mesh::MAX_MESH)],
633 param_state: BindingState::default(),
634 overrides: ParamOverrides::default(),
635 latches: LatchBank::default(),
636 outgoing_latches: LatchBank::default(),
637 layer_state: BindingState::default(),
638 outgoing_layer_state: BindingState::default(),
639 tier,
640 tier_pinned: opts.tier.is_some(),
641 tier_demoted: false,
642 grid_scale_pin: opts.grid_scale,
643 frame_budget_secs: tier::budget_secs(tier::DEFAULT_DISPLAY_HZ),
644 outgoing_state: BindingState::default(),
645 };
646 // Apply the initial preset's structural config (ADR-0007) so a line
647 // scene at roster index 0 renders with its geometry built.
648 renderer.configure_active_scene();
649 renderer
650 }
651
652 /// Build a renderer drawing into `target` (a safe window handle — the
653 /// standalone path). Starts with the embedded default presets.
654 ///
655 /// `opts` carries the quality-tier pin and the adapter choice;
656 /// [`RendererOptions::default()`](RendererOptions) is auto (rich, governed)
657 /// on whatever adapter wgpu picks for the surface.
658 pub fn new(
659 target: impl Into<wgpu::SurfaceTarget<'static>>,
660 width: u32,
661 height: u32,
662 opts: RendererOptions,
663 ) -> Result<Self, RenderError> {
664 let ctx = RenderContext::new(target, width, height, &opts.adapter)?;
665 Ok(Self::from_context(ctx, opts))
666 }
667
668 /// Build a **headless** renderer that draws into offscreen textures instead
669 /// of a window (Plan 0013 capture tooling). Same scenes, presets, and
670 /// per-frame evaluation as the on-surface path — only the target differs.
671 /// Starts with the embedded default presets.
672 ///
673 /// **Pinned [`Tier::Floor`], and there is no argument to say otherwise**
674 /// (ADR-0045). A capture is a pure function of its inputs (NFR §6), and a
675 /// baseline that moved because the machine that blessed it was fast is not a
676 /// baseline — so the floor is the default by construction here rather than by
677 /// every call site remembering to ask for it. Use
678 /// [`new_headless_tiered`](Self::new_headless_tiered) for a deliberate
679 /// rich-tier capture.
680 pub fn new_headless(opts: HeadlessOptions) -> Result<Self, RenderError> {
681 Self::new_headless_tiered(opts, Tier::Floor)
682 }
683
684 /// A headless renderer pinned to `tier` — the opt-in behind the `shot` CLI's
685 /// `--tier`, for spot-checking that the rich tier's raised budgets actually
686 /// render (Plan 0044 Phase 3). Always **pinned**, so a capture never demotes
687 /// mid-run and stays reproducible.
688 pub fn new_headless_tiered(opts: HeadlessOptions, tier: Tier) -> Result<Self, RenderError> {
689 Self::new_headless_on(opts, tier, &AdapterChoice::from(opts.prefer_software))
690 }
691
692 /// A headless renderer pinned to `tier`, on a **named** adapter (ADR-0146).
693 ///
694 /// The one real headless constructor; the two above delegate here with the
695 /// choice their `prefer_software` flag already implies, so every capture
696 /// path resolves exactly the adapter it resolved before.
697 ///
698 /// A live video-out needs this because the adapter is not a performance
699 /// preference there: a Spout receiver can only open a sender that lives on
700 /// the GPU it renders with, and on a hybrid machine a console process is
701 /// handed the power-saving one. The **sender's** adapter is what that
702 /// constrains; this one is the renderer's, and the two are matched by name
703 /// on each side rather than by a shared index.
704 pub fn new_headless_on(
705 opts: HeadlessOptions,
706 tier: Tier,
707 adapter: &AdapterChoice,
708 ) -> Result<Self, RenderError> {
709 Self::new_headless_scaled(opts, tier, adapter, None)
710 }
711
712 /// [`new_headless_on`](Self::new_headless_on) with an explicit grid-scale
713 /// pin (ADR-0245) — the constructor `--stream --grid-scale` reaches.
714 ///
715 /// `None` is what every other headless constructor passes, and it resolves
716 /// [`GridScale::FULL`] on every adapter, so a capture never depends on the
717 /// class of the machine that took it. A pin is the only way a headless
718 /// renderer draws its grids at less.
719 pub fn new_headless_scaled(
720 opts: HeadlessOptions,
721 tier: Tier,
722 adapter: &AdapterChoice,
723 grid_scale: Option<GridScale>,
724 ) -> Result<Self, RenderError> {
725 Ok(Self::from_context(
726 RenderContext::new_headless_on(opts.width, opts.height, adapter)?,
727 RendererOptions {
728 grid_scale,
729 ..RendererOptions::pinned(tier)
730 },
731 ))
732 }
733
734 /// A headless renderer pinned to `tier` and resolving the **offline** sample
735 /// ceiling (ADR-0140) — the one constructor `shot --render` reaches, and the
736 /// only one in the engine that does.
737 ///
738 /// Separate from [`new_headless_tiered`](Self::new_headless_tiered) rather
739 /// than a flag on it, because the two answer to different bounds and only one
740 /// of them may ever produce a baseline: a render walks a clip with `dt`
741 /// injected and no display to miss, so its ceiling is memory; every other
742 /// headless path is a capture, and a capture takes the live ceiling so the
743 /// allocation — and with it the seeded scatter — is the one every committed
744 /// baseline was blessed against.
745 pub fn new_headless_offline(opts: HeadlessOptions, tier: Tier) -> Result<Self, RenderError> {
746 Ok(Self::from_context(
747 RenderContext::new_headless_on(
748 opts.width,
749 opts.height,
750 &AdapterChoice::from(opts.prefer_software),
751 )?,
752 RendererOptions::pinned_offline(tier),
753 ))
754 }
755
756 /// Renderer targeting a surface the host owns and built the handle for —
757 /// the C ABI path, and the only constructor that does not create its own
758 /// surface. Starts with the embedded default presets (no ABI surface for
759 /// preset selection yet).
760 ///
761 /// **The platform lives on the caller's side of this seam, not here**
762 /// (ADR-0001, ADR-0072): the host knows what kind of window it has and
763 /// builds the [`wgpu::SurfaceTargetUnsafe`] for it, so `core` stays
764 /// source-agnostic and platform-free. `core-cabi` is the one that knows
765 /// about `HWND`.
766 ///
767 /// Auto tier: the plugin gets rich-with-governor, because the C ABI stays v4
768 /// and a plugin-side tier picker is a future ABI question rather than part of
769 /// ADR-0045.
770 ///
771 /// # Safety
772 /// `target`'s handles must be valid and must outlive this renderer.
773 pub unsafe fn new_from_surface_target(
774 target: wgpu::SurfaceTargetUnsafe,
775 width: u32,
776 height: u32,
777 ) -> Result<Self, RenderError> {
778 // The `unsafe` is exactly the surface-from-raw-handle call: the caller's
779 // promise about the handles' validity and lifetime. Construction past
780 // that point is the same safe code the other two paths run.
781 // `RendererOptions::default()` carries `AdapterChoice::Default`, which
782 // is the request this path made before the choice was a parameter — the
783 // shim has no flag surface to select an adapter with, and the C ABI
784 // does not move for this (ADR-0155).
785 let opts = RendererOptions::default();
786 let ctx = unsafe { RenderContext::new_unsafe(target, width, height, &opts.adapter) }?;
787 Ok(Self::from_context(ctx, opts))
788 }
789
790 /// The active quality tier (ADR-0045) — what the diagnostics overlay and the
791 /// `shot` report header name.
792 pub fn tier(&self) -> Tier {
793 self.tier.tier
794 }
795
796 /// **Change the quality tier on the running renderer** (ADR-0054).
797 ///
798 /// Rebuilds the tier-dependent GPU resources — the scene roster and the
799 /// composite side — against the new [`TierConfig`], on the existing
800 /// [`RenderContext`]. The device, queue, surface, preset roster, active
801 /// preset, engine clock, text layer and diagnostics all survive, so the
802 /// operator stays on the preset they were watching. A dissolve in flight is
803 /// dropped: its two sides are GPU state built at the outgoing tier.
804 ///
805 /// The visible cost is one re-accumulation of everything that accumulates —
806 /// trails, reaction-diffusion state, the attractor's deposit. That is the
807 /// correct affordance rather than a defect: the operator asked for this and
808 /// can see that it happened.
809 ///
810 /// **A no-op on a surface-less (headless) context**, so ADR-0045's
811 /// by-construction guarantee that a capture is `Tier::Floor` survives a
812 /// public mutator existing on this type at all. The condition is
813 /// [`tier::tier_change_permitted`], which is a value rather than a comment.
814 ///
815 /// An explicit call **pins** the tier and **clears the governor's demotion
816 /// latch**. The latch means "the governor took a decision the operator did
817 /// not ask for, and must be told about it"; once the operator has asked for
818 /// something, that history is spent. ADR-0045 says the latch is never
819 /// cleared — ADR-0054 narrows that to "never cleared *by the governor*", and
820 /// is the correction of record.
821 pub fn set_tier(&mut self, tier: Tier) {
822 if !tier::tier_change_permitted(self.ctx.surface.is_some()) {
823 return;
824 }
825 self.tier_pinned = true;
826 self.tier_demoted = false;
827 // The same rebuild the governor's demotion runs, reused rather than
828 // open-coded: a tier-sized resource added to a new scene is then covered
829 // by construction instead of by remembering two call sites.
830 self.apply_tier(TierConfig::for_tier(tier));
831 }
832
833 /// The fraction of the render target the internal grids are drawn at
834 /// (ADR-0245) — what the diagnostics overlay prints beside the tier.
835 pub fn grid_scale(&self) -> GridScale {
836 self.tier.grid_scale
837 }
838
839 /// The internal grids this renderer resolves for its current target size:
840 /// the post chain's and the attractor's trail field, at the resolved scale.
841 ///
842 /// A **report**, computed on the call from the same functions the stages
843 /// and the scene size themselves with, so it cannot describe a grid neither
844 /// would build. The trail grid is the one the attractor takes when it draws
845 /// straight into the target; behind an active post stage it draws into that
846 /// stage's grid instead, which is [`post`](InternalGrids::post).
847 pub fn internal_grids(&self) -> InternalGrids {
848 let surface = (self.ctx.config.width, self.ctx.config.height);
849 InternalGrids {
850 post: post::internal_grid_size(surface, self.tier.grid_scale, self.tier.post_cap),
851 trail: scenes::particles::scaled_trail_grid_size(
852 surface.0,
853 surface.1,
854 self.tier.grid_scale,
855 self.tier.attractor_trail_cap,
856 ),
857 }
858 }
859
860 /// **Change the grid scale on the running renderer** (ADR-0245): `Some`
861 /// pins it, `None` returns it to auto — the table's row for this tier and
862 /// adapter.
863 ///
864 /// The same rebuild a tier change runs, and for the same reason: both grids
865 /// are sized from the resolved tier config, so the stages and the scenes are
866 /// rebuilt against the new one. The tier itself, its pin and the governor's
867 /// latch do not move.
868 ///
869 /// **A no-op on a surface-less (headless) context**, on the rule
870 /// [`set_tier`](Self::set_tier) follows: a capture's scale is fixed at
871 /// construction ([`new_headless_scaled`](Self::new_headless_scaled)), so a
872 /// public mutator cannot move a baseline.
873 pub fn set_grid_scale(&mut self, scale: Option<GridScale>) {
874 if !tier::tier_change_permitted(self.ctx.surface.is_some()) {
875 return;
876 }
877 self.grid_scale_pin = scale;
878 self.apply_tier(TierConfig::for_tier(self.tier.tier));
879 }
880
881 /// **Move the running renderer onto another graphics adapter** (ADR-0246)
882 /// — the sibling of [`set_tier`](Self::set_tier) one level down.
883 ///
884 /// A new adapter, device and queue come off the retained instance, a
885 /// fresh surface for `target` — the same window the renderer was built on —
886 /// is configured against the new device, and every GPU-owning member is
887 /// rebuilt on it: the scene roster, the composite side, the blend, the
888 /// tonemap, ink, the overlay, the text layer, and the program preview and
889 /// its readback where they are open. The preset roster, the active preset,
890 /// the engine clock, the now-playing banner and the diagnostics survive,
891 /// so the operator stays on the preset they were watching. A dissolve in
892 /// flight is dropped, as `set_tier` drops one. **Accumulated GPU state does
893 /// not survive**: trail fields and simulation domains live in the old
894 /// device's memory, so the picture visibly starts afresh.
895 ///
896 /// **Transactional.** The new device is requested and validated, and every
897 /// replacement member is built on it, *before* the old device is released;
898 /// a choice that cannot produce a device — absent, ambiguous, unable to
899 /// present, or refusing a device — returns its named error with the
900 /// running picture untouched. A switch can never leave the application
901 /// with no renderer. Two devices are briefly alive at the commit, which
902 /// is the memory cost of that guarantee.
903 ///
904 /// The secondary present target is **released, not carried**: its
905 /// swapchain and atlas belong to the old device, so a shell with a console
906 /// open re-attaches it afterwards with [`attach_aux`](Self::attach_aux).
907 ///
908 /// Asking for the adapter already in use is a no-op returning `Ok`.
909 /// Refused with [`RenderError::Headless`] on a surface-less context, the
910 /// guard [`adapter_change_permitted`] states as a value.
911 pub fn set_adapter(
912 &mut self,
913 choice: &AdapterChoice,
914 target: impl Into<wgpu::SurfaceTarget<'static>>,
915 ) -> Result<(), RenderError> {
916 if !adapter_change_permitted(self.ctx.surface.is_some()) {
917 return Err(RenderError::Headless);
918 }
919 let Some(staged) = self.ctx.stage_adapter(choice, target)? else {
920 return Ok(());
921 };
922 // Every member that holds GPU state, built on the new device while the
923 // old one still runs the picture. `set_tier`'s `apply_tier` rebuilds
924 // the first two of these; the rest are what a device change adds — a
925 // member that is rebuilt here and forgotten there, or the reverse,
926 // fails at runtime as a stale handle rather than at compile time.
927 // The grid scale is the new adapter's (ADR-0245): a move from an
928 // integrated GPU to a discrete one changes the table's row, and a pin
929 // survives the move either way. A window is the only path here.
930 let tier = self.tier.with_grid_scale(tier::resolve_grid_scale(
931 self.grid_scale_pin,
932 self.tier.tier,
933 staged.adapter_class(),
934 true,
935 ));
936 let scenes = scenes::create_all(&staged.device, COMPOSITE_FORMAT, &tier, self.budget);
937 let side = CompositeSide::new(&staged.device, COMPOSITE_FORMAT, &tier);
938 let blend = Blend::new(&staged.device, COMPOSITE_FORMAT);
939 let tonemap = Tonemap::new(&staged.device, staged.config.format);
940 let ink = Ink::new(&staged.device, staged.config.format);
941 let overlay = Overlay::new(&staged.device, staged.config.format);
942 #[cfg(feature = "text")]
943 let text_layer = TextLayer::new(&staged.device, &staged.queue, staged.config.format);
944 let preview_open = self.preview.is_open();
945 let readback_size = self.preview.readback_size();
946
947 // The commit point: nothing below can fail.
948 self.cancel_transition();
949 self.incoming_side = None;
950 #[cfg(feature = "text")]
951 {
952 self.aux = None;
953 }
954 self.preview.close();
955 self.ctx.commit(staged);
956 self.tier = tier;
957 self.scenes = scenes;
958 self.side = side;
959 self.blend = blend;
960 self.tonemap = tonemap;
961 self.ink = ink;
962 self.overlay = overlay;
963 #[cfg(feature = "text")]
964 {
965 self.text_layer = text_layer;
966 // The picture's texture lived on the old device, so it goes with it;
967 // a shell reads `overlay_image_size` as `None` and sets it again.
968 self.image_layer = ImageLayer::new(self.ctx.surface_format());
969 }
970 // The preview follows at the output's size and the readback at its
971 // own, so a consumer told the readback's geometry once is not told
972 // again (ADR-0187). The readback's one refusal — a surface format with
973 // no nameable order — is a property of the new adapter's negotiation,
974 // not of this switch, which has already happened: the readback then
975 // stays closed and `preview_readback_size` says so, rather than an
976 // `Err` claiming the picture was left untouched.
977 if preview_open {
978 self.preview.open_target(
979 &self.ctx.device,
980 self.ctx.surface_format(),
981 self.ctx.config.width,
982 self.ctx.config.height,
983 );
984 }
985 if let Some((width, height)) = readback_size
986 && self.open_preview_readback(width, height).is_err()
987 {
988 self.preview.close_readback();
989 }
990 self.configure_active_scene();
991 Ok(())
992 }
993
994 /// The active preset's index in the roster — what the browse overlay opens
995 /// on, and what a caller checks a tier rebuild against.
996 pub fn active_index(&self) -> usize {
997 self.roster.active
998 }
999
1000 /// The index the show is **going** to: the dissolve's target while a
1001 /// transition is in flight, and [`active_index`](Self::active_index)
1002 /// otherwise. This is the "where the show is going, not where it has been"
1003 /// convention [`cycle_preset`](Self::cycle_preset) already returns a name by,
1004 /// expressed as an index — a host checkmarking a menu wants the user's most
1005 /// recent choice to be ticked immediately, not a quarter-second later.
1006 ///
1007 /// Meaningless on an empty roster (`0`, like `active_index`); a caller that
1008 /// distinguishes that case reads [`preset_names`](Self::preset_names) first.
1009 pub fn target_preset_index(&self) -> usize {
1010 self.transition
1011 .as_ref()
1012 .map_or(self.roster.active, Transition::incoming_index)
1013 }
1014
1015 /// Whether the frame-time governor demoted this session's tier.
1016 ///
1017 /// The frontend reports the **transition**, so a demotion is announced once
1018 /// rather than shouted every frame — the same pattern
1019 /// [`cap_overflow`](Self::cap_overflow) is surfaced through. A pinned floor
1020 /// answers `false`; only a governed demotion sets this.
1021 pub fn tier_demoted(&self) -> bool {
1022 self.tier_demoted
1023 }
1024
1025 /// Tell the renderer the display's refresh rate, which sets the frame budget
1026 /// the governor measures against (ADR-0045). Defaults to
1027 /// [`DEFAULT_DISPLAY_HZ`](tier::DEFAULT_DISPLAY_HZ); a rate that is not usable
1028 /// falls back to it rather than producing a degenerate budget.
1029 ///
1030 /// Off the hot path — call it at startup and on a monitor change. The core
1031 /// does not read this from the platform itself: a refresh rate is a shell
1032 /// concern, and the whole point of the split is that `core` knows nothing
1033 /// about windows.
1034 pub fn set_display_hz(&mut self, hz: f32) {
1035 self.frame_budget_secs = tier::budget_secs(hz);
1036 }
1037
1038 /// Reconfigure the surface for a new window size.
1039 pub fn resize(&mut self, width: u32, height: u32) {
1040 self.ctx.resize(width, height);
1041 // The intermediate's copy extent is fixed at construction, so a live
1042 // preview is rebuilt at the new size rather than left disagreeing with
1043 // the destination it copies into.
1044 //
1045 // **An open readback is left alone.** It samples the intermediate
1046 // through a blit into its own fixed-size tap, so the rebuild below
1047 // changes what that blit reads and nothing about the geometry the
1048 // readback hands out — which is what lets a consumer be told the size
1049 // once, before the first frame, and never again (ADR-0187).
1050 if self.preview.is_open() {
1051 self.preview.open_target(
1052 &self.ctx.device,
1053 self.ctx.surface_format(),
1054 self.ctx.config.width,
1055 self.ctx.config.height,
1056 );
1057 }
1058 }
1059
1060 /// Open the program preview: the frame starts being drawn into an
1061 /// intermediate and copied to its destination, so a second consumer can
1062 /// sample the same pixels the show is getting.
1063 ///
1064 /// Fails when the destination surface does not accept `COPY_DST`, which is
1065 /// the one thing that makes the copy exact. Reported rather than degraded
1066 /// to a sampling blit: a preview is worth less than a show whose encoded
1067 /// values silently changed.
1068 ///
1069 /// Idempotent in effect — an already-open preview is rebuilt at the current
1070 /// size, which is also how a caller follows a resize it did not see.
1071 pub fn open_preview(&mut self) -> Result<(), RenderError> {
1072 if !self.ctx.can_copy_to_target() {
1073 return Err(RenderError::UnsupportedSurface);
1074 }
1075 self.preview.open_target(
1076 &self.ctx.device,
1077 self.ctx.surface_format(),
1078 self.ctx.config.width,
1079 self.ctx.config.height,
1080 );
1081 Ok(())
1082 }
1083
1084 /// Release the intermediate. Idempotent, and the frame path returns to
1085 /// drawing straight at its destination on the very next frame.
1086 ///
1087 /// Takes any readback with it: the readback copies out of the intermediate,
1088 /// so one left behind would hold a staging buffer for a texture that no
1089 /// longer exists and never yield another frame.
1090 pub fn close_preview(&mut self) {
1091 self.preview.close();
1092 }
1093
1094 /// The open preview's size and identity, or `None` when closed.
1095 pub fn preview_state(&self) -> Option<((u32, u32), u64)> {
1096 self.preview.state()
1097 }
1098
1099 /// **The channel order every frame this renderer hands a consumer carries.**
1100 ///
1101 /// One answer for every path, because there is one source: the frame tap's
1102 /// target, the capture target and the preview's intermediate are all built
1103 /// at the context's configured format, so a windowed run reports whatever
1104 /// the swapchain negotiated and a headless one reports `HEADLESS_FORMAT`.
1105 /// A caller announcing a frame pipe reads this instead of naming a constant
1106 /// — a constant is true on one of those two paths and false on the other
1107 /// (ADR-0187).
1108 ///
1109 /// `Err` where that format has no name a consumer knows, which is a refusal
1110 /// to publish bytes under a guess.
1111 pub fn pixel_order(&self) -> Result<PixelOrder, RenderError> {
1112 let format = self.ctx.surface_format();
1113 PixelOrder::of(format).ok_or(RenderError::UnnameablePixelOrder(format))
1114 }
1115
1116 /// Attach a **secondary present target** — a second window's surface, on
1117 /// this renderer's existing device (ADR-0143).
1118 ///
1119 /// The core learns nothing about what that window is for. It presents the
1120 /// runs it is handed and nothing else; the shell decides their meaning.
1121 /// Returns the present mode the surface negotiated, so the caller can log
1122 /// which arm ran.
1123 ///
1124 /// `frame_latency` is the secondary swapchain's
1125 /// `desired_maximum_frame_latency`; see [`AuxTarget::new`] for what it paces
1126 /// and for the range it is clamped to.
1127 ///
1128 /// An already-attached target is replaced. An `Err` means this adapter
1129 /// cannot drive that surface — the dual-GPU case — and the caller is
1130 /// expected to degrade rather than treat it as fatal: the show is on the
1131 /// primary surface, which is unaffected.
1132 ///
1133 /// This is also the re-entry after [`set_adapter`](Self::set_adapter),
1134 /// which releases the attached target with the device it belonged to: a
1135 /// shell hands the same window back and a new surface is built on the new
1136 /// device, with the `Err` above meaning what it means on a first attach.
1137 #[cfg(feature = "text")]
1138 pub fn attach_aux(
1139 &mut self,
1140 target: impl Into<wgpu::SurfaceTarget<'static>>,
1141 width: u32,
1142 height: u32,
1143 frame_latency: u32,
1144 ) -> Result<AuxPresentMode, RenderError> {
1145 let aux = AuxTarget::new(&self.ctx, target, width, height, frame_latency)?;
1146 let mode = aux.present_mode();
1147 self.aux = Some(aux);
1148 Ok(mode)
1149 }
1150
1151 /// The secondary target's configured frame latency, or `None` when detached.
1152 #[cfg(feature = "text")]
1153 pub fn aux_frame_latency(&self) -> Option<u32> {
1154 self.aux.as_ref().map(AuxTarget::frame_latency)
1155 }
1156
1157 /// What the secondary target's present path has done since it was attached,
1158 /// or `None` when detached.
1159 ///
1160 /// The counts live with the target and die with it, so a caller that wants
1161 /// a session's totals reads them **before** [`detach_aux`](Self::detach_aux).
1162 #[cfg(feature = "text")]
1163 pub fn aux_counts(&self) -> Option<AuxCounts> {
1164 self.aux.as_ref().map(AuxTarget::counts)
1165 }
1166
1167 /// Release the secondary target, its swapchain and its text atlas. Idempotent.
1168 #[cfg(feature = "text")]
1169 pub fn detach_aux(&mut self) {
1170 self.aux = None;
1171 }
1172
1173 /// Whether a secondary target is currently attached.
1174 #[cfg(feature = "text")]
1175 pub fn aux_attached(&self) -> bool {
1176 self.aux.is_some()
1177 }
1178
1179 /// Resize the secondary target's swapchain. No-op with none attached.
1180 #[cfg(feature = "text")]
1181 pub fn resize_aux(&mut self, width: u32, height: u32) {
1182 if let Some(aux) = self.aux.as_mut() {
1183 aux.resize(&self.ctx.device, width, height);
1184 }
1185 }
1186
1187 /// The secondary target's size in physical pixels, or `None` when detached.
1188 #[cfg(feature = "text")]
1189 pub fn aux_size(&self) -> Option<(u32, u32)> {
1190 self.aux.as_ref().map(AuxTarget::size)
1191 }
1192
1193 /// Draw `runs` on the secondary target and present it. No-op with none
1194 /// attached.
1195 ///
1196 /// Deliberately **not** called from [`render`](Self::render): the two
1197 /// surfaces present independently, so a frame the output drops does not
1198 /// have to cost the console one, and neither surface's state can reach the
1199 /// other's.
1200 ///
1201 /// **Independent is not free.** The caller decides when this runs, and in
1202 /// the standalone that is the display thread — so a console that stalls
1203 /// stalls the loop that called it, whatever the two surfaces do
1204 /// separately. The cost is a measurement: Plan 0147 Phase 4 put it inside
1205 /// noise across three frame-time regimes on an integrated Radeon, and
1206 /// [`aux_counts`](Self::aux_counts) is what makes such a reading
1207 /// distinguishable from a console that never presented at all.
1208 #[cfg(feature = "text")]
1209 pub fn present_aux(
1210 &mut self,
1211 runs: &[TextRun<'_>],
1212 panels: &[Panel],
1213 ) -> Result<(), RenderError> {
1214 match self.aux.as_mut() {
1215 Some(aux) => aux.present(&self.ctx, runs, panels, self.preview.target()),
1216 None => Ok(()),
1217 }
1218 }
1219
1220 /// Queue text runs to composite over the next rendered frame; the queue is
1221 /// cleared after each `render`. The standalone fills it each frame with the
1222 /// active preset name and, while the browse overlay is open, its rows. A
1223 /// `text`-feature (standalone) path — the plugin/default build has no text.
1224 #[cfg(feature = "text")]
1225 pub fn queue_text(&mut self, runs: &[TextRun<'_>]) {
1226 self.text_layer.queue(runs);
1227 }
1228
1229 /// Queue the panels the next frame's text sits on; replaced by each call
1230 /// and cleared after each `render`, like [`queue_text`](Self::queue_text).
1231 /// All of a frame's panels are one draw, under the picture and the text.
1232 #[cfg(feature = "text")]
1233 pub fn queue_panels(&mut self, panels: &[Panel]) {
1234 self.text_layer.queue_panels(panels);
1235 }
1236
1237 /// The laid-out width of `text` at `size` device pixels, shaped with the
1238 /// same fonts the output's text is drawn with.
1239 #[cfg(feature = "text")]
1240 pub fn measure_text(&mut self, text: &str, size: f32) -> f32 {
1241 self.text_layer.measure(text, size)
1242 }
1243
1244 /// Set the one picture the shell may draw over the frame, or clear it with
1245 /// `None`. **The only upload**: the bytes are copied into a texture here
1246 /// and every later frame reuses it; the texture is reallocated only when the
1247 /// dimensions change. The first call is also what builds the layer's
1248 /// pipeline — a renderer that never calls this holds none of it.
1249 ///
1250 /// The bytes are RGBA8 as a capture reads them back, sRGB-encoded, so a
1251 /// still from `capture_*` draws unchanged. A refused image changes nothing.
1252 ///
1253 /// Not carried across [`set_adapter`](Self::set_adapter): the texture
1254 /// belongs to the old device, and [`overlay_image_size`](Self::overlay_image_size)
1255 /// answers `None` afterwards.
1256 #[cfg(feature = "text")]
1257 pub fn set_overlay_image(
1258 &mut self,
1259 image: Option<OverlayImage<'_>>,
1260 ) -> Result<(), OverlayImageError> {
1261 self.image_layer
1262 .set(&self.ctx.device, &self.ctx.queue, image)
1263 }
1264
1265 /// Draw the set picture into `rect` on the next rendered frame, under that
1266 /// frame's text. The frame consumes it, as it does queued text, so a
1267 /// picture wanted on screen is queued on every frame. A no-op with no
1268 /// picture set. Writes no texture.
1269 #[cfg(feature = "text")]
1270 pub fn queue_image(&mut self, rect: ImageRect) {
1271 self.image_layer.queue(rect);
1272 }
1273
1274 /// The set picture's size, or `None` when none is set.
1275 #[cfg(feature = "text")]
1276 pub fn overlay_image_size(&self) -> Option<(u32, u32)> {
1277 self.image_layer.size()
1278 }
1279
1280 /// Announce the currently playing track (ADR-0110). The banner fades in,
1281 /// holds, and fades out on its own; the caller only says *what*, never
1282 /// *when to stop*.
1283 ///
1284 /// The string is `artist - title`, split on the first ` - `. Setting the
1285 /// string that is already set does nothing, so a metadata source may push on
1286 /// every update it receives. An empty string clears the banner.
1287 ///
1288 /// **Source-agnostic by construction** (ADR-0001): the argument carries no
1289 /// evidence of whether it came from Windows SMTC or foobar's `titleformat`.
1290 /// Callers must not call this from an audio callback — the copy allocates.
1291 pub fn set_now_playing(&mut self, text: &str) {
1292 self.now_playing.set(text);
1293 }
1294
1295 /// Make the banner's fade a step (`true`) or the theme's eased envelope
1296 /// (`false`, the default) — the shell's reduced-motion choice reaching the
1297 /// one envelope the core owns.
1298 pub fn set_reduced_motion(&mut self, reduced: bool) {
1299 self.now_playing.set_reduced_motion(reduced);
1300 }
1301
1302 /// Append the banner's lines to this frame's text queue, after whatever the
1303 /// frontend queued — [`queue_text`](Self::queue_text) *replaces* the queue,
1304 /// so the core's own furniture has to go in afterwards or a shell that draws
1305 /// nothing would erase it.
1306 #[cfg(feature = "text")]
1307 fn queue_now_playing(&mut self) {
1308 // Split-borrowed: the layout reads `now_playing` and `ctx` while the
1309 // queue is mutated, which a `&mut self` method call would forbid.
1310 let Self {
1311 now_playing,
1312 text_layer,
1313 ctx,
1314 ..
1315 } = self;
1316 let (width, height) = (ctx.config.width as f32, ctx.config.height as f32);
1317 let lines = now_playing.layout(width, height);
1318 let widths = lines.each_ref().map(|line| {
1319 line.as_ref()
1320 .map_or(0.0, |l| text_layer.measure(&l.text, l.size))
1321 });
1322 if let Some(panel) = now_playing::backdrop(&lines, widths) {
1323 text_layer.push_panel(panel);
1324 }
1325 for line in lines.into_iter().flatten() {
1326 text_layer.push(TextRun {
1327 text: &line.text,
1328 x: line.x,
1329 y: line.y,
1330 size: line.size,
1331 color: line.color,
1332 });
1333 }
1334 }
1335
1336 /// Enable or disable rolling frame-time collection — the gated diagnostics
1337 /// clock read (Plan 0011). The standalone leaves this on so the title always
1338 /// shows live fps/p99; turning it off keeps the core fully clock-free.
1339 pub fn enable_diagnostics(&mut self, on: bool) {
1340 self.diag.set_collecting(on);
1341 }
1342
1343 /// Turn the on-screen debug overlay on or off (off by default). Independent
1344 /// of collection, so the plugin can log metrics without painting the overlay.
1345 pub fn set_overlay(&mut self, on: bool) {
1346 self.diag.set_overlay(on);
1347 }
1348
1349 /// Whether the debug overlay is currently painted.
1350 pub fn overlay_enabled(&self) -> bool {
1351 self.diag.overlay_enabled()
1352 }
1353
1354 /// The current diagnostics snapshot (fps, p99, GPU bytes, …).
1355 pub fn metrics(&self) -> Metrics {
1356 self.diag.metrics()
1357 }
1358
1359 /// Median frame time over the diagnostics window, in milliseconds.
1360 ///
1361 /// **Native-only**, beside the analysis snapshot below and for its reason:
1362 /// [`Metrics`] mirrors the C ABI's `RlxMetrics`, so a field added there
1363 /// widens that surface (ADR-0052). A median beside the p99 is what makes a
1364 /// frame-time reading legible — typical against worst — where the mean the
1365 /// snapshot already carries sits between them saying neither.
1366 pub fn frame_ms_p50(&self) -> f32 {
1367 self.diag.frame_ms_p50()
1368 }
1369
1370 /// The last drawn frame's analysis snapshot — the levels and the downbeat
1371 /// lock state. **Native-only**: deliberately absent from the C ABI, so the
1372 /// foobar plugin has no counterpart (ADR-0052).
1373 pub fn analysis_metrics(&self) -> AnalysisMetrics {
1374 self.diag.analysis()
1375 }
1376
1377 /// Name of the currently active preset.
1378 pub fn preset_name(&self) -> &str {
1379 self.roster.name()
1380 }
1381
1382 /// Whether the active GPU adapter is a CPU/software rasterizer (WARP on DX12).
1383 /// Visual-QA tests read this to skip differential checks the software
1384 /// rasterizer can't render faithfully — notably the fullscreen-scene +
1385 /// background-pipeline coexistence, which WARP mis-renders while real hardware
1386 /// renders it correctly (Plan 0025 / ADR-0026).
1387 pub fn adapter_is_software(&self) -> bool {
1388 self.ctx.is_software()
1389 }
1390
1391 /// The active adapter's description — name, backend, device type and driver.
1392 ///
1393 /// **For reports that have to name the machine they were taken on**
1394 /// (ADR-0071): a frame time is a fact about a GPU and a driver rather
1395 /// than about the code, so a cost instrument that prints one has to say
1396 /// which. Read by `core/tests/collage_cost.rs`; nothing on a render path
1397 /// consults it.
1398 pub fn adapter_description(&self) -> &str {
1399 self.ctx.adapter()
1400 }
1401
1402 /// Name of the built-in system the active preset drives (e.g. the frontend
1403 /// shows it next to the preset name).
1404 ///
1405 /// This is the scene's **display** string — `"fragment field"`, with a
1406 /// space. It is not the key anything looks a system up by; see
1407 /// [`Renderer::active_system_key`], which is a different string for every
1408 /// system whose name is more than one word.
1409 pub fn active_system_name(&self) -> &'static str {
1410 self.roster
1411 .active_preset()
1412 .and_then(|p| scene_for(&self.scenes, p.system))
1413 .map(|scene| scene.name())
1414 .unwrap_or("")
1415 }
1416
1417 /// The **canonical key** of the system the active preset drives —
1418 /// `"fragment_field"`, the exact string a preset writes in its `system`
1419 /// field and the schema export labels that system's parameter roster with.
1420 ///
1421 /// Distinct from [`Renderer::active_system_name`] and not interchangeable
1422 /// with it: the two coincide on the four systems whose names are one word
1423 /// (`swarm`, `spectrum`, `emitter`, `attractor`) and differ on every other,
1424 /// so code that resolves a schema roster from the display name works for a
1425 /// quarter of the systems and silently finds nothing for the rest. Anything
1426 /// keyed by system takes this one (ADR-0184).
1427 ///
1428 /// `""` on an empty roster, matching its sibling.
1429 pub fn active_system_key(&self) -> &'static str {
1430 self.roster
1431 .active_preset()
1432 .map(|p| p.system.as_str())
1433 .unwrap_or("")
1434 }
1435
1436 /// The **family** the active preset draws, spelled exactly as a preset
1437 /// writes it and as the schema document's `families[].family` spells it —
1438 /// `"lissajous"`, `"thomas"`, `"chladni"`, `"life_like"` (ADR-0194 point 3).
1439 ///
1440 /// `None` for a system whose parameters read the same on every family it
1441 /// draws, which is every system [`family_params`](scenes::family_params)
1442 /// answers nothing for. A consumer that gets `None` has no family to key a
1443 /// range on and falls back to the parameter's single declared `range`.
1444 ///
1445 /// **It is the value `Scene::configure` received**, out of the loaded
1446 /// preset's structural config, rather than a second reading of the scene's
1447 /// own state. So a `parametric_curve` preset that declares no `[curve]`
1448 /// table answers `None`: nothing configured the scene, and what it draws is
1449 /// whatever the last configured preset left it on — a fact about that hole
1450 /// rather than about this accessor, and reporting a guess would paper over
1451 /// it. Every other family-bearing system builds its config unconditionally.
1452 pub fn active_family_key(&self) -> Option<&'static str> {
1453 use scenes::GeneratorConfig;
1454 match self.roster.active_preset()?.config.as_ref()? {
1455 GeneratorConfig::Curve { family } => Some(family.as_str()),
1456 GeneratorConfig::Particles { family, .. } => Some(family.as_str()),
1457 GeneratorConfig::Field(config) => Some(config.family.as_str()),
1458 GeneratorConfig::Cellular(config) => Some(config.family.as_str()),
1459 GeneratorConfig::Plexus(config) => Some(config.layout.as_str()),
1460 // Exhaustive rather than a wildcard, so a system that grows a family
1461 // has to answer here as well as in `family_params`.
1462 GeneratorConfig::LSystem { .. }
1463 | GeneratorConfig::Star { .. }
1464 | GeneratorConfig::Spectrum { .. }
1465 | GeneratorConfig::WarpMesh { .. }
1466 | GeneratorConfig::Path { .. } => None,
1467 }
1468 }
1469
1470 /// The file the active preset was read from, or `None` when it came from
1471 /// the embedded set and has no file on disk.
1472 ///
1473 /// Absolute, as [`crate::preset::load_dir`] recorded it, so a consumer in
1474 /// another process can act on it.
1475 pub fn active_preset_source(&self) -> Option<&std::path::Path> {
1476 self.roster
1477 .active_preset()
1478 .and_then(|p| p.source.as_deref())
1479 }
1480
1481 /// The segment-cap truncation from the active preset's last `configure`, if
1482 /// its geometry hit the fixed cap (ADR-0007: the cap is never a silent cut).
1483 /// Refreshed on every active-preset change (select / cycle / hot-reload); the
1484 /// standalone surfaces it at load. `None` in the normal case where geometry
1485 /// fit — which is every shipped preset.
1486 pub fn cap_overflow(&self) -> Option<&CapOverflow> {
1487 // The configure-time overflow (an oversized L-system depth) takes
1488 // precedence; otherwise the active scene's per-frame overflow, set once
1489 // a frame has rendered — a line scene's geometry mirror (Plan 0018
1490 // Phase 4) or the analytic field's iteration clamp to the tier. All
1491 // reuse the same `CapOverflow` type so the frontend surfaces any.
1492 if let Some(overflow) = self.cap_overflow.as_ref() {
1493 return Some(overflow);
1494 }
1495 self.roster
1496 .active_preset()
1497 .and_then(|preset| scene_for(&self.scenes, preset.system))
1498 .and_then(|scene| scene.mirror_overflow())
1499 }
1500
1501 /// Draw the current preset for this analysis frame, advancing all animation
1502 /// by `dt` real seconds (Plan 0014 Phase 2). The frontend measures and
1503 /// injects elapsed wall-clock time so the visuals run at the same speed on
1504 /// any refresh rate; `core` never reads a clock. Lost/outdated surfaces
1505 /// self-heal by reconfiguring; timeouts/occlusion skip the frame; only a
1506 /// validation failure (a bug) bubbles up.
1507 ///
1508 /// A `dt` that is not finite and positive is replaced by one nominal step
1509 /// before anything reads it (`sanitize_frame_dt`, ADR-0191).
1510 pub fn render(&mut self, frame: &AnalysisFrame, dt: f32) -> Result<(), RenderError> {
1511 let dt = sanitize_frame_dt(dt);
1512 self.time += dt;
1513 // The banner rides the same injected `dt` the scene does, so it lasts the
1514 // same number of seconds on any refresh rate (ADR-0110 / Plan 0014).
1515 self.now_playing.advance(dt);
1516
1517 // Core-tracked GPU footprint: the swapchain dominates what the core
1518 // allocates. An approximation (ADR-0008), refreshed each frame so it
1519 // tracks resizes and Phase 6's swapchain trim.
1520 self.diag.set_gpu_bytes(
1521 self.ctx.config.width as u64
1522 * self.ctx.config.height as u64
1523 * SWAPCHAIN_BYTES_PER_PIXEL
1524 * SWAPCHAIN_IMAGE_COUNT,
1525 );
1526
1527 let Some(surface_tex) = Self::acquire(&self.ctx)? else {
1528 self.diag.record_dropped(); // transient (timeout/occluded) — skip
1529 return Ok(());
1530 };
1531 let view = surface_tex
1532 .texture
1533 .create_view(&wgpu::TextureViewDescriptor::default());
1534 let mut encoder = self
1535 .ctx
1536 .device
1537 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1538 label: Some("rlx-frame"),
1539 });
1540
1541 // After the acquire, so a dropped frame does not leave a second copy of
1542 // the banner queued behind the one the next frame pushes.
1543 #[cfg(feature = "text")]
1544 self.queue_now_playing();
1545
1546 let (width, height) = (self.ctx.config.width, self.ctx.config.height);
1547 // Moved out of `self` for the draw, which takes `&mut self`; put back
1548 // below. With no preview open this is `None` and the frame is drawn
1549 // straight at the swapchain view, exactly as it was.
1550 let preview = self.preview.take_target();
1551 // The one live call site: a preset that asked for `seed = "random"` gets
1552 // the salt it drew at load (ADR-0051). Every other caller of `draw_frame`
1553 // is a capture and pins.
1554 let draw_calls = self.draw_frame(
1555 frame,
1556 &mut encoder,
1557 preview.as_ref().map_or(&view, |p| &p.view),
1558 (width, height),
1559 dt,
1560 SaltMode::Live,
1561 );
1562 if let Some(p) = preview.as_ref() {
1563 p.record_copy_to(&mut encoder, &surface_tex.texture);
1564 }
1565 self.preview.restore_target(preview);
1566 // The readback rides this frame's own submission and takes the previous
1567 // frame's map on the way past, without waiting for either. `false` when
1568 // no readback is open or when the map has not landed.
1569 let recorded = self.preview.step_readback(&self.ctx.device, &mut encoder);
1570
1571 self.ctx.queue.submit(std::iter::once(encoder.finish()));
1572 self.ctx.queue.present(surface_tex);
1573 if recorded {
1574 self.preview.arm_readback();
1575 }
1576
1577 // Free atlas glyphs unused this frame and clear the queue for the next.
1578 #[cfg(feature = "text")]
1579 self.text_layer.end_frame();
1580
1581 self.diag.set_draw_calls(draw_calls);
1582 self.diag.record_frame();
1583 // The quality governor, after this frame is recorded: on sustained
1584 // evidence that the rich tier does not fit the display budget it latches
1585 // to the floor for the remainder of the session (ADR-0045). Cheap in the
1586 // steady state — a pin, an already-floor tier, or a fired latch all return
1587 // before the series is read — and it can rebuild GPU state at most once.
1588 self.govern_tier();
1589 Ok(())
1590 }
1591
1592 /// Record this frame's scene pass — plus the optional text and overlay
1593 /// passes — into `encoder`, drawing into `view` at `width`×`height`. Shared
1594 /// by the on-surface present path and headless capture; the caller owns
1595 /// acquire/submit/present (or the offscreen copy-back). Evaluates the active
1596 /// preset into the active system using the current scene clock
1597 /// (`self.time`, advanced by the caller — this does not touch it) and
1598 /// injects `dt` real seconds into the scene's [`advance`](scenes::Scene::advance)
1599 /// so its simulation steps at the same wall-clock rate on any refresh.
1600 /// `salt` says which of the active preset's two salts its `hash()`/`noise()`
1601 /// calls mix in (ADR-0051) — [`SaltMode::Live`] from the one on-surface
1602 /// caller, [`SaltMode::Pinned`] from every capture path.
1603 ///
1604 /// **Precondition: `dt` is finite and positive.** Nothing here checks it. A
1605 /// caller forwarding a delta it was handed passes it through
1606 /// [`sanitize_frame_dt`] first; a capture path passes
1607 /// [`FALLBACK_DT`](scenes::FALLBACK_DT) directly.
1608 ///
1609 /// Returns the draw-call count.
1610 // Eight arguments, one past the lint: they are the frame's inputs and each is
1611 // read once. The same allowance `evaluate_preset` above carries, and for the
1612 // same reason — bundling them would name a struct after a call site rather
1613 // than after anything in the design.
1614 fn draw_frame(
1615 &mut self,
1616 frame: &AnalysisFrame,
1617 encoder: &mut wgpu::CommandEncoder,
1618 view: &wgpu::TextureView,
1619 surface: (u32, u32),
1620 dt: f32,
1621 salt: SaltMode,
1622 ) -> u32 {
1623 let Self {
1624 ctx,
1625 // Read where the scenes are BUILT, not where they are drawn - a
1626 // frame never consults it.
1627 budget: _,
1628 scenes,
1629 side,
1630 incoming_side,
1631 ink,
1632 tonemap,
1633 blend,
1634 transition,
1635 roster,
1636 time,
1637 diag,
1638 overlay,
1639 #[cfg(feature = "text")]
1640 text_layer,
1641 #[cfg(feature = "text")]
1642 image_layer,
1643 // Advanced and queued in `render`, before this is called — the banner
1644 // is a live-surface concern, so a headless capture never draws one.
1645 now_playing: _,
1646 // Set at preset load, surfaced by the frontend — not a per-frame concern.
1647 cap_overflow: _,
1648 // The caller decided which view this frame draws into, owns the copy
1649 // out of it, and steps the readback either side of the submission
1650 // where the encoder and the queue both are; from in here the
1651 // intermediate is just the target.
1652 preview: _,
1653 series_scratch,
1654 vertex_scratch,
1655 param_state,
1656 overrides,
1657 layer_state,
1658 outgoing_state,
1659 outgoing_layer_state,
1660 latches,
1661 outgoing_latches,
1662 // Resolved once at construction; the overlay names it (ADR-0045). The
1663 // capacity values themselves were consumed at construction time — the
1664 // frame path reads the tier only to print it.
1665 tier,
1666 // Whether that tier was the governor's doing, so the overlay can tell
1667 // a demoted floor from a pinned one.
1668 tier_demoted,
1669 // Governor inputs, read after the frame is encoded (see `govern_tier`)
1670 // rather than while encoding it — not a per-frame drawing concern.
1671 tier_pinned: _,
1672 frame_budget_secs: _,
1673 // Read where the tier is resolved; the frame reads the resolved scale
1674 // off `tier`.
1675 grid_scale_pin: _,
1676 // Switch-site policy state (the kind rotation) — not a per-frame concern.
1677 transitions_started: _,
1678 // The secondary surface presents on its own encoder in `present_aux`,
1679 // never from inside a frame encode: the output's pixels must not
1680 // depend on whether a console is attached (ADR-0143).
1681 #[cfg(feature = "text")]
1682 aux: _,
1683 } = self;
1684
1685 // The analysis snapshot for the overlay and the 1 Hz log (ADR-0052).
1686 // Taken here rather than in `render` so a capture records it too, and
1687 // before the early return below so it is the frame's own values even when
1688 // there is no preset to draw them under.
1689 diag.set_analysis(frame);
1690
1691 let Some(preset) = roster.active_preset() else {
1692 return 0; // no presets loaded — nothing to draw
1693 };
1694 let routes = roster.active_routes();
1695
1696 // Evaluate against the shared clock and this frame's analysis. Both sides
1697 // of a dissolve read the same variables — they differ in what they *bind*.
1698 //
1699 // The band array rides along **by borrow** (ADR-0036): this bundle is
1700 // built once per frame here but read once per binding below, so a
1701 // by-value spectrum would put a 256-byte copy on the per-binding path.
1702 //
1703 // Through `from_frame` rather than the nine positional arguments, so the
1704 // harness probe that reads the same frame cannot bind it differently.
1705 //
1706 // The one thing that is *not* shared is the salt (ADR-0051): it is a fact
1707 // about a preset, not about the audio, so each side re-salts this bundle
1708 // below with its own. Sharing it would put the incoming preset's seed on
1709 // the outgoing preset's `hash()` for the second a dissolve lasts.
1710 let vars = Variables::from_frame(frame, *time);
1711
1712 // Fixed-order composite (ADR-0018/0028/0032/0046): background (owns the
1713 // clear) -> scene -> the per-preset post chain -> [blend] -> tonemap ->
1714 // ink -> present. Everything left of the tonemap is linear light at
1715 // `COMPOSITE_FORMAT`; everything right of it is display-referred at the
1716 // surface's. Where the scene draws and which chain stage folds into which
1717 // is the chain's business, not the renderer's — see `post.rs` for the
1718 // order and the skip rule. The blend, the tonemap and ink are engine-wide
1719 // passes the renderer drives.
1720 // The **render target's** aspect, which `PostChain::begin` reports as the
1721 // surface's whatever internal grid the chain routes through (ADR-0037).
1722 // Computed once here because the per-vertex evaluation below happens
1723 // before the chain opens, and `rad`/`ang` must be aspect-corrected.
1724 let surface_aspect = surface.0 as f32 / surface.1.max(1) as f32;
1725 let mut draw_calls = 0;
1726 // What both sides evaluate through. Held across the two `evaluate_side`
1727 // calls below rather than rebuilt, so the scratch buffers are sliced from
1728 // one owner and the salt cannot be taken from two different bundles.
1729 let mut shared = SideInputs {
1730 tier,
1731 series: series_scratch,
1732 vertex: vertex_scratch,
1733 aspect: surface_aspect,
1734 vars,
1735 frame,
1736 time: *time,
1737 dt,
1738 salt,
1739 };
1740
1741 // The outgoing side first, because it feeds the blend.
1742 let dual_live = transition.as_ref().is_some_and(Transition::is_dual_live);
1743 if dual_live {
1744 draw_calls += encode_outgoing_side(
1745 ctx,
1746 encoder,
1747 surface,
1748 Outgoing {
1749 transition: transition.as_ref(),
1750 roster,
1751 blend,
1752 scenes,
1753 side,
1754 state: outgoing_state,
1755 layer_state: outgoing_layer_state,
1756 latches: outgoing_latches,
1757 },
1758 &mut shared,
1759 );
1760 }
1761
1762 // --- the active preset: the incoming side during a dissolve, the only
1763 // side otherwise ---
1764 //
1765 // The opening frame is the exception that makes the whole scheme cheap: the
1766 // roster still points at the *outgoing* preset there, so this one ordinary
1767 // composite is the snapshot, and `side` is the right chain for it.
1768 let live_side = match incoming_side.as_mut() {
1769 Some(incoming) if !transition.as_ref().is_some_and(Transition::needs_snapshot) => {
1770 incoming
1771 }
1772 _ => side,
1773 };
1774 let Some(scene) = scene_for_mut(scenes, preset.system) else {
1775 return draw_calls;
1776 };
1777 draw_calls += encode_active_side(
1778 ctx,
1779 encoder,
1780 view,
1781 surface,
1782 ActiveSide {
1783 active: Active {
1784 preset,
1785 routes,
1786 // The only side that takes them: the active preset is the one
1787 // a control surface is addressing (ADR-0176).
1788 overrides: Some(overrides),
1789 },
1790 scene,
1791 composite: live_side,
1792 state: param_state,
1793 layer_state,
1794 latches,
1795 },
1796 DisplayTail {
1797 blend,
1798 tonemap,
1799 ink,
1800 transition: transition.as_ref(),
1801 },
1802 &mut shared,
1803 );
1804
1805 // Hold the outgoing preset's evaluated terminal params off the capture
1806 // frame, where the roster still points at it — the one frame they exist.
1807 let captured_ink = ink.params();
1808 let captured_exposure = tonemap.exposure();
1809
1810 draw_calls += encode_on_canvas(
1811 ctx,
1812 encoder,
1813 view,
1814 surface,
1815 OnCanvas {
1816 #[cfg(feature = "text")]
1817 text_layer,
1818 #[cfg(feature = "text")]
1819 image_layer,
1820 diag,
1821 overlay,
1822 tier: tier.tier,
1823 tier_demoted: *tier_demoted,
1824 grid_scale: tier.grid_scale,
1825 },
1826 );
1827
1828 // The borrows above all end here, so `self` is free again (NLL).
1829 self.advance_transition(dt, dual_live, captured_ink, captured_exposure);
1830
1831 draw_calls
1832 }
1833
1834 fn acquire(ctx: &RenderContext) -> Result<Option<wgpu::SurfaceTexture>, RenderError> {
1835 use wgpu::CurrentSurfaceTexture as C;
1836 let Some(surface) = ctx.surface.as_ref() else {
1837 return Ok(None); // headless context — no swapchain to present into
1838 };
1839 match surface.get_current_texture() {
1840 C::Success(t) | C::Suboptimal(t) => Ok(Some(t)),
1841 C::Timeout | C::Occluded => Ok(None),
1842 C::Outdated | C::Lost => {
1843 ctx.reconfigure();
1844 match surface.get_current_texture() {
1845 C::Success(t) | C::Suboptimal(t) => Ok(Some(t)),
1846 C::Validation => Err(RenderError::SurfaceValidation),
1847 _ => Ok(None),
1848 }
1849 }
1850 C::Validation => Err(RenderError::SurfaceValidation),
1851 }
1852 }
1853}
1854
1855#[cfg(test)]
1856mod tests;
1857
1858#[cfg(test)]
1859mod milk_wash;