feat: add buddhabrot fractal
This commit is contained in:
+163
-7
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user