// Deep-zoom Mandelbrot via perturbation theory with rebasing. // // Instead of iterating each pixel's orbit directly (which f32 can't do at deep // zoom), we iterate the *delta* from a high-precision reference orbit computed // on the CPU. For a pixel c = c_ref + dc, its orbit y_n = X_n + e_n where: // // e_{n+1} = 2 * X_n * e_n + e_n^2 + dc (all f32) // // The full value y_n = X_n + e_n is used for the escape test. Rebasing // (Zhuoran's method) keeps the delta small and avoids glitches: whenever the // true value |y| drops below the delta |e|, or the reference runs out, we reset // the reference index to 0 and carry the full value as the new delta (valid // because X_0 = 0). @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var ref_orbit: array>; // Only read by `fs_color`'s shadow branch (custom-lights palette); the // iteration pass (`fs_data`) never touches it. @group(0) @binding(2) var lights: array; struct VsOut { @builtin(position) pos: vec4, // Position within the view, in [-0.5, 0.5] at the visible edges. @location(0) centered: vec2, }; @vertex fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { let ndc = fullscreen_triangle_pos(idx); var out: VsOut; out.pos = vec4(ndc, 0.0, 1.0); // Flip y so +imaginary points up the screen. out.centered = vec2(ndc.x, -ndc.y) * 0.5; return out; } // Complex conjugate. fn conj(a: vec2) -> vec2 { return vec2(a.x, -a.y); } // Complex division a / b. fn cdiv(a: vec2, b: vec2) -> vec2 { let d = dot(b, b); return vec2(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d; } // |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. fn diffabs(c: f32, d: f32) -> f32 { let cd = c + d; if c >= 0.0 { return select(-(2.0 * c + d), d, cd >= 0.0); } return select(-d, 2.0 * c + d, cd > 0.0); } // Binomial coefficient C(n, k) as f32 (exact for the small powers we use). fn binom(n: u32, k: u32) -> f32 { var num = 1.0; var den = 1.0; for (var i: u32 = 0u; i < k; i = i + 1u) { num = num * f32(n - i); den = den * f32(i + 1u); } return num / den; } // Perturbation delta for z -> z^p: sum_{k=1}^{p} C(p,k) Z^{p-k} e^k. Expanded so // the large z^p term is never formed (that would cancel catastrophically). fn multibrot_delta(z: vec2, e: vec2, p: u32) -> vec2 { var zp: array, 9>; // Z^0 .. Z^8 zp[0] = vec2(1.0, 0.0); for (var j: u32 = 1u; j <= p; j = j + 1u) { zp[j] = cmul(zp[j - 1u], z); } var acc = vec2(0.0, 0.0); var ek = vec2(1.0, 0.0); // e^0 for (var k: u32 = 1u; k <= p; k = k + 1u) { ek = cmul(ek, e); // e^k acc = acc + binom(p, k) * cmul(zp[p - k], ek); } 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, e: vec2, p: vec2) -> vec2 { // |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(1.0, 0.0); // w^0 var coef = vec2(1.0, 0.0); // C(p,0) var acc = vec2(0.0, 0.0); for (var k: u32 = 1u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) { coef = cdiv(cmul(coef, p - vec2(f32(k - 1u), 0.0)), vec2(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. fn advance_delta(z: vec2, e: vec2) -> vec2 { if u.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 // old sign(X)sign(Y) shortcut got wrong whenever the delta was large // enough to flip it (all the time at shallow zoom). 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(base.x, 2.0 * diffabs(z.x * z.y, dp)); } else if u.kind == KIND_TRICORN { let cz = conj(z); let ce = conj(e); return 2.0 * cmul(cz, ce) + cmul(ce, ce); } else if u.kind == KIND_MULTIBROT { return multibrot_delta(z, e, clamp(u.power, 2u, 8u)); } else if u.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(diffabs(z.x * z.x - z.y * z.y, sq.x), sq.y); } else if u.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(diffabs(z.x * z.x - z.y * z.y, sq.x), -diffabs(2.0 * z.x * z.y, sq.y)); } else if u.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(sq.x, -2.0 * (z.x * da + e.x * abs_yf)); } else if u.kind == KIND_LAMBDA { // Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e). let one_minus_2z_minus_e = vec2(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) } // Derivative f'(Z) of the iteration map at the full value Z, used to propagate // the orbit derivative for distance-estimation shading. Exact for the // 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) -> vec2 { if u.kind == KIND_MULTIBROT { let p = clamp(u.power, 2u, 8u); var zk = vec2(1.0, 0.0); // Z^0 for (var k: u32 = 1u; k < p; k = k + 1u) { zk = cmul(zk, z); // -> Z^{p-1} } return f32(p) * zk; } else if u.kind == KIND_LAMBDA { // Lambda: f'(z) = λ·(1-2z). return cmul(u.lambda_l, vec2(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(1.0, 0.0))); } return 2.0 * z; } // Escape data for one sample: `ci` is the (color-independent) palette parameter, // `de` the distance-estimate darkening factor in [0,1], `escaped` false for the // interior of the set. Splitting iteration from coloring lets a colour change be // remapped cheaply (see the colourise pass) without re-iterating. struct Sample { ci: f32, de: f32, escaped: bool, }; // Perturbation iterate a single sample. `offset` is the per-pixel offset in // complex units. For Mandelbrot it is the c-plane offset added every step (delta // starts at 0); for Julia it is the z-plane offset that seeds the initial delta // (c is fixed, so nothing is added per step). fn iterate_sample(offset: vec2, px: f32) -> Sample { let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia) var step_add = offset; var e = vec2(0.0, 0.0); // Orbit derivative for distance estimation. For the set plane it is d/dc // (starts at 0, gains +1 each step); for Julia it is d/dz0 (starts at 1). var dz = vec2(0.0, 0.0); var dz_seed = vec2(1.0, 0.0); // Previous-iterate state for the Phoenix two-term recurrence (delta of // y_{n-1}, and its derivative for DE). Both start at 0 (y_{-1} = 0). var e_prev = vec2(0.0, 0.0); var dz_prev = vec2(0.0, 0.0); if u.is_julia != 0u { step_add = vec2(0.0, 0.0); e = offset; dz = vec2(1.0, 0.0); dz_seed = vec2(0.0, 0.0); } var m: u32 = 0u; // reference index; invariant: y_n = X[m] + e var n: u32 = 0u; // total iteration count var z = vec2(0.0, 0.0); // full value y_n, kept for coloring var escaped = false; loop { let xm = ref_orbit[m]; z = xm + e; let z2 = dot(z, z); if z2 > u.bailout_sq { escaped = true; break; } if n >= u.max_iter { break; // interior } // Propagate the derivative of the full orbit (unaffected by rebasing, // which only re-expresses the same value). Only when DE is enabled. // Phoenix's two-term map adds p·dz_{n-1} and carries the previous dz. if u.de_coloring != 0u { var dz_new = cmul(fprime(z), dz) + dz_seed; if u.kind == KIND_PHOENIX { dz_new = dz_new + cmul(u.phoenix_p, dz_prev); dz_prev = dz; } dz = dz_new; } // Advance the delta by this fractal's formula (+ dc for the set plane). // Phoenix additionally adds p·e_{n-1} and carries the previous delta. let e_old = e; e = advance_delta(xm, e) + step_add; if u.kind == KIND_PHOENIX { e = e + cmul(u.phoenix_p, e_prev); e_prev = e_old; } m = m + 1u; n = n + 1u; // Keep the reference index valid and the delta small. if m >= u.ref_len { // Reference exhausted: any pixel that followed it this far has // effectively escaped (interior pixels rebase before reaching here). z = ref_orbit[u.ref_len - 1u] + e; escaped = true; break; } let y = ref_orbit[m] + e; if dot(y, y) < dot(e, e) { // Rebase to index 0: carry the full value as the new delta. Valid // because y_n = X[0] + (y_n - X[0]); for Mandelbrot X[0]=0. // Phoenix: after rebasing the implied previous reference is Y[-1]=0, // so the previous delta becomes the full previous value y_n (= z). if u.kind == KIND_PHOENIX { e_prev = z; } e = y - z0; m = 0u; } } if !escaped { return Sample(0.0, 1.0, false); // interior of the set } let z2 = dot(z, z); // Continuous (smooth) iteration count. let log_zn = 0.5 * log(max(z2, 1.0)); let nu = log2(log_zn / log(2.0)); let smooth_i = f32(n) + 1.0 - nu; // sqrt compresses the huge iteration counts of deep zooms so the palette // varies smoothly instead of aliasing into speckle. let ci = sqrt(max(smooth_i, 0.0)); var de = 1.0; if u.de_coloring != 0u { // Exterior distance estimate (complex-plane units): |z|·ln|z| / |dz|. // Divided by the pixel footprint it becomes a distance in pixels; we // darken toward the boundary (< ~1 px away) so filaments stay crisp // instead of aliasing into speckle. If |dz| overflowed, de -> 0 and the // boundary simply reads as dark, which is the correct limit. let zmag = sqrt(max(z2, 1.0)); let dzmag = sqrt(max(dot(dz, dz), 1e-20)); let d = zmag * log(zmag) / dzmag; var max_de = 1.; if u.shadow != 0u { max_de = 1000.; } de = clamp(d / max(px, 1e-30), 0.0, max_de); } return Sample(ci, de, true); } // Map a sample's escape data through the palette (+ DE darkening). This is the // only color-dependent step, so it can be redone without re-iterating. Interior // samples are black. fn color_sample(s: Sample) -> vec3 { if !s.escaped { return vec3(0.0, 0.0, 0.0); } return classic_color(s.ci, s.de); } // Supersampled escape data at one point: average (ci, DE factor) over the // AA grid's escaped sub-samples, plus the fraction that landed in the // interior. Shared by `fs_data` (writes it straight to the data texture) and // `fs_color`'s shadow branch (used both at the pixel and at its two // neighbours, to build a DE height field without a texture round-trip). fn aggregate_sample(base: vec2, dx: vec2, dy: vec2, px: f32) -> vec3 { let aa = max(u.aa_level, 1u); let inv = 1.0 / f32(aa); var ci_sum = 0.0; var de_sum = 0.0; var escaped_n = 0u; for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) { for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) { let jx = (f32(sx) + 0.5) * inv - 0.5; let jy = (f32(sy) + 0.5) * inv - 0.5; let s = iterate_sample(base + jx * dx + jy * dy, px); if s.escaped { ci_sum = ci_sum + s.ci; de_sum = de_sum + s.de; escaped_n = escaped_n + 1u; } } } let total = f32(aa * aa); let ci_avg = select(0.0, ci_sum / f32(escaped_n), escaped_n > 0u); let de_avg = select(1.0, de_sum / f32(escaped_n), escaped_n > 0u); let interior_frac = 1.0 - f32(escaped_n) / total; return vec3(ci_avg, de_avg, interior_frac); } // Iteration pass: write per-pixel escape data (color-independent) so a colour // change is remapped by the cheap colourise pass without re-iterating. // R = ci (palette parameter), G = DE factor, B = interior fraction (for AA). // AA is grid-supersampled here; the interior fraction lets the colourise pass // anti-alias the set boundary (blend toward black) after the fact. @fragment fn fs_data(in: VsOut) -> @location(0) vec4 { let base = in.centered * u.span + u.dc_offset; let dx = dpdx(base); let dy = dpdy(base); let px = length(abs(dx) + abs(dy)); return vec4(aggregate_sample(base, dx, dy, px), 1.0); } // Combined iterate + colour in a single pass, for PNG export (which never needs // incremental recolouring). The interactive path uses fs_data + the colourise // pass so colour changes skip iteration. @fragment fn fs_color(in: VsOut) -> @location(0) vec4 { let base = in.centered * u.span + u.dc_offset; let dx = dpdx(base); let dy = dpdy(base); let px = length(abs(dx) + abs(dy)); if u.shadow != 0u { // No data texture to sample neighbours from (this pass never runs // one), so build the same DE height field colorize.wgsl reads from // the texture by aggregating live, at the pixel and its two // neighbours a `dx`/`dy` step away. let here = aggregate_sample(base, dx, dy, px); if here.z != 0.0 { return vec4(0.1, 0.1, 0.1, 1.0); } let right = aggregate_sample(base + dx, dx, dy, px); let down = aggregate_sample(base + dy, dx, dy, px); let normal = normal_from_heights(here.y, right.y, down.y); return vec4(shadow_color(normal), 1.0); } let aa = max(u.aa_level, 1u); let inv = 1.0 / f32(aa); var acc = vec3(0.0, 0.0, 0.0); for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) { for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) { let jx = (f32(sx) + 0.5) * inv - 0.5; let jy = (f32(sy) + 0.5) * inv - 0.5; acc = acc + color_sample(iterate_sample(base + jx * dx + jy * dy, px)); } } return vec4(acc / f32(aa * aa), 1.0); }