First commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
dist
|
||||||
+37
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
edition = "2024"
|
||||||
+693
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user