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