feat: add more animations and 3d export

This commit is contained in:
2026-09-24 22:12:56 +02:00
parent 61d4766088
commit 38cbb1f132
7 changed files with 796 additions and 236 deletions
+385 -137
View File
@@ -202,6 +202,126 @@ struct ExportShared {
result: Option<Result<String, String>>,
}
/// Drift of a complex constant around a circle in its plane (Julia `c`,
/// Phoenix `p`, Lambda `λ`).
#[derive(Clone)]
struct ConstOrbit {
on: bool,
/// Revolutions per second.
speed: f32,
/// Circle radius.
radius: f64,
/// Circle center, captured when the animation is enabled.
base: (f64, f64),
angle: f64,
}
impl Default for ConstOrbit {
fn default() -> Self {
Self {
on: false,
speed: 0.05,
radius: 0.08,
base: (0.0, 0.0),
angle: 0.0,
}
}
}
impl ConstOrbit {
/// Start orbiting around `current`.
fn enable(&mut self, current: (f64, f64)) {
self.base = current;
self.angle = 0.0;
}
/// Advance by `dt` seconds and return the new value.
fn step(&mut self, dt: f64) -> (f64, f64) {
self.angle += std::f64::consts::TAU * self.speed as f64 * dt;
let (s, c) = self.angle.sin_cos();
(self.base.0 + self.radius * c, self.base.1 + self.radius * s)
}
/// Checkbox + speed/radius sliders; (re)centers the orbit on `current`
/// when switched on.
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) {
if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on {
self.enable(current);
}
if self.on {
ui.add(
egui::Slider::new(&mut self.speed, 0.005..=0.5)
.text(format!("{name} rev/s"))
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.radius, 0.005..=0.5)
.text(format!("{name} radius"))
.logarithmic(true),
);
}
}
}
/// Sine oscillation of one real parameter around a base value (used for each
/// component of the Complex Multibrot exponent, independently).
#[derive(Clone)]
struct AxisOsc {
on: bool,
/// Oscillation center, captured when the animation is enabled.
base: f64,
amplitude: f64,
/// Oscillations per second.
speed: f32,
phase: f64,
}
impl Default for AxisOsc {
fn default() -> Self {
Self {
on: false,
base: 0.0,
amplitude: 0.5,
speed: 0.05,
phase: 0.0,
}
}
}
impl AxisOsc {
fn enable(&mut self, current: f64) {
self.base = current;
self.phase = 0.0;
}
fn step(&mut self, dt: f64) -> f64 {
self.phase += std::f64::consts::TAU * self.speed as f64 * dt;
self.base + self.amplitude * self.phase.sin()
}
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) {
if ui
.checkbox(&mut self.on, format!("Animate {name}"))
.changed()
&& self.on
{
self.enable(current);
}
if self.on {
ui.add(
egui::Slider::new(&mut self.amplitude, 0.01..=4.0)
.text(format!("{name} amplitude"))
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.speed, 0.005..=0.5)
.text(format!("{name} Hz"))
.logarithmic(true),
);
}
}
}
/// Time-based animation of a few view/coloring parameters. Each toggle drives
/// continuous repaints while on; orbit-affecting ones (Julia c, Phoenix p, zoom)
/// recompute the reference each frame and render the cheap low-res pass so they
@@ -214,28 +334,15 @@ struct AnimState {
color_speed: f32,
/// Drift the Julia constant `c` around a circle to morph the Julia set.
julia: bool,
/// Revolutions per second.
julia_speed: f32,
/// Circle radius in the c-plane.
julia_radius: f64,
/// Circle center, captured when the animation is enabled.
julia_base: (f64, f64),
julia_angle: f64,
julia: ConstOrbit,
/// Drift the Phoenix distortion `p` around a circle.
phoenix: bool,
phoenix_speed: f32,
phoenix_radius: f64,
phoenix_base: (f64, f64),
phoenix_angle: f64,
phoenix: ConstOrbit,
/// Drift the Lambda distortion `λ` around a circle.
lambda: bool,
lambda_speed: f32,
lambda_radius: f64,
lambda_base: (f64, f64),
lambda_angle: f64,
lambda: ConstOrbit,
/// Oscillate the Complex Multibrot exponent's real part.
cpow_re: AxisOsc,
/// Oscillate the Complex Multibrot exponent's imaginary part.
cpow_im: AxisOsc,
/// Continuously zoom toward the current center.
zoom: bool,
@@ -248,6 +355,26 @@ struct AnimState {
/// Kind-switch morph duration, in seconds.
kind_morph_duration: f32,
/// Step through every fractal kind in turn (each switch morphs if
/// `kind_morph` is on).
kind_cycle: bool,
/// Seconds to rest on each kind before switching to the next.
kind_cycle_hold: f32,
/// Seconds spent on the current kind since the last cycle step.
kind_cycle_timer: f32,
/// Orbit the 3D camera: spin the yaw and bob the pitch.
cam_orbit: bool,
/// Yaw rate, degrees per second.
cam_yaw_speed: f32,
/// Pitch bob amplitude, degrees (0 = constant pitch).
cam_pitch_amp: f32,
/// Pitch bob frequency, Hz.
cam_pitch_speed: f32,
/// Pitch the bob oscillates around, captured when the orbit is enabled.
cam_pitch_base: f32,
cam_pitch_phase: f32,
/// Linear 2D <-> 3D transition progress in [0, 1], advanced at a constant
/// rate; `camera_state` is its smoothstep-eased value.
camera_progress: f32,
@@ -260,31 +387,37 @@ impl Default for AnimState {
Self {
color: false,
color_speed: 0.15,
julia: false,
julia_speed: 0.05,
julia_radius: 0.08,
julia_base: (0.0, 0.0),
julia_angle: 0.0,
phoenix: false,
phoenix_speed: 0.05,
phoenix_radius: 0.08,
phoenix_base: (0.0, 0.0),
phoenix_angle: 0.0,
lambda: false,
lambda_speed: 0.05,
lambda_radius: 0.08,
lambda_base: (0.0, 0.0),
lambda_angle: 0.0,
julia: ConstOrbit::default(),
phoenix: ConstOrbit::default(),
lambda: ConstOrbit::default(),
cpow_re: AxisOsc::default(),
cpow_im: AxisOsc::default(),
zoom: false,
zoom_speed: 0.5,
kind_morph: true,
kind_morph_duration: 1.5,
kind_cycle: false,
kind_cycle_hold: 3.0,
kind_cycle_timer: 0.0,
cam_orbit: false,
cam_yaw_speed: 15.0,
cam_pitch_amp: 0.0,
cam_pitch_speed: 0.05,
cam_pitch_base: 0.0,
cam_pitch_phase: 0.0,
camera_progress: 0.,
camera_state: 0.,
}
}
}
/// Parse a "re,im" pair of plain `f64`s (per-kind constants on the CLI).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn parse_complex_pair(spec: &str) -> Option<(f64, f64)> {
let (re, im) = spec.split_once(',')?;
Some((re.trim().parse().ok()?, im.trim().parse().ok()?))
}
/// Top-level egui application.
pub struct FractalApp {
view: ViewState,
@@ -554,15 +687,6 @@ impl FractalApp {
if let Some(p) = cli.power {
self.power = p.clamp(2, 8);
}
if let Some(cp) = &cli.complex_power {
let p: Vec<&str> = cp.split(',').collect();
if let (Some(Ok(re)), Some(Ok(im))) = (
p.first().map(|s| s.trim().parse::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
self.complex_power = (re, im);
}
}
self.view = Self::default_view_for(self.mode, self.kind);
}
if let Some(k) = cli.rendering_kind {
@@ -573,6 +697,17 @@ impl FractalApp {
RenderingKindArg::Shadow => self.rendering_mode = 1,
RenderingKindArg::Dimension3 => self.rendering_mode = 2,
}
// Start fully in 3D rather than transitioning in from top-down
// (headless renders a single frame, with no transition to run).
let p = if self.rendering_mode == 2 { 1.0 } else { 0.0 };
self.anim.camera_progress = p;
self.anim.camera_state = p;
}
if cli.yaw.is_some() || cli.pitch.is_some() {
let yaw = cli.yaw.map_or(self.camera.yaw, f32::to_radians);
let pitch = cli.pitch.map_or(self.camera.pitch, f32::to_radians);
self.camera.set_angles(yaw, pitch);
self.camera.rotate(0.0, 0.0); // wrap yaw
}
if let Some(jc) = cli.julia {
let p: Vec<&str> = jc.split(',').collect();
@@ -608,6 +743,10 @@ impl FractalApp {
{
self.apply_share(&state);
}
// After --share so it can override the link's exponent.
if let Some(cp) = cli.complex_power.as_deref().and_then(parse_complex_pair) {
self.complex_power = cp;
}
if let Some(spec) = cli.view {
self.apply_view_spec(&spec);
}
@@ -701,6 +840,75 @@ impl FractalApp {
self.max_iterations = i;
}
/// Per-kind constants `(julia_c, phoenix_p, lambda_l, complex_power)`.
/// Used by headless animation to snapshot their start values.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn constants(&self) -> [(f64, f64); 4] {
[
self.julia_c,
self.phoenix_p,
self.lambda_l,
self.complex_power,
]
}
/// Set the per-kind constants, in the order `constants` returns them.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn set_constants(&mut self, [c, p, l, cp]: [(f64, f64); 4]) {
self.julia_c = c;
self.phoenix_p = p;
self.lambda_l = l;
self.complex_power = cp;
}
/// 3D camera `(yaw, pitch)`, radians.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn camera_angles(&self) -> (f32, f32) {
(self.camera.yaw, self.camera.pitch)
}
/// Set the 3D camera angles (radians; yaw unwrapped, see
/// `Camera::set_angles`).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn set_camera_angles(&mut self, yaw: f32, pitch: f32) {
self.camera.set_angles(yaw, pitch);
}
/// Size the 3D camera and raymarcher for a `width`×`height` render with
/// no window (they normally follow the widget rect each frame).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn set_output_size(&mut self, width: u32, height: u32) {
self.screen_dim = [width as f32, height as f32];
self.camera.set_aspect_ratio(width as f32 / height as f32);
}
/// The current fractal kind.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn kind(&self) -> FractalKind {
self.kind
}
/// Render a fraction `t` in [0, 1] of the way through a kind morph from
/// `from` to `to`: the per-step formula blend, without touching the
/// camera. `t >= 1` (or `from == to`) is plain `to`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn set_kind_morph(&mut self, from: FractalKind, to: FractalKind, t: f64) {
self.kind = to;
self.morph = (from != to && t < 1.0).then(|| {
// `KindMorph` eases its progress with smoothstep; invert that so
// the blend follows `t` (already eased or not by the caller).
let e = t.clamp(0.0, 1.0);
let progress = 0.5 - ((1.0 - 2.0 * e).asin() / 3.0).sin();
KindMorph {
from,
progress: progress as f32,
from_view: self.view.clone(),
to_view: self.view.clone(),
camera: false,
}
});
}
/// Get `max_iterations`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn max_iterations(&mut self) -> u32 {
@@ -1218,7 +1426,7 @@ impl FractalApp {
let device = rs.device.clone();
let queue = rs.queue.clone();
let (pipeline, bind_group_layout, format) = {
let handles = {
let guard = rs.renderer.read();
let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else {
self.status = Some("export unavailable".into());
@@ -1247,9 +1455,7 @@ impl FractalApp {
let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
&handles,
w,
h,
uniforms,
@@ -1277,9 +1483,7 @@ impl FractalApp {
let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
&handles,
w,
h,
uniforms,
@@ -1646,9 +1850,13 @@ impl FractalApp {
/// trigger the interaction low-res pass).
fn tick_animations(&mut self, ui: &egui::Ui) {
// Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind.
let julia_on = self.anim.julia && self.mode == FractalMode::Julia;
let phoenix_on = self.anim.phoenix && self.kind == FractalKind::Phoenix;
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda;
let julia_on = self.anim.julia.on && self.mode == FractalMode::Julia;
let phoenix_on = self.anim.phoenix.on && self.kind == FractalKind::Phoenix;
let lambda_on = self.anim.lambda.on && self.kind == FractalKind::Lambda;
let cmulti = self.kind == FractalKind::ComplexMultibrot;
let cpow_on = cmulti && (self.anim.cpow_re.on || self.anim.cpow_im.on);
let cam_on = self.anim.cam_orbit && self.rendering_mode == 2;
let cycle_on = self.anim.kind_cycle && self.mode != FractalMode::Buddhabrot;
// Clamp dt so a stall (tab hidden, first frame) can't jump the animation.
let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1);
@@ -1688,7 +1896,15 @@ impl FractalApp {
ui.ctx().request_repaint();
}
if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) {
if !(self.anim.color
|| self.anim.zoom
|| julia_on
|| phoenix_on
|| lambda_on
|| cpow_on
|| cam_on
|| cycle_on)
{
return;
}
@@ -1697,29 +1913,47 @@ impl FractalApp {
(self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0);
}
if julia_on {
self.anim.julia_angle += std::f64::consts::TAU * self.anim.julia_speed as f64 * dt;
let (s, c) = self.anim.julia_angle.sin_cos();
self.julia_c = (
self.anim.julia_base.0 + self.anim.julia_radius * c,
self.anim.julia_base.1 + self.anim.julia_radius * s,
);
self.julia_c = self.anim.julia.step(dt);
}
if phoenix_on {
self.anim.phoenix_angle += std::f64::consts::TAU * self.anim.phoenix_speed as f64 * dt;
let (s, c) = self.anim.phoenix_angle.sin_cos();
self.phoenix_p = (
self.anim.phoenix_base.0 + self.anim.phoenix_radius * c,
self.anim.phoenix_base.1 + self.anim.phoenix_radius * s,
);
self.phoenix_p = self.anim.phoenix.step(dt);
}
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda;
if lambda_on {
self.anim.lambda_angle += std::f64::consts::TAU * self.anim.lambda_speed as f64 * dt;
let (s, c) = self.anim.lambda_angle.sin_cos();
self.lambda_l = (
self.anim.lambda_base.0 + self.anim.lambda_radius * c,
self.anim.lambda_base.1 + self.anim.lambda_radius * s,
);
self.lambda_l = self.anim.lambda.step(dt);
}
if cmulti && self.anim.cpow_re.on {
self.complex_power.0 = self.anim.cpow_re.step(dt).clamp(-8.0, 8.0);
}
if cmulti && self.anim.cpow_im.on {
self.complex_power.1 = self.anim.cpow_im.step(dt).clamp(-8.0, 8.0);
}
if cam_on {
let dyaw = self.anim.cam_yaw_speed.to_radians() * dt as f32;
self.anim.cam_pitch_phase +=
std::f32::consts::TAU * self.anim.cam_pitch_speed * dt as f32;
// With no bob, leave pitch alone so it stays draggable mid-orbit.
let dpitch = if self.anim.cam_pitch_amp > 0.0 {
self.anim.cam_pitch_base
+ self.anim.cam_pitch_amp.to_radians() * self.anim.cam_pitch_phase.sin()
- self.camera.pitch
} else {
0.0
};
// `rotate` wraps yaw and clamps pitch.
self.camera.rotate(dyaw, dpitch);
}
if cycle_on && self.morph.is_none() {
self.anim.kind_cycle_timer += dt as f32;
if self.anim.kind_cycle_timer >= self.anim.kind_cycle_hold {
self.anim.kind_cycle_timer = 0.0;
let prev = self.kind;
let i = FractalKind::ALL
.iter()
.position(|&k| k == prev)
.unwrap_or(0);
self.kind = FractalKind::ALL[(i + 1) % FractalKind::ALL.len()];
self.switch_kind(prev);
}
}
if self.anim.zoom && self.anim.zoom_speed != 0.0 {
let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth
@@ -1736,6 +1970,25 @@ impl FractalApp {
ui.ctx().request_repaint();
}
/// React to `self.kind` having just changed from `prev`: jump (or, with
/// kind morphing on, glide) to the new kind's default view.
fn switch_kind(&mut self, prev: FractalKind) {
let to_view = Self::default_view_for(self.mode, self.kind);
// Buddhabrot has its own pipeline without the blended formula, so
// it keeps the instant switch.
self.morph =
(self.anim.kind_morph && self.mode != FractalMode::Buddhabrot).then(|| KindMorph {
from: prev,
progress: 0.0,
from_view: self.view.clone(),
to_view: to_view.clone(),
camera: true,
});
if self.morph.is_none() {
self.view = to_view;
}
}
fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer");
ui.separator();
@@ -1803,20 +2056,7 @@ impl FractalApp {
});
}
if self.kind != prev_kind {
let to_view = Self::default_view_for(self.mode, self.kind);
// Buddhabrot has its own pipeline without the blended formula, so
// it keeps the instant switch.
self.morph =
(self.anim.kind_morph && self.mode != FractalMode::Buddhabrot).then(|| KindMorph {
from: prev_kind,
progress: 0.0,
from_view: self.view.clone(),
to_view: to_view.clone(),
camera: true,
});
if self.morph.is_none() {
self.view = to_view;
}
self.switch_kind(prev_kind);
}
let prev_mode = self.mode;
@@ -2013,60 +2253,68 @@ impl FractalApp {
);
}
// Julia c only affects Julia mode; Phoenix p only the Phoenix kind.
if self.mode != FractalMode::Buddhabrot {
if ui
.checkbox(&mut self.anim.kind_cycle, "Cycle kinds")
.on_hover_text("Step through every fractal kind in turn.")
.changed()
{
self.anim.kind_cycle_timer = 0.0;
}
if self.anim.kind_cycle {
ui.add(
egui::Slider::new(&mut self.anim.kind_cycle_hold, 0.5..=30.0)
.text("hold s")
.logarithmic(true),
);
}
}
if self.rendering_mode == 2 {
if ui
.checkbox(&mut self.anim.cam_orbit, "Orbit camera")
.on_hover_text(
"Spin the 3D camera around the view, optionally bobbing its pitch.",
)
.changed()
&& self.anim.cam_orbit
{
self.anim.cam_pitch_base = self.camera.pitch;
self.anim.cam_pitch_phase = 0.0;
}
if self.anim.cam_orbit {
ui.add(
egui::Slider::new(&mut self.anim.cam_yaw_speed, -90.0..=90.0)
.text("yaw °/s"),
);
ui.add(
egui::Slider::new(&mut self.anim.cam_pitch_amp, 0.0..=30.0)
.text("pitch bob °"),
);
if self.anim.cam_pitch_amp > 0.0 {
ui.add(
egui::Slider::new(&mut self.anim.cam_pitch_speed, 0.005..=0.5)
.text("bob Hz")
.logarithmic(true),
);
}
}
}
// Julia c only affects Julia mode; Phoenix p / λ / complex power
// only their own kinds.
if self.mode == FractalMode::Julia {
if ui.checkbox(&mut self.anim.julia, "Morph c").changed() && self.anim.julia {
self.anim.julia_base = self.julia_c; // orbit around the current c
self.anim.julia_angle = 0.0;
}
if self.anim.julia {
ui.add(
egui::Slider::new(&mut self.anim.julia_speed, 0.005..=0.5)
.text("c rev/s")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.anim.julia_radius, 0.005..=0.5)
.text("c radius")
.logarithmic(true),
);
}
self.anim.julia.ui(ui, "c", self.julia_c);
}
if self.kind == FractalKind::Phoenix {
if ui.checkbox(&mut self.anim.phoenix, "Morph p").changed() && self.anim.phoenix {
self.anim.phoenix_base = self.phoenix_p;
self.anim.phoenix_angle = 0.0;
}
if self.anim.phoenix {
ui.add(
egui::Slider::new(&mut self.anim.phoenix_speed, 0.005..=0.5)
.text("p rev/s")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.anim.phoenix_radius, 0.005..=0.5)
.text("p radius")
.logarithmic(true),
);
}
self.anim.phoenix.ui(ui, "p", self.phoenix_p);
}
if self.kind == FractalKind::Lambda {
if ui.checkbox(&mut self.anim.lambda, "Morph λ").changed() && self.anim.lambda {
self.anim.lambda_base = self.lambda_l;
self.anim.lambda_angle = 0.0;
}
if self.anim.lambda {
ui.add(
egui::Slider::new(&mut self.anim.lambda_speed, 0.005..=0.5)
.text("λ rev/s")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.anim.lambda_radius, 0.005..=0.5)
.text("λ radius")
.logarithmic(true),
);
}
self.anim.lambda.ui(ui, "λ", self.lambda_l);
}
if self.kind == FractalKind::ComplexMultibrot {
self.anim.cpow_re.ui(ui, "Re(power)", self.complex_power.0);
self.anim.cpow_im.ui(ui, "Im(power)", self.complex_power.1);
}
});