From 09d7976ebd9474e3969a24d8196ce96faf40b321 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Mon, 14 Sep 2026 20:56:09 +0200 Subject: [PATCH] First commit --- .gitignore | 3 + Cargo.toml | 37 ++ README.md | 98 +++++ build-web.sh | 25 ++ index.html | 53 +++ rustfmt.toml | 1 + src/app.rs | 693 ++++++++++++++++++++++++++++++++++++ src/fractal/mod.rs | 10 + src/fractal/reference.rs | 136 +++++++ src/fractal/renderer.rs | 353 ++++++++++++++++++ src/fractal/share.rs | 94 +++++ src/main.rs | 80 +++++ src/shaders/mandelbrot.wgsl | 146 ++++++++ src/view.rs | 156 ++++++++ src/worker.rs | 104 ++++++ 15 files changed, 1989 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 build-web.sh create mode 100644 index.html create mode 100644 rustfmt.toml create mode 100644 src/app.rs create mode 100644 src/fractal/mod.rs create mode 100644 src/fractal/reference.rs create mode 100644 src/fractal/renderer.rs create mode 100644 src/fractal/share.rs create mode 100644 src/main.rs create mode 100644 src/shaders/mandelbrot.wgsl create mode 100644 src/view.rs create mode 100644 src/worker.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19c9289 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +dist diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..78bf296 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "mandelbrot" +version = "0.1.0" +edition = "2024" + +[dependencies] +bytemuck = { version = "1.25.2", features = ["derive"] } +dashu-float = "0.6.0" +eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] } +egui = "0.36.2" +futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] } +log = "0.4.34" +png = "0.18.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +env_logger = "0.11.11" +pollster = "1.0.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1.7" +console_log = "1.1.0" +js-sys = "0.3.105" +wasm-bindgen = "0.2.128" +wasm-bindgen-futures = "0.4.78" +web-sys = { version = "0.3.105", features = ["Window", "Location", "Url", "UrlSearchParams", "Document", "HtmlCanvasElement", "Blob", "BlobPropertyBag", "HtmlAnchorElement", "Element", "Navigator", "History"] } + +# Release: optimize hard (fractal math is hot). +[profile.release] +opt-level = 3 + +# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu, +# egui) so the explorer is actually interactive during development. +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d43d712 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Fractal Explorer + +A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets — +built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of +plain `f64` using **perturbation theory**: one high-precision reference orbit is +computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is +rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid +glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU). + +The `f32` GPU tier reaches roughly **10³⁰× magnification** with sharp detail. + +## Features + +- Mandelbrot and Julia sets (with Julia-constant editor + presets) +- Smooth continuous coloring, `sqrt`-compressed to stay clean at deep zoom +- Several palettes +- Drag to pan, scroll to zoom toward the cursor +- Arbitrary-precision center; per-view reference orbit computed on a background + thread (native) so the UI stays responsive +- Reference reuse: small pans/zooms reuse the current reference (no recompute) +- PNG export at up to 4× the on-screen resolution +- Shareable/bookmarkable deep-zoom links (`#…` URL fragment, full precision) + +## Build & run — native + +```sh +cargo run --release +``` + +## Build & run — web (WebGPU) + +Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the +`wasm-bindgen` crate version, currently 0.2.x): + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.128 # once + +./build-web.sh # outputs ./dist (index.html, .js, .wasm) +python3 -m http.server -d dist 8080 # serve over http +``` + +Then open in a **WebGPU-capable browser** (recent +Chrome/Edge, Firefox, or Safari 26+). WebGPU needs a secure context; `localhost` +qualifies. Deploy by serving the `dist/` directory as static files. + +> Note: `trunk` also works in principle, but on some systems its `libdeflate-sys` +> C dependency fails to compile; `build-web.sh` uses `wasm-bindgen-cli` directly +> to avoid that. + +## Controls + +- **Drag** — pan +- **Scroll** — zoom toward the cursor +- **iterations** — raise this as you zoom deeper (deep boundary pixels need many + more iterations; too few shows solid black) +- **Copy link** — copies a URL that restores the exact view +- **Export PNG** — saves `fractal-.png` (native, in the working dir) + or downloads it (web) + +## How it works + +- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel + scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range). +- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`. +- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc` + with Zhuoran rebasing (`e ← z − Z₀` when the true value drops below the delta), + a `dc_offset` so a reused/slightly-stale reference still maps correctly, and + smooth coloring. +- `src/fractal/renderer.rs` — wgpu pipeline, storage buffer for the orbit, egui + paint callback, and offscreen render-to-PNG. +- `src/worker.rs` — native background thread for the reference orbit (coalesces + bursts of requests). The web build computes it inline. +- `src/fractal/share.rs` — URL-fragment encode/decode. + +### Limits & possible extensions + +The `f32` tier degrades past ~10³⁰×. Natural next steps (scaffolding is in +place): an emulated **double-float** GPU tier (~10⁶⁰×), **floatexp** rescaling +and **BLA** iteration-skipping for near-unlimited depth, and moving the web +reference computation to a Web Worker. + +## Tests + +```sh +cargo test +``` + +Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia) +and share-link round-tripping. + +## Debug/testing env vars (native) + +- `MANDEL_VIEW="re,im,half_height[,iterations]"` — start at a specific view +- `MANDEL_JULIA="cre,cim"` — start in Julia mode with constant `c` +- `MANDEL_SHARE=""` — restore a share fragment +- `MANDEL_EXPORT=1` (+ optional `MANDEL_EXPORT_PATH=out.png`) — export on the + first frame, for scripted captures diff --git a/build-web.sh b/build-web.sh new file mode 100755 index 0000000..a6e1eb5 --- /dev/null +++ b/build-web.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Build the WebAssembly bundle into ./dist using wasm-bindgen. +# Serve it over HTTP (e.g. `python3 -m http.server -d dist 8080`) and open in a +# WebGPU-capable browser (recent Chrome/Edge, Firefox, or Safari 26+). +set -euo pipefail + +export PATH="$HOME/.cargo/bin:$PATH" +OUT="${1:-dist}" + +echo "==> cargo build (wasm32, release)" +cargo build --release --target wasm32-unknown-unknown + +echo "==> wasm-bindgen -> $OUT" +mkdir -p "$OUT" +wasm-bindgen \ + --target web \ + --no-typescript \ + --out-dir "$OUT" \ + --out-name mandelbrot \ + target/wasm32-unknown-unknown/release/mandelbrot.wasm + +cp index.html "$OUT/index.html" + +echo "==> done: $OUT/ (index.html, mandelbrot.js, mandelbrot_bg.wasm)" +echo " serve: python3 -m http.server -d $OUT 8080" diff --git a/index.html b/index.html new file mode 100644 index 0000000..3d01131 --- /dev/null +++ b/index.html @@ -0,0 +1,53 @@ + + + + + + Fractal Explorer + + + + +
Loading…
+ + + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0c85a4f --- /dev/null +++ b/src/app.rs @@ -0,0 +1,693 @@ +use std::sync::Arc; + +use eframe::CreationContext; +use eframe::egui_wgpu; +use eframe::egui_wgpu::wgpu; + +use crate::fractal::{FractalCallback, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms}; +#[cfg(target_arch = "wasm32")] +use crate::fractal::{compute_mandelbrot_reference, compute_reference}; +use crate::view::{ + Big, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, precision_for, +}; + +const BAILOUT_SQ: f32 = 1.0e6; +/// Cap on exported image dimension (px), to stay within GPU texture limits. +const MAX_EXPORT_DIM: u32 = 8192; +/// Palette names; index maps to `palette_id` in the shader. +const PALETTE_NAMES: &[&str] = &["Rainbow", "Amber", "Ember", "Lime", "Grayscale"]; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FractalMode { + Mandelbrot, + Julia, +} + +/// Nice-looking Julia constants offered as presets. +const JULIA_PRESETS: &[(&str, f64, f64)] = &[ + ("dendrite", -0.8, 0.156), + ("rabbit", -0.123, 0.745), + ("spiral", -0.4, 0.6), + ("san marco", -0.75, 0.0), + ("siegel", -0.391, -0.587), +]; + +/// Parameters a reference orbit was (or will be) computed for. Used to decide +/// when the current reference is stale enough to recompute. +struct RequestKey { + center_re: Big, + center_im: Big, + half_height: f64, + julia: bool, + julia_c: (f64, f64), + iter: u32, +} + +/// Top-level egui application. +pub struct FractalApp { + view: ViewState, + mode: FractalMode, + julia_c: (f64, f64), + max_iterations: u32, + color_scale: f32, + color_offset: f32, + palette: u32, + + /// Reference orbit (`Z_n` as f32 pairs) for the current view. + reference: Arc>, + /// Bumped whenever `reference` is replaced, so the GPU re-uploads it. + generation: u64, + /// Center + zoom the current `reference` was computed at (may differ + /// slightly from the live view; the shader compensates via `dc_offset`). + ref_center_re: Big, + ref_center_im: Big, + ref_half_height: f64, + /// Parameters of the most recent reference request (drift baseline / dedupe). + last_request: Option, + + #[cfg(not(target_arch = "wasm32"))] + worker: crate::worker::RefWorker, + /// A reference computation is in flight (native async worker). + pending: bool, + + /// PNG export resolution multiplier over the on-screen size. + export_scale: f32, + /// Last on-screen fractal size in physical pixels (for export sizing). + last_size_px: egui::Vec2, + /// Set when the user requests a PNG export (handled after the panels draw). + export_requested: bool, + /// Short status line (saved path, "link copied", errors). + status: Option, +} + +impl FractalApp { + pub fn new(cc: &CreationContext<'_>) -> Self { + let render_state = cc + .wgpu_render_state + .as_ref() + .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 view = ViewState::default(); + let ref_center_re = view.center_re.clone(); + let ref_center_im = view.center_im.clone(); + let ref_half_height = view.half_height; + + let mut app = Self { + view, + mode: FractalMode::Mandelbrot, + julia_c: (-0.8, 0.156), + max_iterations: 512, + color_scale: 0.15, + color_offset: 0.0, + palette: 0, + reference: Arc::new(Vec::new()), + generation: 0, + ref_center_re, + ref_center_im, + ref_half_height, + last_request: None, + #[cfg(not(target_arch = "wasm32"))] + worker: crate::worker::RefWorker::spawn(), + pending: false, + export_scale: 2.0, + last_size_px: egui::vec2(1280.0, 720.0), + export_requested: false, + status: None, + }; + + // On the web, restore a shared view from the URL fragment (#...). + #[cfg(target_arch = "wasm32")] + if let Some(frag) = web_location_hash() { + if let Some(state) = ShareState::decode(&frag) { + app.apply_share(&state); + } + } + + // Debug/testing hooks. + #[cfg(not(target_arch = "wasm32"))] + { + if let Ok(jc) = std::env::var("MANDEL_JULIA") { + let p: Vec<&str> = jc.split(',').collect(); + if let (Some(Ok(re)), Some(Ok(im))) = ( + p.first().map(|s| s.trim().parse::()), + p.get(1).map(|s| s.trim().parse::()), + ) { + app.mode = FractalMode::Julia; + app.julia_c = (re, im); + app.view = Self::default_view_for(FractalMode::Julia); + } + } + if let Ok(frag) = std::env::var("MANDEL_SHARE") + && let Some(state) = ShareState::decode(&frag) + { + app.apply_share(&state); + } + if let Ok(spec) = std::env::var("MANDEL_VIEW") { + app.apply_view_spec(&spec); + } + if std::env::var("MANDEL_EXPORT").is_ok() { + app.export_requested = true; + } + } + + app + } + + /// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal, + /// parsed at full precision). Used by the native debug env var. + #[allow(dead_code)] + pub fn apply_view_spec(&mut self, spec: &str) -> bool { + let parts: Vec<&str> = spec.split(',').collect(); + if parts.len() < 3 { + return false; + } + let Ok(half_height) = parts[2].trim().parse::() else { + return false; + }; + if !(half_height > 0.0 && half_height.is_finite()) { + return false; + } + let bits = precision_for(half_height); + let (Some(re), Some(im)) = ( + big_from_decimal_str(parts[0], bits), + big_from_decimal_str(parts[1], bits), + ) else { + return false; + }; + self.view = ViewState::with_center(re, im, half_height); + if let Some(it) = parts.get(3) + && let Ok(v) = it.trim().parse::() + { + self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); + } + true + } + + /// Snapshot the current view as a shareable state. + fn share_state(&self) -> ShareState { + let bits = self.view.precision_bits(); + let sig_digits = ((bits as f64) * std::f64::consts::LOG10_2).ceil() as usize + 3; + ShareState { + julia: matches!(self.mode, FractalMode::Julia), + center_re: big_to_decimal_str(&self.view.center_re, sig_digits), + center_im: big_to_decimal_str(&self.view.center_im, sig_digits), + half_height: self.view.half_height, + iterations: self.max_iterations, + julia_c: self.julia_c, + color_scale: self.color_scale, + color_offset: self.color_offset, + } + } + + /// Restore a shared state into this app. + fn apply_share(&mut self, s: &ShareState) { + self.mode = if s.julia { + FractalMode::Julia + } else { + FractalMode::Mandelbrot + }; + self.julia_c = s.julia_c; + self.color_scale = s.color_scale; + self.color_offset = s.color_offset; + self.max_iterations = s.iterations.clamp(32, MAX_REF_POINTS as u32 - 1); + let bits = precision_for(s.half_height); + if let (Some(re), Some(im)) = ( + big_from_decimal_str(&s.center_re, bits), + big_from_decimal_str(&s.center_im, bits), + ) { + self.view = ViewState::with_center(re, im, s.half_height); + } + } + + /// A full shareable URL. On web this is the page URL with a `#fragment`; on + /// native (no page) it is just the fragment for pasting onto a deployment. + fn share_url(&self) -> String { + let fragment = self.share_state().encode(); + #[cfg(target_arch = "wasm32")] + { + if let Some(w) = web_sys::window() { + let loc = w.location(); + let origin = loc.origin().unwrap_or_default(); + let path = loc.pathname().unwrap_or_default(); + return format!("{origin}{path}#{fragment}"); + } + } + format!("#{fragment}") + } + + /// Default view for a given fractal mode. + fn default_view_for(mode: FractalMode) -> ViewState { + match mode { + FractalMode::Mandelbrot => ViewState::default(), + FractalMode::Julia => { + ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), 1.5) + } + } + } + + fn current_key(&self) -> RequestKey { + RequestKey { + center_re: self.view.center_re.clone(), + center_im: self.view.center_im.clone(), + half_height: self.view.half_height, + julia: matches!(self.mode, FractalMode::Julia), + julia_c: self.julia_c, + iter: self.max_iterations, + } + } + + /// Distance (complex units) the live view center has drifted from `key`. + fn drift_from(&self, key: &RequestKey) -> f64 { + let dre = (&self.view.center_re - &key.center_re).to_f64().value(); + let dim = (&self.view.center_im - &key.center_im).to_f64().value(); + (dre * dre + dim * dim).sqrt() + } + + /// Whether the reference should be (re)computed: parameters changed, or the + /// view drifted / zoomed far enough that the current reference no longer + /// serves it well. + fn should_request(&self) -> bool { + let Some(key) = &self.last_request else { + return true; + }; + if key.julia != matches!(self.mode, FractalMode::Julia) + || key.julia_c != self.julia_c + || key.iter != self.max_iterations + { + return true; + } + let ratio = self.view.half_height / key.half_height; + self.drift_from(key) > 0.5 * self.view.half_height || !(0.5..=2.0).contains(&ratio) + } + + /// Complex offset of the live view center from the reference center, in f32. + fn dc_offset(&self) -> [f32; 2] { + let dre = (&self.view.center_re - &self.ref_center_re) + .to_f64() + .value() as f32; + let dim = (&self.view.center_im - &self.ref_center_im) + .to_f64() + .value() as f32; + [dre, dim] + } + + fn apply_reference(&mut self, points: Vec<[f32; 2]>, cre: Big, cim: Big, hh: f64) { + self.reference = Arc::new(points); + self.ref_center_re = cre; + self.ref_center_im = cim; + self.ref_half_height = hh; + self.generation = self.generation.wrapping_add(1); + } + + /// Recompute the reference orbit when needed. Native: dispatch to a worker + /// thread and pick up completed results. Web: compute inline. + fn ensure_reference(&mut self) { + if self.should_request() { + let key = self.current_key(); + let precision = self.view.precision_bits(); + let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1); + + #[cfg(not(target_arch = "wasm32"))] + { + self.worker.request(crate::worker::RefRequest { + center_re: key.center_re.clone(), + center_im: key.center_im.clone(), + half_height: key.half_height, + julia: key.julia, + julia_c: key.julia_c, + max_iter, + precision, + }); + self.pending = true; + } + #[cfg(target_arch = "wasm32")] + { + let points = if key.julia { + let jr = big_from_f64(key.julia_c.0, precision); + let ji = big_from_f64(key.julia_c.1, precision); + compute_reference( + &key.center_re, + &key.center_im, + &jr, + &ji, + max_iter, + precision, + ) + } else { + compute_mandelbrot_reference( + &key.center_re, + &key.center_im, + max_iter, + precision, + ) + }; + self.apply_reference( + points, + key.center_re.clone(), + key.center_im.clone(), + key.half_height, + ); + } + + self.last_request = Some(key); + } + + #[cfg(not(target_arch = "wasm32"))] + if let Some(res) = self.worker.try_take_latest() { + self.apply_reference(res.points, res.center_re, res.center_im, res.half_height); + self.pending = false; + } + } + + fn make_uniforms(&self, aspect: f64) -> Uniforms { + let (span_x, span_y) = self.view.span(aspect); + Uniforms { + span: [span_x as f32, span_y as f32], + max_iter: self.max_iterations.min(MAX_REF_POINTS as u32 - 1), + ref_len: self.reference.len() as u32, + color_offset: self.color_offset, + color_scale: self.color_scale, + bailout_sq: BAILOUT_SQ, + is_julia: matches!(self.mode, FractalMode::Julia) as u32, + palette_id: self.palette, + _pad0: 0, + dc_offset: self.dc_offset(), + } + } + + /// Render the current view to a PNG at `export_scale` × the on-screen size, + /// then save it (native: file in cwd; web: browser download). + fn do_export(&mut self, frame: &mut eframe::Frame) { + let Some(rs) = frame.wgpu_render_state() else { + self.status = Some("export unavailable (no wgpu backend)".into()); + return; + }; + if self.reference.is_empty() { + self.status = Some("still computing reference…".into()); + return; + } + + let scale = self.export_scale.max(1.0); + let w = ((self.last_size_px.x * scale).round() as u32).clamp(16, MAX_EXPORT_DIM); + let h = ((self.last_size_px.y * scale).round() as u32).clamp(16, MAX_EXPORT_DIM); + let uniforms = self.make_uniforms(w as f64 / h as f64); + + let device = rs.device.clone(); + let queue = rs.queue.clone(); + let (buffer, padded_bpr, swap) = { + let guard = rs.renderer.read(); + let Some(renderer) = guard.callback_resources.get::() else { + self.status = Some("export unavailable".into()); + return; + }; + renderer.upload_reference(&queue, &self.reference); + let (buffer, bpr) = renderer.render_to_readback(&device, &queue, w, h, uniforms); + (buffer, bpr, renderer.needs_rb_swap()) + }; + + #[cfg(not(target_arch = "wasm32"))] + { + let png = pollster::block_on(read_and_encode(&device, buffer, w, h, padded_bpr, swap)); + let name = std::env::var("MANDEL_EXPORT_PATH") + .unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp())); + match std::fs::write(&name, &png) { + Ok(_) => self.status = Some(format!("saved {name} ({w}×{h})")), + Err(e) => self.status = Some(format!("save failed: {e}")), + } + } + #[cfg(target_arch = "wasm32")] + { + self.status = Some(format!("exporting {w}×{h}…")); + wasm_bindgen_futures::spawn_local(async move { + let png = read_and_encode(&device, buffer, w, h, padded_bpr, swap).await; + web_download_png(&png, "fractal.png"); + }); + } + } + + fn controls_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("Fractal Explorer"); + ui.separator(); + + ui.horizontal(|ui| { + ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Mandelbrot"); + ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia"); + }); + + if self.mode == FractalMode::Julia { + ui.horizontal(|ui| { + ui.label("c ="); + ui.add( + egui::DragValue::new(&mut self.julia_c.0) + .speed(0.001) + .range(-2.0..=2.0), + ); + ui.add( + egui::DragValue::new(&mut self.julia_c.1) + .speed(0.001) + .range(-2.0..=2.0), + ); + ui.label("i"); + }); + ui.horizontal_wrapped(|ui| { + for &(name, re, im) in JULIA_PRESETS { + if ui.small_button(name).clicked() { + self.julia_c = (re, im); + } + } + }); + } + + ui.separator(); + ui.add( + egui::Slider::new(&mut self.max_iterations, 32..=100_000) + .text("iterations") + .logarithmic(true), + ); + ui.add( + egui::Slider::new(&mut self.color_scale, 0.01..=1.0) + .text("color scale") + .logarithmic(true), + ); + ui.add(egui::Slider::new(&mut self.color_offset, 0.0..=1.0).text("color offset")); + egui::ComboBox::from_label("palette") + .selected_text(PALETTE_NAMES[self.palette as usize]) + .show_ui(ui, |ui| { + for (i, name) in PALETTE_NAMES.iter().enumerate() { + ui.selectable_value(&mut self.palette, i as u32, *name); + } + }); + + ui.separator(); + let (cre, cim) = self.view.center_f64(); + ui.label(format!("center re:\n {cre:+.17}")); + ui.label(format!("center im:\n {cim:+.17}")); + ui.label(format!("magnification: {:.3e}×", self.view.magnification())); + ui.label(format!("reference: {} pts", self.reference.len())); + ui.label(format!("precision: {} bits", self.view.precision_bits())); + if self.pending { + ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…"); + } + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Copy link").clicked() { + let url = self.share_url(); + ui.ctx().copy_text(url); + self.status = Some("link copied".into()); + } + if ui.button("Export PNG").clicked() { + self.export_requested = true; + } + }); + ui.horizontal(|ui| { + ui.label("export scale"); + ui.add( + egui::DragValue::new(&mut self.export_scale) + .range(1.0..=4.0) + .speed(0.1), + ); + ui.label(format!( + "→ {}×{}", + (self.last_size_px.x * self.export_scale) as u32, + (self.last_size_px.y * self.export_scale) as u32, + )); + }); + if let Some(status) = &self.status { + ui.small(status); + } + + ui.separator(); + if ui.button("Reset view").clicked() { + self.view = Self::default_view_for(self.mode); + } + ui.add_space(8.0); + ui.small("Drag to pan · scroll to zoom toward the cursor"); + } + + 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()); + if rect.width() < 1.0 || rect.height() < 1.0 { + return; + } + let height_px = rect.height() as f64; + let aspect = (rect.width() / rect.height()) as f64; + self.last_size_px = rect.size(); + + // Pan by dragging. + if response.dragged() { + let d = response.drag_delta(); + if d.x != 0.0 || d.y != 0.0 { + self.view.pan_pixels(d.x as f64, d.y as f64, height_px); + } + } + + // Zoom toward the cursor on scroll. + let (scroll_y, hover) = ui.input(|i| (i.smooth_scroll_delta.y, i.pointer.hover_pos())); + if scroll_y != 0.0 + && let Some(pos) = hover + && rect.contains(pos) + { + let off = pos - rect.center(); + let factor = (-scroll_y as f64 * 0.0015).exp(); + self.view + .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); + ui.ctx().request_repaint(); + } + + self.ensure_reference(); + + // Nothing to draw until the first reference orbit is ready. + if self.reference.is_empty() { + ui.ctx().request_repaint(); + return; + } + // Keep polling the worker while a newer reference is computing. + if self.pending { + ui.ctx().request_repaint(); + } + + let uniforms = self.make_uniforms(aspect); + ui.painter().add(egui_wgpu::Callback::new_paint_callback( + rect, + FractalCallback { + uniforms, + reference: Arc::clone(&self.reference), + generation: self.generation, + }, + )); + } +} + +impl eframe::App for FractalApp { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + egui::Panel::right("controls") + .default_size(280.0) + .show(ui, |ui| self.controls_ui(ui)); + + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| self.fractal_ui(ui)); + + if std::mem::take(&mut self.export_requested) { + self.do_export(frame); + } + } + + #[cfg(target_arch = "wasm32")] + fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(&mut *self) + } +} + +/// Map the readback buffer, unpad it, and encode a PNG. Awaited on both targets +/// (blocked on via pollster natively; spawned on the web). +async fn read_and_encode( + device: &wgpu::Device, + buffer: wgpu::Buffer, + width: u32, + height: u32, + padded_bpr: u32, + swap_rb: bool, +) -> Vec { + let (tx, rx) = futures_channel::oneshot::channel(); + buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + + #[cfg(not(target_arch = "wasm32"))] + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + #[cfg(target_arch = "wasm32")] + let _ = device; + + let _ = rx.await; + + let png = { + let data = buffer + .slice(..) + .get_mapped_range() + .expect("map readback buffer"); + crate::fractal::encode_png(&data, width, height, padded_bpr, swap_rb) + }; + buffer.unmap(); + png +} + +#[cfg(not(target_arch = "wasm32"))] +fn unix_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(target_arch = "wasm32")] +fn web_location_hash() -> Option { + let hash = web_sys::window()?.location().hash().ok()?; + if hash.trim_start_matches('#').is_empty() { + None + } else { + Some(hash) + } +} + +#[cfg(target_arch = "wasm32")] +fn web_download_png(bytes: &[u8], filename: &str) { + use wasm_bindgen::JsCast as _; + + let Some(document) = web_sys::window().and_then(|w| w.document()) else { + return; + }; + let array = js_sys::Uint8Array::from(bytes); + let parts = js_sys::Array::new(); + parts.push(&array); + let options = web_sys::BlobPropertyBag::new(); + options.set_type("image/png"); + let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) else { + return; + }; + let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { + return; + }; + if let Some(anchor) = document + .create_element("a") + .ok() + .and_then(|el| el.dyn_into::().ok()) + { + anchor.set_href(&url); + anchor.set_download(filename); + anchor.click(); + } + let _ = web_sys::Url::revoke_object_url(&url); +} diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs new file mode 100644 index 0000000..5a83257 --- /dev/null +++ b/src/fractal/mod.rs @@ -0,0 +1,10 @@ +//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the +//! egui paint callback. + +pub mod reference; +pub mod renderer; +pub mod share; + +pub use reference::{compute_mandelbrot_reference, compute_reference}; +pub use renderer::{FractalCallback, FractalRenderer, Uniforms, MAX_REF_POINTS, encode_png}; +pub use share::ShareState; diff --git a/src/fractal/reference.rs b/src/fractal/reference.rs new file mode 100644 index 0000000..f4ae348 --- /dev/null +++ b/src/fractal/reference.rs @@ -0,0 +1,136 @@ +//! High-precision reference-orbit computation for perturbation rendering. +//! +//! We iterate `Z_{n+1} = Z_n^2 + C` at high precision (`dashu-float`), storing +//! each `Z_n` as an `f32` pair. Every pixel is then rendered on the GPU as a +//! small `f32` delta from this orbit — that is what makes deep zoom cheap. See +//! `shaders/mandelbrot.wgsl` for the delta side. +//! +//! The `(z0, c)` form serves both fractals: +//! * Mandelbrot: `z0 = 0`, `c = view center` (the c-plane point per pixel). +//! * Julia: `z0 = view center`, `c = julia constant` (fixed for all pixels). + +use crate::view::Big; + +/// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel +/// bailout so pixels escaping alongside the reference can still reach their +/// bailout before the stored orbit runs out. +const REFERENCE_ESCAPE_SQ: f64 = 1.0e10; + +/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and +/// `Z_{n+1} = Z_n^2 + c`, up to `max_iter` steps at `precision` bits. Each entry +/// is `[re, im]` in f32. +pub fn compute_reference( + z0_re: &Big, + z0_im: &Big, + c_re: &Big, + c_im: &Big, + max_iter: u32, + precision: usize, +) -> Vec<[f32; 2]> { + let cr = c_re.clone().with_precision(precision).value(); + let ci = c_im.clone().with_precision(precision).value(); + + let mut zr = z0_re.clone().with_precision(precision).value(); + let mut zi = z0_im.clone().with_precision(precision).value(); + + let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1); + + for _ in 0..=max_iter { + let fr = zr.to_f64().value() as f32; + let fi = zi.to_f64().value() as f32; + points.push([fr, fi]); + + let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64); + if mag > REFERENCE_ESCAPE_SQ { + break; + } + + // Z = Z^2 + C, with Z^2 = (zr^2 - zi^2) + (2 zr zi) i. + let zr2 = zr.sqr(); + let zi2 = zi.sqr(); + let new_zr = ((&zr2 - &zi2) + &cr).with_precision(precision).value(); + let two_zr_zi = (&zr * &zi) << 1; // exact multiply-by-2 in base 2 + let new_zi = (two_zr_zi + &ci).with_precision(precision).value(); + + zr = new_zr; + zi = new_zi; + } + + points +} + +fn big_zero(precision: usize) -> Big { + Big::from(0i32).with_precision(precision).value() +} + +/// Convenience: Mandelbrot reference (`z0 = 0`, `c = center`). +pub fn compute_mandelbrot_reference( + center_re: &Big, + center_im: &Big, + max_iter: u32, + precision: usize, +) -> Vec<[f32; 2]> { + let zero = big_zero(precision); + compute_reference(&zero, &zero, center_re, center_im, max_iter, precision) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The high-precision reference must agree with a plain f64 iteration for a + /// shallow point (where f64 is accurate). + #[test] + fn reference_matches_naive_f64() { + let cr = Big::try_from(-0.75_f64).unwrap(); + let ci = Big::try_from(0.1_f64).unwrap(); + let points = compute_mandelbrot_reference(&cr, &ci, 60, 200); + + // Independent naive f64 orbit. + let (c_re, c_im) = (-0.75_f64, 0.1_f64); + let (mut zr, mut zi) = (0.0_f64, 0.0_f64); + for point in &points { + // Tolerance is relative to magnitude: f32 storage only keeps ~7 + // significant figures. + let tol_re = 1e-4 * (1.0 + zr.abs()); + let tol_im = 1e-4 * (1.0 + zi.abs()); + assert!((point[0] as f64 - zr).abs() < tol_re, "re mismatch: {point:?} vs {zr}"); + assert!((point[1] as f64 - zi).abs() < tol_im, "im mismatch: {point:?} vs {zi}"); + let nzr = zr * zr - zi * zi + c_re; + let nzi = 2.0 * zr * zi + c_im; + zr = nzr; + zi = nzi; + } + } + + /// A point inside the main cardioid never escapes: full-length orbit. + #[test] + fn interior_orbit_runs_full_length() { + let cr = Big::try_from(-0.2_f64).unwrap(); + let ci = Big::try_from(0.0_f64).unwrap(); + let points = compute_mandelbrot_reference(&cr, &ci, 500, 120); + assert_eq!(points.len(), 501, "interior orbit should not escape"); + } + + /// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration. + #[test] + fn julia_reference_matches_naive_f64() { + let z0_re = Big::try_from(0.15_f64).unwrap(); + let z0_im = Big::try_from(-0.1_f64).unwrap(); + let c_re = Big::try_from(-0.8_f64).unwrap(); + let c_im = Big::try_from(0.156_f64).unwrap(); + let points = compute_reference(&z0_re, &z0_im, &c_re, &c_im, 60, 200); + + let (mut zr, mut zi) = (0.15_f64, -0.1_f64); + let (cr, ci) = (-0.8_f64, 0.156_f64); + for point in &points { + let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs())); + assert!((point[0] as f64 - zr).abs() < tol); + assert!((point[1] as f64 - zi).abs() < tol); + let nzr = zr * zr - zi * zi + cr; + let nzi = 2.0 * zr * zi + ci; + zr = nzr; + zi = nzi; + } + } +} diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs new file mode 100644 index 0000000..bf2f61d --- /dev/null +++ b/src/fractal/renderer.rs @@ -0,0 +1,353 @@ +//! wgpu resources for the fractal: the render pipeline, the uniform buffer, the +//! reference-orbit storage buffer, and the egui paint callback that drives them. +//! +//! Rendering strategy: a single fullscreen triangle is drawn into the rectangle +//! egui allocates for the fractal widget (egui presets the render pass viewport +//! for us). The fragment shader iterates each pixel as an f32 perturbation delta +//! from the high-precision reference orbit stored in `ref_buffer`. + +use std::sync::Arc; + +use eframe::egui_wgpu::{self, wgpu}; + +/// 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; + +/// GPU-side view + coloring parameters. Layout must match `Uniforms` in the +/// WGSL shader; total size is a multiple of 16 bytes for uniform-buffer rules. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Uniforms { + /// Complex-plane span (width, height) covered by the view. Per-pixel `dc` + /// is `centered * span`, where `centered` is in [-0.5, 0.5]. + pub span: [f32; 2], + pub max_iter: u32, + pub ref_len: u32, + pub color_offset: f32, + pub color_scale: f32, + pub bailout_sq: f32, + /// 0 = Mandelbrot, 1 = Julia. + pub is_julia: u32, + pub palette_id: u32, + pub _pad0: u32, + /// Complex offset of the view center from the reference center, so a stale + /// or reused reference (computed at a slightly different center) still maps + /// correctly. Added to every pixel's per-pixel offset. + pub dc_offset: [f32; 2], +} + +pub struct FractalRenderer { + pipeline: wgpu::RenderPipeline, + uniform_buffer: wgpu::Buffer, + ref_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, + target_format: wgpu::TextureFormat, + /// Generation of the reference orbit currently uploaded to `ref_buffer`. + uploaded_generation: u64, +} + +impl FractalRenderer { + pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("mandelbrot"), + source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/mandelbrot.wgsl").into()), + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("fractal uniforms"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("reference orbit"), + size: (MAX_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("fractal 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: 1, + 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 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(), + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("fractal pipeline layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("fractal pipeline"), + layout: Some(&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_main"), + 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 { + pipeline, + uniform_buffer, + ref_buffer, + bind_group, + target_format, + uploaded_generation: u64::MAX, + } + } + + /// Upload a reference orbit to the storage buffer (used by PNG export to + /// guarantee the buffer is current before an offscreen render). + pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) { + let count = points.len().min(MAX_REF_POINTS); + if count > 0 { + queue.write_buffer(&self.ref_buffer, 0, bytemuck::cast_slice(&points[..count])); + } + } + + /// True if the render target stores bytes as BGRA (so a PNG needs R/B + /// swapped). Surfaces are usually `Bgra8UnormSrgb`. + pub fn needs_rb_swap(&self) -> bool { + matches!( + self.target_format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ) + } + + /// Render the current fractal (using `uniforms` and the already-uploaded + /// reference orbit) into an offscreen texture at `width`x`height`, then copy + /// it into a mappable buffer. Returns the buffer and its padded row stride. + /// The caller maps the buffer (blocking on native, async on web). + pub fn render_to_readback( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, + uniforms: Uniforms, + ) -> (wgpu::Buffer, u32) { + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("export target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.target_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let unpadded_bpr = width * 4; + let padded_bpr = unpadded_bpr.div_ceil(align) * align; + + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("export readback"), + size: (padded_bpr * height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("export"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("export pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + } + + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bpr), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + + queue.submit(std::iter::once(encoder.finish())); + (readback, padded_bpr) + } +} + +/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it +/// as PNG bytes. +pub fn encode_png( + padded: &[u8], + width: u32, + height: u32, + padded_bpr: u32, + swap_rb: bool, +) -> Vec { + let row = (width * 4) as usize; + let mut rgba = vec![0u8; row * height as usize]; + for y in 0..height as usize { + let src_off = y * padded_bpr as usize; + let src = &padded[src_off..src_off + row]; + let dst = &mut rgba[y * row..y * row + row]; + if swap_rb { + for x in 0..width as usize { + dst[x * 4] = src[x * 4 + 2]; + dst[x * 4 + 1] = src[x * 4 + 1]; + dst[x * 4 + 2] = src[x * 4]; + dst[x * 4 + 3] = src[x * 4 + 3]; + } + } else { + dst.copy_from_slice(src); + } + } + + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("png header"); + writer.write_image_data(&rgba).expect("png data"); + } + out +} + +/// A per-frame paint callback. Carries this frame's uniforms plus a reference to +/// the current reference orbit (cheap `Arc` clone). The orbit is only re-uploaded +/// to the GPU when its `generation` changes. +pub struct FractalCallback { + pub uniforms: Uniforms, + pub reference: Arc>, + pub generation: u64, +} + +impl egui_wgpu::CallbackTrait for FractalCallback { + 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 { + if let Some(renderer) = resources.get_mut::() { + queue.write_buffer( + &renderer.uniform_buffer, + 0, + bytemuck::bytes_of(&self.uniforms), + ); + + if renderer.uploaded_generation != self.generation && !self.reference.is_empty() { + let count = self.reference.len().min(MAX_REF_POINTS); + queue.write_buffer( + &renderer.ref_buffer, + 0, + bytemuck::cast_slice(&self.reference[..count]), + ); + renderer.uploaded_generation = self.generation; + } + } + Vec::new() + } + + fn paint( + &self, + _info: egui::PaintCallbackInfo, + render_pass: &mut wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + if let Some(renderer) = resources.get::() { + render_pass.set_pipeline(&renderer.pipeline); + render_pass.set_bind_group(0, &renderer.bind_group, &[]); + render_pass.draw(0..3, 0..1); + } + } +} diff --git a/src/fractal/share.rs b/src/fractal/share.rs new file mode 100644 index 0000000..c4f20e8 --- /dev/null +++ b/src/fractal/share.rs @@ -0,0 +1,94 @@ +//! Encode/decode a full view (fractal mode, high-precision center, zoom, +//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom +//! locations can be shared or bookmarked. +//! +//! Format: `m=m&re=&im=&hh=&it=&cs=&co=` with +//! `m=j&jr=&ji=` added for Julia. `re`/`im` are full-precision decimal +//! strings. + +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub struct ShareState { + pub julia: bool, + pub center_re: String, + pub center_im: String, + pub half_height: f64, + pub iterations: u32, + pub julia_c: (f64, f64), + pub color_scale: f32, + pub color_offset: f32, +} + +impl ShareState { + pub fn encode(&self) -> String { + let mut s = String::new(); + s.push_str(if self.julia { "m=j" } else { "m=m" }); + s.push_str(&format!( + "&re={}&im={}&hh={}&it={}", + self.center_re, self.center_im, self.half_height, self.iterations + )); + if self.julia { + s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1)); + } + s.push_str(&format!("&cs={}&co={}", self.color_scale, self.color_offset)); + s + } + + pub fn decode(fragment: &str) -> Option { + let fragment = fragment.trim_start_matches(['#', '?']); + let mut map: HashMap<&str, &str> = HashMap::new(); + for kv in fragment.split('&') { + if let Some((k, v)) = kv.split_once('=') { + map.insert(k, v); + } + } + + Some(ShareState { + julia: map.get("m").map(|m| *m == "j").unwrap_or(false), + center_re: (*map.get("re")?).to_string(), + center_im: (*map.get("im")?).to_string(), + half_height: map.get("hh")?.parse().ok()?, + iterations: map.get("it").and_then(|s| s.parse().ok()).unwrap_or(512), + julia_c: ( + map.get("jr").and_then(|s| s.parse().ok()).unwrap_or(-0.8), + map.get("ji").and_then(|s| s.parse().ok()).unwrap_or(0.156), + ), + color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02), + color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let s = ShareState { + julia: true, + center_re: "-0.743643887037158704752191506114774".into(), + center_im: "0.131825904205311970493132056385139".into(), + half_height: 1.5e-20, + iterations: 4000, + julia_c: (-0.123, 0.745), + color_scale: 0.02, + color_offset: 0.25, + }; + let d = ShareState::decode(&s.encode()).unwrap(); + assert_eq!(d.julia, s.julia); + assert_eq!(d.center_re, s.center_re); + assert_eq!(d.center_im, s.center_im); + assert_eq!(d.half_height, s.half_height); + assert_eq!(d.iterations, s.iterations); + assert_eq!(d.julia_c, s.julia_c); + } + + #[test] + fn decode_with_leading_hash() { + let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap(); + assert!(!d.julia); + assert_eq!(d.iterations, 256); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ed6fe86 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,80 @@ +// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot. +// +// A single binary drives both native and web (WASM/WebGPU) builds; the two +// `main` functions below are selected by target. Trunk builds the wasm32 target +// and calls the wasm `main`, which boots eframe onto the page's . + +mod app; +mod fractal; +mod view; + +#[cfg(not(target_arch = "wasm32"))] +mod worker; + +use app::FractalApp; + +#[cfg(not(target_arch = "wasm32"))] +fn main() -> eframe::Result { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .parse_default_env() + .init(); + + let native_options = eframe::NativeOptions { + renderer: eframe::Renderer::Wgpu, + viewport: egui::ViewportBuilder::default() + .with_inner_size([1280.0, 800.0]) + .with_min_inner_size([640.0, 480.0]) + .with_title("Fractal Explorer"), + ..Default::default() + }; + + eframe::run_native( + "Fractal Explorer", + native_options, + Box::new(|cc| Ok(Box::new(FractalApp::new(cc)))), + ) +} + +#[cfg(target_arch = "wasm32")] +fn main() { + use eframe::wasm_bindgen::JsCast as _; + + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Info); + + let web_options = eframe::WebOptions::default(); + + wasm_bindgen_futures::spawn_local(async { + let document = web_sys::window() + .expect("no window") + .document() + .expect("no document"); + let canvas = document + .get_element_by_id("the_canvas_id") + .expect("missing element with id `the_canvas_id`") + .dyn_into::() + .expect("`the_canvas_id` is not a "); + + let result = eframe::WebRunner::new() + .start( + canvas, + web_options, + Box::new(|cc| Ok(Box::new(FractalApp::new(cc)))), + ) + .await; + + // Remove the "Loading…" splash regardless of success/failure. + if let Some(loading) = document.get_element_by_id("loading_text") { + match result { + Ok(_) => loading.remove(), + Err(e) => { + loading.set_inner_html( + "

