diff --git a/src/app.rs b/src/app.rs index dfbbbca..1f79111 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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::() + { + 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(); diff --git a/src/fractal/buddhabrot.rs b/src/fractal/buddhabrot.rs new file mode 100644 index 0000000..0ef3589 --- /dev/null +++ b/src/fractal/buddhabrot.rs @@ -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, + /// What the current histogram's content was last accumulated for; a + /// mismatch clears the histogram and restarts accumulation. + last_content: Option, + /// 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::() 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::() 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 { + let Some(renderer) = resources.get_mut::() 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::() + && 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); + } + } +} diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index 709f6e5..fc3856d 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -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, diff --git a/src/shaders/buddhabrot.wgsl b/src/shaders/buddhabrot.wgsl new file mode 100644 index 0000000..58eb08c --- /dev/null +++ b/src/shaders/buddhabrot.wgsl @@ -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, + 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); +} diff --git a/src/view.rs b/src/view.rs index dd9f763..97867db 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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; } diff --git a/tests/shader_valid.rs b/tests/shader_valid.rs index 063db2a..5f7104d 100644 --- a/tests/shader_valid.rs +++ b/tests/shader_valid.rs @@ -34,3 +34,11 @@ fn colorize_shader_is_valid() { fn blit_shader_is_valid() { validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl")); } + +#[test] +fn buddhabrot_shader_is_valid() { + validate( + "buddhabrot.wgsl", + include_str!("../src/shaders/buddhabrot.wgsl"), + ); +}