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