The app has crashed. See the developer console for details.

", + ); + log::error!("failed to start eframe: {e:?}"); + } + } + } + }); +} diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl new file mode 100644 index 0000000..08c0c9e --- /dev/null +++ b/src/shaders/mandelbrot.wgsl @@ -0,0 +1,146 @@ +// Deep-zoom Mandelbrot via perturbation theory with rebasing. +// +// Instead of iterating each pixel's orbit directly (which f32 can't do at deep +// zoom), we iterate the *delta* from a high-precision reference orbit computed +// on the CPU. For a pixel c = c_ref + dc, its orbit y_n = X_n + e_n where: +// +// e_{n+1} = 2 * X_n * e_n + e_n^2 + dc (all f32) +// +// The full value y_n = X_n + e_n is used for the escape test. Rebasing +// (Zhuoran's method) keeps the delta small and avoids glitches: whenever the +// true value |y| drops below the delta |e|, or the reference runs out, we reset +// the reference index to 0 and carry the full value as the new delta (valid +// because X_0 = 0). + +struct Uniforms { + span: vec2, + max_iter: u32, + ref_len: u32, + color_offset: f32, + color_scale: f32, + bailout_sq: f32, + is_julia: u32, + palette_id: u32, + _pad0: u32, + dc_offset: vec2, +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var ref_orbit: array>; + +struct VsOut { + @builtin(position) pos: vec4, + // Position within the view, in [-0.5, 0.5] at the visible edges. + @location(0) centered: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { + var verts = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + let ndc = verts[idx]; + var out: VsOut; + out.pos = vec4(ndc, 0.0, 1.0); + // Flip y so +imaginary points up the screen. + out.centered = vec2(ndc.x, -ndc.y) * 0.5; + return out; +} + +// Complex multiply. +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); +} + +// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. +fn palette(id: u32, t: f32) -> vec3 { + if (id == 4u) { + return vec3(t, t, t); // grayscale + } + let a = vec3(0.5, 0.5, 0.5); + let b = vec3(0.5, 0.5, 0.5); + var c = vec3(1.0, 1.0, 1.0); + var d = vec3(0.00, 0.33, 0.67); // 0: rainbow + if (id == 1u) { + d = vec3(0.00, 0.10, 0.20); // amber / blue + } else if (id == 2u) { + d = vec3(0.30, 0.20, 0.20); // warm ember + } else if (id == 3u) { + c = vec3(1.0, 1.0, 0.5); + d = vec3(0.80, 0.90, 0.30); // lime / magenta + } + return a + b * cos(6.28318530718 * (c * t + d)); +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + // Per-pixel offset. For Mandelbrot this is the c-plane offset added every + // step (delta starts at 0). For Julia it is the z-plane offset that seeds + // the initial delta (c is fixed, so nothing is added per step). + let offset = in.centered * u.span + u.dc_offset; + let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia) + + var step_add = offset; + var e = vec2(0.0, 0.0); + if (u.is_julia != 0u) { + step_add = vec2(0.0, 0.0); + e = offset; + } + + var m: u32 = 0u; // reference index; invariant: y_n = X[m] + e + var n: u32 = 0u; // total iteration count + var z = vec2(0.0, 0.0); // full value y_n, kept for coloring + var escaped = false; + + loop { + let xm = ref_orbit[m]; + z = xm + e; + + let z2 = dot(z, z); + if (z2 > u.bailout_sq) { + escaped = true; + break; + } + if (n >= u.max_iter) { + break; // interior + } + + // Advance the delta: e = 2*X_m*e + e^2 (+ dc for Mandelbrot). + e = 2.0 * cmul(xm, e) + cmul(e, e) + step_add; + m = m + 1u; + n = n + 1u; + + // Keep the reference index valid and the delta small. + if (m >= u.ref_len) { + // Reference exhausted: any pixel that followed it this far has + // effectively escaped (interior pixels rebase before reaching here). + z = ref_orbit[u.ref_len - 1u] + e; + escaped = true; + break; + } + let y = ref_orbit[m] + e; + if (dot(y, y) < dot(e, e)) { + // Rebase to index 0: carry the full value as the new delta. Valid + // because y_n = X[0] + (y_n - X[0]); for Mandelbrot X[0]=0. + e = y - z0; + m = 0u; + } + } + + if (!escaped) { + return vec4(0.0, 0.0, 0.0, 1.0); // interior of the set + } + + // Continuous (smooth) iteration count. + let log_zn = 0.5 * log(max(dot(z, z), 1.0)); + let nu = log2(log_zn / log(2.0)); + let smooth_i = f32(n) + 1.0 - nu; + + // sqrt compresses the huge iteration counts of deep zooms so the palette + // varies smoothly instead of aliasing into speckle. + let ci = sqrt(max(smooth_i, 0.0)); + let t = fract(ci * u.color_scale + u.color_offset); + return vec4(palette(u.palette_id, t), 1.0); +} diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..7260a6b --- /dev/null +++ b/src/view.rs @@ -0,0 +1,156 @@ +//! Camera / view state over the complex plane. +//! +//! The center is stored in arbitrary precision (`FBig`) — this is what lets us +//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at +//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the +//! center needs the extra digits. + +use core::str::FromStr; + +use dashu_float::round::mode::HalfAway; +use dashu_float::{DBig, FBig}; + +/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate. +pub type Big = FBig; + +/// Half-height (complex units) of the default view; also the zoom-1 reference. +pub const DEFAULT_HALF_HEIGHT: f64 = 1.25; + +/// Guard bits added on top of the zoom-dictated precision. +const GUARD_BITS: usize = 48; +/// Upper bound on center precision (f32 GPU perturbation degrades long before +/// this; the cap just prevents pathological allocation). +const MAX_PRECISION_BITS: usize = 2048; + +#[derive(Clone, Debug)] +pub struct ViewState { + pub center_re: Big, + pub center_im: Big, + /// Half the view height in complex-plane units. Zooming in shrinks this. + pub half_height: f64, +} + +impl Default for ViewState { + fn default() -> Self { + let bits = precision_for(DEFAULT_HALF_HEIGHT); + Self { + center_re: big_from_f64(-0.5, bits), + center_im: big_from_f64(0.0, bits), + half_height: DEFAULT_HALF_HEIGHT, + } + } +} + +impl ViewState { + /// Complex-plane span (width, height) for the given pixel aspect ratio. + pub fn span(&self, aspect: f64) -> (f64, f64) { + let h = self.half_height * 2.0; + (h * aspect, h) + } + + /// Complex-plane units per pixel, given the viewport height in pixels. + pub fn complex_per_pixel(&self, height_px: f64) -> f64 { + (self.half_height * 2.0) / height_px + } + + /// Current magnification relative to the default view. + pub fn magnification(&self) -> f64 { + DEFAULT_HALF_HEIGHT / self.half_height + } + + /// Bits of precision the center currently needs for this zoom level. + pub fn precision_bits(&self) -> usize { + precision_for(self.half_height) + } + + /// Ensure the center carries enough precision for the current zoom. Must be + /// called before mutating the center so arithmetic keeps the needed digits. + pub fn sync_precision(&mut self) { + let bits = self.precision_bits(); + if self.center_re.precision() < bits { + self.center_re = self.center_re.clone().with_precision(bits).value(); + } + if self.center_im.precision() < bits { + self.center_im = self.center_im.clone().with_precision(bits).value(); + } + } + + /// Pan by a pixel delta (screen space: +x right, +y down). + pub fn pan_pixels(&mut self, dx: f64, dy: f64, height_px: f64) { + self.sync_precision(); + let cpp = self.complex_per_pixel(height_px); + 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 + } + + /// Zoom by `factor` (<1 zooms in) keeping the complex point currently under + /// the cursor fixed on screen. `off_*` is the cursor offset from the + /// viewport center in pixels. + pub fn zoom_at_pixel(&mut self, off_x: f64, off_y: f64, height_px: f64, factor: f64) { + self.sync_precision(); + let cpp = self.complex_per_pixel(height_px); + let bits = self.precision_bits(); + // The cursor's complex offset from the center is (off * cpp). Keeping it + // fixed while scaling the view by `factor` moves the center by + // 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.half_height *= factor; + } + + /// f64 approximation of the center, for display. + pub fn center_f64(&self) -> (f64, f64) { + (self.center_re.to_f64().value(), self.center_im.to_f64().value()) + } + + /// Build a view from full-precision center coordinates and a half-height. + pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self { + let mut v = Self { + center_re, + center_im, + half_height, + }; + v.sync_precision(); + v + } +} + +/// Parse a decimal string (any number of digits) losslessly into a `Big` with at +/// least `bits` of precision. Used for share links and debug view specs. +pub fn big_from_decimal_str(s: &str, bits: usize) -> Option { + let dec = DBig::from_str(s.trim()).ok()?; + Some(dec.with_base_and_precision::<2>(bits.max(53)).value()) +} + +/// Render a `Big` as a decimal string with `sig_digits` significant digits. +pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String { + let dec = x + .to_decimal() + .value() + .with_precision(sig_digits.max(1)) + .value(); + format!("{dec}") +} + +/// Precision (bits) needed to resolve the center at a given half-height. +pub fn precision_for(half_height: f64) -> usize { + // We need enough bits to distinguish points a pixel apart, i.e. roughly + // log2(1 / half_height) significant bits, plus a guard margin. + let zoom_bits = if half_height > 0.0 && half_height.is_finite() { + (-half_height.log2()).ceil().max(0.0) as usize + } else { + 0 + }; + (zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS) +} + +/// Build an `FBig` from an f64 with an explicit precision context. +pub fn big_from_f64(x: f64, bits: usize) -> Big { + Big::try_from(x) + .unwrap_or_default() + .with_precision(bits) + .value() +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..cd1e63c --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,104 @@ +//! Native background worker for reference-orbit computation. +//! +//! At deep zoom the high-precision reference can take many milliseconds (tens of +//! thousands of `FBig` iterations), which would stutter the UI if done inline. +//! This runs it on a thread and coalesces bursts of requests (e.g. during a +//! drag) down to the most recent one. On the web we compute inline instead +//! (browsers need a Web Worker for threads); see `app.rs`. + +use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel}; +use std::thread; + +use crate::fractal::{compute_mandelbrot_reference, compute_reference}; +use crate::view::{Big, big_from_f64}; + +pub struct RefRequest { + pub center_re: Big, + pub center_im: Big, + pub half_height: f64, + pub julia: bool, + pub julia_c: (f64, f64), + pub max_iter: u32, + pub precision: usize, +} + +pub struct RefResult { + pub center_re: Big, + pub center_im: Big, + pub half_height: f64, + pub points: Vec<[f32; 2]>, +} + +pub struct RefWorker { + req_tx: Sender, + res_rx: Receiver, +} + +impl RefWorker { + pub fn spawn() -> Self { + let (req_tx, req_rx) = channel::(); + let (res_tx, res_rx) = channel::(); + + thread::Builder::new() + .name("reference-orbit".into()) + .spawn(move || { + while let Ok(mut req) = req_rx.recv() { + // Coalesce: if newer requests are already queued, skip to the + // latest so a fast drag doesn't compute every intermediate view. + loop { + match req_rx.try_recv() { + Ok(newer) => req = newer, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + + let points = compute(&req); + if res_tx + .send(RefResult { + center_re: req.center_re, + center_im: req.center_im, + half_height: req.half_height, + points, + }) + .is_err() + { + return; + } + } + }) + .expect("spawn reference-orbit thread"); + + Self { req_tx, res_rx } + } + + pub fn request(&self, req: RefRequest) { + let _ = self.req_tx.send(req); + } + + /// Drain all pending results, returning only the most recent. + pub fn try_take_latest(&self) -> Option { + let mut latest = None; + while let Ok(res) = self.res_rx.try_recv() { + latest = Some(res); + } + latest + } +} + +fn compute(req: &RefRequest) -> Vec<[f32; 2]> { + if req.julia { + let jr = big_from_f64(req.julia_c.0, req.precision); + let ji = big_from_f64(req.julia_c.1, req.precision); + compute_reference( + &req.center_re, + &req.center_im, + &jr, + &ji, + req.max_iter, + req.precision, + ) + } else { + compute_mandelbrot_reference(&req.center_re, &req.center_im, req.max_iter, req.precision) + } +}