feat: add other fractals

This commit is contained in:
2026-09-15 15:50:57 +02:00
parent 72c3ea2ee8
commit c36c8b9829
7 changed files with 363 additions and 47 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod reference;
pub mod renderer;
pub mod share;
pub use reference::{compute_mandelbrot_reference, compute_reference};
pub use reference::{FractalKind, compute_reference, compute_set_reference};
pub use renderer::{
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
encode_png_with_progress,
+146 -23
View File
@@ -1,24 +1,51 @@
//! High-precision reference-orbit computation for perturbation rendering.
//!
//! We iterate `Z_{n+1} = Z_n^2 + C` at high precision (`dashu-float`), storing
//! each `Z_n` as an `f32` pair. Every pixel is then rendered on the GPU as a
//! small `f32` delta from this orbit — that is what makes deep zoom cheap. See
//! `shaders/mandelbrot.wgsl` for the delta side.
//! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision
//! (`dashu-float`), storing each `Z_n` as an `f32` pair. Every pixel is then
//! rendered on the GPU as a small `f32` delta from this orbit — that is what
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
//! delta formula there must match the orbit formula here.
//!
//! The `(z0, c)` form serves both fractals:
//! * Mandelbrot: `z0 = 0`, `c = view center` (the c-plane point per pixel).
//! * Julia: `z0 = view center`, `c = julia constant` (fixed for all pixels).
//! The `(z0, c)` form serves both set types:
//! * Mandelbrot-set: `z0 = 0`, `c = view center` (the c-plane point per pixel).
//! * Julia-set: `z0 = view center`, `c = fractal constant` (fixed per view).
use crate::view::Big;
/// The iteration formula. Must be kept in sync with `advance_delta` and the
/// `KIND_*` constants in the shader.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FractalKind {
/// `z -> z^2 + c`.
Mandelbrot,
/// `z -> (|Re z| + i|Im z|)^2 + c`.
BurningShip,
/// `z -> conj(z)^2 + c` (the Mandelbar).
Tricorn,
/// `z -> z^power + c` (power >= 2).
Multibrot,
}
impl FractalKind {
/// Integer id matching the shader's `KIND_*` constants.
pub fn shader_id(self) -> u32 {
match self {
FractalKind::Mandelbrot => 0,
FractalKind::BurningShip => 1,
FractalKind::Tricorn => 2,
FractalKind::Multibrot => 3,
}
}
}
/// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel
/// bailout so pixels escaping alongside the reference can still reach their
/// bailout before the stored orbit runs out.
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
/// `Z_{n+1} = Z_n^2 + c`, up to `max_iter` steps at `precision` bits. Each entry
/// is `[re, im]` in f32.
/// `Z_{n+1} = f(Z_n, c)` for the given `kind` (and `power`, for Multibrot), up
/// to `max_iter` steps at `precision` bits. Each entry is `[re, im]` in f32.
pub fn compute_reference(
z0_re: &Big,
z0_im: &Big,
@@ -26,6 +53,8 @@ pub fn compute_reference(
c_im: &Big,
max_iter: u32,
precision: usize,
kind: FractalKind,
power: u32,
) -> Vec<[f32; 2]> {
let cr = c_re.clone().with_precision(precision).value();
let ci = c_im.clone().with_precision(precision).value();
@@ -45,15 +74,33 @@ pub fn compute_reference(
break;
}
// Z = Z^2 + C, with Z^2 = (zr^2 - zi^2) + (2 zr zi) i.
let zr2 = zr.sqr();
let zi2 = zi.sqr();
let new_zr = ((&zr2 - &zi2) + &cr).with_precision(precision).value();
let two_zr_zi = (&zr * &zi) << 1; // exact multiply-by-2 in base 2
let new_zi = (two_zr_zi + &ci).with_precision(precision).value();
let (new_zr, new_zi) = match kind {
FractalKind::Mandelbrot => {
// Z^2 = (zr^2 - zi^2) + (2 zr zi) i.
let re = &zr.sqr() - &zi.sqr() + &cr;
let im = ((&zr * &zi) << 1) + &ci; // << 1 is exact ×2 in base 2
(re, im)
}
FractalKind::BurningShip => {
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
let re = &zr.sqr() - &zi.sqr() + &cr;
let im = big_abs((&zr * &zi) << 1) + &ci;
(re, im)
}
FractalKind::Tricorn => {
// conj(z)^2 = (zr^2 - zi^2) - 2 zr zi i.
let re = &zr.sqr() - &zi.sqr() + &cr;
let im = &ci - ((&zr * &zi) << 1);
(re, im)
}
FractalKind::Multibrot => {
let (pr, pi) = complex_pow(&zr, &zi, power.max(2), precision);
(pr + &cr, pi + &ci)
}
};
zr = new_zr;
zi = new_zi;
zr = new_zr.with_precision(precision).value();
zi = new_zi.with_precision(precision).value();
}
points
@@ -63,15 +110,40 @@ fn big_zero(precision: usize) -> Big {
Big::from(0i32).with_precision(precision).value()
}
/// Convenience: Mandelbrot reference (`z0 = 0`, `c = center`).
pub fn compute_mandelbrot_reference(
/// Absolute value of a `Big`. The sign check via f64 is exact except for values
/// so tiny that |x| ≈ x either way — negligible against the f32 orbit storage.
fn big_abs(x: Big) -> Big {
if x.to_f64().value() < 0.0 { -x } else { x }
}
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
let mut rr = Big::from(1i32).with_precision(precision).value();
let mut ri = big_zero(precision);
for _ in 0..power {
// (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i.
let nr = (&rr * zr - &ri * zi).with_precision(precision).value();
let ni = (&rr * zi + &ri * zr).with_precision(precision).value();
rr = nr;
ri = ni;
}
(rr, ri)
}
/// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`,
/// `c = center`) for any `kind`.
pub fn compute_set_reference(
center_re: &Big,
center_im: &Big,
max_iter: u32,
precision: usize,
kind: FractalKind,
power: u32,
) -> Vec<[f32; 2]> {
let zero = big_zero(precision);
compute_reference(&zero, &zero, center_re, center_im, max_iter, precision)
compute_reference(
&zero, &zero, center_re, center_im, max_iter, precision, kind, power,
)
}
#[cfg(test)]
@@ -84,7 +156,7 @@ mod tests {
fn reference_matches_naive_f64() {
let cr = Big::try_from(-0.75_f64).unwrap();
let ci = Big::try_from(0.1_f64).unwrap();
let points = compute_mandelbrot_reference(&cr, &ci, 60, 200);
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Mandelbrot, 2);
// Independent naive f64 orbit.
let (c_re, c_im) = (-0.75_f64, 0.1_f64);
@@ -108,10 +180,52 @@ mod tests {
fn interior_orbit_runs_full_length() {
let cr = Big::try_from(-0.2_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let points = compute_mandelbrot_reference(&cr, &ci, 500, 120);
let points = compute_set_reference(&cr, &ci, 500, 120, FractalKind::Mandelbrot, 2);
assert_eq!(points.len(), 501, "interior orbit should not escape");
}
/// Burning Ship reference matches a naive f64 iteration of the same formula.
#[test]
fn burning_ship_reference_matches_naive_f64() {
let cr = Big::try_from(-1.75_f64).unwrap();
let ci = Big::try_from(-0.03_f64).unwrap();
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::BurningShip, 2);
let (c_re, c_im) = (-1.75_f64, -0.03_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
let nzr = zr * zr - zi * zi + c_re;
let nzi = 2.0 * (zr * zi).abs() + c_im;
zr = nzr;
zi = nzi;
}
}
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
#[test]
fn multibrot3_reference_matches_naive_f64() {
let cr = Big::try_from(0.3_f64).unwrap();
let ci = Big::try_from(0.2_f64).unwrap();
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Multibrot, 3);
let (c_re, c_im) = (0.3_f64, 0.2_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
// z^3 = z * z^2.
let (r2, i2) = (zr * zr - zi * zi, 2.0 * zr * zi);
let nzr = zr * r2 - zi * i2 + c_re;
let nzi = zr * i2 + zi * r2 + c_im;
zr = nzr;
zi = nzi;
}
}
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
#[test]
fn julia_reference_matches_naive_f64() {
@@ -119,7 +233,16 @@ mod tests {
let z0_im = Big::try_from(-0.1_f64).unwrap();
let c_re = Big::try_from(-0.8_f64).unwrap();
let c_im = Big::try_from(0.156_f64).unwrap();
let points = compute_reference(&z0_re, &z0_im, &c_re, &c_im, 60, 200);
let points = compute_reference(
&z0_re,
&z0_im,
&c_re,
&c_im,
60,
200,
FractalKind::Mandelbrot,
2,
);
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
let (cr, ci) = (-0.8_f64, 0.156_f64);
+6
View File
@@ -35,10 +35,16 @@ pub struct Uniforms {
pub palette_id: u32,
/// Supersampling factor per axis: 1 = off, 2 = 2×2 (4 samples).
pub aa_level: u32,
/// Iteration formula (`FractalKind::shader_id`).
pub kind: u32,
/// Exponent for the Multibrot kind.
pub power: u32,
/// Complex offset of the view center from the reference center, so a stale
/// or reused reference (computed at a slightly different center) still maps
/// correctly. Added to every pixel's per-pixel offset.
pub dc_offset: [f32; 2],
/// Padding to a 16-byte multiple (uniform buffer requirement).
pub _pad: [u32; 2],
}
/// Offscreen texture the fractal is rendered into, plus the bind group used to
+27 -5
View File
@@ -2,15 +2,18 @@
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
//! locations can be shared or bookmarked.
//!
//! Format: `m=m&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
//! strings.
use std::collections::HashMap;
use crate::fractal::FractalKind;
#[derive(Clone, Debug)]
pub struct ShareState {
pub julia: bool,
pub kind: FractalKind,
pub center_re: String,
pub center_im: String,
pub half_height: f64,
@@ -24,14 +27,21 @@ impl ShareState {
pub fn encode(&self) -> String {
let mut s = String::new();
s.push_str(if self.julia { "m=j" } else { "m=m" });
s.push_str(&format!("&f={}", match self.kind {
FractalKind::Mandelbrot => "mandel",
FractalKind::BurningShip => "burning",
FractalKind::Multibrot => "multi",
FractalKind::Tricorn => "tricorn",
}));
s.push_str(&format!(
"&re={}&im={}&hh={}&it={}",
self.center_re, self.center_im, self.half_height, self.iterations
));
if self.julia {
s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1));
}
s.push_str(&format!("&cs={}&co={}", self.color_scale, self.color_offset));
s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1));
s.push_str(&format!(
"&cs={}&co={}",
self.color_scale, self.color_offset
));
s
}
@@ -46,6 +56,16 @@ impl ShareState {
Some(ShareState {
julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
kind: map
.get("f")
.map(|f| match *f {
"mandel" => FractalKind::Mandelbrot,
"multi" => FractalKind::Multibrot,
"burning" => FractalKind::BurningShip,
"tricorn" => FractalKind::Tricorn,
_ => FractalKind::Mandelbrot
})
.unwrap_or(FractalKind::Mandelbrot),
center_re: (*map.get("re")?).to_string(),
center_im: (*map.get("im")?).to_string(),
half_height: map.get("hh")?.parse().ok()?,
@@ -68,6 +88,7 @@ mod tests {
fn round_trip() {
let s = ShareState {
julia: true,
kind: FractalKind::Tricorn,
center_re: "-0.743643887037158704752191506114774".into(),
center_im: "0.131825904205311970493132056385139".into(),
half_height: 1.5e-20,
@@ -78,6 +99,7 @@ mod tests {
};
let d = ShareState::decode(&s.encode()).unwrap();
assert_eq!(d.julia, s.julia);
assert_eq!(d.kind, s.kind);
assert_eq!(d.center_re, s.center_re);
assert_eq!(d.center_im, s.center_im);
assert_eq!(d.half_height, s.half_height);