feat: add interpolation between fractals

This commit is contained in:
2026-09-24 21:43:36 +02:00
parent 9cc5a80650
commit 51fa5ffa02
8 changed files with 624 additions and 212 deletions
+5
View File
@@ -19,6 +19,8 @@ struct Uniforms {
kind: u32,
// Exponent for the Multibrot kind.
power: u32,
// Kind-switch morph: the kind blended *from* (see morph_w).
morph_from: u32,
dc_offset: vec2<f32>,
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
@@ -43,6 +45,9 @@ struct Uniforms {
camera_inv_proj: mat4x4<f32>,
// Screen dimensions
screen_dim: vec2<f32>,
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
morph_w: f32,
// Complex binomial coefficients C(complex_power, k) for k = 1..16, two per
// vec4 (k odd in .xy, k even in .zw), for the Complex Multibrot delta
// series. Precomputed on the CPU since they only depend on the power.
+66 -24
View File
@@ -31,6 +31,10 @@
override KIND: u32 = 0u;
override IS_JULIA: bool = false;
override DE: bool = false;
// A kind-switch morph is in progress (`u.morph_w > 0`): each step blends in a
// second kind, `u.morph_from`. That one is a runtime value (it only lives for
// the length of the animation), so only MORPH pipelines pay for its branches.
override MORPH: bool = false;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@@ -137,11 +141,11 @@ fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32
return cpow(z + e, p) - cpow(z, p);
}
// One perturbation step of the current fractal's delta: e -> f(Z+e) - f(Z),
// where `z` is the reference orbit value X_m. `step_add` (dc) is added by the
// caller. Must match `FractalKind` on the CPU side.
fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
if KIND == KIND_BURNING_SHIP {
// One perturbation step of `kind`'s delta: e -> f(Z+e) - f(Z), where `z` is
// the reference orbit value X_m. `step_add` (dc) is added by the caller. Must
// match `FractalKind` on the CPU side.
fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
if kind == KIND_BURNING_SHIP {
// (|x| + i|y|)^2 has real part x^2 - y^2 (an ordinary square delta) and
// imaginary part 2|x y|. The imaginary delta is 2(|x y| - |X Y|); diffabs
// computes it exactly, even where the product x y changes sign — which the
@@ -150,34 +154,34 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
let base = 2.0 * cmul(z, e) + cmul(e, e);
let dp = z.x * e.y + z.y * e.x + e.x * e.y;
return vec2<f32>(base.x, 2.0 * diffabs(z.x * z.y, dp));
} else if KIND == KIND_TRICORN {
} else if kind == KIND_TRICORN {
let cz = conj(z);
let ce = conj(e);
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
} else if KIND == KIND_MULTIBROT {
} else if kind == KIND_MULTIBROT {
return multibrot_delta(z, e, clamp(u.power, 2u, 8u));
} else if KIND == KIND_CELTIC {
} 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).
let sq = 2.0 * cmul(z, e) + cmul(e, e);
return vec2<f32>(diffabs(z.x * z.x - z.y * z.y, sq.x), sq.y);
} else if KIND == KIND_BUFFALO {
} else if kind == KIND_BUFFALO {
// Abs both outputs: real |Re(z^2)|, imag -|Im(z^2)| (Im(Z^2) = 2 X Y).
let sq = 2.0 * cmul(z, e) + cmul(e, e);
return vec2<f32>(diffabs(z.x * z.x - z.y * z.y, sq.x),
-diffabs(2.0 * z.x * z.y, sq.y));
} else if KIND == KIND_PERPENDICULAR {
} else if kind == KIND_PERPENDICULAR {
// real x^2 - y^2 (ordinary square delta), imag -2 x |y|.
// d(-2 x |y|) = -2[ X·(|Y+ey|-|Y|) + ex·|Y+ey| ]; diffabs gives |Y+ey|-|Y|.
let sq = 2.0 * cmul(z, e) + cmul(e, e);
let da = diffabs(z.y, e.y); // |Y + ey| - |Y|
let abs_yf = abs(z.y) + da; // |Y + ey|
return vec2<f32>(sq.x, -2.0 * (z.x * da + e.x * abs_yf));
} else if KIND == KIND_LAMBDA {
} else if kind == KIND_LAMBDA {
// Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e).
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y);
return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e));
} else if KIND == KIND_COMPLEX_MULTIBROT {
} else if kind == KIND_COMPLEX_MULTIBROT {
return complex_multibrot_delta(z, e, u.complex_power);
}
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part)
@@ -188,24 +192,59 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// holomorphic kinds (z^2 -> 2Z, z^p -> p Z^{p-1}); for the non-holomorphic
// Burning Ship / Tricorn we use |f'| ~ |2Z|, which keeps the DE magnitude close
// enough to de-speckle filaments.
fn fprime(z: vec2<f32>) -> vec2<f32> {
if KIND == KIND_MULTIBROT {
fn fprime_kind(kind: u32, z: vec2<f32>) -> vec2<f32> {
if kind == KIND_MULTIBROT {
let p = clamp(u.power, 2u, 8u);
var zk = z; // Z^1
for (var k: u32 = 2u; k < p; k = k + 1u) {
zk = cmul(zk, z); // -> Z^{p-1}
}
return f32(p) * zk;
} else if KIND == KIND_LAMBDA {
} else if kind == KIND_LAMBDA {
// Lambda: f'(z) = λ·(1-2z).
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y));
} else if KIND == KIND_COMPLEX_MULTIBROT {
} else if kind == KIND_COMPLEX_MULTIBROT {
// f'(z) = p * z^(p-1).
return cmul(u.complex_power, cpow(z, u.complex_power - vec2<f32>(1.0, 0.0)));
}
return 2.0 * z;
}
// Delta step of the current map. While switching kinds (MORPH), the map is
// blended per iteration, (1 - w)*f_kind + w*f_from; that's linear in the two
// outputs, so its delta is the same blend of both kinds' deltas (the CPU
// reference in reference.rs uses the same blend, so rebasing stays exact).
fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
let d = advance_delta_kind(KIND, z, e);
if MORPH {
return mix(d, advance_delta_kind(u.morph_from, z, e), u.morph_w);
}
return d;
}
// Derivative of the current (possibly morphing) map; blended like
// `advance_delta`.
fn fprime(z: vec2<f32>) -> vec2<f32> {
let d = fprime_kind(KIND, z);
if MORPH {
return mix(d, fprime_kind(u.morph_from, z), u.morph_w);
}
return d;
}
// Weight of the Phoenix kind's p*z_{n-1} term in the current map: 1 for plain
// Phoenix, its morph share while switching to/from Phoenix, else 0.
fn phoenix_weight() -> f32 {
var w = 0.0;
if KIND == KIND_PHOENIX {
w = select(1.0, 1.0 - u.morph_w, MORPH);
}
if MORPH && u.morph_from == KIND_PHOENIX {
w = w + u.morph_w;
}
return w;
}
// Periodicity (interior) detection, Brent-style: the full orbit value is
// saved at iterations PERIOD_FIRST_CHECK, 2x that, 4x ..., and every later
// iterate is compared against the last saved one. Returning within
@@ -229,7 +268,8 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
// Every kind here except Phoenix is
// (piecewise) conformal, so |f'| from `fprime` is the exact local scale
// factor, including the abs-folding kinds, whose folds are isometries.
// Phoenix's two-term map would need a 2x2 Jacobian, so it's excluded. So is
// Phoenix's two-term map would need a 2x2 Jacobian, so it's excluded (as
// is a kind-switch morph, for the same reason). So is
// Complex Multibrot without DE, where `fprime` would add a second `cpow`
// (log/atan2/exp) per step for a check that rarely fires on its views.
const PERIOD_FIRST_CHECK: u32 = 16u;
@@ -240,7 +280,8 @@ const PERIOD_CONFIRMATIONS: u32 = 2u;
// Whether `iterate_sample` runs periodicity detection for this kind (folds to
// a constant per pipeline).
fn periodic_enabled() -> bool {
if KIND == KIND_PHOENIX {
// A blend of two maps isn't conformal, so |f'| isn't its scale factor.
if MORPH || KIND == KIND_PHOENIX {
return false;
}
if KIND == KIND_COMPLEX_MULTIBROT && !DE {
@@ -276,7 +317,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// orbit itself, since X_1 = X_0^2 + C_ref = C_ref. That's only f32-accurate,
// so skip the test once a pixel is smaller than that error (deep zoom),
// where it could misclassify pixels right at the boundary.
if KIND == KIND_MANDELBROT && !IS_JULIA && ref_len > 1u && px > 1e-6 {
if KIND == KIND_MANDELBROT && !MORPH && !IS_JULIA && ref_len > 1u && px > 1e-6 {
let c = ref_orbit[1] + offset;
let xq = c.x - 0.25;
let q = xq * xq + c.y * c.y;
@@ -308,6 +349,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// y_{n-1}, and its scaled derivative for DE). Both start at 0 (y_{-1} = 0).
var e_prev = vec2<f32>(0.0, 0.0);
var dzs_prev = vec2<f32>(0.0, 0.0);
let phoenix_w = phoenix_weight();
var m: u32 = 0u; // reference index; invariant: y_n = xm + e, xm = X[m]
var n: u32 = 0u; // total iteration count
@@ -352,8 +394,8 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
if !IS_JULIA {
dzs_new.x = dzs_new.x + px;
}
if KIND == KIND_PHOENIX {
dzs_new = dzs_new + cmul(u.phoenix_p, dzs_prev);
if phoenix_w > 0.0 {
dzs_new = dzs_new + phoenix_w * cmul(u.phoenix_p, dzs_prev);
dzs_prev = dzs;
}
dzs = dzs_new;
@@ -364,8 +406,8 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
let e_old = e;
let z_old = z;
e = advance_delta(xm, e) + step_add;
if KIND == KIND_PHOENIX {
e = e + cmul(u.phoenix_p, e_prev);
if phoenix_w > 0.0 {
e = e + phoenix_w * cmul(u.phoenix_p, e_prev);
e_prev = e_old;
}
m = m + 1u;
@@ -388,7 +430,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// full value `z` (and `z2`) is unchanged by the re-expression.
// Phoenix: after rebasing the implied previous reference is Y[-1]=0,
// so the previous delta becomes the full previous value y_{n-1}.
if KIND == KIND_PHOENIX {
if phoenix_w > 0.0 {
e_prev = z_old;
}
e = z - z0;