feat: multibrot exponent up to 20

This commit is contained in:
2026-09-27 12:23:11 +02:00
parent 1bb3113581
commit 28ff2471b3
8 changed files with 96 additions and 46 deletions
+42 -12
View File
@@ -19,14 +19,28 @@ use crate::lights::{Light, gpu_lights};
use crate::view::parse_half_height_spec;
use crate::view::parse_re_im_spec;
use crate::view::{
Big, DEFAULT_HALF_HEIGHT, MAX_PRECISION_BITS, Scale, ViewState, big_from_decimal_str, big_from_f64,
big_to_decimal_str, deep_scale_exp, interpolate_view, needs_deep, parse_view_spec,
precision_for,
Big, DEFAULT_HALF_HEIGHT, MAX_PRECISION_BITS, Scale, ViewState, big_from_decimal_str,
big_from_f64, big_to_decimal_str, deep_scale_exp, interpolate_view, needs_deep,
parse_view_spec, precision_for,
};
#[cfg(not(target_arch = "wasm32"))]
use clap::Parser;
const BAILOUT_SQ: f32 = 1.0e6;
/// Pixel bailout |z|^2 for `kind`. For Multibrot z^p, one step from |z| = R
/// (with the reference within 2R, which rebasing guarantees) must stay a
/// finite f32, |z|^2 included: (2R)^(2p) <= 2^126, i.e. R^2 <= 2^(126/p - 2).
/// Otherwise inf - inf turns into NaN, which never compares above the bailout
/// and paints exterior pixels as interior. Unchanged for p <= 5; still well
/// above the escape radius (<= 2) at the maximum power.
fn bailout_sq(kind: FractalKind, power: u32) -> f32 {
if kind == FractalKind::Multibrot {
BAILOUT_SQ.min((126.0 / power.max(2) as f32 - 2.0).exp2())
} else {
BAILOUT_SQ
}
}
/// Cap on exported image dimension (px), to stay within GPU texture limits.
const MAX_EXPORT_DIM: u32 = 8192 * 16;
/// While the user is actively panning/zooming, the fractal is rendered into a
@@ -1034,7 +1048,7 @@ impl FractalApp {
FractalMode::Mandelbrot
};
self.kind = s.kind;
self.power = s.power.clamp(2, 200);
self.power = s.power.clamp(2, 20);
self.julia_c = s.julia_c;
self.phoenix_p = s.phoenix_p;
self.lambda_l = s.lambda_l;
@@ -1078,10 +1092,18 @@ impl FractalApp {
/// each kind's interesting region.
fn default_view_for(mode: FractalMode, kind: FractalKind) -> ViewState {
if mode == FractalMode::Julia {
return ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), Scale::from_f64(1.5));
return ViewState::with_center(
big_from_f64(0.0, 53),
big_from_f64(0.0, 53),
Scale::from_f64(1.5),
);
}
let (cr, ci, hh) = kind.default_set_view();
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), Scale::from_f64(hh))
ViewState::with_center(
big_from_f64(cr, 53),
big_from_f64(ci, 53),
Scale::from_f64(hh),
)
}
/// The request key for the current state. Its `iter` is the reference
@@ -1110,8 +1132,12 @@ impl FractalApp {
fn drift_from(&self, key: &RequestKey) -> f64 {
let hh = self.view.half_height;
let k = -hh.exponent() as isize;
let dre = ((&self.view.center_re - &key.center_re) << k).to_f64().value();
let dim = ((&self.view.center_im - &key.center_im) << k).to_f64().value();
let dre = ((&self.view.center_re - &key.center_re) << k)
.to_f64()
.value();
let dim = ((&self.view.center_im - &key.center_im) << k)
.to_f64()
.value();
(dre * dre + dim * dim).sqrt() / hh.scaled_f64(-hh.exponent())
}
@@ -1422,7 +1448,7 @@ impl FractalApp {
ref_len: self.reference.len() as u32,
color_offset: self.color_offset,
color_scale: self.color_scale,
bailout_sq: BAILOUT_SQ,
bailout_sq: bailout_sq(self.ref_kind.unwrap_or(self.kind), self.power),
is_julia: matches!(self.mode, FractalMode::Julia) as u32,
palette_id: self.palette,
shadow_palette_id: self.shadow_palette,
@@ -1465,7 +1491,7 @@ impl FractalApp {
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32],
bailout_sq: BAILOUT_SQ,
bailout_sq: bailout_sq(self.kind, self.power),
kind: self.kind as u32,
power: self.power,
r_cap: self.buddha_r_cap,
@@ -2028,7 +2054,11 @@ impl FractalApp {
if self.anim.zoom && self.anim.zoom_speed != 0.0 {
let max_hh = Scale::from_f64(DEFAULT_HALF_HEIGHT * 4.0);
let factor = (-(self.anim.zoom_speed as f64) * dt).exp();
let target = self.view.half_height.mul_f64(factor).clamp(Scale::MIN, max_hh);
let target = self
.view
.half_height
.mul_f64(factor)
.clamp(Scale::MIN, max_hh);
let f = target.ratio(self.view.half_height);
if (f - 1.0).abs() > 1.0e-9 {
self.view
@@ -2074,7 +2104,7 @@ impl FractalApp {
}
});
if self.kind == FractalKind::Multibrot {
ui.add(egui::Slider::new(&mut self.power, 2..=8).text("power"));
ui.add(egui::Slider::new(&mut self.power, 2..=20).text("power"));
}
if self.kind == FractalKind::Phoenix {
ui.horizontal(|ui| {
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct Cli {
#[arg(long)]
pub rendering_kind: Option<RenderingKindArg>,
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
#[arg(long)]
pub power: Option<u32>,
+1 -1
View File
@@ -16,7 +16,7 @@ pub enum FractalKind {
BurningShip = 1,
/// `z -> conj(z)^2 + c` (the Mandelbar).
Tricorn = 2,
/// `z -> z^power + c` (power >= 2).
/// `z -> z^power + c` (integer power in [2, 20]).
Multibrot = 3,
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
Celtic = 4,
+7 -3
View File
@@ -564,13 +564,17 @@ mod tests {
fn f64_fast_path_matches_big() {
let bits_fast = F64_MAX_PRECISION;
let bits_big = F64_MAX_PRECISION + 64;
for kind in FractalKind::ALL {
let cases = FractalKind::ALL
.into_iter()
.map(|kind| (kind, 3))
.chain([(FractalKind::Multibrot, 20)]); // highest supported power
for (kind, power) in cases {
for julia in [false, true] {
for morph in [None, Some((FractalKind::Phoenix, 0.3))] {
let run = |bits: usize| {
let (a, b) = (big_from_f64(-0.3, bits), big_from_f64(0.2, bits));
let (jr, ji) = (big_from_f64(-0.4, bits), big_from_f64(0.55, bits));
let args = (60, bits, kind, 3, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
let args = (60, bits, kind, power, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
if julia {
compute_reference(
&a, &b, &jr, &ji, args.0, args.1, args.2, args.3, args.4, args.5,
@@ -583,7 +587,7 @@ mod tests {
)
}
};
let ctx = format!("{kind:?} julia={julia} morph={morph:?}");
let ctx = format!("{kind:?} power={power} julia={julia} morph={morph:?}");
let (fast, big) = (run(bits_fast), run(bits_big));
assert_eq!(fast.len(), big.len(), "{ctx}: length");
for (i, (f, b)) in fast.iter().zip(&big).enumerate() {
+1 -1
View File
@@ -106,7 +106,7 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
} else if KIND == KIND_TRICORN {
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
} else if KIND == KIND_MULTIBROT {
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
} else if KIND == KIND_CELTIC {
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
} else if KIND == KIND_PERPENDICULAR {
+4
View File
@@ -43,6 +43,10 @@ const KIND_MANDELBROT: u32 = 0u;
const KIND_BURNING_SHIP: u32 = 1u;
const KIND_TRICORN: u32 = 2u;
const KIND_MULTIBROT: u32 = 3u;
// Highest Multibrot power (the UI/CLI/share-link clamp in app.rs matches).
// `bailout_sq` in app.rs shrinks the bailout with the power so z^p stays a
// finite f32.
const MULTIBROT_MAX_POWER: u32 = 20u;
const KIND_CELTIC: u32 = 4u;
const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
+5 -5
View File
@@ -187,7 +187,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
let ce = conj(e);
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
} else if kind == KIND_MULTIBROT {
return multibrot_delta(z, e, clamp(u.power, 2u, 8u));
return multibrot_delta(z, e, clamp(u.power, 2u, MULTIBROT_MAX_POWER));
} else if kind == KIND_CELTIC {
// z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2).
// Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x).
@@ -222,7 +222,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// enough to de-speckle filaments.
fn fprime_kind(kind: u32, z: vec2<f32>) -> vec2<f32> {
if kind == KIND_MULTIBROT {
let p = clamp(u.power, 2u, 8u);
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
var zk = z; // Z^1
for (var k: u32 = 2u; k < p; k = k + 1u) {
zk = cmul(zk, z); // -> Z^{p-1}
@@ -382,7 +382,7 @@ fn advance_delta_scaled_kind(kind: u32, x: vec2<f32>, w: vec2<f32>, sc: f32, se:
let cw = conj(w);
return 2.0 * cmul(cx, cw) + sc * cmul(cw, cw);
} else if kind == KIND_MULTIBROT {
return cmul(w, multibrot_sum(x, x + sc * w, clamp(u.power, 2u, 8u)));
return cmul(w, multibrot_sum(x, x + sc * w, clamp(u.power, 2u, MULTIBROT_MAX_POWER)));
} else if kind == KIND_CELTIC {
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
return vec2<f32>(diffabs_scaled(x.x * x.x - x.y * x.y, sq.x, se), sq.y);
@@ -434,7 +434,7 @@ fn deep_step_kind(kind: u32, x: vec2<f32>, xf: Fe, w: vec2<f32>, sc: f32, s: i32
}
var deg = 2;
if kind == KIND_MULTIBROT {
deg = i32(clamp(u.power, 2u, 8u));
deg = i32(clamp(u.power, 2u, MULTIBROT_MAX_POWER));
}
let xk = ldexp2_sat(xf.m, xf.e - ue);
let se = s - ue;
@@ -545,7 +545,7 @@ fn deep_fprime(xf: Fe, w: vec2<f32>, s: i32, yt: vec2<f32>) -> Fe {
return Fe(fprime(vec2<f32>(0.0, 0.0)), 0);
}
if KIND == KIND_MULTIBROT {
let p = clamp(u.power, 2u, 8u);
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
var ym = yf.m; // m^(p-1)
for (var k: u32 = 2u; k < p; k = k + 1u) {
ym = cmul(ym, yf.m);
+35 -23
View File
@@ -19,7 +19,6 @@ pub type Big = FBig<HalfAway, 2>;
/// Half-height (complex units) of the default view; also the zoom-1 reference.
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
/// Below this pixel size (complex units per pixel) the GPU renders with the
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
/// `2^scale_exp`. Plain f32 stays exact as long as the smallest per-pixel
@@ -89,7 +88,10 @@ impl Scale {
return Self::MIN;
}
if m.is_infinite() {
return Scale { m: 1.0, e: i32::MAX / 2 };
return Scale {
m: 1.0,
e: i32::MAX / 2,
};
}
// Bring m into [1, 2) through its own binary exponent (exact).
let k = m.log2().floor() as i32;
@@ -175,11 +177,7 @@ impl Scale {
impl PartialOrd for Scale {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
// Normalized and positive: the exponent decides, then the mantissa.
Some(
self.e
.cmp(&other.e)
.then(self.m.partial_cmp(&other.m)?),
)
Some(self.e.cmp(&other.e).then(self.m.partial_cmp(&other.m)?))
}
}
@@ -197,7 +195,12 @@ impl core::fmt::Display for Scale {
}
// Out of f64's range: round the exact decimal expansion instead.
let sig = f.precision().map_or(17, |p| p + 1);
let dec = self.to_big().to_decimal().value().with_precision(sig).value();
let dec = self
.to_big()
.to_decimal()
.value()
.with_precision(sig)
.value();
let repr = dec.repr();
let digits = repr.significand().to_string();
let digits = digits.trim_end_matches('0');
@@ -227,7 +230,11 @@ impl FromStr for Scale {
if let Ok(x) = s.parse::<f64>()
&& x.is_normal()
{
return if x > 0.0 { Ok(Self::from_f64(x)) } else { Err(()) };
return if x > 0.0 {
Ok(Self::from_f64(x))
} else {
Err(())
};
}
// Too small (or large) for f64: go through an exact decimal.
let dec = DBig::from_str(s).map_err(|_| ())?;
@@ -490,7 +497,8 @@ mod tests {
#[test]
fn interpolate_view_hits_exact_endpoints() {
let bits = precision_for(sc(1.0));
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let from =
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let to = ViewState::with_center(
big_from_f64(-0.7515, precision_for(sc(1e-20))),
big_from_f64(0.1013, precision_for(sc(1e-20))),
@@ -515,7 +523,8 @@ mod tests {
#[test]
fn interpolate_view_keeps_target_offset_bounded() {
let bits = precision_for(sc(1.0));
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let from =
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let to = ViewState::with_center(
big_from_f64(-0.7515, precision_for(sc(1e-20))),
big_from_f64(0.1013, precision_for(sc(1e-20))),
@@ -539,7 +548,14 @@ mod tests {
#[test]
fn scale_parse_display_round_trip() {
for s in ["1.25", "1e-20", "1.5e-20", "3.7e-4000", "1e-400", "9.99999e-310"] {
for s in [
"1.25",
"1e-20",
"1.5e-20",
"3.7e-4000",
"1e-400",
"9.99999e-310",
] {
let a: Scale = s.parse().unwrap();
let b: Scale = a.to_string().parse().unwrap();
assert_eq!(a, b, "{s} -> {a}");
@@ -565,7 +581,10 @@ mod tests {
assert_eq!(sc(1.0).exponent(), 0);
assert_eq!(sc(0.75).exponent(), -1);
assert_eq!(sc(4.0).scaled_f64(-2), 1.0);
assert_eq!(a.scaled_f64(-a.exponent()), a.mul_f64(1.0).scaled_f64(-a.exponent()));
assert_eq!(
a.scaled_f64(-a.exponent()),
a.mul_f64(1.0).scaled_f64(-a.exponent())
);
assert!((1.0..2.0).contains(&a.scaled_f64(-a.exponent())));
assert_eq!(a.to_f64(), 0.0);
assert_eq!(Scale::MIN.mul_f64(0.5), Scale::MIN);
@@ -577,18 +596,11 @@ mod tests {
/// same geometric pace as the half-height.
#[test]
fn interpolate_view_past_f64_range() {
let from = ViewState::with_center(
big_from_f64(-0.5, 64),
big_from_f64(0.0, 64),
sc(1.5),
);
let from = ViewState::with_center(big_from_f64(-0.5, 64), big_from_f64(0.0, 64), sc(1.5));
let hh: Scale = "1e-1000".parse().unwrap();
let bits = precision_for(hh);
let to = ViewState::with_center(
big_from_f64(-0.7515, bits),
big_from_f64(0.1013, bits),
hh,
);
let to =
ViewState::with_center(big_from_f64(-0.7515, bits), big_from_f64(0.1013, bits), hh);
let end = interpolate_view(&from, &to, 1.0);
assert_eq!(end.half_height, hh);
assert_eq!(re_im_f64(&end), re_im_f64(&to));