feat: add buddhabrot fractal

This commit is contained in:
2026-09-16 22:04:21 +02:00
parent afcd3c74da
commit 7c347a5abf
6 changed files with 853 additions and 9 deletions
+163 -7
View File
@@ -5,8 +5,8 @@ use eframe::egui_wgpu;
use eframe::egui_wgpu::wgpu;
use crate::fractal::{
ExportRender, FractalCallback, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState,
Uniforms,
BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback,
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms,
};
#[cfg(target_arch = "wasm32")]
use crate::fractal::{compute_reference, compute_set_reference};
@@ -29,6 +29,8 @@ const INTERACT_DOWNSCALE: u32 = 2;
const INTERACT_SETTLE: f64 = 0.12;
/// Palette names; index maps to `palette_id` in the shader.
const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"];
/// Buddhabrot tonemap style names; index maps to `BuddhabrotUniforms::palette`.
const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"];
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FractalMode {
@@ -265,6 +267,23 @@ pub struct FractalApp {
/// Distance-estimation shading: darkens toward the set boundary using the
/// orbit derivative, giving crisp filaments at deep zoom instead of speckle.
de_coloring: bool,
/// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of
/// the ordinary escape-time set. Plain f32 view — no deep zoom, no
/// perturbation/reference-orbit machinery (see `fractal::buddhabrot`).
buddhabrot: bool,
/// Nested escape-iteration caps for the R/G/B histogram channels
/// (Nebulabrot coloring); kept ordered r <= g <= b by the UI.
buddha_r_cap: u32,
buddha_g_cap: u32,
buddha_b_cap: u32,
/// Tonemap brightness multiplier.
buddha_exposure: f32,
/// Tonemap colour style (index into `BUDDHA_PALETTE_NAMES`).
buddha_palette: u32,
/// Keep dispatching new sample batches every frame (progressive
/// accumulation). Turning it off freezes the current histogram.
buddha_accumulate: bool,
/// Whether the controls side panel is expanded. Collapsible so the fractal
/// can take (nearly) the whole screen — important on a phone.
controls_open: bool,
@@ -352,11 +371,13 @@ impl FractalApp {
.expect("eframe must run with the wgpu backend");
let renderer = FractalRenderer::new(&render_state.device, render_state.target_format);
render_state
.renderer
.write()
.callback_resources
.insert(renderer);
let buddhabrot_renderer =
BuddhabrotRenderer::new(&render_state.device, render_state.target_format);
{
let mut guard = render_state.renderer.write();
guard.callback_resources.insert(renderer);
guard.callback_resources.insert(buddhabrot_renderer);
}
let view = ViewState::default();
let ref_center_re = view.center_re.clone();
@@ -382,6 +403,13 @@ impl FractalApp {
palette: 0,
antialias: false,
de_coloring: false,
buddhabrot: false,
buddha_r_cap: 50,
buddha_g_cap: 500,
buddha_b_cap: 2000,
buddha_exposure: 1.0,
buddha_palette: 0,
buddha_accumulate: true,
controls_open: true,
fullscreen: false,
anim: AnimState::default(),
@@ -460,6 +488,14 @@ impl FractalApp {
if std::env::var("MANDEL_DE").is_ok() {
app.de_coloring = true;
}
if std::env::var("MANDEL_BUDDHABROT").is_ok() {
app.buddhabrot = true;
}
if let Ok(p) = std::env::var("MANDEL_BUDDHA_PALETTE")
&& let Ok(p) = p.trim().parse::<u32>()
{
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
}
if std::env::var("MANDEL_EXPORT").is_ok() {
app.export_requested = true;
}
@@ -777,6 +813,37 @@ impl FractalApp {
}
}
/// Buddhabrot pass uniforms. Unlike `make_uniforms`, the view center is
/// collapsed straight to f32 (no arbitrary-precision reference orbit) —
/// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`).
fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms {
let center = [
self.view.center_re.to_f64().value() as f32,
self.view.center_im.to_f64().value() as f32,
];
BuddhabrotUniforms {
center,
half_height: self.view.half_height as f32,
aspect: aspect as f32,
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
bailout_sq: BAILOUT_SQ,
kind: self.kind as u32,
power: self.power,
r_cap: self.buddha_r_cap,
g_cap: self.buddha_g_cap,
b_cap: self.buddha_b_cap,
seed: 0, // set by the callback's own dispatch counter
samples_this_dispatch: 0, // set by the callback
exposure: self.buddha_exposure,
width: 0, // set by the callback from size_px
height: 0, // set by the callback from size_px
total_samples: 0.0, // tracked by the renderer across frames
palette: self.buddha_palette,
_pad: [0; 3],
}
}
/// Render the current view to a PNG at `export_scale` × the on-screen size,
/// then save it (native: file in cwd; web: browser download). Runs off the
/// UI thread so a progress bar can animate; progress lands in `self.export`.
@@ -784,6 +851,10 @@ impl FractalApp {
if self.export.is_some() {
return; // one export at a time
}
if self.buddhabrot {
self.status = Some("PNG export isn't available in Buddhabrot mode yet".into());
return;
}
let Some(rs) = frame.wgpu_render_state() else {
self.status = Some("export unavailable (no wgpu backend)".into());
return;
@@ -1195,6 +1266,24 @@ impl FractalApp {
self.view = Self::default_view_for(self.mode, self.kind);
}
ui.checkbox(&mut self.buddhabrot, "Buddhabrot")
.on_hover_text(
"Monte-Carlo density of escaping orbits instead of the ordinary \
escape-time set. Plain f32 view (no deep zoom); the image \
progressively sharpens while the view stays still.",
);
if self.buddhabrot {
self.buddhabrot_ui(ui);
ui.separator();
if ui.button("Reset view").clicked() {
self.view = Self::default_view_for(self.mode, self.kind);
}
ui.add_space(8.0);
ui.small("Drag to pan · scroll to zoom toward the cursor");
return;
}
ui.horizontal(|ui| {
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
@@ -1484,6 +1573,48 @@ impl FractalApp {
ui.small("Drag to pan · scroll to zoom toward the cursor");
}
/// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B
/// coloring), exposure, and the progressive-accumulation toggle.
fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) {
ui.separator();
ui.add(
egui::Slider::new(&mut self.buddha_r_cap, 5..=5_000)
.text("red cap")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.buddha_g_cap, 5..=20_000)
.text("green cap")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.buddha_b_cap, 5..=50_000)
.text("blue cap")
.logarithmic(true),
);
ui.add(
egui::Slider::new(&mut self.buddha_exposure, 0.02..=50.0)
.text("exposure")
.logarithmic(true),
);
egui::ComboBox::from_label("colors")
.selected_text(BUDDHA_PALETTE_NAMES[self.buddha_palette as usize])
.show_ui(ui, |ui| {
for (i, name) in BUDDHA_PALETTE_NAMES.iter().enumerate() {
ui.selectable_value(&mut self.buddha_palette, i as u32, *name);
}
});
ui.checkbox(&mut self.buddha_accumulate, "Keep sampling")
.on_hover_text("Dispatch a fresh batch of random samples every frame.");
if self.view.magnification() > 1.0e5 {
ui.colored_label(
egui::Color32::LIGHT_YELLOW,
"deep zoom isn't supported here (f32 precision only)",
);
}
ui.small("PNG export isn't available in Buddhabrot mode yet.");
}
fn fractal_ui(&mut self, ui: &mut egui::Ui) {
let size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
@@ -1544,6 +1675,31 @@ impl FractalApp {
ui.ctx().request_repaint();
}
if self.buddhabrot {
// No reference orbit / perturbation machinery: iterate directly in
// f32 from the live view. Progressive accumulation means this
// needs its own continuous repaint, separate from the escape-time
// interaction-driven one above.
let ppp = ui.ctx().pixels_per_point();
let size_px = [
((rect.width() * ppp).round() as u32).max(1),
((rect.height() * ppp).round() as u32).max(1),
];
let uniforms = self.make_buddhabrot_uniforms(aspect);
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
BuddhabrotCallback {
uniforms,
accumulate: self.buddha_accumulate,
size_px,
},
));
if self.buddha_accumulate {
ui.ctx().request_repaint();
}
return;
}
// Keep the iteration count matched to the zoom depth while auto is on.
if self.auto_iterations {
self.max_iterations = self.auto_iteration_count();
+379
View File
@@ -0,0 +1,379 @@
//! Buddhabrot / Nebulabrot rendering: a Monte-Carlo orbit-density histogram,
//! accumulated progressively across frames by a compute pass and tone-mapped
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
//! this is a separate pipeline from the escape-time perturbation renderer.
use eframe::egui_wgpu::{self, wgpu};
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
/// interactive on a modest GPU even when most samples run the full `b_cap`
/// (e.g. the view sits entirely inside the set, so nothing escapes).
const SAMPLES_PER_DISPATCH: u32 = 150_000;
const WORKGROUP_SIZE: u32 = 64;
/// GPU-side parameters for both the accumulate (compute) and tonemap
/// (fragment) passes. Layout must match `Uniforms` in `buddhabrot.wgsl`.
#[repr(C)]
#[derive(Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BuddhabrotUniforms {
pub center: [f32; 2],
pub half_height: f32,
pub aspect: f32,
pub phoenix_p: [f32; 2],
pub lambda_l: [f32; 2],
pub bailout_sq: f32,
/// Iteration formula (`FractalKind::shader_id`); `KIND_LAMBDA` samples z0
/// instead of c (see the shader's doc comment).
pub kind: u32,
/// Exponent for the Multibrot kind.
pub power: u32,
/// Nested escape-iteration caps (r_cap <= g_cap <= b_cap) that bucket an
/// orbit's points into the R/G/B histogram planes.
pub r_cap: u32,
pub g_cap: u32,
pub b_cap: u32,
/// RNG nonce, bumped every dispatch so each frame samples fresh points.
pub seed: u32,
pub samples_this_dispatch: u32,
/// Tonemap brightness multiplier (user-controlled).
pub exposure: f32,
pub width: u32,
pub height: u32,
/// Running total of samples accumulated into the current histogram
/// (across all dispatches since the last reset); normalizes brightness.
pub total_samples: f32,
/// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
/// — excluded from `ContentKey` so changing it doesn't reset accumulation.
pub palette: u32,
pub _pad: [u32; 3],
}
/// The subset of `BuddhabrotUniforms` that determines the *content* of the
/// histogram (as opposed to `exposure`, a display-only rescale). A change in
/// any of these invalidates the accumulated histogram.
#[derive(Copy, Clone, PartialEq)]
struct ContentKey {
center: [f32; 2],
half_height: f32,
aspect: f32,
phoenix_p: [f32; 2],
lambda_l: [f32; 2],
bailout_sq: f32,
kind: u32,
power: u32,
r_cap: u32,
g_cap: u32,
b_cap: u32,
}
impl From<&BuddhabrotUniforms> for ContentKey {
fn from(u: &BuddhabrotUniforms) -> Self {
Self {
center: u.center,
half_height: u.half_height,
aspect: u.aspect,
phoenix_p: u.phoenix_p,
lambda_l: u.lambda_l,
bailout_sq: u.bailout_sq,
kind: u.kind,
power: u.power,
r_cap: u.r_cap,
g_cap: u.g_cap,
b_cap: u.b_cap,
}
}
}
/// The histogram buffer and its two bind groups, sized to the widget.
struct Histogram {
buffer: wgpu::Buffer,
compute_bind_group: wgpu::BindGroup,
tonemap_bind_group: wgpu::BindGroup,
width: u32,
height: u32,
}
pub struct BuddhabrotRenderer {
compute_pipeline: wgpu::ComputePipeline,
compute_bind_group_layout: wgpu::BindGroupLayout,
tonemap_pipeline: wgpu::RenderPipeline,
tonemap_bind_group_layout: wgpu::BindGroupLayout,
uniform_buffer: wgpu::Buffer,
histogram: Option<Histogram>,
/// What the current histogram's content was last accumulated for; a
/// mismatch clears the histogram and restarts accumulation.
last_content: Option<ContentKey>,
/// Running sample count since the last reset (mirrors what was written
/// into `total_samples`, since the callback doesn't own that state).
total_samples: f32,
seed: u32,
}
impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/buddhabrot.wgsl").into()),
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot uniforms"),
size: std::mem::size_of::<BuddhabrotUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let compute_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("buddhabrot compute bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let compute_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("buddhabrot compute pipeline layout"),
bind_group_layouts: &[Some(&compute_bind_group_layout)],
immediate_size: 0,
});
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("buddhabrot compute pipeline"),
layout: Some(&compute_pipeline_layout),
module: &shader,
entry_point: Some("cs_main"),
compilation_options: Default::default(),
cache: None,
});
let tonemap_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("buddhabrot tonemap bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let tonemap_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("buddhabrot tonemap pipeline layout"),
bind_group_layouts: &[Some(&tonemap_bind_group_layout)],
immediate_size: 0,
});
let tonemap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("buddhabrot tonemap pipeline"),
layout: Some(&tonemap_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_tonemap"),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Self {
compute_pipeline,
compute_bind_group_layout,
tonemap_pipeline,
tonemap_bind_group_layout,
uniform_buffer,
histogram: None,
last_content: None,
total_samples: 0.0,
seed: 0,
}
}
/// Ensure the histogram buffer exists at `width`×`height`, recreating (and
/// resetting accumulation) on a size change.
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
if let Some(h) = &self.histogram
&& h.width == width
&& h.height == height
{
return;
}
let plane = (width as u64) * (height as u64);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot histogram"),
size: plane * 3 * std::mem::size_of::<u32>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("buddhabrot compute bind group"),
layout: &self.compute_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: buffer.as_entire_binding(),
},
],
});
let tonemap_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("buddhabrot tonemap bind group"),
layout: &self.tonemap_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: buffer.as_entire_binding(),
},
],
});
self.histogram = Some(Histogram {
buffer,
compute_bind_group,
tonemap_bind_group,
width,
height,
});
// New (zero-initialized) buffer: accumulation starts fresh.
self.last_content = None;
self.total_samples = 0.0;
}
}
/// Per-frame paint callback. `accumulate` controls whether a new batch of
/// samples is dispatched this frame (a content change always forces one
/// dispatch regardless, so a parameter/view change is never left blank).
pub struct BuddhabrotCallback {
pub uniforms: BuddhabrotUniforms,
pub accumulate: bool,
/// Widget size in physical pixels — the histogram resolution.
pub size_px: [u32; 2],
}
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
egui_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(renderer) = resources.get_mut::<BuddhabrotRenderer>() else {
return Vec::new();
};
let width = self.size_px[0].max(1);
let height = self.size_px[1].max(1);
renderer.ensure_histogram(device, width, height);
let content = ContentKey::from(&self.uniforms);
let content_changed = renderer.last_content != Some(content);
let should_dispatch = content_changed || self.accumulate;
if let Some(histogram) = &renderer.histogram {
if content_changed {
egui_encoder.clear_buffer(&histogram.buffer, 0, None);
renderer.total_samples = 0.0;
renderer.last_content = Some(content);
}
let mut uniforms = self.uniforms;
uniforms.width = width;
uniforms.height = height;
if should_dispatch {
renderer.seed = renderer.seed.wrapping_add(1);
renderer.total_samples += SAMPLES_PER_DISPATCH as f32;
uniforms.seed = renderer.seed;
uniforms.samples_this_dispatch = SAMPLES_PER_DISPATCH;
} else {
uniforms.samples_this_dispatch = 0;
}
uniforms.total_samples = renderer.total_samples;
queue.write_buffer(&renderer.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
if should_dispatch {
let mut pass = egui_encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("buddhabrot accumulate pass"),
timestamp_writes: None,
});
pass.set_pipeline(&renderer.compute_pipeline);
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
pass.dispatch_workgroups(workgroups, 1, 1);
}
}
Vec::new()
}
fn paint(
&self,
_info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
if let Some(renderer) = resources.get::<BuddhabrotRenderer>()
&& let Some(histogram) = &renderer.histogram
{
render_pass.set_pipeline(&renderer.tonemap_pipeline);
render_pass.set_bind_group(0, &histogram.tonemap_bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
}
}
+2
View File
@@ -1,10 +1,12 @@
//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the
//! egui paint callback.
pub mod buddhabrot;
pub mod reference;
pub mod renderer;
pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use reference::{FractalKind, compute_reference, compute_set_reference};
pub use renderer::{
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
+299
View File
@@ -0,0 +1,299 @@
// 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<f32>,
half_height: f32,
aspect: f32,
phoenix_p: vec2<f32>,
lambda_l: vec2<f32>,
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<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,
};
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<uniform> u: Uniforms;
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>>;
// Tonemap pass: read-only plain view of the same buffer.
@group(0) @binding(2) var<storage, read> tm_histogram: array<u32>;
// --- 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<f32>, b: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
var r = vec2<f32>(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<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
if u.kind == KIND_BURNING_SHIP {
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
} else if u.kind == KIND_TRICORN {
return vec2<f32>(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<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
} else if u.kind == KIND_PERPENDICULAR {
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
} else if u.kind == KIND_BUFFALO {
return vec2<f32>(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<f32>(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<f32>(1.0 - z.x, -z.y)));
}
return vec2<f32>(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<f32>) -> 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<f32>, 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<u32>) {
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<f32>(
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<f32>(0.0, 0.0);
if u.kind == KIND_LAMBDA {
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
z0 = sample;
}
// First pass: just find the escape iteration (if any).
var zp = vec2<f32>(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<f32>(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<f32> {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return vec4<f32>(verts[idx], 0.0, 1.0);
}
@fragment
fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
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<f32>(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<f32>;
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<f32>(
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<f32>(fb, fb, fb);
} else {
col = vec3<f32>(fr, fg, fb); // classic: raw per-cap R/G/B
}
return vec4<f32>(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
}
+2 -2
View File
@@ -82,7 +82,7 @@ impl ViewState {
let bits = self.precision_bits();
// Grab-and-drag: moving the mouse right shows content to the left.
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits); // y-down -> imag-up
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
}
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
@@ -97,7 +97,7 @@ impl ViewState {
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
let k = cpp * (1.0 - factor);
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits); // y flip
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
self.half_height *= factor;
}