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
+31 -17
View File
@@ -34,7 +34,8 @@ python3 -m http.server -d dist 8080
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`, Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`, `--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
`--palette`, `--share <fragment>`, `--palette`, `--share <fragment>`,
`--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 `--buddha-palette`. `--headless` (`src/headless.rs`) skips the window
entirely: it builds the same view from the other flags, creates its own entirely: it builds the same view from the other flags, creates its own
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`, 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 window/event loop. Not yet supported with `--buddhabrot`. Run
`mandelbrot --help` for the full list. `mandelbrot --help` for the full list.
`--headless` also has an animation mode, for feeding into `ffmpeg`: add `--headless` also has an animation mode, for feeding into `ffmpeg`: give any
`--to-view re,im,half_height[,iterations]` (or `--to-share <fragment>`, which end-state flag alongside the start flags (`--view`/`--share`/`--kind`/
only pulls position/zoom/iterations out of the link) alongside a start view `--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags:
(`--view`/`--share`/`--kind`/`--julia`), plus `--frames N` or `--to-view re,im,half_height[,iterations]` or `--to-share <fragment>` (only
`--fps`/`--duration`. `--export-path` then names an output *directory* of position/zoom/iterations are pulled out of the link), `--to-iterations`,
`frame-00001.png`, `frame-00002.png`, ... instead of a single file. Only the `--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power`
camera (center + half-height) is animated — kind, colors, and per-kind (or `--to-complex-power-re`/`--to-complex-power-im` to move one component),
constants stay fixed at whatever the start flags set. `view::interpolate_view` and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
does the interpolation: half-height geometrically (log-linear, since zoom Anything without a target stays at its start value; colors stay fixed.
spans many decades), center linearly through the complex plane at full `--export-path` then names an output *directory* of `frame-00001.png`,
`Big` precision; `--linear` swaps the default smoothstep easing for constant `frame-00002.png`, ... instead of a single file. `headless.rs::AnimTargets`
pacing. Iteration count auto-scales with zoom depth per frame (same collects the targets; the export pipeline is rebuilt only when the
`auto_iteration_count` the interactive app uses while zooming), overriding `PipelineKey` changes between frames (kind morph). `view::interpolate_view`
any iteration count from `--view`/`--share`/`--to-view`/`--to-share`. 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` 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 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: - `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
`should_request`/`ensure_reference` (decide when the reference is stale and `should_request`/`ensure_reference` (decide when the reference is stale and
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`), 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 `default_view_for` (wraps `FractalKind::default_set_view`, adding the
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind` `[T; FractalKind::<last variant> 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 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 `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 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 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 field straight from the data texture. It's cheap: rays start on the z = 0
+384 -136
View File
@@ -202,6 +202,126 @@ struct ExportShared {
result: Option<Result<String, String>>, 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 /// Time-based animation of a few view/coloring parameters. Each toggle drives
/// continuous repaints while on; orbit-affecting ones (Julia c, Phoenix p, zoom) /// 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 /// recompute the reference each frame and render the cheap low-res pass so they
@@ -214,28 +334,15 @@ struct AnimState {
color_speed: f32, color_speed: f32,
/// Drift the Julia constant `c` around a circle to morph the Julia set. /// Drift the Julia constant `c` around a circle to morph the Julia set.
julia: bool, julia: ConstOrbit,
/// 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,
/// Drift the Phoenix distortion `p` around a circle. /// Drift the Phoenix distortion `p` around a circle.
phoenix: bool, phoenix: ConstOrbit,
phoenix_speed: f32,
phoenix_radius: f64,
phoenix_base: (f64, f64),
phoenix_angle: f64,
/// Drift the Lambda distortion `λ` around a circle. /// Drift the Lambda distortion `λ` around a circle.
lambda: bool, lambda: ConstOrbit,
lambda_speed: f32, /// Oscillate the Complex Multibrot exponent's real part.
lambda_radius: f64, cpow_re: AxisOsc,
lambda_base: (f64, f64), /// Oscillate the Complex Multibrot exponent's imaginary part.
lambda_angle: f64, cpow_im: AxisOsc,
/// Continuously zoom toward the current center. /// Continuously zoom toward the current center.
zoom: bool, zoom: bool,
@@ -248,6 +355,26 @@ struct AnimState {
/// Kind-switch morph duration, in seconds. /// Kind-switch morph duration, in seconds.
kind_morph_duration: f32, 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 /// Linear 2D <-> 3D transition progress in [0, 1], advanced at a constant
/// rate; `camera_state` is its smoothstep-eased value. /// rate; `camera_state` is its smoothstep-eased value.
camera_progress: f32, camera_progress: f32,
@@ -260,31 +387,37 @@ impl Default for AnimState {
Self { Self {
color: false, color: false,
color_speed: 0.15, color_speed: 0.15,
julia: false, julia: ConstOrbit::default(),
julia_speed: 0.05, phoenix: ConstOrbit::default(),
julia_radius: 0.08, lambda: ConstOrbit::default(),
julia_base: (0.0, 0.0), cpow_re: AxisOsc::default(),
julia_angle: 0.0, cpow_im: AxisOsc::default(),
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,
zoom: false, zoom: false,
zoom_speed: 0.5, zoom_speed: 0.5,
kind_morph: true, kind_morph: true,
kind_morph_duration: 1.5, 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_progress: 0.,
camera_state: 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. /// Top-level egui application.
pub struct FractalApp { pub struct FractalApp {
view: ViewState, view: ViewState,
@@ -554,15 +687,6 @@ impl FractalApp {
if let Some(p) = cli.power { if let Some(p) = cli.power {
self.power = p.clamp(2, 8); 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); self.view = Self::default_view_for(self.mode, self.kind);
} }
if let Some(k) = cli.rendering_kind { if let Some(k) = cli.rendering_kind {
@@ -573,6 +697,17 @@ impl FractalApp {
RenderingKindArg::Shadow => self.rendering_mode = 1, RenderingKindArg::Shadow => self.rendering_mode = 1,
RenderingKindArg::Dimension3 => self.rendering_mode = 2, 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 { if let Some(jc) = cli.julia {
let p: Vec<&str> = jc.split(',').collect(); let p: Vec<&str> = jc.split(',').collect();
@@ -608,6 +743,10 @@ impl FractalApp {
{ {
self.apply_share(&state); 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 { if let Some(spec) = cli.view {
self.apply_view_spec(&spec); self.apply_view_spec(&spec);
} }
@@ -701,6 +840,75 @@ impl FractalApp {
self.max_iterations = i; 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`. /// Get `max_iterations`.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) fn max_iterations(&mut self) -> u32 { pub(crate) fn max_iterations(&mut self) -> u32 {
@@ -1218,7 +1426,7 @@ impl FractalApp {
let device = rs.device.clone(); let device = rs.device.clone();
let queue = rs.queue.clone(); let queue = rs.queue.clone();
let (pipeline, bind_group_layout, format) = { let handles = {
let guard = rs.renderer.read(); let guard = rs.renderer.read();
let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else { let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else {
self.status = Some("export unavailable".into()); self.status = Some("export unavailable".into());
@@ -1247,9 +1455,7 @@ impl FractalApp {
let er = ExportRender::new( let er = ExportRender::new(
&device, &device,
&queue, &queue,
pipeline, &handles,
&bind_group_layout,
format,
w, w,
h, h,
uniforms, uniforms,
@@ -1277,9 +1483,7 @@ impl FractalApp {
let er = ExportRender::new( let er = ExportRender::new(
&device, &device,
&queue, &queue,
pipeline, &handles,
&bind_group_layout,
format,
w, w,
h, h,
uniforms, uniforms,
@@ -1646,9 +1850,13 @@ impl FractalApp {
/// trigger the interaction low-res pass). /// trigger the interaction low-res pass).
fn tick_animations(&mut self, ui: &egui::Ui) { 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. // 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 julia_on = self.anim.julia.on && self.mode == FractalMode::Julia;
let phoenix_on = self.anim.phoenix && self.kind == FractalKind::Phoenix; let phoenix_on = self.anim.phoenix.on && self.kind == FractalKind::Phoenix;
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda; 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. // 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); 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(); 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; return;
} }
@@ -1697,29 +1913,47 @@ impl FractalApp {
(self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0); (self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0);
} }
if julia_on { if julia_on {
self.anim.julia_angle += std::f64::consts::TAU * self.anim.julia_speed as f64 * dt; self.julia_c = self.anim.julia.step(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,
);
} }
if phoenix_on { if phoenix_on {
self.anim.phoenix_angle += std::f64::consts::TAU * self.anim.phoenix_speed as f64 * dt; self.phoenix_p = self.anim.phoenix.step(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,
);
} }
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda;
if lambda_on { if lambda_on {
self.anim.lambda_angle += std::f64::consts::TAU * self.anim.lambda_speed as f64 * dt; self.lambda_l = self.anim.lambda.step(dt);
let (s, c) = self.anim.lambda_angle.sin_cos(); }
self.lambda_l = ( if cmulti && self.anim.cpow_re.on {
self.anim.lambda_base.0 + self.anim.lambda_radius * c, self.complex_power.0 = self.anim.cpow_re.step(dt).clamp(-8.0, 8.0);
self.anim.lambda_base.1 + self.anim.lambda_radius * s, }
); 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 { if self.anim.zoom && self.anim.zoom_speed != 0.0 {
let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth
@@ -1736,6 +1970,25 @@ impl FractalApp {
ui.ctx().request_repaint(); 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) { fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer"); ui.heading("Fractal Explorer");
ui.separator(); ui.separator();
@@ -1803,20 +2056,7 @@ impl FractalApp {
}); });
} }
if self.kind != prev_kind { if self.kind != prev_kind {
let to_view = Self::default_view_for(self.mode, self.kind); self.switch_kind(prev_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;
}
} }
let prev_mode = self.mode; 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 self.mode == FractalMode::Julia {
if ui.checkbox(&mut self.anim.julia, "Morph c").changed() && self.anim.julia { self.anim.julia.ui(ui, "c", self.julia_c);
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),
);
}
} }
if self.kind == FractalKind::Phoenix { if self.kind == FractalKind::Phoenix {
if ui.checkbox(&mut self.anim.phoenix, "Morph p").changed() && self.anim.phoenix { self.anim.phoenix.ui(ui, "p", self.phoenix_p);
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),
);
}
} }
if self.kind == FractalKind::Lambda { if self.kind == FractalKind::Lambda {
if ui.checkbox(&mut self.anim.lambda, "Morph λ").changed() && self.anim.lambda { self.anim.lambda.ui(ui, "λ", self.lambda_l);
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),
);
} }
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);
} }
}); });
+14 -1
View File
@@ -2,6 +2,10 @@ use std::f32::consts::{PI, TAU};
use glam::Vec3; 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)] #[derive(Default, Clone)]
pub struct Camera { pub struct Camera {
pub position: glam::Vec3, pub position: glam::Vec3,
@@ -35,11 +39,20 @@ impl Camera {
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales /// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
/// yaw by `t`) always unwinds the short way instead of every past turn. /// yaw by `t`) always unwinds the short way instead of every past turn.
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) { 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.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); 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 { pub fn orthographic(&self, t: f32) -> glam::Mat4 {
let yaw = self.yaw * t; let yaw = self.yaw * t;
let pitch = self.pitch * t; let pitch = self.pitch * t;
+54 -2
View File
@@ -61,6 +61,15 @@ pub struct Cli {
#[arg(long("zoom"), short('z'))] #[arg(long("zoom"), short('z'))]
pub half_height: Option<String>, pub half_height: Option<String>,
/// 3D camera yaw in degrees (with --rendering-kind 3d).
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub yaw: Option<f32>,
/// 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<f32>,
/// Enable distance-estimation shading. /// Enable distance-estimation shading.
#[arg(long)] #[arg(long)]
pub de: bool, pub de: bool,
@@ -93,6 +102,49 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub to_iterations: Option<u32>, pub to_iterations: Option<u32>,
/// 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<String>,
/// 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<String>,
/// End Lambda constant λ for an animation (from --lambda-l).
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
pub to_lambda_l: Option<String>,
/// 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<String>,
/// 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<f64>,
/// 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<f64>,
/// 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<f32>,
/// End 3D camera pitch for an animation, in degrees (from --pitch).
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub to_pitch: Option<f32>,
/// 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<KindArg>,
/// Number of frames to render for an animation. Alternative to --fps + /// Number of frames to render for an animation. Alternative to --fps +
/// --duration. /// --duration.
#[arg(long, value_name = "N")] #[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 /// 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 /// 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 /// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
/// single frame. Not yet supported with --buddhabrot. /// render an animation instead of a single frame. Not yet supported with --buddhabrot.
#[arg(long)] #[arg(long)]
pub headless: bool, pub headless: bool,
+2
View File
@@ -10,6 +10,8 @@ pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms}; pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use kind::FractalKind; pub use kind::FractalKind;
pub use reference::{compute_reference, compute_set_reference}; pub use reference::{compute_reference, compute_set_reference};
#[cfg(not(target_arch = "wasm32"))]
pub use renderer::PipelineKey;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use renderer::encode_png_with_progress; pub use renderer::encode_png_with_progress;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+170 -19
View File
@@ -759,15 +759,11 @@ impl FractalRenderer {
/// a combined iterate + colour pipeline (`fs_color`) specialized for /// a combined iterate + colour pipeline (`fs_color`) specialized for
/// `uniforms` (built fresh — exports are rare, and this only needs a read /// `uniforms` (built fresh — exports are rare, and this only needs a read
/// lock on the renderer), its bind-group layout, and the target format. /// lock on the renderer), its bind-group layout, and the target format.
pub fn export_handles( /// In 3D mode (`rendering_mode == 2`) also the interactive iterate →
&self, /// refine → colourise chain, since the raymarcher needs a whole data
device: &wgpu::Device, /// texture to march over and `fs_color` has no 3D path.
uniforms: &Uniforms, pub fn export_handles(&self, device: &wgpu::Device, uniforms: &Uniforms) -> ExportHandles {
) -> ( let constants = PipelineKey::from_uniforms(uniforms).constants();
wgpu::RenderPipeline,
wgpu::BindGroupLayout,
wgpu::TextureFormat,
) {
let pipeline = fullscreen_pipeline( let pipeline = fullscreen_pipeline(
device, device,
"fractal export pipeline", "fractal export pipeline",
@@ -775,11 +771,73 @@ impl FractalRenderer {
&self.pipeline_layout, &self.pipeline_layout,
"fs_color", "fs_color",
self.target_format, 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<RaymarchHandles>,
}
/// 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 /// 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 /// reference buffers (a snapshot of the view at export time), so it is unaffected
@@ -799,6 +857,8 @@ pub struct ExportRender {
/// Number of horizontal tiles the render is split into. /// Number of horizontal tiles the render is split into.
pub tiles: u32, pub tiles: u32,
pub swap_rb: bool, pub swap_rb: bool,
/// 3D mode: tiles fill a data texture instead of the target.
raymarch: Option<RaymarchExport>,
} }
impl ExportRender { impl ExportRender {
@@ -807,9 +867,7 @@ impl ExportRender {
pub fn new( pub fn new(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
pipeline: wgpu::RenderPipeline, handles: &ExportHandles,
bind_group_layout: &wgpu::BindGroupLayout,
target_format: wgpu::TextureFormat,
width: u32, width: u32,
height: u32, height: u32,
uniforms: Uniforms, uniforms: Uniforms,
@@ -846,9 +904,10 @@ impl ExportRender {
let (gpu_lights, _) = gpu_lights(lights); let (gpu_lights, _) = gpu_lights(lights);
queue.write_buffer(&lights_buffer, 0, bytemuck::cast_slice(&gpu_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 { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("export bind group"), label: Some("export bind group"),
layout: bind_group_layout, layout: &handles.bind_group_layout,
entries: &[ entries: &[
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 0, binding: 0,
@@ -899,8 +958,73 @@ impl ExportRender {
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb 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 { Self {
pipeline, pipeline: handles.pipeline.clone(),
bind_group, bind_group,
texture, texture,
view, view,
@@ -910,6 +1034,7 @@ impl ExportRender {
height, height,
tiles, tiles,
swap_rb, swap_rb,
raymarch,
} }
} }
@@ -922,7 +1047,9 @@ impl ExportRender {
} }
/// Render one horizontal tile into the export texture and submit it. Tile 0 /// 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) { pub fn render_tile(&self, device: &wgpu::Device, queue: &wgpu::Queue, t: u32) {
let (y0, y1) = self.tile_rows(t); let (y0, y1) = self.tile_rows(t);
if y1 <= y0 { if y1 <= y0 {
@@ -937,11 +1064,15 @@ impl ExportRender {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export tile"), 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 { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("export tile pass"), label: Some("export tile pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view, view: target,
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
@@ -957,10 +1088,30 @@ impl ExportRender {
// Full-viewport triangle (so pixel→plane mapping matches the whole // Full-viewport triangle (so pixel→plane mapping matches the whole
// image), scissored to this tile's rows. // image), scissored to this tile's rows.
pass.set_scissor_rect(0, y0, self.width, y1 - y0); 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.set_bind_group(0, &self.bind_group, &[]);
pass.draw(0..3, 0..1); 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())); queue.submit(std::iter::once(encoder.finish()));
} }
+140 -60
View File
@@ -7,9 +7,11 @@
use eframe::egui_wgpu::wgpu; 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::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::{ use crate::view::{
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
precision_for, precision_for,
@@ -28,32 +30,15 @@ pub fn run(cli: Cli) -> Result<(), String> {
// These drive the animation path below; grab them before `apply_cli` // These drive the animation path below; grab them before `apply_cli`
// consumes `cli` to build the start state. // consumes `cli` to build the start state.
let to_view = cli.to_view.clone(); let targets = AnimTargets::from_cli(&cli)?;
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 export_path = cli.export_path.clone(); let export_path = cli.export_path.clone();
let mut app = FractalApp::default_state(); let mut app = FractalApp::default_state();
app.apply_cli(cli); app.apply_cli(cli);
app.set_output_size(width, height);
if to_view.is_some() || to_share.is_some() { if targets.any() {
return run_animation( return run_animation(app, targets, width, height, export_path);
app,
to_view,
to_share,
to_iterations,
frames_arg,
fps,
duration,
linear,
width,
height,
export_path,
);
} }
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp())); 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 format = wgpu::TextureFormat::Bgra8Unorm;
let renderer = FractalRenderer::new(&device, format); let renderer = FractalRenderer::new(&device, format);
let uniforms = app.make_uniforms(width as f64 / height as f64); 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( let er = ExportRender::new(
&device, &device,
&queue, &queue,
pipeline, &handles,
&bind_group_layout,
format,
width, width,
height, height,
uniforms, uniforms,
@@ -91,28 +74,90 @@ pub fn run(cli: Cli) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Render a sequence of frames sweeping the camera from the app's current /// The `--to-*` end state of a headless animation, plus its pacing. Each
/// (start) view to an end view, for feeding into ffmpeg. Everything other /// target is optional; anything left unset stays at its start value.
/// than the view (kind, colors, iteration cap policy, ...) stays fixed at struct AnimTargets {
/// whatever `apply_cli` set up for the start; only the camera moves.
#[allow(clippy::too_many_arguments)]
fn run_animation(
mut app: FractalApp,
to_view: Option<String>, to_view: Option<String>,
to_share: Option<String>, to_share: Option<String>,
mut to_iterations: Option<u32>, to_iterations: Option<u32>,
frames_arg: Option<u32>, 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<f64>,
to_cpow_im: Option<f64>,
to_kind: Option<FractalKind>,
/// 3D camera, degrees.
to_yaw: Option<f32>,
to_pitch: Option<f32>,
frames: Option<u32>,
fps: f64, fps: f64,
duration: Option<f64>, duration: Option<f64>,
linear: bool, linear: bool,
}
impl AnimTargets {
fn from_cli(cli: &Cli) -> Result<Self, String> {
let pair = |flag: &str, v: &Option<String>| -> Result<Option<(f64, f64)>, 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, width: u32,
height: u32, height: u32,
export_path: Option<String>, export_path: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let frames = match frames_arg { let fps = targets.fps;
let frames = match targets.frames {
Some(n) => n, Some(n) => n,
None => { 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) ((fps * dur).round() as u32).max(2)
} }
}; };
@@ -120,14 +165,11 @@ fn run_animation(
return Err("animation needs at least 2 frames".into()); 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 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(); let from_iterations = app.max_iterations();
if to_iterations.is_none() { if to_iterations.is_none() {
// Iteration count auto-scales with zoom depth per frame, the same way it // 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); 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())); 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}"))?; 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 (device, queue) = pollster::block_on(request_device())?;
let format = wgpu::TextureFormat::Bgra8Unorm; let format = wgpu::TextureFormat::Bgra8Unorm;
let renderer = FractalRenderer::new(&device, format); let renderer = FractalRenderer::new(&device, format);
// Only the camera animates, so the shader specialization (kind, Julia, // The shader specialization (kind, Julia, DE, morph) can change between
// DE) is the same for every frame. // frames during a kind morph; rebuild the pipeline only when it does.
let (pipeline, bind_group_layout, format) = let mut pipeline_cache: Option<(PipelineKey, _)> = None;
renderer.export_handles(&device, &app.make_uniforms(width as f64 / height as f64));
for i in 0..frames { for i in 0..frames {
let raw_t = i as f64 / (frames - 1) as f64; 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 { if let Some(to) = to_iterations {
app.set_max_iterations( app.set_max_iterations(
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32, interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
); );
} }
app.set_view(interpolate_view(&from, &to, t)); 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); eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1);
app.compute_reference_blocking(); app.compute_reference_blocking();
let uniforms = app.make_uniforms(width as f64 / height as f64); 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( let er = ExportRender::new(
&device, &device,
&queue, &queue,
pipeline.clone(), handles,
&bind_group_layout,
format,
width, width,
height, height,
uniforms, uniforms,
@@ -193,19 +269,23 @@ fn run_animation(
Ok(()) Ok(())
} }
/// Parse `--to-view`/`--to-share` (exactly one must be set) into the end /// Parse `--to-view`/`--to-share` (at most one is used) into the end view
/// view of an animation. Only position/zoom/iterations are pulled from a /// 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 /// 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 /// pasting a link from the app doesn't unexpectedly change the fractal kind
/// mid-animation. /// mid-animation.
fn parse_animation_target( fn parse_animation_target(
to_view: Option<&str>, to_view: Option<&str>,
to_share: Option<&str>, to_share: Option<&str>,
) -> Result<(ViewState, Option<u32>), String> { ) -> Result<Option<(ViewState, Option<u32>)>, String> {
if let Some(spec) = to_view { 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 = let state =
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?; ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
let bits = precision_for(state.half_height); 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)")?; big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
let im = let im =
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (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), ViewState::with_center(re, im, state.half_height),
Some(state.iterations), Some(state.iterations),
)) )))
} }
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle. /// Ease-in/ease-out pacing: slow at both ends, fast through the middle.