Skip to main content

rlx_core/render/scenes/analytic_field/
mod.rs

1//! The analytic field: one fullscreen fragment pass whose output is a closed-form
2//! function of position, coloured through the shared palette LUT (ADR-0021).
3//!
4//! Every stateless per-pixel mathematical world lives here as a named
5//! **family** (ADR-0180 rule 1), selected by `[field] family`: the Chladni
6//! plate, whose two integer mode numbers are the whole figure, and escape time
7//! — the Julia and Mandelbrot sets — coloured by the smooth iteration count.
8//!
9//! # No state between frames
10//!
11//! Nothing here accumulates: the picture is a pure function of the parameters
12//! the preset binds this frame, the palette, and the target's aspect. So there
13//! is no `Phase`, no offscreen of its own, and nothing for a capture to reset —
14//! motion comes from what the bindings read (`time`, the bands), never from the
15//! scene.
16//!
17//! # The aspect is the render target's
18//!
19//! ADR-0037's rule, restated because a fullscreen field computing a radius is
20//! exactly the shape that gets it wrong: the field's x axis is scaled by the
21//! `aspect` `Scene::render` is handed, which is the target's, so one field
22//! unit is the same number of pixels on both axes and a square plate stays
23//! square at 1280x800.
24
25// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
26// 0003 Phase 0). Runs every displayed frame.
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35mod shader;
36
37use super::common;
38use super::lines::GeneratorConfig;
39use super::{FamilyParam, FamilyRange, Scene};
40use crate::dsp::AnalysisFrame;
41use crate::render::gpu;
42use crate::render::palette::{self, Palette};
43use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
44
45/// Which closed-form world the field draws (ADR-0180 rule 1).
46///
47/// Quasicrystal, Voronoi and hyperbolic tiling are placed in this system by
48/// that rule and are not built; each is a later arm here, not a new system.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum FieldFamily {
51    /// The cymatics plate: the nodal set of two standing waves whose mode
52    /// numbers are `mode_n` and `mode_m`.
53    #[default]
54    Chladni,
55    /// Escape time: `z -> z^power + c` iterated to an escape radius, coloured
56    /// by the smooth iteration count — the Julia and Mandelbrot sets.
57    EscapeTime,
58}
59
60impl FieldFamily {
61    /// Every family, in the order the shader's family index numbers them and
62    /// the generated reference lists them.
63    pub const ALL: [FieldFamily; 2] = [FieldFamily::Chladni, FieldFamily::EscapeTime];
64
65    /// Parse the canonical `[field] family = "..."` value, or `None`.
66    pub fn from_name(name: &str) -> Option<Self> {
67        Self::ALL.into_iter().find(|family| family.as_str() == name)
68    }
69
70    /// The canonical name — [`from_name`](Self::from_name)'s inverse.
71    pub fn as_str(self) -> &'static str {
72        match self {
73            FieldFamily::Chladni => "chladni",
74            FieldFamily::EscapeTime => "escape_time",
75        }
76    }
77
78    /// The integer the shader's `switch` selects on. Its position in
79    /// [`ALL`](Self::ALL), which is what keeps the two numbered alike.
80    fn index(self) -> u32 {
81        match self {
82            FieldFamily::Chladni => 0,
83            FieldFamily::EscapeTime => 1,
84        }
85    }
86}
87
88/// Escape time only: what the pixel is (ADR-0180's `[field] map`).
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum EscapeMap {
91    /// The pixel seeds the orbit and `c` is the `c_re`/`c_im` constant — one
92    /// Julia set, which the constant reshapes.
93    #[default]
94    Julia,
95    /// The pixel is `c` and every orbit starts at zero — the Mandelbrot set,
96    /// the map of which constants give a connected Julia set. `c_re`/`c_im` are
97    /// inert.
98    Mandelbrot,
99}
100
101impl EscapeMap {
102    /// Both maps, for the load error and the schema export.
103    pub const ALL: [EscapeMap; 2] = [EscapeMap::Julia, EscapeMap::Mandelbrot];
104
105    /// Parse the canonical `[field] map = "..."` value, or `None`.
106    pub fn from_name(name: &str) -> Option<Self> {
107        Self::ALL.into_iter().find(|map| map.as_str() == name)
108    }
109
110    /// The canonical name — [`from_name`](Self::from_name)'s inverse.
111    pub fn as_str(self) -> &'static str {
112        match self {
113            EscapeMap::Julia => "julia",
114            EscapeMap::Mandelbrot => "mandelbrot",
115        }
116    }
117}
118
119/// Escape time only: the shape an orbit is measured against (`[field] trap`).
120///
121/// With a trap, a pixel is coloured by the **smallest distance its orbit came
122/// to the shape** rather than by how fast it escaped — which is what draws the
123/// filaments, rings and stained-glass cells of orbit-trap imagery. Each shape
124/// sits `trap_radius` from the origin, turned by `trap_rotate`.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum TrapShape {
127    /// No trap: the smooth escape count colours the picture.
128    #[default]
129    None,
130    /// A point `trap_radius` from the origin along `trap_rotate`.
131    Point,
132    /// A line `trap_radius` from the origin, its normal along `trap_rotate`.
133    Line,
134    /// Two perpendicular lines crossing at the point `Point` would sit at,
135    /// turned by `trap_rotate`.
136    Cross,
137    /// A circle of radius `trap_radius` about the origin; `trap_rotate` is inert
138    /// on it.
139    Circle,
140}
141
142impl TrapShape {
143    /// Every shape, in the order the shader's trap index numbers them.
144    pub const ALL: [TrapShape; 5] = [
145        TrapShape::None,
146        TrapShape::Point,
147        TrapShape::Line,
148        TrapShape::Cross,
149        TrapShape::Circle,
150    ];
151
152    /// Parse the canonical `[field] trap = "..."` value, or `None`.
153    pub fn from_name(name: &str) -> Option<Self> {
154        Self::ALL.into_iter().find(|trap| trap.as_str() == name)
155    }
156
157    /// The canonical name — [`from_name`](Self::from_name)'s inverse.
158    pub fn as_str(self) -> &'static str {
159        match self {
160            TrapShape::None => "none",
161            TrapShape::Point => "point",
162            TrapShape::Line => "line",
163            TrapShape::Cross => "cross",
164            TrapShape::Circle => "circle",
165        }
166    }
167
168    /// The integer the shader's trap `switch` selects on: its position in
169    /// [`ALL`](Self::ALL), `0` being no trap at all.
170    fn index(self) -> u32 {
171        match self {
172            TrapShape::None => 0,
173            TrapShape::Point => 1,
174            TrapShape::Line => 2,
175            TrapShape::Cross => 3,
176            TrapShape::Circle => 4,
177        }
178    }
179}
180
181/// `[field]` — the structural configuration, fixed while the preset is loaded
182/// and delivered through `Scene::configure`, in the shape `[curve]` and
183/// `[particles]` already use.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub struct FieldConfig {
186    /// Which world is drawn.
187    pub family: FieldFamily,
188    /// Escape time only: whether `c` is a parameter or the pixel. The loader
189    /// refuses a `map` on any other family.
190    pub map: EscapeMap,
191    /// Escape time only: the orbit trap, if any. The loader refuses a `trap`
192    /// on any other family.
193    pub trap: TrapShape,
194}
195
196/// The largest mode number either axis of the plate accepts. Past it the nodal
197/// lines are closer together than a 1080p frame has pixels to separate them.
198pub const MAX_MODE: f32 = 16.0;
199
200/// A bound mode number as the plate reads it: clamped into `1..=MAX_MODE` and
201/// rounded, so the figure is always one of the plate's own standing waves and
202/// never a fractional mode — which is not a figure at all, since the boundary
203/// condition the formula encodes holds only at whole numbers. A non-finite value
204/// falls back to the declared default rather than reaching the shader.
205pub(crate) fn applied_mode(value: f32, default: f32) -> f32 {
206    if value.is_finite() {
207        value.clamp(1.0, MAX_MODE).round()
208    } else {
209        default
210    }
211}
212
213/// The most iterations any tier lets an escape-time preset ask for — the top of
214/// `iterations`' declared range, and the loop bound the shader is handed.
215pub const MAX_ITERATIONS: f32 = 512.0;
216
217/// A bound iteration budget as the shader reads it: clamped into
218/// `1..=cap` and rounded, so the loop bound is a whole number the tier allows.
219/// A non-finite value falls back to the declared default, itself capped.
220pub(crate) fn applied_iterations(value: f32, cap: f32) -> f32 {
221    let cap = cap.clamp(1.0, MAX_ITERATIONS);
222    if value.is_finite() {
223        value.clamp(1.0, cap).round()
224    } else {
225        DEFAULT_ITERATIONS.min(cap)
226    }
227}
228
229/// The clamp to report for a bound budget of `value` under `cap`, or `None`
230/// when the preset asked within it — or drew a family that reads no budget,
231/// where there is nothing to clamp. `value` is compared as the shader would
232/// take it before the cap, so a bound 64.4 under a cap of 64 is not a clamp.
233pub(crate) fn iteration_clamp(
234    family: FieldFamily,
235    value: f32,
236    cap: f32,
237) -> Option<super::CapOverflow> {
238    if family != FieldFamily::EscapeTime {
239        return None;
240    }
241    let asked = applied_iterations(value, MAX_ITERATIONS);
242    let applied = applied_iterations(value, cap);
243    (asked > applied).then_some(super::CapOverflow {
244        dropped: (asked - applied) as usize,
245        context: super::OverflowContext::Iterations(asked as u32),
246        cap: applied as usize,
247    })
248}
249
250/// `value` clamped into `[lo, hi]`, or `fallback` when it is not finite. The
251/// escape-time parameters are clamped CPU-side because the shader's finiteness
252/// argument rests on their bounds (see its `escape_time` comment): a power at 1
253/// divides by `ln 1`, a radius under 2 lets bounded orbits "escape", and an
254/// unbounded `c` can overflow f32 in one step.
255fn bounded(value: f32, lo: f32, hi: f32, fallback: f32) -> f32 {
256    if value.is_finite() {
257        value.clamp(lo, hi)
258    } else {
259        fallback
260    }
261}
262
263/// The smallest `power` the shader is handed: the smooth count divides by
264/// `ln power`, which vanishes at 1.
265pub const MIN_POWER: f32 = 1.1;
266/// The largest `power`, which with the largest radius keeps `|z|^power` inside
267/// f32 for one step past escape.
268pub const MAX_POWER: f32 = 8.0;
269/// The escape radius's clamp. Below 2 an orbit that stays bounded can still
270/// cross it; above 256 one step past it can overflow at the largest power.
271pub const ESCAPE_RADIUS_RANGE: [f32; 2] = [2.0, 256.0];
272/// How far `c` may sit from the origin on either axis. Every constant with a
273/// connected Julia set lies inside `|c| <= 2`; this leaves room for a binding
274/// to overshoot into dust without letting it reach f32's ceiling.
275pub const C_LIMIT: f32 = 4.0;
276
277const DEFAULT_HUE: f32 = 0.0;
278const DEFAULT_ZOOM: f32 = 1.0;
279const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
280const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
281const DEFAULT_MODE_N: f32 = default_of(PARAMS, "mode_n");
282const DEFAULT_MODE_M: f32 = default_of(PARAMS, "mode_m");
283const DEFAULT_LINE_WIDTH: f32 = default_of(PARAMS, "line_width");
284const DEFAULT_PLATE_MIX: f32 = default_of(PARAMS, "plate_mix");
285const DEFAULT_C_RE: f32 = default_of(PARAMS, "c_re");
286const DEFAULT_C_IM: f32 = default_of(PARAMS, "c_im");
287const DEFAULT_ITERATIONS: f32 = default_of(PARAMS, "iterations");
288const DEFAULT_ESCAPE_RADIUS: f32 = default_of(PARAMS, "escape_radius");
289const DEFAULT_POWER: f32 = default_of(PARAMS, "power");
290const DEFAULT_INTERIOR: f32 = default_of(PARAMS, "interior");
291const DEFAULT_TRAP_RADIUS: f32 = default_of(PARAMS, "trap_radius");
292const DEFAULT_TRAP_ROTATE: f32 = default_of(PARAMS, "trap_rotate");
293
294/// How far from the origin a trap may sit, and how far `trap_radius` may push
295/// it — past every orbit's escape radius a trap is simply never approached.
296pub const TRAP_RADIUS_LIMIT: f32 = 256.0;
297
298/// The parameter names this scene consumes — the vocabulary a preset binding is
299/// checked against at load (ADR-0020). **Keep in sync with `set_param` below**;
300/// `declared_params_match_set_param` in `core/tests/suite/preset.rs` fails if the two
301/// drift, and [`FAMILY_PARAMS`] says which family reads each family-bound one.
302pub const PARAMS: &[ParamSpec] = &[
303    ParamSpec {
304        name: "mode_n",
305        default: 3.0,
306        range: Some([1.0, MAX_MODE]),
307        doc: "The plate's first mode number: how many nodal lines cross one axis. Equal to \
308              `mode_m`, the two waves cancel and the plate is blank.",
309        kind: ParamKind::Structural,
310        group: ParamGroup::Shape,
311        main: true,
312    },
313    ParamSpec {
314        name: "mode_m",
315        default: 5.0,
316        range: Some([1.0, MAX_MODE]),
317        doc: "The plate's second mode number: how many nodal lines cross the other axis.",
318        kind: ParamKind::Structural,
319        group: ParamGroup::Shape,
320        main: true,
321    },
322    ParamSpec {
323        name: "line_width",
324        default: 0.04,
325        range: Some([0.0, 0.3]),
326        doc: "How wide a band around the nodal lines lights, in plate units (the plate is 2 \
327              across); 0 is a one-pixel line.",
328        kind: ParamKind::Modal,
329        group: ParamGroup::Shape,
330        main: false,
331    },
332    ParamSpec {
333        name: "plate_mix",
334        default: 0.0,
335        range: Some([0.0, 1.0]),
336        doc: "Blends from the nodal lines alone toward the whole signed wave, which reads as a \
337              standing wave rather than as sand.",
338        kind: ParamKind::Modal,
339        group: ParamGroup::Shape,
340        main: false,
341    },
342    ParamSpec {
343        name: "iterations",
344        default: 64.0,
345        range: Some([1.0, MAX_ITERATIONS]),
346        doc: "How many steps an orbit is followed before it is called part of the set; more \
347              resolves finer boundary detail. Capped by the quality tier.",
348        kind: ParamKind::Structural,
349        group: ParamGroup::Shape,
350        main: false,
351    },
352    ParamSpec {
353        name: "c_re",
354        default: -0.8,
355        range: Some([-1.5, 0.5]),
356        doc: "The real part of the Julia constant: the lever that reshapes the set, from one \
357              connected piece to dust. Inert on the `mandelbrot` map.",
358        kind: ParamKind::Modal,
359        group: ParamGroup::Shape,
360        main: true,
361    },
362    ParamSpec {
363        name: "c_im",
364        default: 0.156,
365        range: Some([-1.0, 1.0]),
366        doc: "The imaginary part of the Julia constant. Inert on the `mandelbrot` map.",
367        kind: ParamKind::Modal,
368        group: ParamGroup::Shape,
369        main: true,
370    },
371    ParamSpec {
372        name: "escape_radius",
373        default: 16.0,
374        range: Some([2.0, 256.0]),
375        doc: "How far an orbit must travel to count as escaped; larger smooths the colour \
376              bands' spacing.",
377        kind: ParamKind::Modal,
378        group: ParamGroup::Shape,
379        main: false,
380    },
381    ParamSpec {
382        name: "power",
383        default: 2.0,
384        range: Some([1.5, MAX_POWER]),
385        doc: "The exponent in z -> z^power + c: 2 is the classic set, higher whole powers add \
386              lobes, and a fractional power tears along the negative real axis.",
387        kind: ParamKind::Modal,
388        group: ParamGroup::Shape,
389        main: false,
390    },
391    ParamSpec {
392        name: "interior",
393        default: 0.0,
394        range: Some([0.0, 1.0]),
395        doc: "How much light the set itself emits; 0 is the textbook black interior.",
396        kind: ParamKind::Modal,
397        group: ParamGroup::Colour,
398        main: false,
399    },
400    ParamSpec {
401        name: "trap_radius",
402        default: 0.5,
403        range: Some([0.0, 2.0]),
404        doc: "How far the orbit trap sits from the origin — the circle's radius, the line's \
405              offset, the point's and the cross's distance. Inert with no `trap`.",
406        kind: ParamKind::Modal,
407        group: ParamGroup::Shape,
408        main: false,
409    },
410    ParamSpec {
411        name: "trap_rotate",
412        default: 0.0,
413        range: Some([0.0, 1.0]),
414        doc: "Turns the orbit trap about the origin, in whole turns. Inert on a `circle` and \
415              with no `trap`.",
416        kind: ParamKind::Modal,
417        group: ParamGroup::Motion,
418        main: false,
419    },
420    ParamSpec {
421        name: "color_span",
422        default: 1.0,
423        range: Some([0.0, 4.0]),
424        doc: "How much of the palette the field's level covers; 0 is one flat colour.",
425        kind: ParamKind::Modal,
426        group: ParamGroup::Colour,
427        main: true,
428    },
429    ParamSpec {
430        name: "color_center",
431        default: 0.0,
432        range: Some([-1.0, 1.0]),
433        doc: "Shifts which part of the palette the field's level starts from.",
434        kind: ParamKind::Modal,
435        group: ParamGroup::Colour,
436        main: false,
437    },
438    common::brightness(common::DEFAULT_BRIGHTNESS),
439    common::hue(DEFAULT_HUE),
440    common::zoom(DEFAULT_ZOOM),
441    common::PAN_X,
442    common::PAN_Y,
443    common::SATURATION,
444    common::PALETTE_MIX,
445    common::PALETTE_STEPS,
446    common::PALETTE_CONTOUR,
447    common::PALETTE_CONTOUR_STYLE,
448    common::PALETTE_CONTOUR_INK,
449];
450
451/// One row of [`FAMILY_PARAMS`], its ranges in [`FieldFamily::ALL`]'s order.
452/// `None` is a family that does not read the parameter.
453macro_rules! per_family {
454    ($name:literal: $chladni:expr, $escape_time:expr $(,)?) => {
455        FamilyParam {
456            name: $name,
457            ranges: &[
458                FamilyRange {
459                    family: "chladni",
460                    range: $chladni,
461                },
462                FamilyRange {
463                    family: "escape_time",
464                    range: $escape_time,
465                },
466            ],
467        }
468    };
469}
470
471/// Every parameter only one family reads (ADR-0180 rule 4), with the range that
472/// reads there — so the generated reference prints `mode_n` as Chladni's and
473/// inert elsewhere, rather than leaving an author to find it dead. Ranges are in
474/// [`FieldFamily::ALL`]'s order, and each row's spec range is one of them; both
475/// held by this module's tests. A parameter missing from here reads the same on
476/// every family.
477pub const FAMILY_PARAMS: &[FamilyParam] = &[
478    per_family!("mode_n": Some([1.0, MAX_MODE]), None),
479    per_family!("mode_m": Some([1.0, MAX_MODE]), None),
480    per_family!("line_width": Some([0.0, 0.3]), None),
481    per_family!("plate_mix": Some([0.0, 1.0]), None),
482    per_family!("iterations": None, Some([1.0, MAX_ITERATIONS])),
483    per_family!("c_re": None, Some([-1.5, 0.5])),
484    per_family!("c_im": None, Some([-1.0, 1.0])),
485    per_family!("escape_radius": None, Some([2.0, 256.0])),
486    per_family!("power": None, Some([1.5, MAX_POWER])),
487    per_family!("interior": None, Some([0.0, 1.0])),
488    per_family!("trap_radius": None, Some([0.0, 2.0])),
489    per_family!("trap_rotate": None, Some([0.0, 1.0])),
490];
491
492#[repr(C)]
493#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
494struct Params {
495    a: [f32; 4],
496    b: [f32; 4],
497    c: [f32; 4],
498    d: [f32; 4],
499    e: [f32; 4],
500    f: [f32; 4],
501    g: [f32; 4],
502    h: [f32; 4],
503    i: [f32; 4],
504}
505
506/// The fullscreen analytic field, driven by named preset parameters and one
507/// `[field]` table.
508pub struct AnalyticFieldScene {
509    /// The pipeline, the uniform buffer, the A/B gradient LUT pair (ADR-0021)
510    /// and the one bind group that binds all four.
511    gpu: gpu::FullscreenScene,
512    /// The `[field]` table of the preset last configured.
513    config: FieldConfig,
514    /// Height of the target this scene renders into this frame, in pixels —
515    /// what turns "one pixel" into field units for the line's antialiasing.
516    target_height: u32,
517    /// The tier's [`field_iterations`](crate::render::TierConfig::field_iterations):
518    /// the most iterations a bound budget reaches the shader with.
519    iteration_cap: f32,
520    /// This frame's clamp of the budget to [`iteration_cap`](Self::iteration_cap),
521    /// or `None` when the preset asked within it — read back through
522    /// [`Scene::mirror_overflow`] so the frontend announces the clamp. A `Copy`
523    /// value rewritten each frame, so reporting it allocates nothing.
524    clamp: Option<super::CapOverflow>,
525    colour: common::PaletteParams,
526    pan: common::PanParams,
527    zoom: f32,
528    color_span: f32,
529    color_center: f32,
530    mode_n: f32,
531    mode_m: f32,
532    line_width: f32,
533    plate_mix: f32,
534    c_re: f32,
535    c_im: f32,
536    iterations: f32,
537    escape_radius: f32,
538    power: f32,
539    interior: f32,
540    trap_radius: f32,
541    trap_rotate: f32,
542    /// How much of this field's coverage the backdrop resolves against
543    /// (ADR-0085). Set by the renderer every frame through
544    /// [`Scene::set_occlude`] — **not** a named param, so `reset_params` leaves
545    /// it alone.
546    occlude: f32,
547}
548
549impl AnalyticFieldScene {
550    /// Build the scene's pipeline and uniform buffer on `device`, holding a
551    /// bound escape-time budget to `iteration_cap` — the tier's
552    /// [`field_iterations`](crate::render::TierConfig::field_iterations).
553    pub fn new(
554        device: &wgpu::Device,
555        surface_format: wgpu::TextureFormat,
556        iteration_cap: u32,
557    ) -> Self {
558        let shader = gpu::fullscreen_shader(
559            device,
560            "analytic-field-shader",
561            gpu::FULLSCREEN_VS_NDC,
562            shader::SHADER,
563        );
564        let parts =
565            gpu::FullscreenParts::new(device, "analytic-field", std::mem::size_of::<Params>());
566        // `[Texture, Texture, Sampler, Uniform]` in one group — a shape the
567        // crate's layout enumeration shows nothing else has (ADR-0058). See the
568        // WGSL note for why this scene does not split them over two groups.
569        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
570            label: Some("analytic-field-bind-layout"),
571            entries: &[
572                gpu::texture(0, true),
573                gpu::texture(1, true),
574                gpu::sampler(2),
575                gpu::uniform(3, wgpu::ShaderStages::FRAGMENT),
576            ],
577        });
578        let [lut_a, lut_b, sampler] = parts.luts().bind_entries(0, 1, 2);
579        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
580            label: Some("analytic-field-bind-group"),
581            layout: &layout,
582            entries: &[
583                lut_a,
584                lut_b,
585                sampler,
586                wgpu::BindGroupEntry {
587                    binding: 3,
588                    resource: parts.uniforms().as_entire_binding(),
589                },
590            ],
591        });
592
593        Self {
594            gpu: parts.finish(
595                device,
596                &shader,
597                &[&layout],
598                bind_group,
599                None,
600                surface_format,
601                wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
602                "analytic-field",
603            ),
604            config: FieldConfig::default(),
605            target_height: 1,
606            iteration_cap: (iteration_cap as f32).clamp(1.0, MAX_ITERATIONS),
607            clamp: None,
608            colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
609            pan: common::PanParams::default(),
610            zoom: DEFAULT_ZOOM,
611            color_span: DEFAULT_COLOR_SPAN,
612            color_center: DEFAULT_COLOR_CENTER,
613            mode_n: DEFAULT_MODE_N,
614            mode_m: DEFAULT_MODE_M,
615            line_width: DEFAULT_LINE_WIDTH,
616            plate_mix: DEFAULT_PLATE_MIX,
617            c_re: DEFAULT_C_RE,
618            c_im: DEFAULT_C_IM,
619            iterations: DEFAULT_ITERATIONS,
620            escape_radius: DEFAULT_ESCAPE_RADIUS,
621            power: DEFAULT_POWER,
622            interior: DEFAULT_INTERIOR,
623            trap_radius: DEFAULT_TRAP_RADIUS,
624            trap_rotate: DEFAULT_TRAP_ROTATE,
625            occlude: crate::render::post::DEFAULT_OCCLUDE,
626        }
627    }
628}
629
630impl Scene for AnalyticFieldScene {
631    fn name(&self) -> &'static str {
632        "analytic field"
633    }
634
635    fn set_target_size(&mut self, _width: u32, height: u32) {
636        self.target_height = height.max(1);
637    }
638
639    fn set_occlude(&mut self, occlude: f32) {
640        self.occlude = occlude;
641    }
642
643    fn set_palette(&mut self, palette: &Palette) {
644        // Stored here, uploaded by `render` — off the hot path, once a switch.
645        self.gpu.set_palette(palette);
646    }
647
648    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<super::CapOverflow> {
649        if let GeneratorConfig::Field(config) = cfg {
650            self.config = *config;
651            // The outgoing preset's clamp is not this one's to report.
652            self.clamp = None;
653        }
654        None
655    }
656
657    fn mirror_overflow(&self) -> Option<&super::CapOverflow> {
658        self.clamp.as_ref()
659    }
660
661    fn reset_params(&mut self) {
662        self.colour.reset();
663        self.pan.reset();
664        self.zoom = DEFAULT_ZOOM;
665        self.color_span = DEFAULT_COLOR_SPAN;
666        self.color_center = DEFAULT_COLOR_CENTER;
667        self.mode_n = DEFAULT_MODE_N;
668        self.mode_m = DEFAULT_MODE_M;
669        self.line_width = DEFAULT_LINE_WIDTH;
670        self.plate_mix = DEFAULT_PLATE_MIX;
671        self.c_re = DEFAULT_C_RE;
672        self.c_im = DEFAULT_C_IM;
673        self.iterations = DEFAULT_ITERATIONS;
674        self.escape_radius = DEFAULT_ESCAPE_RADIUS;
675        self.power = DEFAULT_POWER;
676        self.interior = DEFAULT_INTERIOR;
677        self.trap_radius = DEFAULT_TRAP_RADIUS;
678        self.trap_rotate = DEFAULT_TRAP_ROTATE;
679    }
680
681    fn set_param(&mut self, name: &str, value: f32) {
682        // The shared param blocks first, this scene's own names after
683        // (`scenes::common`).
684        if self.colour.set(name, value) || self.pan.set(name, value) {
685            return;
686        }
687        match name {
688            "zoom" => self.zoom = value,
689            "color_span" => self.color_span = value,
690            "color_center" => self.color_center = value,
691            "mode_n" => self.mode_n = value,
692            "mode_m" => self.mode_m = value,
693            "line_width" => self.line_width = value,
694            "plate_mix" => self.plate_mix = value,
695            "c_re" => self.c_re = value,
696            "c_im" => self.c_im = value,
697            "iterations" => self.iterations = value,
698            "escape_radius" => self.escape_radius = value,
699            "power" => self.power = value,
700            "interior" => self.interior = value,
701            "trap_radius" => self.trap_radius = value,
702            "trap_rotate" => self.trap_rotate = value,
703            _ => {}
704        }
705    }
706
707    fn update(&mut self, _frame: &AnalysisFrame) {
708        // Stateless: the analysis reaches this scene only through the bindings.
709    }
710
711    fn render(
712        &mut self,
713        queue: &wgpu::Queue,
714        encoder: &mut wgpu::CommandEncoder,
715        view: &wgpu::TextureView,
716        aspect: f32,
717    ) {
718        self.gpu.flush_palette(queue);
719
720        let zoom = if self.zoom.is_finite() && self.zoom > 1e-3 {
721            self.zoom
722        } else {
723            DEFAULT_ZOOM
724        };
725        // One pixel of the target, in field units: the short axis spans 2 / zoom.
726        let pixel = 2.0 / (zoom * self.target_height as f32);
727        // A pan is unbounded but must be a number: the escape arm clamps the
728        // point it seeds an orbit with, and a clamp cannot rescue a NaN.
729        let pan = |v: f32| if v.is_finite() { v } else { 0.0 };
730        self.clamp = iteration_clamp(self.config.family, self.iterations, self.iteration_cap);
731        let params = Params {
732            a: [aspect.max(0.1), zoom, pan(self.pan.x), pan(self.pan.y)],
733            b: [
734                self.colour.hue,
735                self.color_span,
736                self.color_center,
737                self.colour.saturation,
738            ],
739            c: [
740                self.colour.mix,
741                self.occlude,
742                palette::band_steps(self.colour.steps),
743                palette::band_contour(self.colour.contour),
744            ],
745            d: [
746                self.colour.brightness,
747                self.config.family.index() as f32,
748                applied_mode(self.mode_n, DEFAULT_MODE_N),
749                applied_mode(self.mode_m, DEFAULT_MODE_M),
750            ],
751            e: [self.line_width, self.plate_mix, pixel, 0.0],
752            f: [
753                bounded(self.c_re, -C_LIMIT, C_LIMIT, DEFAULT_C_RE),
754                bounded(self.c_im, -C_LIMIT, C_LIMIT, DEFAULT_C_IM),
755                applied_iterations(self.iterations, self.iteration_cap),
756                bounded(
757                    self.escape_radius,
758                    ESCAPE_RADIUS_RANGE[0],
759                    ESCAPE_RADIUS_RANGE[1],
760                    DEFAULT_ESCAPE_RADIUS,
761                ),
762            ],
763            g: [
764                bounded(self.power, MIN_POWER, MAX_POWER, DEFAULT_POWER),
765                bounded(self.interior, 0.0, 1.0, DEFAULT_INTERIOR),
766                match self.config.map {
767                    EscapeMap::Julia => 0.0,
768                    EscapeMap::Mandelbrot => 1.0,
769                },
770                0.0,
771            ],
772            h: [
773                self.config.trap.index() as f32,
774                bounded(
775                    self.trap_radius,
776                    -TRAP_RADIUS_LIMIT,
777                    TRAP_RADIUS_LIMIT,
778                    DEFAULT_TRAP_RADIUS,
779                ),
780                // Whole turns to radians; `fract` keeps a long-running bound
781                // angle from losing precision without moving the picture.
782                std::f32::consts::TAU * bounded(self.trap_rotate, -1e6, 1e6, 0.0).fract(),
783                0.0,
784            ],
785            i: [
786                palette::band_contour_style(self.colour.contour_style),
787                self.colour.contour_ink,
788                0.0,
789                0.0,
790            ],
791        };
792        self.gpu.write_uniform(queue, &params);
793
794        // Load over the engine backdrop (ADR-0018); the field covers every pixel
795        // and says how much through alpha.
796        self.gpu
797            .draw(encoder, "analytic-field-pass", view, wgpu::LoadOp::Load);
798    }
799}
800
801#[cfg(test)]
802mod mirror;
803#[cfg(test)]
804mod tests;