// Buddhabrot / Nebulabrot rendering: a Monte-Carlo density histogram of // escaping orbits, accumulated progressively across frames by a compute pass, // then tone-mapped to colour by a fragment pass every frame. // // This does NOT use the deep-zoom perturbation/reference-orbit machinery in // mandelbrot.wgsl: Buddhabrot's structure is a global Monte-Carlo property of // the whole basin (a random sample's orbit scatters across the *whole* image, // not just its own pixel), so the "gather" per-pixel model doesn't apply, and // deep zoom isn't meaningful for it the way it is for the escape-time set. // Samples are iterated directly in f32 from the current view's bounds. // // Sampling convention: for KIND_LAMBDA the formula z -> l*z*(1-z) has no `c` // term at all (l is a fixed distortion constant, not a per-sample parameter), // so the randomly sampled point instead seeds z0 (a "Julia-Buddhabrot" over // z0 with l fixed). Every other kind samples c with z0 = 0, matching its // ordinary parameter plane. // // A sample's orbit is only plotted if it escapes within b_cap iterations (the // classic Buddhabrot rule: only escaping orbits are drawn). Its points are // then splat into up to three histogram channels by cap (r_cap <= g_cap <= // b_cap): fast-escaping (common) orbits light all three channels (bright), // slow-escaping (rare) orbits only light the b_cap channel — the classic // Nebulabrot false-colour split. // // Two-pass iteration avoids needing a per-thread orbit buffer sized to // max_iter: the first pass just finds the escape iteration (if any); the // second replays the same orbit from scratch, splatting each point. struct Uniforms { center: vec2, half_height: f32, aspect: f32, phoenix_p: vec2, lambda_l: vec2, bailout_sq: f32, kind: u32, power: u32, r_cap: u32, g_cap: u32, b_cap: u32, seed: u32, samples_this_dispatch: u32, exposure: f32, width: u32, height: u32, total_samples: f32, // 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 — 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, }; const PALETTE_NEBULA: u32 = 0u; const PALETTE_YELLOW: u32 = 1u; const PALETTE_GRAYSCALE: u32 = 2u; const KIND_MANDELBROT: u32 = 0u; const KIND_BURNING_SHIP: u32 = 1u; const KIND_TRICORN: u32 = 2u; const KIND_MULTIBROT: u32 = 3u; const KIND_CELTIC: u32 = 4u; const KIND_PERPENDICULAR: u32 = 5u; const KIND_BUFFALO: u32 = 6u; const KIND_PHOENIX: u32 = 7u; const KIND_LAMBDA: u32 = 8u; @group(0) @binding(0) var u: Uniforms; // Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B). @group(0) @binding(1) var histogram: array>; // Tonemap pass: read-only plain view of the same buffer. @group(0) @binding(2) var tm_histogram: array; // --- RNG: a small, fast integer hash (WGSL has no native RNG). --- fn hash_u32(x: u32) -> u32 { var h = x; h = h ^ (h >> 16u); h = h * 0x7feb352du; h = h ^ (h >> 15u); h = h * 0x846ca68bu; h = h ^ (h >> 16u); return h; } fn rand01(seed: u32) -> f32 { return f32(hash_u32(seed)) * (1.0 / 4294967295.0); } fn cmul(a: vec2, b: vec2) -> vec2 { return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); } fn complex_pow(z: vec2, p: u32) -> vec2 { var r = vec2(1.0, 0.0); for (var i: u32 = 0u; i < p; i = i + 1u) { r = cmul(r, z); } return r; } // 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 // of the same formulas). fn advance(z: vec2, zp: vec2, c: vec2) -> vec2 { if u.kind == KIND_BURNING_SHIP { return vec2(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c; } else if u.kind == KIND_TRICORN { return vec2(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c; } else if u.kind == KIND_MULTIBROT { return complex_pow(z, clamp(u.power, 2u, 8u)) + c; } else if u.kind == KIND_CELTIC { return vec2(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c; } else if u.kind == KIND_PERPENDICULAR { return vec2(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c; } else if u.kind == KIND_BUFFALO { return vec2(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c; } else if u.kind == KIND_PHOENIX { let sq = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y); return sq + c + cmul(u.phoenix_p, zp); } 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(1.0 - z.x, -z.y))); } return vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot } // Map a complex-plane point to a flat pixel index, or -1 if outside the // current viewport (the sampling region and the display region are the same). // // This must be the exact inverse of how `view.rs::pan_pixels`/`zoom_at_pixel` // relate screen pixels to world points (those are the confirmed-correct, // user-tested ground truth — NOT the shader-comment-derived convention tried // here previously, which was wrong: dragging/zooming treat +y screen exactly // like +x, no flip, so screen-down means im *increasing*, not decreasing). fn pixel_index(p: vec2) -> i32 { let half_w = u.half_height * u.aspect; let uu = (p.x - u.center.x) / half_w * 0.5 + 0.5; let vv = 0.5 + (p.y - u.center.y) / u.half_height * 0.5; if uu < 0.0 || uu >= 1.0 || vv < 0.0 || vv >= 1.0 { return -1; } let px = i32(uu * f32(u.width)); let py = i32(vv * f32(u.height)); return py * i32(u.width) + px; } // Splat one visited orbit point into the R/G/B histogram planes it qualifies // for by the orbit's total escape iteration `n` (nested caps: a fast escape // lights all three; only a slow, rare one lights just the blue plane). fn splat(p: vec2, n: u32) { let idx = pixel_index(p); if idx < 0 { return; } let plane = i32(u.width) * i32(u.height); if n <= u.b_cap { atomicAdd(&histogram[idx + 2 * plane], 1u); } if n <= u.g_cap { atomicAdd(&histogram[idx + plane], 1u); } if n <= u.r_cap { atomicAdd(&histogram[idx], 1u); } } @compute @workgroup_size(64) fn cs_main(@builtin(global_invocation_id) gid: vec3) { if gid.x >= u.samples_this_dispatch { return; } let base = hash_u32(gid.x ^ (u.seed * 0x9e3779b9u)); let rx = rand01(base); let ry = rand01(hash_u32(base ^ 0x68bc21ebu)); let half_w = u.half_height * u.aspect; let sample = vec2( u.center.x + (rx * 2.0 - 1.0) * half_w, u.center.y + (ry * 2.0 - 1.0) * u.half_height, ); var c = sample; var z0 = vec2(0.0, 0.0); if u.kind == KIND_LAMBDA { c = vec2(0.0, 0.0); // unused by the Lambda step z0 = sample; } // First pass: just find the escape iteration (if any). var zp = vec2(0.0, 0.0); var z = z0; var n: u32 = 0u; var escaped = false; loop { if dot(z, z) > u.bailout_sq { escaped = true; break; } if n >= u.b_cap { break; } let next = advance(z, zp, c); zp = z; z = next; n = n + 1u; } if !escaped || n == 0u { return; } // Second pass: replay the same orbit, splatting each visited point. // z0 itself is not splat: it's the same fixed point (0,0), or the sample // itself for Lambda, for every orbit — plotting it would just spike the // origin instead of showing the orbit's actual shape. zp = vec2(0.0, 0.0); z = z0; for (var i: u32 = 0u; i < n; i = i + 1u) { let next = advance(z, zp, c); zp = z; z = next; splat(z, n); } } // --- Tonemap: histogram counts -> colour, drawn as a fullscreen triangle. --- @vertex fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { var verts = array, 3>( vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0), ); return vec4(verts[idx], 0.0, 1.0); } @fragment fn fs_tonemap(@builtin(position) pos: vec4) -> @location(0) vec4 { let x = i32(pos.x); let y = i32(pos.y); if x < 0 || y < 0 || x >= i32(u.width) || y >= i32(u.height) { return vec4(0.0, 0.0, 0.0, 1.0); } let idx = y * i32(u.width) + x; let plane = i32(u.width) * i32(u.height); let r = f32(tm_histogram[idx]); let g = f32(tm_histogram[idx + plane]); let b = f32(tm_histogram[idx + 2 * plane]); // Normalize by the *average* density (total samples / pixel count) rather // than total samples alone, so the scale stays sane across widget sizes // and sample-dispatch rates. Buddhabrot density is extremely peaked (the // brightest pixels run tens of times the average), so the compressive // exponential tonemap only needs a small fraction of the average to reach // full brightness at those peaks; 0.05 is a hand-tuned starting point, // the exposure slider covers the rest. let avg_density = max(u.total_samples / f32(u.width * u.height), 1.0e-6); let scale = u.exposure * 0.05 / avg_density; // Per-cap brightness, each already compressed to [0,1]. Nested caps mean // r <= g <= b pointwise (every orbit counted in a smaller cap is also // counted in every larger one), so fb alone is the full escaping-orbit // density and fr picks out just the common, fast-escaping ones. let fr = 1.0 - exp(-r * scale); let fg = 1.0 - exp(-g * scale); let fb = 1.0 - exp(-b * scale); var col: vec3; if u.palette == PALETTE_YELLOW { // fr is *not* a good stand-alone brightness signal: with c sampled // uniformly over the whole viewport, nearly every sample outside the // set escapes within a handful of iterations and splats a couple of // points near itself, so fr is a near-uniform wash across the entire // image (not concentrated near the boundary the way fb is) — adding // it directly (tried first, both raw and gamma-lifted) drags that // wash up to full brightness and floods the background with solid // colour. Instead use it as a *multiplicative* warm (yellow) tint on // top of fb's brightness, so it only shows up where fb is already // bright (i.e. real near-boundary density) and stays near-zero across // the background (fb ≈ 0 there, so warmth * fb ≈ 0 regardless of fr). col = vec3( fb + fb * fr * 1.3, fb + fb * fr * 0.6, fb, ); } else if u.palette == PALETTE_GRAYSCALE { // fb is the full escaping-orbit density (the cumulative superset); // reuse it directly as a single luminance channel. col = vec3(fb, fb, fb); } else { col = vec3(fr, fg, fb); // classic: raw per-cap R/G/B } return vec4(clamp(col, vec3(0.0), vec3(1.0)), 1.0); }