feat: Add complex multibrot fractal

This commit is contained in:
2026-09-20 13:37:33 +02:00
parent c7d687c107
commit 06b52fe954
10 changed files with 311 additions and 19 deletions
+26 -7
View File
@@ -47,14 +47,15 @@ struct Uniforms {
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
// (yellow core, blue halo), 2 = grayscale.
palette: u32,
// Padding to a 16-byte multiple. NOT vec3<u32> — that type aligns to 16
// bytes in WGSL (unlike Rust's `[u32; 3]`, which aligns to 4), which
// silently added 32 bytes instead of 16 and mismatched the Rust struct's
// size (a wgpu validation error at dispatch time: "size 96 where the
// shader expects 112").
// Padding so `complex_power` (a vec2, 8-byte aligned) starts on an
// 8-byte boundary. NOT vec3<u32> — that type aligns to 16 bytes in WGSL
// (unlike Rust's `[u32; 3]`, which aligns to 4), which silently added 32
// bytes instead of 16 and mismatched the Rust struct's size (a wgpu
// validation error at dispatch time: "size 96 where the shader expects
// 112").
_pad0: u32,
_pad1: u32,
_pad2: u32,
// Complex exponent for the Complex Multibrot kind; unused by other kinds.
complex_power: vec2<f32>,
};
const PALETTE_NEBULA: u32 = 0u;
@@ -70,6 +71,7 @@ const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u;
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
@group(0) @binding(0) var<uniform> u: Uniforms;
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
@@ -103,6 +105,21 @@ fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
return r;
}
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let r2 = dot(z, z);
if r2 < 1e-30 {
return vec2<f32>(0.0, 0.0);
}
let ln_r = 0.5 * log(r2);
let theta = atan2(z.y, z.x);
let mag = exp(p.x * ln_r - p.y * theta);
let ang = p.x * theta + p.y * ln_r;
return mag * vec2<f32>(cos(ang), sin(ang));
}
// One iteration step z_n -> z_{n+1} for the current kind. `zp` is the
// previous iterate (z_{n-1}), used only by the Phoenix two-term recurrence.
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
@@ -126,6 +143,8 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
} else if u.kind == KIND_LAMBDA {
// l * z * (1 - z); c is unused (see file doc comment above).
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return cpow(z, u.complex_power) + c;
}
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
}
+1
View File
@@ -27,6 +27,7 @@ struct Uniforms {
dc_offset: vec2<f32>,
phoenix_p: vec2<f32>,
lambda_l: vec2<f32>,
complex_power: vec2<f32>,
de_coloring: u32,
shadow: u32,
};
+71
View File
@@ -34,6 +34,9 @@ struct Uniforms {
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
// by other kinds.
lambda_l: vec2<f32>,
// Complex exponent for the Complex Multibrot kind (z^power + c); unused
// by other kinds.
complex_power: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32,
// 0 = classic colors, 1 = shadows
@@ -49,6 +52,7 @@ const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u;
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
@@ -84,6 +88,27 @@ fn conj(a: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x, -a.y);
}
// Complex division a / b.
fn cdiv(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
let d = dot(b, b);
return vec2<f32>(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d;
}
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let r2 = dot(z, z);
if r2 < 1e-30 {
return vec2<f32>(0.0, 0.0);
}
let ln_r = 0.5 * log(r2);
let theta = atan2(z.y, z.x);
let mag = exp(p.x * ln_r - p.y * theta);
let ang = p.x * theta + p.y * ln_r;
return mag * vec2<f32>(cos(ang), sin(ang));
}
// |c + d| - |c|, evaluated exactly (no catastrophic cancellation even when the
// sum crosses zero). This is what makes the Burning Ship delta correct through
// the sign flips that happen all along the axes, where the ship's detail lives.
@@ -123,6 +148,47 @@ fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
return acc;
}
// Number of terms kept in `complex_multibrot_delta`'s series. Truncation, not
// exactness: unlike `multibrot_delta` (a finite binomial sum for an integer
// power), a complex power has no finite expansion, so this converges rather
// than terminates. Fine as long as perturbation's usual invariant (|e| << |z|,
// kept true by rebasing) holds, since each extra term is O(w^k) smaller.
const COMPLEX_MULTIBROT_TERMS: u32 = 16u;
// Perturbation delta for z -> z^p with a complex p: (Z+e)^p - Z^p.
//
// When |e| << |Z| (the common case: it's the whole reason perturbation
// works), forming Z+e directly would round e away in f32, so instead expand
// = Z^p * ((1+w)^p - 1), w = e/Z, as a Taylor series in w: (1+w)^p - 1 =
// sum_{k=1}^N C(p,k) w^k, with the complex binomial coefficient built up
// incrementally: C(p,k) = C(p,k-1) * (p-(k-1)) / k. Unlike `multibrot_delta`
// (a finite binomial sum for an integer power), this only *converges* — and
// only for |w| < 1 — rather than terminating exactly.
//
// Right after a rebase (or near a reference point close to zero, where w is
// singular), e is *not* small relative to Z — that's normal perturbation
// dynamics, not a deep-zoom edge case — and the series above would diverge.
// But forming Z+e directly is numerically safe exactly there (e isn't many
// orders of magnitude smaller than Z), so fall back to a plain subtraction.
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
// |w|^2 = |e|^2 / |Z|^2; inf or nan (Z ~ 0, or both ~ 0) correctly fails
// the `< 0.25` test below and falls through to the direct branch.
let w2 = dot(e, e) / dot(z, z);
if w2 < 0.25 {
let w = cdiv(e, z);
var wk = vec2<f32>(1.0, 0.0); // w^0
var coef = vec2<f32>(1.0, 0.0); // C(p,0)
var acc = vec2<f32>(0.0, 0.0);
for (var k: u32 = 1u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) {
coef = cdiv(cmul(coef, p - vec2<f32>(f32(k - 1u), 0.0)), vec2<f32>(f32(k), 0.0));
wk = cmul(wk, w);
acc = acc + cmul(coef, wk);
}
return cmul(cpow(z, p), acc);
}
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.
@@ -163,6 +229,8 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// 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 u.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)
}
@@ -183,6 +251,9 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
} else if u.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 u.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;
}