diff --git a/CLAUDE.md b/CLAUDE.md index dea2690..7f4885d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,8 @@ python3 -m http.server -d dist 8080 Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`, `--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`, `--palette`, `--share `, -`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`, +`--view re,im,half_height[,iterations]`, `--rendering-kind`, +`--yaw`/`--pitch` (3D camera, degrees), `--de`, `--buddhabrot`, `--buddha-palette`. `--headless` (`src/headless.rs`) skips the window entirely: it builds the same view from the other flags, creates its own offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`, @@ -42,20 +43,27 @@ default 1920×1080, `--export-path out.png`) without needing a GPU-backed window/event loop. Not yet supported with `--buddhabrot`. Run `mandelbrot --help` for the full list. -`--headless` also has an animation mode, for feeding into `ffmpeg`: add -`--to-view re,im,half_height[,iterations]` (or `--to-share `, which -only pulls position/zoom/iterations out of the link) alongside a start view -(`--view`/`--share`/`--kind`/`--julia`), plus `--frames N` or -`--fps`/`--duration`. `--export-path` then names an output *directory* of -`frame-00001.png`, `frame-00002.png`, ... instead of a single file. Only the -camera (center + half-height) is animated — kind, colors, and per-kind -constants stay fixed at whatever the start flags set. `view::interpolate_view` -does the interpolation: half-height geometrically (log-linear, since zoom -spans many decades), center linearly through the complex plane at full -`Big` precision; `--linear` swaps the default smoothstep easing for constant -pacing. Iteration count auto-scales with zoom depth per frame (same -`auto_iteration_count` the interactive app uses while zooming), overriding -any iteration count from `--view`/`--share`/`--to-view`/`--to-share`. +`--headless` also has an animation mode, for feeding into `ffmpeg`: give any +end-state flag alongside the start flags (`--view`/`--share`/`--kind`/ +`--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags: +`--to-view re,im,half_height[,iterations]` or `--to-share ` (only +position/zoom/iterations are pulled out of the link), `--to-iterations`, +`--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power` +(or `--to-complex-power-re`/`--to-complex-power-im` to move one component), +and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched). +Anything without a target stays at its start value; colors stay fixed. +`--export-path` then names an output *directory* of `frame-00001.png`, +`frame-00002.png`, ... instead of a single file. `headless.rs::AnimTargets` +collects the targets; the export pipeline is rebuilt only when the +`PipelineKey` changes between frames (kind morph). `view::interpolate_view` +does the camera: half-height geometrically (log-linear, since zoom spans many +decades), center linearly through the complex plane at full `Big` precision; +constants interpolate linearly. `--linear` swaps the default smoothstep +easing for constant pacing. Without `--to-iterations` (or a share link's), +iteration count auto-scales with zoom depth per frame (same +`auto_iteration_count` the interactive app uses while zooming). +`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so +`--to-yaw 720` is two turns) orbit the 3D camera with `--rendering-kind 3d`. There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid` are the fast, headless way to validate a change. `cargo test` also runs but @@ -158,7 +166,10 @@ pixel is a handful of `f32` complex multiplies. - `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods: `should_request`/`ensure_reference` (decide when the reference is stale and dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`), - `tick_animations` (drives the "morph c/p/λ" and auto-zoom animations), + `tick_animations` (drives the interactive animations: colour cycle, +auto-zoom, c/p/λ circle drift via `ConstOrbit`, per-component complex-power +oscillation via `AxisOsc`, 3D camera orbit, kind cycling through +`switch_kind`), `default_view_for` (wraps `FractalKind::default_set_view`, adding the kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as `[T; FractalKind:: as usize + 1]` — adding a new `FractalKind` @@ -211,7 +222,10 @@ sample per pixel. When AA is on, `fs_refine` reads that texture and runs the 2×2 grid only on pixels whose 4-neighbours differ (interior/exterior edge, or `ci`/DE beyond `AA_CI_EPS`/`AA_DE_EPS`), copying the rest. Colourise then reads the refined texture. PNG export (`fs_color`) still supersamples every -pixel. +pixel, except in 3D: the raymarcher needs the whole height field, so a 3D +`ExportRender` (`RaymarchExport`) runs the interactive chain instead, with +its tiles iterating `fs_data` into its own data texture and the last tile adding +refine + colourise into the target. The 3D view (`colorize.wgsl::ray_marching`) sphere-traces the DE height field straight from the data texture. It's cheap: rays start on the z = 0 diff --git a/src/app.rs b/src/app.rs index afeb6db..5251d33 100644 --- a/src/app.rs +++ b/src/app.rs @@ -202,6 +202,126 @@ struct ExportShared { result: Option>, } +/// 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::()), - p.get(1).map(|s| s.trim().parse::()), - ) { - 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::() 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); } }); diff --git a/src/camera.rs b/src/camera.rs index 9991ae3..4328f0b 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -2,6 +2,10 @@ use std::f32::consts::{PI, TAU}; use glam::Vec3; +/// Pitch is kept just short of straight up/down so the view never flips past +/// the pole (and the raymarcher's rays always have `z > 0`). +const PITCH_LIMIT: f32 = PI / 2.0 - 0.01; + #[derive(Default, Clone)] pub struct Camera { pub position: glam::Vec3, @@ -35,11 +39,20 @@ impl Camera { /// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales /// yaw by `t`) always unwinds the short way instead of every past turn. pub fn rotate(&mut self, dyaw: f32, dpitch: f32) { - const PITCH_LIMIT: f32 = PI / 2.0 - 0.01; self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI; self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); } + /// Set yaw/pitch (radians) outright. Pitch is clamped as in `rotate`, + /// but yaw is left unwrapped: only a camera that stays in 3D (headless + /// animation, which interpolates yaw across several turns) should use + /// this; `rotate(0., 0.)` afterwards wraps it for the 2D <-> 3D transition. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_angles(&mut self, yaw: f32, pitch: f32) { + self.yaw = yaw; + self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + pub fn orthographic(&self, t: f32) -> glam::Mat4 { let yaw = self.yaw * t; let pitch = self.pitch * t; diff --git a/src/cli.rs b/src/cli.rs index ee35270..6ca797f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,6 +61,15 @@ pub struct Cli { #[arg(long("zoom"), short('z'))] pub half_height: Option, + /// 3D camera yaw in degrees (with --rendering-kind 3d). + #[arg(long, value_name = "DEG", allow_hyphen_values = true)] + pub yaw: Option, + + /// 3D camera pitch in degrees (with --rendering-kind 3d); negative tilts + /// the view down toward the fractal. Clamped short of ±90. + #[arg(long, value_name = "DEG", allow_hyphen_values = true)] + pub pitch: Option, + /// Enable distance-estimation shading. #[arg(long)] pub de: bool, @@ -93,6 +102,49 @@ pub struct Cli { #[arg(long)] pub to_iterations: Option, + /// End Julia constant for an animation: c is interpolated from --julia + /// to this over the frames. + #[arg(long, value_name = "RE,IM", allow_hyphen_values = true)] + pub to_julia: Option, + + /// End Phoenix constant p for an animation (from --phoenix-p). + #[arg(long, value_name = "RE,IM", allow_hyphen_values = true)] + pub to_phoenix_p: Option, + + /// End Lambda constant λ for an animation (from --lambda-l). + #[arg(long, value_name = "RE,IM", allow_hyphen_values = true)] + pub to_lambda_l: Option, + + /// End Complex Multibrot exponent for an animation (from + /// --complex-power). Shorthand for --to-complex-power-re + + /// --to-complex-power-im. + #[arg(long, value_name = "RE,IM", allow_hyphen_values = true)] + pub to_complex_power: Option, + + /// End real part of the Complex Multibrot exponent for an animation; + /// the imaginary part stays put unless --to-complex-power-im is given. + #[arg(long, value_name = "RE", allow_hyphen_values = true)] + pub to_complex_power_re: Option, + + /// End imaginary part of the Complex Multibrot exponent for an animation; + /// the real part stays put unless --to-complex-power-re is given. + #[arg(long, value_name = "IM", allow_hyphen_values = true)] + pub to_complex_power_im: Option, + + /// End 3D camera yaw for an animation, in degrees (from --yaw). Not + /// wrapped: --yaw 0 --to-yaw 720 orbits twice. + #[arg(long, value_name = "DEG", allow_hyphen_values = true)] + pub to_yaw: Option, + + /// End 3D camera pitch for an animation, in degrees (from --pitch). + #[arg(long, value_name = "DEG", allow_hyphen_values = true)] + pub to_pitch: Option, + + /// Morph the iteration formula from the start kind (--kind) to this one + /// over the animation. The camera is unaffected (use --to-view for that). + #[arg(long, value_enum)] + pub to_kind: Option, + /// Number of frames to render for an animation. Alternative to --fps + /// --duration. #[arg(long, value_name = "N")] @@ -115,8 +167,8 @@ pub struct Cli { /// Run without opening a window: render the current view to a PNG and /// exit. Combine with --kind/--julia/--share/--view etc. to pick what to - /// render, or --to-view/--to-share to render an animation instead of a - /// single frame. Not yet supported with --buddhabrot. + /// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to + /// render an animation instead of a single frame. Not yet supported with --buddhabrot. #[arg(long)] pub headless: bool, diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index 23ca04d..1f8eecc 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -10,6 +10,8 @@ pub mod share; pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms}; pub use kind::FractalKind; pub use reference::{compute_reference, compute_set_reference}; +#[cfg(not(target_arch = "wasm32"))] +pub use renderer::PipelineKey; #[cfg(target_arch = "wasm32")] pub use renderer::encode_png_with_progress; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index c107d5d..f833391 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -759,15 +759,11 @@ impl FractalRenderer { /// a combined iterate + colour pipeline (`fs_color`) specialized for /// `uniforms` (built fresh — exports are rare, and this only needs a read /// lock on the renderer), its bind-group layout, and the target format. - pub fn export_handles( - &self, - device: &wgpu::Device, - uniforms: &Uniforms, - ) -> ( - wgpu::RenderPipeline, - wgpu::BindGroupLayout, - wgpu::TextureFormat, - ) { + /// In 3D mode (`rendering_mode == 2`) also the interactive iterate → + /// refine → colourise chain, since the raymarcher needs a whole data + /// texture to march over and `fs_color` has no 3D path. + pub fn export_handles(&self, device: &wgpu::Device, uniforms: &Uniforms) -> ExportHandles { + let constants = PipelineKey::from_uniforms(uniforms).constants(); let pipeline = fullscreen_pipeline( device, "fractal export pipeline", @@ -775,12 +771,74 @@ impl FractalRenderer { &self.pipeline_layout, "fs_color", self.target_format, - &PipelineKey::from_uniforms(uniforms).constants(), + &constants, ); - (pipeline, self.bind_group_layout.clone(), self.target_format) + let raymarch = (uniforms.rendering_mode == 2).then(|| RaymarchHandles { + iterate: fullscreen_pipeline( + device, + "fractal export iterate pipeline", + &self.shader, + &self.pipeline_layout, + "fs_data", + DATA_FORMAT, + &constants, + ), + refine: fullscreen_pipeline( + device, + "fractal export AA refine pipeline", + &self.shader, + &self.refine_pipeline_layout, + "fs_refine", + DATA_FORMAT, + &constants, + ), + colorize: self.colorize_pipeline.clone(), + refine_bind_group_layout: self.refine_bind_group_layout.clone(), + colorize_bind_group_layout: self.colorize_bind_group_layout.clone(), + }); + ExportHandles { + pipeline, + bind_group_layout: self.bind_group_layout.clone(), + format: self.target_format, + raymarch, + } } } +/// Everything an [`ExportRender`] needs from the [`FractalRenderer`], cloned +/// out so the export can run off the UI thread (see `export_handles`). +#[derive(Clone)] +pub struct ExportHandles { + /// Combined iterate + colour pipeline (`fs_color`), for 2D modes. + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + format: wgpu::TextureFormat, + /// The two-pass chain, for 3D mode only. + raymarch: Option, +} + +/// The interactive two-pass pipelines, for a 3D export. +#[derive(Clone)] +struct RaymarchHandles { + iterate: wgpu::RenderPipeline, + refine: wgpu::RenderPipeline, + colorize: wgpu::RenderPipeline, + refine_bind_group_layout: wgpu::BindGroupLayout, + colorize_bind_group_layout: wgpu::BindGroupLayout, +} + +/// A 3D export's own data textures and the passes that fill them: the tiles +/// iterate into `data_view`, then one refine (if AA) + colourise pass +/// raymarches the finished height field into the export target. +struct RaymarchExport { + iterate: wgpu::RenderPipeline, + /// Refine pipeline, output view and input bind group, when AA is on. + refine: Option<(wgpu::RenderPipeline, wgpu::TextureView, wgpu::BindGroup)>, + colorize: wgpu::RenderPipeline, + colorize_bind_group: wgpu::BindGroup, + data_view: wgpu::TextureView, +} + /// A self-contained render of one export image. It owns its own uniform and /// reference buffers (a snapshot of the view at export time), so it is unaffected /// by panning/zooming on the main thread, and can run on a background thread. @@ -799,6 +857,8 @@ pub struct ExportRender { /// Number of horizontal tiles the render is split into. pub tiles: u32, pub swap_rb: bool, + /// 3D mode: tiles fill a data texture instead of the target. + raymarch: Option, } impl ExportRender { @@ -807,9 +867,7 @@ impl ExportRender { pub fn new( device: &wgpu::Device, queue: &wgpu::Queue, - pipeline: wgpu::RenderPipeline, - bind_group_layout: &wgpu::BindGroupLayout, - target_format: wgpu::TextureFormat, + handles: &ExportHandles, width: u32, height: u32, uniforms: Uniforms, @@ -846,9 +904,10 @@ impl ExportRender { let (gpu_lights, _) = gpu_lights(lights); queue.write_buffer(&lights_buffer, 0, bytemuck::cast_slice(&gpu_lights)); + let target_format = handles.format; let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("export bind group"), - layout: bind_group_layout, + layout: &handles.bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -899,8 +958,73 @@ impl ExportRender { wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb ); + let raymarch = handles.raymarch.as_ref().map(|rm| { + let data_texture = |label| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DATA_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + let data_view = data_texture("export data"); + let refine = (uniforms.aa_level > 1).then(|| { + let refine_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("export refine bind group"), + layout: &rm.refine_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&data_view), + }], + }); + ( + rm.refine.clone(), + data_texture("export data (AA)"), + refine_bind_group, + ) + }); + // Colourise reads the refined texture when AA is on. + let colorize_input = refine.as_ref().map_or(&data_view, |(_, v, _)| v); + let colorize_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("export colorize bind group"), + layout: &rm.colorize_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(colorize_input), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: lights_buffer.as_entire_binding(), + }, + ], + }); + RaymarchExport { + iterate: rm.iterate.clone(), + refine, + colorize: rm.colorize.clone(), + colorize_bind_group, + data_view, + } + }); + Self { - pipeline, + pipeline: handles.pipeline.clone(), bind_group, texture, view, @@ -910,6 +1034,7 @@ impl ExportRender { height, tiles, swap_rb, + raymarch, } } @@ -922,7 +1047,9 @@ impl ExportRender { } /// Render one horizontal tile into the export texture and submit it. Tile 0 - /// clears the whole attachment; later tiles preserve earlier ones. + /// clears the whole attachment; later tiles preserve earlier ones. In 3D + /// mode the tiles iterate into the data texture instead, and the last one + /// also runs the (whole-image) refine + raymarching colourise passes. pub fn render_tile(&self, device: &wgpu::Device, queue: &wgpu::Queue, t: u32) { let (y0, y1) = self.tile_rows(t); if y1 <= y0 { @@ -937,11 +1064,15 @@ impl ExportRender { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export tile"), }); + let (target, pipeline) = match &self.raymarch { + Some(rm) => (&rm.data_view, &rm.iterate), + None => (&self.view, &self.pipeline), + }; { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("export tile pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.view, + view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { @@ -957,10 +1088,30 @@ impl ExportRender { // Full-viewport triangle (so pixel→plane mapping matches the whole // image), scissored to this tile's rows. pass.set_scissor_rect(0, y0, self.width, y1 - y0); - pass.set_pipeline(&self.pipeline); + pass.set_pipeline(pipeline); pass.set_bind_group(0, &self.bind_group, &[]); pass.draw(0..3, 0..1); } + if let Some(rm) = &self.raymarch + && y1 == self.height + { + if let Some((refine, aa_view, refine_bind_group)) = &rm.refine { + data_pass( + &mut encoder, + "export AA refine pass", + aa_view, + refine, + &[&self.bind_group, refine_bind_group], + ); + } + data_pass( + &mut encoder, + "export colorize pass", + &self.view, + &rm.colorize, + &[&rm.colorize_bind_group], + ); + } queue.submit(std::iter::once(encoder.finish())); } diff --git a/src/headless.rs b/src/headless.rs index a02d23e..388e6dd 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -7,9 +7,11 @@ use eframe::egui_wgpu::wgpu; -use crate::app::{FractalApp, unix_timestamp}; +use crate::app::{FractalApp, parse_complex_pair, unix_timestamp}; use crate::cli::Cli; -use crate::fractal::{ExportRender, FractalRenderer, ShareState, export_to_png_blocking}; +use crate::fractal::{ + ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, export_to_png_blocking, +}; use crate::view::{ ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, precision_for, @@ -28,32 +30,15 @@ pub fn run(cli: Cli) -> Result<(), String> { // These drive the animation path below; grab them before `apply_cli` // consumes `cli` to build the start state. - let to_view = cli.to_view.clone(); - let to_share = cli.to_share.clone(); - let to_iterations = cli.to_iterations; - let frames_arg = cli.frames; - let fps = cli.fps; - let duration = cli.duration; - let linear = cli.linear; + let targets = AnimTargets::from_cli(&cli)?; let export_path = cli.export_path.clone(); let mut app = FractalApp::default_state(); app.apply_cli(cli); + app.set_output_size(width, height); - if to_view.is_some() || to_share.is_some() { - return run_animation( - app, - to_view, - to_share, - to_iterations, - frames_arg, - fps, - duration, - linear, - width, - height, - export_path, - ); + if targets.any() { + return run_animation(app, targets, width, height, export_path); } let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp())); @@ -65,14 +50,12 @@ pub fn run(cli: Cli) -> Result<(), String> { let format = wgpu::TextureFormat::Bgra8Unorm; let renderer = FractalRenderer::new(&device, format); let uniforms = app.make_uniforms(width as f64 / height as f64); - let (pipeline, bind_group_layout, format) = renderer.export_handles(&device, &uniforms); + let handles = renderer.export_handles(&device, &uniforms); let er = ExportRender::new( &device, &queue, - pipeline, - &bind_group_layout, - format, + &handles, width, height, uniforms, @@ -91,28 +74,90 @@ pub fn run(cli: Cli) -> Result<(), String> { Ok(()) } -/// Render a sequence of frames sweeping the camera from the app's current -/// (start) view to an end view, for feeding into ffmpeg. Everything other -/// than the view (kind, colors, iteration cap policy, ...) stays fixed at -/// whatever `apply_cli` set up for the start; only the camera moves. -#[allow(clippy::too_many_arguments)] -fn run_animation( - mut app: FractalApp, +/// The `--to-*` end state of a headless animation, plus its pacing. Each +/// target is optional; anything left unset stays at its start value. +struct AnimTargets { to_view: Option, to_share: Option, - mut to_iterations: Option, - frames_arg: Option, + to_iterations: Option, + to_julia: Option<(f64, f64)>, + to_phoenix_p: Option<(f64, f64)>, + to_lambda_l: Option<(f64, f64)>, + /// Complex Multibrot exponent, per component (either may move alone). + to_cpow_re: Option, + to_cpow_im: Option, + to_kind: Option, + /// 3D camera, degrees. + to_yaw: Option, + to_pitch: Option, + frames: Option, fps: f64, duration: Option, linear: bool, +} + +impl AnimTargets { + fn from_cli(cli: &Cli) -> Result { + let pair = |flag: &str, v: &Option| -> Result, String> { + v.as_deref() + .map(|s| parse_complex_pair(s).ok_or_else(|| format!("invalid --{flag}: {s}"))) + .transpose() + }; + let to_cpow = pair("to-complex-power", &cli.to_complex_power)?; + Ok(Self { + to_view: cli.to_view.clone(), + to_share: cli.to_share.clone(), + to_iterations: cli.to_iterations, + to_julia: pair("to-julia", &cli.to_julia)?, + to_phoenix_p: pair("to-phoenix-p", &cli.to_phoenix_p)?, + to_lambda_l: pair("to-lambda-l", &cli.to_lambda_l)?, + to_cpow_re: cli.to_complex_power_re.or(to_cpow.map(|p| p.0)), + to_cpow_im: cli.to_complex_power_im.or(to_cpow.map(|p| p.1)), + to_kind: cli.to_kind.map(Into::into), + to_yaw: cli.to_yaw, + to_pitch: cli.to_pitch, + frames: cli.frames, + fps: cli.fps, + duration: cli.duration, + linear: cli.linear, + }) + } + + /// Whether any end state was given, i.e. this is an animation. + fn any(&self) -> bool { + self.to_view.is_some() + || self.to_share.is_some() + || self.to_iterations.is_some() + || self.to_julia.is_some() + || self.to_phoenix_p.is_some() + || self.to_lambda_l.is_some() + || self.to_cpow_re.is_some() + || self.to_cpow_im.is_some() + || self.to_kind.is_some() + || self.to_yaw.is_some() + || self.to_pitch.is_some() + } +} + +/// Render a sequence of frames interpolating from the app's current (start) +/// state to `targets`, for feeding into ffmpeg: the camera, iteration count, +/// per-kind constants (c, p, λ, complex power) and, through a kind morph, +/// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at +/// whatever `apply_cli` set up for the start. +fn run_animation( + mut app: FractalApp, + targets: AnimTargets, width: u32, height: u32, export_path: Option, ) -> Result<(), String> { - let frames = match frames_arg { + let fps = targets.fps; + let frames = match targets.frames { Some(n) => n, None => { - let dur = duration.ok_or("animation needs --frames, or --duration (with --fps)")?; + let dur = targets + .duration + .ok_or("animation needs --frames, or --duration (with --fps)")?; ((fps * dur).round() as u32).max(2) } }; @@ -120,14 +165,11 @@ fn run_animation( return Err("animation needs at least 2 frames".into()); } - let (to, to_iterations_share) = - parse_animation_target(to_view.as_deref(), to_share.as_deref())?; - if to_iterations.is_none() - && let Some(to_iterations_share) = to_iterations_share - { - to_iterations = Some(to_iterations_share); - } let from = app.view_state().clone(); + let (to, to_iterations_share) = + parse_animation_target(targets.to_view.as_deref(), targets.to_share.as_deref())? + .unwrap_or_else(|| (from.clone(), None)); + let to_iterations = targets.to_iterations.or(to_iterations_share); let from_iterations = app.max_iterations(); if to_iterations.is_none() { // Iteration count auto-scales with zoom depth per frame, the same way it @@ -135,37 +177,71 @@ fn run_animation( app.set_auto_iterations(true); } + let from_consts = app.constants(); + let [c0, p0, l0, cp0] = from_consts; + let to_consts = [ + targets.to_julia.unwrap_or(c0), + targets.to_phoenix_p.unwrap_or(p0), + targets.to_lambda_l.unwrap_or(l0), + ( + targets.to_cpow_re.unwrap_or(cp0.0), + targets.to_cpow_im.unwrap_or(cp0.1), + ), + ]; + let from_kind = app.kind(); + let to_kind = targets.to_kind.unwrap_or(from_kind); + let (yaw0, pitch0) = app.camera_angles(); + let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians); + let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians); + let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp())); std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?; let (device, queue) = pollster::block_on(request_device())?; let format = wgpu::TextureFormat::Bgra8Unorm; let renderer = FractalRenderer::new(&device, format); - // Only the camera animates, so the shader specialization (kind, Julia, - // DE) is the same for every frame. - let (pipeline, bind_group_layout, format) = - renderer.export_handles(&device, &app.make_uniforms(width as f64 / height as f64)); + // The shader specialization (kind, Julia, DE, morph) can change between + // frames during a kind morph; rebuild the pipeline only when it does. + let mut pipeline_cache: Option<(PipelineKey, _)> = None; for i in 0..frames { let raw_t = i as f64 / (frames - 1) as f64; - let t = if linear { raw_t } else { smoothstep(raw_t) }; + let t = if targets.linear { + raw_t + } else { + smoothstep(raw_t) + }; if let Some(to) = to_iterations { app.set_max_iterations( interpolate_f64(from_iterations as f64, to as f64, t).round() as u32, ); } app.set_view(interpolate_view(&from, &to, t)); + app.set_constants(std::array::from_fn(|k| { + ( + interpolate_f64(from_consts[k].0, to_consts[k].0, t), + interpolate_f64(from_consts[k].1, to_consts[k].1, t), + ) + })); + app.set_kind_morph(from_kind, to_kind, t); + app.set_camera_angles( + interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32, + interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32, + ); eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1); app.compute_reference_blocking(); let uniforms = app.make_uniforms(width as f64 / height as f64); + let key = PipelineKey::from_uniforms(&uniforms); + if pipeline_cache.as_ref().is_none_or(|(k, _)| *k != key) { + pipeline_cache = Some((key, renderer.export_handles(&device, &uniforms))); + } + let (_, handles) = pipeline_cache.as_ref().unwrap(); let er = ExportRender::new( &device, &queue, - pipeline.clone(), - &bind_group_layout, - format, + handles, width, height, uniforms, @@ -193,19 +269,23 @@ fn run_animation( Ok(()) } -/// Parse `--to-view`/`--to-share` (exactly one must be set) into the end -/// view of an animation. Only position/zoom/iterations are pulled from a +/// Parse `--to-view`/`--to-share` (at most one is used) into the end view +/// of an animation, or `None` if neither is set (the camera stays put). Only position/zoom/iterations are pulled from a /// share fragment — the rest of its state (kind, colors, ...) is ignored, so /// pasting a link from the app doesn't unexpectedly change the fractal kind /// mid-animation. fn parse_animation_target( to_view: Option<&str>, to_share: Option<&str>, -) -> Result<(ViewState, Option), String> { +) -> Result)>, String> { if let Some(spec) = to_view { - return parse_view_spec(spec).ok_or_else(|| format!("invalid --to-view spec: {spec}")); + return parse_view_spec(spec) + .map(Some) + .ok_or_else(|| format!("invalid --to-view spec: {spec}")); } - let frag = to_share.expect("run_animation only called with one of to_view/to_share set"); + let Some(frag) = to_share else { + return Ok(None); + }; let state = ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?; let bits = precision_for(state.half_height); @@ -213,10 +293,10 @@ fn parse_animation_target( big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?; let im = big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?; - Ok(( + Ok(Some(( ViewState::with_center(re, im, state.half_height), Some(state.iterations), - )) + ))) } /// Ease-in/ease-out pacing: slow at both ends, fast through the middle.