diff --git a/CLAUDE.md b/CLAUDE.md index 28317c4..e4161a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,13 @@ pixel is a handful of `f32` complex multiplies. passes near 0 at a deep minibrot. `has_scaled()` then forces the deep pipeline, the only one that reads `exps`. Requests are made with 1.5× iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations - creeping up during a zoom doesn't recompute the orbit every frame. + creeping up during a zoom doesn't recompute the orbit every frame. The + interactive reference buffers start at 2^17 points and grow on demand + (`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the + 128 MiB WebGPU default binding size). That is also the hard iteration + ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted + reference as escaped. The UI slider only goes to 100k when dragged; typed + values can go higher. - `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has no `#include`, so each is compiled by concatenating plain-text fragments with `concat!`/`include_str!` at the `create_shader_module` call site (see diff --git a/src/app.rs b/src/app.rs index e66114d..84c001b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -43,6 +43,10 @@ fn bailout_sq(kind: FractalKind, power: u32) -> f32 { } /// Cap on exported image dimension (px), to stay within GPU texture limits. const MAX_EXPORT_DIM: u32 = 8192 * 16; +/// Hard ceiling on the iteration count: the longest reference orbit the GPU +/// buffer holds (past it, the shader would read pixels as escaped). +const MAX_ITERATIONS: u32 = MAX_REF_POINTS as u32 - 1; + /// While the user is actively panning/zooming, the fractal is rendered into a /// cache texture downscaled by this factor per axis (and with AA forced off), so /// each interacting frame is cheap; the linear blit upsamples it to the widget. @@ -827,7 +831,7 @@ impl FractalApp { } if let Some(iterations) = cli.iterations { self.auto_iterations = false; - self.max_iterations = iterations; + self.max_iterations = iterations.clamp(32, MAX_ITERATIONS); } if let Some(half_height) = cli.half_height { self.apply_half_height_spec(&half_height); @@ -858,7 +862,7 @@ impl FractalApp { self.view = view; if let Some(v) = iterations { self.auto_iterations = false; - self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = v.clamp(32, MAX_ITERATIONS); } true } @@ -912,7 +916,7 @@ impl FractalApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn set_max_iterations(&mut self, i: u32) { self.auto_iterations = false; - self.max_iterations = i; + self.max_iterations = i.clamp(32, MAX_ITERATIONS); } /// Per-kind constants `(julia_c, phoenix_p, lambda_l, complex_power)`. @@ -1004,7 +1008,7 @@ impl FractalApp { self.morph = None; // Presets carry a hand-tuned count; don't let the auto-scaler clobber it. self.auto_iterations = false; - self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = iterations.clamp(32, MAX_ITERATIONS); } } @@ -1014,7 +1018,7 @@ impl FractalApp { fn auto_iteration_count(&self) -> u32 { let decades = self.view.magnification_log10().max(0.0); let iters = 400.0 + 900.0 * decades; - (iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1) + (iters.round() as u32).clamp(200, MAX_ITERATIONS) } /// Snapshot the current view as a shareable state. @@ -1061,7 +1065,7 @@ impl FractalApp { // The link carries an explicit iteration count; honor it rather than // letting the auto-scaler immediately overwrite it. self.auto_iterations = false; - self.max_iterations = s.iterations.clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = s.iterations.clamp(32, MAX_ITERATIONS); let bits = precision_for(s.half_height); if let (Some(re), Some(im)) = ( big_from_decimal_str(&s.center_re, bits), @@ -1157,7 +1161,7 @@ impl FractalApp { // while auto-iterations creep up during a zoom (the shader clamps // to `max_iterations`); only recompute once it's too short, or // far longer than needed. - || self.max_iterations > key.iter + || self.max_iterations.min(MAX_ITERATIONS) > key.iter || self.max_iterations.saturating_mul(4) < key.iter || key.kind != self.kind || key.power != self.power @@ -2209,7 +2213,7 @@ impl FractalApp { for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] { if ui.small_button(name).clicked() { self.julia_c = (re, im); - self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = iterations.clamp(32, MAX_ITERATIONS); if let Some(phoenix) = phoenix { self.phoenix_p = phoenix; @@ -2266,11 +2270,15 @@ impl FractalApp { if self.auto_iterations { ui.label(format!("iterations: {} (auto)", self.max_iterations)); } else { + // Dragging stays within the slider's range, but a typed value + // isn't clamped to it: only to what the reference buffer can hold. ui.add( egui::Slider::new(&mut self.max_iterations, 32..=100_000) .text("iterations") - .logarithmic(true), + .logarithmic(true) + .clamping(egui::SliderClamping::Never), ); + self.max_iterations = self.max_iterations.clamp(32, MAX_ITERATIONS); } ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") .on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); @@ -2812,13 +2820,13 @@ impl FractalApp { } if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) { self.auto_iterations = false; - self.max_iterations = ((self.max_iterations as f64 * 1.25).round() as u32) - .clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = + ((self.max_iterations as f64 * 1.25).round() as u32).clamp(32, MAX_ITERATIONS); } if ui.input(|i| i.key_pressed(egui::Key::Minus)) { self.auto_iterations = false; - self.max_iterations = ((self.max_iterations as f64 / 1.25).round() as u32) - .clamp(32, MAX_REF_POINTS as u32 - 1); + self.max_iterations = + ((self.max_iterations as f64 / 1.25).round() as u32).clamp(32, MAX_ITERATIONS); } } diff --git a/src/fractal/reference.rs b/src/fractal/reference.rs index cfb4357..0bbe4fd 100644 --- a/src/fractal/reference.rs +++ b/src/fractal/reference.rs @@ -60,6 +60,9 @@ pub struct RefOrbit { impl RefOrbit { fn with_capacity(n: usize) -> Self { + // Most orbits escape long before `max_iter`; don't reserve hundreds of + // MB up front for a multi-million iteration request. + let n = n.min(1 << 17); Self { points: Vec::with_capacity(n), exps: Vec::with_capacity(n), diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 6896ed3..59fd37b 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -19,9 +19,15 @@ use super::kind::FractalKind; use super::reference::RefOrbit; use crate::lights::{GpuLight, Light, MAX_LIGHT_COUNT, gpu_lights}; -/// Maximum reference-orbit length (points) the storage buffer can hold. Also -/// bounds the iteration count. 128k points * 8 bytes = 1 MiB. -pub const MAX_REF_POINTS: usize = 1 << 17; +/// Maximum reference-orbit length (points), and so the hard ceiling on the +/// iteration count (the shader treats an exhausted reference as escaped). +/// 16M points * 8 bytes = 128 MiB, WebGPU's default +/// `max_storage_buffer_binding_size`, so every device can bind it. +pub const MAX_REF_POINTS: usize = 1 << 24; + +/// Initial capacity (points) of the interactive reference buffers; they grow +/// (by powers of two, up to `MAX_REF_POINTS`) when a longer orbit arrives. +const INITIAL_REF_POINTS: usize = 1 << 17; /// Format of the intermediate iteration-data texture holding, per pixel, /// `(ci, DE factor, interior fraction)`. 32-bit float keeps the smooth iteration @@ -233,7 +239,15 @@ impl Lipschitz { immediate_size: 0, }); let pipeline = |label, entry| { - fullscreen_pipeline(device, label, &module, &pipeline_layout, entry, DATA_FORMAT, &[]) + fullscreen_pipeline( + device, + label, + &module, + &pipeline_layout, + entry, + DATA_FORMAT, + &[], + ) }; let mut contents = vec![0u8; (LIPSCHITZ_STRIDE * LIPSCHITZ_SLOTS) as usize]; for k in 0..LIPSCHITZ_SLOTS { @@ -380,7 +394,13 @@ impl Envelope { pass(encoder, &self.views[0], &lp.seed, &self.inputs[1], 0); // Pass i reads seeds i % 2 and writes the other. for (i, &slot) in self.slots.iter().enumerate() { - pass(encoder, &self.views[(i + 1) % 2], &lp.jump, &self.inputs[i % 2], slot); + pass( + encoder, + &self.views[(i + 1) % 2], + &lp.jump, + &self.inputs[i % 2], + slot, + ); } let last = self.slots.len() % 2; pass(encoder, &self.views[2], &lp.compose, &self.inputs[last], 0); @@ -531,6 +551,8 @@ pub struct FractalRenderer { uniform_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer, ref_exp_buffer: wgpu::Buffer, + /// Points `ref_buffer` / `ref_exp_buffer` can hold (see `ensure_ref_capacity`). + ref_capacity: usize, lights_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, target_format: wgpu::TextureFormat, @@ -562,6 +584,37 @@ pub struct FractalRenderer { } impl FractalRenderer { + /// Grow the reference buffers (and rebuild the bind group pointing at + /// them) so they hold at least `needed` points. The caller re-uploads the + /// orbit right after, so the old contents aren't copied over. + fn ensure_ref_capacity(&mut self, device: &wgpu::Device, needed: usize) { + if needed <= self.ref_capacity { + return; + } + let capacity = needed.next_power_of_two().min(MAX_REF_POINTS); + self.ref_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("reference orbit"), + size: (capacity * std::mem::size_of::<[f32; 2]>()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("reference orbit exponents"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.bind_group = iterate_bind_group( + device, + &self.bind_group_layout, + &self.uniform_buffer, + &self.ref_buffer, + &self.lights_buffer, + &self.ref_exp_buffer, + ); + self.ref_capacity = capacity; + } + pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("mandelbrot"), @@ -584,7 +637,7 @@ impl FractalRenderer { let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("reference orbit"), - size: (MAX_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64, + size: (INITIAL_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -593,7 +646,7 @@ impl FractalRenderer { // read by deep pipelines. let ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("reference orbit exponents"), - size: (MAX_REF_POINTS * std::mem::size_of::()) as u64, + size: (INITIAL_REF_POINTS * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -654,28 +707,14 @@ impl FractalRenderer { ], }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("fractal bind group"), - layout: &bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: ref_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: lights_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: ref_exp_buffer.as_entire_binding(), - }, - ], - }); + let bind_group = iterate_bind_group( + device, + &bind_group_layout, + &uniform_buffer, + &ref_buffer, + &lights_buffer, + &ref_exp_buffer, + ); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("fractal pipeline layout"), @@ -872,6 +911,7 @@ impl FractalRenderer { uniform_buffer, ref_buffer, ref_exp_buffer, + ref_capacity: INITIAL_REF_POINTS, lights_buffer, bind_group, target_format, @@ -1648,6 +1688,40 @@ pub fn encode_png_with_progress( /// Colourise pass input: the uniforms, the data texture `data` to colour and /// the lights. +/// Group 0 of the iterate/refine pipelines: uniforms, reference orbit, +/// lights, reference exponents. +fn iterate_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + uniform_buffer: &wgpu::Buffer, + ref_buffer: &wgpu::Buffer, + lights_buffer: &wgpu::Buffer, + ref_exp_buffer: &wgpu::Buffer, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fractal bind group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: ref_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: lights_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: ref_exp_buffer.as_entire_binding(), + }, + ], + }) +} + fn colorize_bind_group( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, @@ -1779,6 +1853,7 @@ impl egui_wgpu::CallbackTrait for FractalCallback { && !self.reference.is_empty() { let count = self.reference.len().min(MAX_REF_POINTS); + renderer.ensure_ref_capacity(device, count); queue.write_buffer( &renderer.ref_buffer, 0,