1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum FieldFamily {
51 #[default]
54 Chladni,
55 EscapeTime,
58}
59
60impl FieldFamily {
61 pub const ALL: [FieldFamily; 2] = [FieldFamily::Chladni, FieldFamily::EscapeTime];
64
65 pub fn from_name(name: &str) -> Option<Self> {
67 Self::ALL.into_iter().find(|family| family.as_str() == name)
68 }
69
70 pub fn as_str(self) -> &'static str {
72 match self {
73 FieldFamily::Chladni => "chladni",
74 FieldFamily::EscapeTime => "escape_time",
75 }
76 }
77
78 fn index(self) -> u32 {
81 match self {
82 FieldFamily::Chladni => 0,
83 FieldFamily::EscapeTime => 1,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum EscapeMap {
91 #[default]
94 Julia,
95 Mandelbrot,
99}
100
101impl EscapeMap {
102 pub const ALL: [EscapeMap; 2] = [EscapeMap::Julia, EscapeMap::Mandelbrot];
104
105 pub fn from_name(name: &str) -> Option<Self> {
107 Self::ALL.into_iter().find(|map| map.as_str() == name)
108 }
109
110 pub fn as_str(self) -> &'static str {
112 match self {
113 EscapeMap::Julia => "julia",
114 EscapeMap::Mandelbrot => "mandelbrot",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum TrapShape {
127 #[default]
129 None,
130 Point,
132 Line,
134 Cross,
137 Circle,
140}
141
142impl TrapShape {
143 pub const ALL: [TrapShape; 5] = [
145 TrapShape::None,
146 TrapShape::Point,
147 TrapShape::Line,
148 TrapShape::Cross,
149 TrapShape::Circle,
150 ];
151
152 pub fn from_name(name: &str) -> Option<Self> {
154 Self::ALL.into_iter().find(|trap| trap.as_str() == name)
155 }
156
157 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub struct FieldConfig {
186 pub family: FieldFamily,
188 pub map: EscapeMap,
191 pub trap: TrapShape,
194}
195
196pub const MAX_MODE: f32 = 16.0;
199
200pub(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
213pub const MAX_ITERATIONS: f32 = 512.0;
216
217pub(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
229pub(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
250fn 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
263pub const MIN_POWER: f32 = 1.1;
266pub const MAX_POWER: f32 = 8.0;
269pub const ESCAPE_RADIUS_RANGE: [f32; 2] = [2.0, 256.0];
272pub 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
294pub const TRAP_RADIUS_LIMIT: f32 = 256.0;
297
298pub 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
451macro_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
471pub 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
506pub struct AnalyticFieldScene {
509 gpu: gpu::FullscreenScene,
512 config: FieldConfig,
514 target_height: u32,
517 iteration_cap: f32,
520 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 occlude: f32,
547}
548
549impl AnalyticFieldScene {
550 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 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 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 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 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 }
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 let pixel = 2.0 / (zoom * self.target_height as f32);
727 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 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, ¶ms);
793
794 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;