First commit

This commit is contained in:
2026-09-15 08:55:18 +02:00
commit 09d7976ebd
15 changed files with 1989 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
Cargo.lock
dist
+37
View File
@@ -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
+98
View File
@@ -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 <http://localhost:8080> 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-<timestamp>.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="<fragment>"` — restore a share fragment
- `MANDEL_EXPORT=1` (+ optional `MANDEL_EXPORT_PATH=out.png`) — export on the
first frame, for scripted captures
Executable
+25
View File
@@ -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"
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Fractal Explorer</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0b0b0f;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#loading_text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #cccccc;
font-family: system-ui, sans-serif;
font-size: 1rem;
text-align: center;
}
</style>
</head>
<body>
<canvas id="the_canvas_id"></canvas>
<div id="loading_text">Loading…</div>
<script type="module">
import init from './mandelbrot.js';
init().catch((err) => {
// Ignore the benign control-flow exception eframe throws to unwind
// the winit event loop on the web; report anything else.
const msg = (err && err.message) || String(err);
if (!msg.includes("Using exceptions for control flow")) {
document.getElementById("loading_text").innerHTML =
"<p>Failed to start. This app needs a WebGPU-capable browser " +
"(recent Chrome/Edge, Firefox, or Safari 26+).</p>";
console.error(err);
}
});
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
edition = "2024"
+693
View File
@@ -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<Vec<[f32; 2]>>,
/// 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<RequestKey>,
#[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<String>,
}
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::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
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::<f64>() 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::<u32>()
{
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::<FractalRenderer>() 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<u8> {
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<String> {
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::<web_sys::HtmlAnchorElement>().ok())
{
anchor.set_href(&url);
anchor.set_download(filename);
anchor.click();
}
let _ = web_sys::Url::revoke_object_url(&url);
}
+10
View File
@@ -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;
+136
View File
@@ -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;
}
}
}
+353
View File
@@ -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::<Uniforms>() 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<u8> {
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<Vec<[f32; 2]>>,
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<wgpu::CommandBuffer> {
if let Some(renderer) = resources.get_mut::<FractalRenderer>() {
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::<FractalRenderer>() {
render_pass.set_pipeline(&renderer.pipeline);
render_pass.set_bind_group(0, &renderer.bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
}
}
+94
View File
@@ -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=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` 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<ShareState> {
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);
}
}
+80
View File
@@ -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 <canvas>.
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::<web_sys::HtmlCanvasElement>()
.expect("`the_canvas_id` is not a <canvas>");
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(
"<p>The app has crashed. See the developer console for details.</p>",
);
log::error!("failed to start eframe: {e:?}");
}
}
}
});
}
+146
View File
@@ -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<f32>,
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<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
struct VsOut {
@builtin(position) pos: vec4<f32>,
// Position within the view, in [-0.5, 0.5] at the visible edges.
@location(0) centered: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let ndc = verts[idx];
var out: VsOut;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
// Flip y so +imaginary points up the screen.
out.centered = vec2<f32>(ndc.x, -ndc.y) * 0.5;
return out;
}
// Complex multiply.
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
fn palette(id: u32, t: f32) -> vec3<f32> {
if (id == 4u) {
return vec3<f32>(t, t, t); // grayscale
}
let a = vec3<f32>(0.5, 0.5, 0.5);
let b = vec3<f32>(0.5, 0.5, 0.5);
var c = vec3<f32>(1.0, 1.0, 1.0);
var d = vec3<f32>(0.00, 0.33, 0.67); // 0: rainbow
if (id == 1u) {
d = vec3<f32>(0.00, 0.10, 0.20); // amber / blue
} else if (id == 2u) {
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
} else if (id == 3u) {
c = vec3<f32>(1.0, 1.0, 0.5);
d = vec3<f32>(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<f32> {
// 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<f32>(0.0, 0.0);
if (u.is_julia != 0u) {
step_add = vec2<f32>(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<f32>(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<f32>(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<f32>(palette(u.palette_id, t), 1.0);
}
+156
View File
@@ -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<HalfAway, 2>;
/// 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<Big> {
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()
}
+104
View File
@@ -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<RefRequest>,
res_rx: Receiver<RefResult>,
}
impl RefWorker {
pub fn spawn() -> Self {
let (req_tx, req_rx) = channel::<RefRequest>();
let (res_tx, res_rx) = channel::<RefResult>();
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<RefResult> {
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)
}
}