Compare commits
6
Commits
9555353404
...
783e3e1f30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783e3e1f30 | ||
|
|
0f01a16d60 | ||
|
|
6caa23accd | ||
|
|
cc0609ffbb | ||
|
|
0f25c89a7b | ||
|
|
908c13bfb0 |
@@ -0,0 +1,139 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms 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. The `f32` GPU tier reaches roughly 10³⁰×. Runs
|
||||
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
|
||||
storage buffers, which the fragment shader needs for the reference orbit).
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
cargo run --release # native, run (release matters: fractal math is hot)
|
||||
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
||||
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
||||
cargo clippy
|
||||
cargo fmt # rustfmt.toml just pins edition = "2024"
|
||||
```
|
||||
|
||||
Web build (WebGPU):
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
||||
./build-web.sh # -> ./dist
|
||||
python3 -m http.server -d dist 8080
|
||||
```
|
||||
|
||||
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
||||
`--power`, `--julia re,im`, `--share <fragment>`,
|
||||
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
|
||||
`--buddha-palette`, `--export` (+ `--export-path out.png`). `--headless`
|
||||
(`src/headless.rs`) skips the window entirely: it builds the same view from
|
||||
the other flags, creates its own offscreen wgpu device, and renders straight
|
||||
to a PNG (`--width`/`--height`, default 1920×1080) — implies `--export`'s
|
||||
save behavior without needing a GPU-backed window/event loop. Not yet
|
||||
supported with `--buddhabrot`. Run `mandelbrot --help` for the full list.
|
||||
|
||||
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
|
||||
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||
doesn't touch the GPU — the reference-orbit tests are pure CPU math (see
|
||||
below), and `shader_valid` parses/validates WGSL with `naga` statically instead
|
||||
of creating a pipeline.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The perturbation pipeline (the core mechanism, spans several files)
|
||||
|
||||
For a pixel at parameter `c = C_ref + dc`, its orbit is written as
|
||||
`y_n = X_n + e_n`, where `X_n` is the (shared, high-precision) reference orbit
|
||||
and `e_n` is a small `f32` delta. Whenever `|y_n| < |e_n|` (or the reference
|
||||
runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This is
|
||||
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
||||
pixel is a handful of `f32` complex multiplies.
|
||||
|
||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
||||
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
|
||||
(bits) scales with zoom depth (`precision_for`).
|
||||
- `src/fractal/reference.rs` — `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda) and
|
||||
`compute_reference`/`compute_set_reference`: iterate the chosen formula at
|
||||
high precision on the CPU, emitting `Z_n` as `f32` pairs — that's the
|
||||
reference orbit the GPU perturbs from.
|
||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
|
||||
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
||||
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
||||
relies on `c` being additive in every current kind's formula (a kind where
|
||||
it isn't, e.g. a rational map with `c` in a denominator, would need its own
|
||||
step function that consumes `dc` internally instead, plus extra per-step
|
||||
reference data since the orbit point alone wouldn't be enough to recover an
|
||||
exact delta). `fprime(z)` is the derivative used for distance-estimation
|
||||
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
|
||||
abs-based ones. A `KIND_*` constant here must match the matching
|
||||
`FractalKind` variant's discriminant exactly.
|
||||
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
|
||||
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
||||
the WGSL `Uniforms` struct field-for-field, including padding), and
|
||||
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
|
||||
changed buffers and decides whether to re-run the iterate pass, the cheap
|
||||
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
||||
self-contained tiled renderer used for PNG export off the UI thread.
|
||||
- `src/worker.rs` — native background thread for reference-orbit computation
|
||||
(coalesces bursts of requests so a fast drag doesn't compute every
|
||||
intermediate view). The wasm32 build computes inline instead (see the
|
||||
`#[cfg(target_arch = "wasm32")]` branch in `app.rs::ensure_reference`) —
|
||||
**any signature change to `compute_reference`/`compute_set_reference` or
|
||||
`RefRequest`/`RefResult` must be applied to both call sites.**
|
||||
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
||||
`should_request`/`ensure_reference` (decide when the reference is stale and
|
||||
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
||||
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
|
||||
`default_view_for` (per-kind starting view). `KINDS`, `JULIA_PRESETS`, and
|
||||
`SET_PRESETS` are sized as `[T; FractalKind::<last variant> as usize + 1]` —
|
||||
adding a new `FractalKind` means bumping all three (and adding an empty
|
||||
`&[]` slot to the two preset arrays if the kind has none).
|
||||
- `src/fractal/share.rs` — `ShareState`: encodes the full view (mode, kind,
|
||||
full-precision decimal center, zoom, iterations, per-kind constants,
|
||||
coloring) as a `#`-fragment URL for bookmarking/sharing deep-zoom locations.
|
||||
|
||||
### Adding a new `FractalKind`
|
||||
|
||||
Touches, in order: `reference.rs` (enum variant + CPU iteration formula, and a
|
||||
test comparing against a naive `f64` iteration), `mandelbrot.wgsl` (matching
|
||||
`KIND_*` const + `advance_delta`/`fprime` arms), `buddhabrot.wgsl` (matching
|
||||
arm in `advance()`, if the kind makes sense as a Buddhabrot), `renderer.rs`
|
||||
`Uniforms` (only if the kind needs a new per-kind constant, e.g. Phoenix's
|
||||
`phoenix_p`), `share.rs` (encode/decode string tag), `app.rs` (`KINDS` label,
|
||||
`JULIA_PRESETS`/`SET_PRESETS` slot, `default_view_for` entry, and optionally a
|
||||
UI control for its constant + an animation toggle, following the
|
||||
Phoenix/Lambda pattern). If `c` doesn't enter the formula additively (e.g. a
|
||||
rational map with `c` in a denominator), the `advance_delta`/`step_add` split
|
||||
doesn't work — that needs its own step function plus extra per-step reference
|
||||
data uploaded in a second GPU buffer alongside the orbit.
|
||||
|
||||
### Buddhabrot is a separate pipeline
|
||||
|
||||
`src/fractal/buddhabrot.rs` + `src/shaders/buddhabrot.wgsl` implement the
|
||||
Monte-Carlo orbit-density histogram. It does **not** use the perturbation/
|
||||
reference-orbit machinery: a Buddhabrot sample's orbit scatters across the
|
||||
whole image rather than staying in one pixel, so it's plain `f32` iteration
|
||||
from the live view (no deep zoom) via a compute pass that accumulates into a
|
||||
histogram buffer, tone-mapped by a fragment pass every frame. Its own
|
||||
`KIND_*` iteration formulas in `advance()` must be kept in sync with
|
||||
`reference.rs` by hand (there's no shared code path).
|
||||
|
||||
### Two-pass render + caching (`renderer.rs`)
|
||||
|
||||
The interactive path splits iteration (expensive, perturbation) from
|
||||
colourising (cheap, palette remap) into separate offscreen textures, so
|
||||
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
|
||||
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
|
||||
is actively panning/zooming, the app renders downscaled with AA off
|
||||
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
|
||||
(`INTERACT_SETTLE`).
|
||||
+4
-1
@@ -13,6 +13,8 @@ png = "0.18.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
env_logger = "0.11.11"
|
||||
clap = { version = "4.5.51", features = ["derive"] }
|
||||
pollster = "1.0.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||
@@ -26,7 +28,8 @@ web-sys = { version = "0.3.105", features = ["Window", "Location", "Url", "UrlSe
|
||||
# Release: optimize hard (fractal math is hot).
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
||||
# codegen-units = 1
|
||||
debug = true
|
||||
|
||||
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
|
||||
# egui) so the explorer is actually interactive during development.
|
||||
|
||||
+549
-140
@@ -2,18 +2,23 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use eframe::CreationContext;
|
||||
use eframe::egui_wgpu;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::egui_wgpu::wgpu;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::cli::Cli;
|
||||
use crate::fractal::{
|
||||
BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback,
|
||||
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms,
|
||||
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms, compute_reference,
|
||||
compute_set_reference,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::fractal::{compute_reference, compute_set_reference};
|
||||
use crate::lights::Light;
|
||||
use crate::view::{
|
||||
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
|
||||
precision_for,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use clap::Parser;
|
||||
|
||||
const BAILOUT_SQ: f32 = 1.0e6;
|
||||
/// Cap on exported image dimension (px), to stay within GPU texture limits.
|
||||
@@ -29,6 +34,8 @@ const INTERACT_DOWNSCALE: u32 = 2;
|
||||
const INTERACT_SETTLE: f64 = 0.12;
|
||||
/// Palette names; index maps to `palette_id` in the shader.
|
||||
const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"];
|
||||
/// Shadow palette names; index maps to `palette_id` in the shader.
|
||||
const SHADOW_PALETTE_NAMES: &[&str] = &["Grayscale", "Red & Blue", "Custom lights"];
|
||||
/// Buddhabrot tonemap style names; index maps to `BuddhabrotUniforms::palette`.
|
||||
const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"];
|
||||
|
||||
@@ -60,6 +67,34 @@ fn kind_label(kind: FractalKind) -> &'static str {
|
||||
.unwrap_or("Mandelbrot")
|
||||
}
|
||||
|
||||
/// The iteration formula for a kind, in human-readable notation (mirrors the
|
||||
/// doc comments on `FractalKind`'s variants). `power` is only used by
|
||||
/// Multibrot.
|
||||
fn kind_formula(kind: FractalKind, power: u32) -> String {
|
||||
match kind {
|
||||
FractalKind::Mandelbrot => "z → z² + c".to_string(),
|
||||
FractalKind::BurningShip => "z → (|Re z| + i|Im z|)² + c".to_string(),
|
||||
FractalKind::Tricorn => "z → conj(z)² + c".to_string(),
|
||||
FractalKind::Multibrot => format!("z → z^{power} + c"),
|
||||
FractalKind::Celtic => "z → |Re(z²)| + i·Im(z²) + c".to_string(),
|
||||
FractalKind::Perpendicular => "z → (x² − y²) − 2x|y|i + c".to_string(),
|
||||
FractalKind::Buffalo => "z → |Re(z²)| − i|Im(z²)| + c".to_string(),
|
||||
FractalKind::Phoenix => "z → z² + c + p·z_prev".to_string(),
|
||||
FractalKind::Lambda => "z → λ·z(1 − z)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a long decimal string for compact display, keeping the leading
|
||||
/// (most significant) digits and marking the cut with "…".
|
||||
fn truncate_digits(s: &str, max_len: usize) -> String {
|
||||
if s.chars().count() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
let head: String = s.chars().take(max_len).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
|
||||
type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>);
|
||||
|
||||
/// Nice-looking Julia constants offered as presets.
|
||||
@@ -121,7 +156,7 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [
|
||||
"-0.7436438870371587",
|
||||
"0.1318259042053",
|
||||
8.0e-8,
|
||||
10000,
|
||||
2000,
|
||||
None,
|
||||
),
|
||||
],
|
||||
@@ -262,11 +297,17 @@ pub struct FractalApp {
|
||||
color_scale: f32,
|
||||
color_offset: f32,
|
||||
palette: u32,
|
||||
shadow_palette: u32,
|
||||
/// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work).
|
||||
antialias: bool,
|
||||
/// Distance-estimation shading: darkens toward the set boundary using the
|
||||
/// orbit derivative, giving crisp filaments at deep zoom instead of speckle.
|
||||
de_coloring: bool,
|
||||
// Use shadow coloring
|
||||
shadow: bool,
|
||||
|
||||
/// List of enabled lights in the world
|
||||
lights: Vec<Light>,
|
||||
|
||||
/// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of
|
||||
/// the ordinary escape-time set. Plain f32 view — no deep zoom, no
|
||||
@@ -290,6 +331,11 @@ pub struct FractalApp {
|
||||
/// Whether the app is in fullscreen (browser Fullscreen API on web, viewport
|
||||
/// fullscreen on native). Kept in sync with the real state each frame.
|
||||
fullscreen: bool,
|
||||
/// Whether the "Fractal Info" popup (formula/constants/zoom for the
|
||||
/// current view) is open.
|
||||
info_open: bool,
|
||||
/// Whether the Help window (about + mouse/touch controls) is open.
|
||||
help_open: bool,
|
||||
/// Time-based animation of colours / Julia c / Phoenix p / zoom.
|
||||
anim: AnimState,
|
||||
|
||||
@@ -329,6 +375,9 @@ pub struct FractalApp {
|
||||
export_requested: bool,
|
||||
/// Progress/handle for an in-flight PNG export, if any.
|
||||
export: Option<Arc<Mutex<ExportShared>>>,
|
||||
/// Output path for `--export` (native CLI only); falls back to a
|
||||
/// timestamped name when unset.
|
||||
export_path: Option<String>,
|
||||
/// Short status line (saved path, "link copied", errors).
|
||||
status: Option<String>,
|
||||
|
||||
@@ -379,6 +428,27 @@ impl FractalApp {
|
||||
guard.callback_resources.insert(buddhabrot_renderer);
|
||||
}
|
||||
|
||||
let mut app = Self::default_state();
|
||||
|
||||
// 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, driven by CLI flags.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.apply_cli(Cli::parse());
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Build the app's default state (no window, no GPU, no CLI applied yet).
|
||||
/// Shared by the windowed app (`new`, which then layers CLI/share-link
|
||||
/// overrides on top) and headless rendering.
|
||||
pub(crate) fn default_state() -> Self {
|
||||
let view = ViewState::default();
|
||||
let ref_center_re = view.center_re.clone();
|
||||
let ref_center_im = view.center_im.clone();
|
||||
@@ -388,7 +458,7 @@ impl FractalApp {
|
||||
let center_im_edit = big_to_decimal_str(&view.center_im, sig);
|
||||
let zoom_edit = format_magnification(view.magnification());
|
||||
|
||||
let mut app = Self {
|
||||
Self {
|
||||
view,
|
||||
mode: FractalMode::Mandelbrot,
|
||||
kind: FractalKind::Mandelbrot,
|
||||
@@ -401,8 +471,11 @@ impl FractalApp {
|
||||
color_scale: 0.15,
|
||||
color_offset: 0.0,
|
||||
palette: 0,
|
||||
shadow_palette: 0,
|
||||
antialias: false,
|
||||
de_coloring: false,
|
||||
shadow: false,
|
||||
lights: vec![Light::default()],
|
||||
buddhabrot: false,
|
||||
buddha_r_cap: 50,
|
||||
buddha_g_cap: 500,
|
||||
@@ -412,6 +485,8 @@ impl FractalApp {
|
||||
buddha_accumulate: true,
|
||||
controls_open: true,
|
||||
fullscreen: false,
|
||||
info_open: false,
|
||||
help_open: false,
|
||||
anim: AnimState::default(),
|
||||
fps: 0.0,
|
||||
fps_frames: 0,
|
||||
@@ -430,78 +505,59 @@ impl FractalApp {
|
||||
last_interact_time: -1.0e9,
|
||||
export_requested: false,
|
||||
export: None,
|
||||
export_path: None,
|
||||
status: None,
|
||||
center_re_edit,
|
||||
center_im_edit,
|
||||
zoom_edit,
|
||||
zoom_edited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
/// Apply native CLI flags on top of the default state: fractal kind/mode,
|
||||
/// a restored share link or explicit view, coloring toggles, and export
|
||||
/// options. Shared by the windowed app and headless rendering.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn apply_cli(&mut self, cli: Cli) {
|
||||
if let Some(k) = cli.kind {
|
||||
self.kind = k.into();
|
||||
if let Some(p) = cli.power {
|
||||
self.power = p.clamp(2, 8);
|
||||
}
|
||||
self.view = Self::default_view_for(self.mode, self.kind);
|
||||
}
|
||||
if let Some(jc) = cli.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>()),
|
||||
) {
|
||||
self.mode = FractalMode::Julia;
|
||||
self.julia_c = (re, im);
|
||||
self.view = Self::default_view_for(FractalMode::Julia, self.kind);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug/testing hooks.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(frag) = cli.share
|
||||
&& let Some(state) = ShareState::decode(&frag)
|
||||
{
|
||||
if let Ok(k) = std::env::var("MANDEL_KIND") {
|
||||
app.kind = match k.trim().to_ascii_lowercase().as_str() {
|
||||
"burningship" | "burning_ship" | "ship" => FractalKind::BurningShip,
|
||||
"tricorn" | "mandelbar" => FractalKind::Tricorn,
|
||||
"multibrot" | "multi" => FractalKind::Multibrot,
|
||||
"celtic" => FractalKind::Celtic,
|
||||
"perpendicular" | "perp" => FractalKind::Perpendicular,
|
||||
"buffalo" => FractalKind::Buffalo,
|
||||
"phoenix" => FractalKind::Phoenix,
|
||||
_ => FractalKind::Mandelbrot,
|
||||
};
|
||||
if let Ok(p) = std::env::var("MANDEL_POWER")
|
||||
&& let Ok(p) = p.trim().parse::<u32>()
|
||||
{
|
||||
app.power = p.clamp(2, 8);
|
||||
}
|
||||
app.view = Self::default_view_for(app.mode, app.kind);
|
||||
}
|
||||
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, app.kind);
|
||||
}
|
||||
}
|
||||
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_DE").is_ok() {
|
||||
app.de_coloring = true;
|
||||
}
|
||||
if std::env::var("MANDEL_BUDDHABROT").is_ok() {
|
||||
app.buddhabrot = true;
|
||||
}
|
||||
if let Ok(p) = std::env::var("MANDEL_BUDDHA_PALETTE")
|
||||
&& let Ok(p) = p.trim().parse::<u32>()
|
||||
{
|
||||
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
|
||||
}
|
||||
if std::env::var("MANDEL_EXPORT").is_ok() {
|
||||
app.export_requested = true;
|
||||
}
|
||||
self.apply_share(&state);
|
||||
}
|
||||
if let Some(spec) = cli.view {
|
||||
self.apply_view_spec(&spec);
|
||||
}
|
||||
if cli.de {
|
||||
self.de_coloring = true;
|
||||
}
|
||||
if cli.buddhabrot {
|
||||
self.buddhabrot = true;
|
||||
}
|
||||
if let Some(p) = cli.buddha_palette {
|
||||
self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
|
||||
}
|
||||
self.export_path = cli.export_path;
|
||||
if cli.export {
|
||||
self.export_requested = true;
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal,
|
||||
@@ -576,6 +632,7 @@ impl FractalApp {
|
||||
color_scale: self.color_scale,
|
||||
color_offset: self.color_offset,
|
||||
palette: self.palette,
|
||||
shadow_palette: self.shadow_palette,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +651,8 @@ impl FractalApp {
|
||||
self.color_scale = s.color_scale;
|
||||
self.color_offset = s.color_offset;
|
||||
self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32;
|
||||
self.shadow_palette =
|
||||
(s.shadow_palette as usize).min(SHADOW_PALETTE_NAMES.len() - 1) as u32;
|
||||
// The link carries an explicit iteration count; honor it rather than
|
||||
// letting the auto-scaler immediately overwrite it.
|
||||
self.auto_iterations = false;
|
||||
@@ -713,6 +772,14 @@ impl FractalApp {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The current reference orbit, as uploaded to the GPU. Used by headless
|
||||
/// rendering to build its own `ExportRender` without going through
|
||||
/// `egui_wgpu`'s callback machinery.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn reference_points(&self) -> &[[f32; 2]] {
|
||||
&self.reference
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -791,7 +858,63 @@ impl FractalApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_uniforms(&self, aspect: f64) -> Uniforms {
|
||||
/// Compute the reference orbit for the current view synchronously, on the
|
||||
/// calling thread — unlike `ensure_reference`, which dispatches to the
|
||||
/// native worker (or, on wasm, computes inline but still runs once per
|
||||
/// frame poll). Used by headless rendering, which has no frame loop to
|
||||
/// poll a background result on and only ever needs one reference.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn compute_reference_blocking(&mut self) {
|
||||
if self.auto_iterations {
|
||||
self.max_iterations = self.auto_iteration_count();
|
||||
}
|
||||
let mut key = self.current_key();
|
||||
let precision = self.view.precision_bits();
|
||||
let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1);
|
||||
|
||||
// Lambda in Set mode has a static fractal centered at origin.
|
||||
if key.kind == FractalKind::Lambda && !key.julia {
|
||||
key.center_re = big_from_f64(0.0, precision);
|
||||
key.center_im = big_from_f64(0.0, precision);
|
||||
}
|
||||
|
||||
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,
|
||||
key.kind,
|
||||
key.power,
|
||||
key.phoenix_p,
|
||||
key.lambda_l,
|
||||
)
|
||||
} else {
|
||||
compute_set_reference(
|
||||
&key.center_re,
|
||||
&key.center_im,
|
||||
max_iter,
|
||||
precision,
|
||||
key.kind,
|
||||
key.power,
|
||||
key.phoenix_p,
|
||||
key.lambda_l,
|
||||
)
|
||||
};
|
||||
self.apply_reference(
|
||||
points,
|
||||
key.center_re.clone(),
|
||||
key.center_im.clone(),
|
||||
key.half_height,
|
||||
);
|
||||
self.last_request = Some(key);
|
||||
}
|
||||
|
||||
pub(crate) 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],
|
||||
@@ -802,14 +925,16 @@ impl FractalApp {
|
||||
bailout_sq: BAILOUT_SQ,
|
||||
is_julia: matches!(self.mode, FractalMode::Julia) as u32,
|
||||
palette_id: self.palette,
|
||||
shadow_palette_id: self.shadow_palette,
|
||||
aa_level: if self.antialias { 2 } else { 1 },
|
||||
kind: self.kind as u32,
|
||||
power: self.power,
|
||||
dc_offset: self.dc_offset(),
|
||||
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
||||
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
||||
de_coloring: self.de_coloring as u32,
|
||||
_pad: [0],
|
||||
de_coloring: (self.de_coloring | self.shadow) as u32,
|
||||
shadow: self.shadow as u32,
|
||||
_pad: [0; _],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,11 +958,11 @@ impl FractalApp {
|
||||
r_cap: self.buddha_r_cap,
|
||||
g_cap: self.buddha_g_cap,
|
||||
b_cap: self.buddha_b_cap,
|
||||
seed: 0, // set by the callback's own dispatch counter
|
||||
seed: 0, // set by the callback's own dispatch counter
|
||||
samples_this_dispatch: 0, // set by the callback
|
||||
exposure: self.buddha_exposure,
|
||||
width: 0, // set by the callback from size_px
|
||||
height: 0, // set by the callback from size_px
|
||||
width: 0, // set by the callback from size_px
|
||||
height: 0, // set by the callback from size_px
|
||||
total_samples: 0.0, // tracked by the renderer across frames
|
||||
palette: self.buddha_palette,
|
||||
_pad: [0; 3],
|
||||
@@ -890,13 +1015,12 @@ impl FractalApp {
|
||||
self.status = None;
|
||||
self.export = Some(Arc::clone(&shared));
|
||||
|
||||
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
|
||||
const RENDER_END: f32 = 0.6;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let name = std::env::var("MANDEL_EXPORT_PATH")
|
||||
.unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp()));
|
||||
let name = self
|
||||
.export_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
std::thread::spawn(move || {
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
@@ -909,51 +1033,11 @@ impl FractalApp {
|
||||
uniforms,
|
||||
reference.as_slice(),
|
||||
);
|
||||
|
||||
// Render the image tile by tile, waiting for each so progress
|
||||
// reflects real GPU work.
|
||||
for t in 0..er.tiles {
|
||||
er.render_tile(&device, &queue, t);
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
let sh = Arc::clone(&shared);
|
||||
let png =
|
||||
crate::fractal::export_to_png_blocking(&device, &queue, &er, |phase, f| {
|
||||
set_progress(&sh, phase, f)
|
||||
});
|
||||
let done = (t + 1) as f32 / er.tiles as f32;
|
||||
set_progress(&shared, "Rendering", RENDER_END * done);
|
||||
}
|
||||
er.copy_to_readback(&device, &queue);
|
||||
|
||||
// Wait for the copy, then read the mapped bytes.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
er.readback()
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |res| {
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let _ = rx.recv();
|
||||
|
||||
set_progress(&shared, "Encoding", RENDER_END);
|
||||
let png = {
|
||||
let data = er
|
||||
.readback()
|
||||
.slice(..)
|
||||
.get_mapped_range()
|
||||
.expect("map readback buffer");
|
||||
let sh = Arc::clone(&shared);
|
||||
crate::fractal::encode_png_with_progress(
|
||||
&data,
|
||||
er.width,
|
||||
er.height,
|
||||
er.padded_bpr,
|
||||
er.swap_rb,
|
||||
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
|
||||
)
|
||||
};
|
||||
er.readback().unmap();
|
||||
|
||||
set_progress(&shared, "Saving", 0.98);
|
||||
let result = std::fs::write(&name, &png)
|
||||
@@ -964,6 +1048,8 @@ impl FractalApp {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
|
||||
const RENDER_END: f32 = 0.6;
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
@@ -1076,6 +1162,13 @@ impl FractalApp {
|
||||
self.fullscreen = !self.fullscreen;
|
||||
self.apply_fullscreen(ui.ctx());
|
||||
}
|
||||
if ui
|
||||
.button("Help")
|
||||
.on_hover_text("About this app, and mouse/touch controls")
|
||||
.clicked()
|
||||
{
|
||||
self.help_open = !self.help_open;
|
||||
}
|
||||
// FPS readout. Monospace + fixed width so the number
|
||||
// changing doesn't jitter the button row.
|
||||
ui.add(
|
||||
@@ -1094,6 +1187,206 @@ impl FractalApp {
|
||||
});
|
||||
}
|
||||
|
||||
/// Floating bottom-left overlay: a single button that toggles the
|
||||
/// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left)
|
||||
/// so it stays out of the way of the panel toggle / fullscreen controls,
|
||||
/// but is still reachable even when the controls panel is collapsed.
|
||||
fn info_button(&mut self, ui: &mut egui::Ui) {
|
||||
egui::Area::new(egui::Id::new("info_button"))
|
||||
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style())
|
||||
.shadow(egui::Shadow::NONE)
|
||||
.show(ui, |ui| {
|
||||
if ui
|
||||
.button("ⓘ Info")
|
||||
.on_hover_text("Show details about the current fractal")
|
||||
.clicked()
|
||||
{
|
||||
self.info_open = !self.info_open;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Window with details about what's currently on screen: formula, active
|
||||
/// per-kind constants, zoom depth, iteration count. Reads live state, so
|
||||
/// it stays correct as the user pans/zooms/switches kinds.
|
||||
fn info_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.info_open;
|
||||
egui::Window::new("Fractal Info")
|
||||
.id(egui::Id::new("info_window"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -44.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(kind_label(self.kind))
|
||||
.strong()
|
||||
.heading(),
|
||||
);
|
||||
let mode_label = match self.mode {
|
||||
FractalMode::Mandelbrot => {
|
||||
"Mandelbrot mode — parameter space (c varies per pixel, z₀ = 0)"
|
||||
}
|
||||
FractalMode::Julia => {
|
||||
"Julia mode — dynamical plane for a fixed c (z₀ varies per pixel)"
|
||||
}
|
||||
};
|
||||
ui.label(mode_label);
|
||||
ui.separator();
|
||||
|
||||
ui.label(format!("formula: {}", kind_formula(self.kind, self.power)));
|
||||
if self.mode == FractalMode::Julia {
|
||||
ui.label(format!("c = {:.6} {:+.6}i", self.julia_c.0, self.julia_c.1));
|
||||
}
|
||||
if self.kind == FractalKind::Phoenix {
|
||||
ui.label(format!(
|
||||
"p = {:.6} {:+.6}i",
|
||||
self.phoenix_p.0, self.phoenix_p.1
|
||||
));
|
||||
}
|
||||
if self.kind == FractalKind::Lambda {
|
||||
ui.label(format!(
|
||||
"λ = {:.6} {:+.6}i",
|
||||
self.lambda_l.0, self.lambda_l.1
|
||||
));
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
if self.buddhabrot {
|
||||
ui.label("rendering: Buddhabrot (Monte-Carlo orbit density)");
|
||||
ui.label(format!(
|
||||
"sample caps: R {} / G {} / B {}",
|
||||
self.buddha_r_cap, self.buddha_g_cap, self.buddha_b_cap
|
||||
));
|
||||
} else {
|
||||
ui.label(format!(
|
||||
"magnification: {}×",
|
||||
format_magnification(self.view.magnification())
|
||||
));
|
||||
ui.label(format!(
|
||||
"iterations: {}{}",
|
||||
self.max_iterations,
|
||||
if self.auto_iterations { " (auto)" } else { "" }
|
||||
));
|
||||
ui.label(format!("precision: {} bits", self.view.precision_bits()));
|
||||
ui.label(format!(
|
||||
"center re: {}",
|
||||
truncate_digits(&self.center_re_edit, 24)
|
||||
));
|
||||
ui.label(format!(
|
||||
"center im: {}",
|
||||
truncate_digits(&self.center_im_edit, 24)
|
||||
));
|
||||
}
|
||||
});
|
||||
self.info_open = open;
|
||||
}
|
||||
|
||||
/// Help window: what the app does, plus a reference for mouse/touch and
|
||||
/// keyboard controls.
|
||||
fn help_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.help_open;
|
||||
egui::Window::new("Help")
|
||||
.id(egui::Id::new("help_window"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.default_width(360.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(480.0)
|
||||
.show(ui, |ui| {
|
||||
ui.heading("About");
|
||||
ui.label(
|
||||
"A deep-zoom fractal explorer. It renders the Mandelbrot set \
|
||||
and several related fractals (Burning Ship, Tricorn, \
|
||||
Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda), \
|
||||
and can zoom in far beyond what ordinary floating-point math \
|
||||
allows by using perturbation theory: one very precise \
|
||||
reference orbit is computed once, and every pixel on screen \
|
||||
is then rendered cheaply as a small offset from that shared \
|
||||
orbit.",
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
"Mandelbrot mode plots one point per pixel in parameter space \
|
||||
(does c escape?). Julia mode fixes c and instead varies the \
|
||||
starting point z₀ across the plane. Buddhabrot mode switches \
|
||||
to a different, Monte-Carlo rendering of orbit density \
|
||||
instead of the ordinary escape-time set.",
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Mouse & touch");
|
||||
egui::Grid::new("help_mouse_grid")
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Drag");
|
||||
ui.label("Pan the view");
|
||||
ui.end_row();
|
||||
ui.label("Scroll / trackpad");
|
||||
ui.label("Zoom toward the cursor");
|
||||
ui.end_row();
|
||||
ui.label("Pinch (touch)");
|
||||
ui.label("Zoom toward the gesture center");
|
||||
ui.end_row();
|
||||
ui.label("Two-finger drag (touch)");
|
||||
ui.label("Pan the view");
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Keyboard");
|
||||
egui::Grid::new("help_keyboard_grid")
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Arrow keys");
|
||||
ui.label("Pan the view");
|
||||
ui.end_row();
|
||||
ui.label("Z / S");
|
||||
ui.label("Zoom in / out toward the center");
|
||||
ui.end_row();
|
||||
ui.label("+ / -");
|
||||
ui.label("Increase / decrease iterations");
|
||||
ui.end_row();
|
||||
ui.label("R");
|
||||
ui.label("Reset to the default view");
|
||||
ui.end_row();
|
||||
ui.label("H");
|
||||
ui.label("Toggle this Help window");
|
||||
ui.end_row();
|
||||
ui.label("I");
|
||||
ui.label("Toggle the Info window");
|
||||
ui.end_row();
|
||||
ui.label("A");
|
||||
ui.label("Toggle antialiasing (2×2)");
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
"Shortcuts are ignored while a text field (e.g. the center/zoom \
|
||||
edit boxes) has focus. Esc / F11 toggle fullscreen via the \
|
||||
browser or OS, as usual.",
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Tips");
|
||||
ui.label(
|
||||
"• The controls panel (top-left \"Menu\" button) holds the \
|
||||
fractal formula, coloring, animation, and export options.\n\
|
||||
• The bottom-left \"Info\" button shows details about what's \
|
||||
currently on screen.\n\
|
||||
• \"Copy link\" (in the panel) encodes the exact view so it \
|
||||
can be reopened later or sent to someone else.",
|
||||
);
|
||||
});
|
||||
});
|
||||
self.help_open = open;
|
||||
}
|
||||
|
||||
/// Push the desired fullscreen state to the platform.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_fullscreen(&mut self, ctx: &egui::Context) {
|
||||
@@ -1354,32 +1647,66 @@ impl FractalApp {
|
||||
.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.checkbox(&mut self.shadow, "Shadow");
|
||||
if !self.shadow {
|
||||
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);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
egui::ComboBox::from_label("palette")
|
||||
.selected_text(SHADOW_PALETTE_NAMES[self.shadow_palette as usize])
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, name) in SHADOW_PALETTE_NAMES.iter().enumerate() {
|
||||
ui.selectable_value(&mut self.shadow_palette, i as u32, *name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if self.shadow && self.shadow_palette as usize == SHADOW_PALETTE_NAMES.len() - 1 {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("lights:");
|
||||
if ui.button("+").clicked() {
|
||||
self.lights.push(Light::default());
|
||||
}
|
||||
});
|
||||
egui::Grid::new("lights")
|
||||
.striped(true)
|
||||
.num_columns(1)
|
||||
.show(ui, |ui| {
|
||||
self.lights.retain_mut(|light| {
|
||||
let delete = !light.widget(ui);
|
||||
ui.end_row();
|
||||
delete
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
|
||||
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
|
||||
ui.checkbox(&mut self.de_coloring, "Distance shading")
|
||||
.on_hover_text(
|
||||
"Shade by distance to the set boundary (from the orbit derivative) \
|
||||
if !self.shadow {
|
||||
ui.checkbox(&mut self.de_coloring, "Distance shading")
|
||||
.on_hover_text(
|
||||
"Shade by distance to the set boundary (from the orbit derivative) \
|
||||
for crisp filaments at deep zoom. Exact for the holomorphic kinds \
|
||||
(Mandelbrot/Multibrot/Phoenix), approximate for the abs-based kinds \
|
||||
(Burning Ship/Tricorn/Celtic/Perpendicular/Buffalo).",
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
ui.collapsing("Animation", |ui| {
|
||||
ui.checkbox(&mut self.anim.color, "Cycle colours")
|
||||
.on_hover_text("Scroll the palette offset over time.");
|
||||
if self.anim.color {
|
||||
ui.add(
|
||||
egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0)
|
||||
.text("cycles/s")
|
||||
.logarithmic(true),
|
||||
);
|
||||
if !self.shadow {
|
||||
ui.checkbox(&mut self.anim.color, "Cycle colours")
|
||||
.on_hover_text("Scroll the palette offset over time.");
|
||||
if self.anim.color {
|
||||
ui.add(
|
||||
egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0)
|
||||
.text("cycles/s")
|
||||
.logarithmic(true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ui.checkbox(&mut self.anim.zoom, "Auto-zoom")
|
||||
@@ -1675,6 +2002,84 @@ impl FractalApp {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
// Keyboard: arrows pan, z/s zoom in/out, +/- adjust iterations, R
|
||||
// resets the view, H/I toggle the Help/Info windows. Skipped while a
|
||||
// text field (e.g. the center/zoom edit boxes) has focus.
|
||||
if !ui.ctx().egui_wants_keyboard_input() {
|
||||
let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1);
|
||||
let (left, right, up, down, zoom_in, zoom_out) = ui.input(|i| {
|
||||
(
|
||||
i.key_down(egui::Key::ArrowLeft),
|
||||
i.key_down(egui::Key::ArrowRight),
|
||||
i.key_down(egui::Key::ArrowUp),
|
||||
i.key_down(egui::Key::ArrowDown),
|
||||
i.key_down(egui::Key::Z),
|
||||
i.key_down(egui::Key::S),
|
||||
)
|
||||
});
|
||||
|
||||
// Pixels/sec pan speed — matches a brisk mouse drag regardless of
|
||||
// frame rate. See `pan_pixels`'s screen-space (+x right, +y down)
|
||||
// convention: Right/Down pan the *camera* right/down, which is
|
||||
// the opposite delta sign from a drag that would show the same
|
||||
// content (a drag grabs the canvas; these keys move the camera).
|
||||
const PAN_SPEED_PX: f64 = 700.0;
|
||||
let mut dx = 0.0;
|
||||
let mut dy = 0.0;
|
||||
if left {
|
||||
dx += PAN_SPEED_PX * dt;
|
||||
}
|
||||
if right {
|
||||
dx -= PAN_SPEED_PX * dt;
|
||||
}
|
||||
if down {
|
||||
dy += PAN_SPEED_PX * dt;
|
||||
}
|
||||
if up {
|
||||
dy -= PAN_SPEED_PX * dt;
|
||||
}
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
self.view.pan_pixels(dx, dy, height_px);
|
||||
interacted = true;
|
||||
}
|
||||
|
||||
// e-folds/sec, same scale as the auto-zoom animation.
|
||||
const ZOOM_SPEED: f64 = 1.0;
|
||||
if zoom_in != zoom_out {
|
||||
let rate = if zoom_in { ZOOM_SPEED } else { -ZOOM_SPEED };
|
||||
let factor = (-rate * dt).exp();
|
||||
self.view.zoom_at_pixel(0.0, 0.0, height_px, factor);
|
||||
interacted = true;
|
||||
}
|
||||
if left || right || up || down || zoom_in || zoom_out {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
if ui.input(|i| i.key_pressed(egui::Key::R)) {
|
||||
self.view = Self::default_view_for(self.mode, self.kind);
|
||||
interacted = true;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::H)) {
|
||||
self.help_open = !self.help_open;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::I)) {
|
||||
self.info_open = !self.info_open;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::A)) {
|
||||
self.antialias = !self.antialias;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) {
|
||||
self.auto_iterations = false;
|
||||
self.max_iterations = ((self.max_iterations as f64 * 1.25).round() as u32)
|
||||
.clamp(32, MAX_REF_POINTS as u32 - 1);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Minus)) {
|
||||
self.auto_iterations = false;
|
||||
self.max_iterations = ((self.max_iterations as f64 / 1.25).round() as u32)
|
||||
.clamp(32, MAX_REF_POINTS as u32 - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if self.buddhabrot {
|
||||
// No reference orbit / perturbation machinery: iterate directly in
|
||||
// f32 from the live view. Progressive accumulation means this
|
||||
@@ -1754,6 +2159,7 @@ impl FractalApp {
|
||||
rect,
|
||||
FractalCallback {
|
||||
uniforms,
|
||||
lights: self.lights.clone(),
|
||||
reference: Arc::clone(&self.reference),
|
||||
generation: self.generation,
|
||||
size_px,
|
||||
@@ -1792,6 +2198,9 @@ impl eframe::App for FractalApp {
|
||||
// Floating overlay, always reachable (even when the panel is collapsed):
|
||||
// toggle the panel and toggle fullscreen. Essential on a phone.
|
||||
self.overlay_buttons(ui);
|
||||
self.info_button(ui);
|
||||
self.info_window(ui.ctx());
|
||||
self.help_window(ui.ctx());
|
||||
|
||||
if std::mem::take(&mut self.export_requested) {
|
||||
self.do_export(frame);
|
||||
@@ -1820,7 +2229,7 @@ fn finish_export(shared: &Arc<Mutex<ExportShared>>, result: Result<String, Strin
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unix_timestamp() -> u64 {
|
||||
pub(crate) fn unix_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Native command-line arguments. Currently mirrors the old `MANDEL_*` debug
|
||||
// env vars one-for-one; this is the foundation a future headless (no-window,
|
||||
// render-to-file) mode will build on.
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
use crate::fractal::FractalKind;
|
||||
|
||||
#[derive(Parser, Debug, Default)]
|
||||
#[command(name = "mandelbrot", about = "Deep-zoom fractal explorer", version)]
|
||||
pub struct Cli {
|
||||
/// Fractal formula to render.
|
||||
#[arg(long, value_enum)]
|
||||
pub kind: Option<KindArg>,
|
||||
|
||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
|
||||
#[arg(long)]
|
||||
pub power: Option<u32>,
|
||||
|
||||
/// Start in Julia mode with this seed constant.
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub julia: Option<String>,
|
||||
|
||||
/// Restore a view from a share-link fragment (the part after '#').
|
||||
#[arg(long, value_name = "FRAGMENT")]
|
||||
pub share: Option<String>,
|
||||
|
||||
/// Jump to a view on startup.
|
||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||
pub view: Option<String>,
|
||||
|
||||
/// Enable distance-estimation shading.
|
||||
#[arg(long)]
|
||||
pub de: bool,
|
||||
|
||||
/// Switch to the Buddhabrot renderer.
|
||||
#[arg(long)]
|
||||
pub buddhabrot: bool,
|
||||
|
||||
/// Buddhabrot tonemap palette index.
|
||||
#[arg(long, value_name = "INDEX")]
|
||||
pub buddha_palette: Option<u32>,
|
||||
|
||||
/// Render a PNG export on startup.
|
||||
#[arg(long)]
|
||||
pub export: bool,
|
||||
|
||||
/// Output path for --export/--headless (default: fractal-<timestamp>.png).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub export_path: Option<String>,
|
||||
|
||||
/// Run without opening a window: render the current view to a PNG and
|
||||
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
||||
/// render. Not yet supported with --buddhabrot.
|
||||
#[arg(long)]
|
||||
pub headless: bool,
|
||||
|
||||
/// Output image width in pixels (--headless only).
|
||||
#[arg(long, value_name = "PX", default_value_t = 1920)]
|
||||
pub width: u32,
|
||||
|
||||
/// Output image height in pixels (--headless only).
|
||||
#[arg(long, value_name = "PX", default_value_t = 1080)]
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum KindArg {
|
||||
Mandelbrot,
|
||||
#[value(alias = "ship")]
|
||||
BurningShip,
|
||||
#[value(alias = "mandelbar")]
|
||||
Tricorn,
|
||||
#[value(alias = "multi")]
|
||||
Multibrot,
|
||||
Celtic,
|
||||
#[value(alias = "perp")]
|
||||
Perpendicular,
|
||||
Buffalo,
|
||||
Phoenix,
|
||||
Lambda,
|
||||
}
|
||||
|
||||
impl From<KindArg> for FractalKind {
|
||||
fn from(k: KindArg) -> Self {
|
||||
match k {
|
||||
KindArg::Mandelbrot => FractalKind::Mandelbrot,
|
||||
KindArg::BurningShip => FractalKind::BurningShip,
|
||||
KindArg::Tricorn => FractalKind::Tricorn,
|
||||
KindArg::Multibrot => FractalKind::Multibrot,
|
||||
KindArg::Celtic => FractalKind::Celtic,
|
||||
KindArg::Perpendicular => FractalKind::Perpendicular,
|
||||
KindArg::Buffalo => FractalKind::Buffalo,
|
||||
KindArg::Phoenix => FractalKind::Phoenix,
|
||||
KindArg::Lambda => FractalKind::Lambda,
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -8,8 +8,9 @@ pub mod share;
|
||||
|
||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||
pub use reference::{FractalKind, compute_reference, compute_set_reference};
|
||||
pub use renderer::{
|
||||
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
|
||||
encode_png_with_progress,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use renderer::encode_png_with_progress;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use renderer::export_to_png_blocking;
|
||||
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
||||
pub use share::ShareState;
|
||||
|
||||
+91
-3
@@ -13,6 +13,8 @@ use std::sync::Arc;
|
||||
|
||||
use eframe::egui_wgpu::{self, wgpu};
|
||||
|
||||
use crate::lights::{Light, MAX_LIGHT_COUNT};
|
||||
|
||||
/// 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;
|
||||
@@ -46,6 +48,8 @@ fn color_differs(a: &Uniforms, b: &Uniforms) -> bool {
|
||||
a.color_offset != b.color_offset
|
||||
|| a.color_scale != b.color_scale
|
||||
|| a.palette_id != b.palette_id
|
||||
|| a.shadow_palette_id != b.shadow_palette_id
|
||||
|| a.shadow != b.shadow
|
||||
}
|
||||
|
||||
/// GPU-side view + coloring parameters. Layout must match `Uniforms` in the
|
||||
@@ -64,12 +68,14 @@ pub struct Uniforms {
|
||||
/// 0 = Mandelbrot, 1 = Julia.
|
||||
pub is_julia: u32,
|
||||
pub palette_id: u32,
|
||||
pub shadow_palette_id: u32,
|
||||
/// Supersampling factor per axis: 1 = off, 2 = 2×2 (4 samples).
|
||||
pub aa_level: u32,
|
||||
/// Iteration formula (`FractalKind::shader_id`).
|
||||
pub kind: u32,
|
||||
/// Exponent for the Multibrot kind.
|
||||
pub power: u32,
|
||||
pub _pad: [u32; 1],
|
||||
/// 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.
|
||||
@@ -83,8 +89,8 @@ pub struct Uniforms {
|
||||
pub lambda_l: [f32; 2],
|
||||
/// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
pub de_coloring: u32,
|
||||
/// Padding to a 16-byte multiple (uniform buffer requirement).
|
||||
pub _pad: [u32; 1],
|
||||
// 0 = classic colors, 1 = shadows
|
||||
pub shadow: u32,
|
||||
}
|
||||
|
||||
/// Offscreen textures for the two-pass render, recreated whenever the widget's
|
||||
@@ -128,6 +134,7 @@ pub struct FractalRenderer {
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
ref_buffer: wgpu::Buffer,
|
||||
lights_buffer: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
target_format: wgpu::TextureFormat,
|
||||
/// Generation of the reference orbit currently uploaded to `ref_buffer`.
|
||||
@@ -170,6 +177,13 @@ impl FractalRenderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let lights_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("lights parameters"),
|
||||
size: (MAX_LIGHT_COUNT * std::mem::size_of::<Light>()) as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | 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: &[
|
||||
@@ -301,6 +315,16 @@ impl FractalRenderer {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let colorize_pipeline_layout =
|
||||
@@ -409,6 +433,7 @@ impl FractalRenderer {
|
||||
bind_group_layout,
|
||||
uniform_buffer,
|
||||
ref_buffer,
|
||||
lights_buffer,
|
||||
bind_group,
|
||||
target_format,
|
||||
uploaded_generation: u64::MAX,
|
||||
@@ -477,6 +502,10 @@ impl FractalRenderer {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&data_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.lights_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -728,6 +757,59 @@ impl ExportRender {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `er` tile by tile (blocking on the GPU after each tile so progress
|
||||
/// reflects real work), read it back, and encode the result as PNG bytes.
|
||||
/// Blocks the calling thread throughout, so it's only for native targets:
|
||||
/// the UI export path runs it on a background thread, headless rendering
|
||||
/// runs it directly since it has no frame loop to share a thread with.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_to_png_blocking(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
er: &ExportRender,
|
||||
mut on_progress: impl FnMut(&'static str, f32),
|
||||
) -> Vec<u8> {
|
||||
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
|
||||
const RENDER_END: f32 = 0.6;
|
||||
|
||||
for t in 0..er.tiles {
|
||||
er.render_tile(device, queue, t);
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let done = (t + 1) as f32 / er.tiles as f32;
|
||||
on_progress("Rendering", RENDER_END * done);
|
||||
}
|
||||
er.copy_to_readback(device, queue);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
er.readback()
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |res| {
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let _ = rx.recv();
|
||||
|
||||
on_progress("Encoding", RENDER_END);
|
||||
let png = {
|
||||
let data = er
|
||||
.readback()
|
||||
.slice(..)
|
||||
.get_mapped_range()
|
||||
.expect("map readback buffer");
|
||||
encode_png_with_progress(&data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| {
|
||||
on_progress("Encoding", RENDER_END + (0.97 - RENDER_END) * f)
|
||||
})
|
||||
};
|
||||
er.readback().unmap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it
|
||||
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
|
||||
/// streamed to the compressor (encoding is the slow, subdividable phase).
|
||||
@@ -781,6 +863,7 @@ pub fn encode_png_with_progress(
|
||||
/// colourise pass (see `prepare`).
|
||||
pub struct FractalCallback {
|
||||
pub uniforms: Uniforms,
|
||||
pub lights: Vec<Light>,
|
||||
pub reference: Arc<Vec<[f32; 2]>>,
|
||||
pub generation: u64,
|
||||
/// Widget size in physical pixels — the cache texture resolution.
|
||||
@@ -827,7 +910,8 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
|
||||
let color_dirty = iter_dirty
|
||||
|| renderer.colored.as_ref().is_none_or(|c| {
|
||||
c.width != width || c.height != height || color_differs(&c.uniforms, &self.uniforms)
|
||||
});
|
||||
})
|
||||
|| true;
|
||||
|
||||
if !color_dirty {
|
||||
return Vec::new(); // cache still valid; paint() just blits it
|
||||
@@ -839,6 +923,10 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
|
||||
0,
|
||||
bytemuck::bytes_of(&self.uniforms),
|
||||
);
|
||||
let mut bytes = [0; size_of::<Light>() * MAX_LIGHT_COUNT];
|
||||
bytes[..self.lights.len() * size_of::<Light>()]
|
||||
.copy_from_slice(bytemuck::cast_slice(&self.lights));
|
||||
queue.write_buffer(&renderer.lights_buffer, 0, &bytes);
|
||||
|
||||
if let Some(cache) = &renderer.cache {
|
||||
if iter_dirty {
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct ShareState {
|
||||
pub color_offset: f32,
|
||||
/// Palette index (`palette_id` in the shader).
|
||||
pub palette: u32,
|
||||
/// Shadow palette index (`shadow_palette_id` in the shader).
|
||||
pub shadow_palette: u32,
|
||||
}
|
||||
|
||||
impl ShareState {
|
||||
@@ -110,6 +112,7 @@ impl ShareState {
|
||||
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),
|
||||
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
shadow_palette: map.get("spal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -134,6 +137,7 @@ mod tests {
|
||||
color_scale: 0.02,
|
||||
color_offset: 0.25,
|
||||
palette: 3,
|
||||
shadow_palette: 1,
|
||||
};
|
||||
let d = ShareState::decode(&s.encode()).unwrap();
|
||||
assert_eq!(d.julia, s.julia);
|
||||
@@ -146,6 +150,7 @@ mod tests {
|
||||
assert_eq!(d.julia_c, s.julia_c);
|
||||
assert_eq!(d.phoenix_p, s.phoenix_p);
|
||||
assert_eq!(d.palette, s.palette);
|
||||
assert_eq!(d.shadow_palette, s.shadow_palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Headless PNG rendering: parse the CLI, build the exact same view/state the
|
||||
// windowed app would from it, then render straight to a file. No window, no
|
||||
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
||||
// render); it just creates its own wgpu device, computes the reference orbit
|
||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||
// button uses.
|
||||
|
||||
use eframe::egui_wgpu::wgpu;
|
||||
|
||||
use crate::app::{FractalApp, unix_timestamp};
|
||||
use crate::cli::Cli;
|
||||
use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking};
|
||||
|
||||
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||
const MAX_DIM: u32 = 8192 * 16;
|
||||
|
||||
pub fn run(cli: Cli) -> Result<(), String> {
|
||||
if cli.buddhabrot {
|
||||
return Err("headless mode doesn't support --buddhabrot yet".into());
|
||||
}
|
||||
|
||||
let width = cli.width.clamp(16, MAX_DIM);
|
||||
let height = cli.height.clamp(16, MAX_DIM);
|
||||
let export_path = cli
|
||||
.export_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
|
||||
let mut app = FractalApp::default_state();
|
||||
app.apply_cli(cli);
|
||||
|
||||
eprintln!("computing reference orbit…");
|
||||
app.compute_reference_blocking();
|
||||
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let (pipeline, bind_group_layout, format) = renderer.export_handles();
|
||||
|
||||
let uniforms = app.make_uniforms(width as f64 / height as f64);
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
pipeline,
|
||||
&bind_group_layout,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
app.reference_points(),
|
||||
);
|
||||
|
||||
eprintln!("rendering {width}×{height}…");
|
||||
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
|
||||
eprint!("\r{phase} {:>3.0}%", fraction * 100.0);
|
||||
});
|
||||
eprintln!();
|
||||
|
||||
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
|
||||
println!("saved {export_path} ({width}×{height})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up a wgpu device with no surface/window attached, matching the limits
|
||||
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
||||
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
||||
async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||
.await
|
||||
.map_err(|e| format!("no compatible GPU adapter: {e}"))?;
|
||||
adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("headless fractal device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("failed to create device: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use egui::{Color32, Ui};
|
||||
|
||||
/// Maximum number of simultaneous lights.
|
||||
pub const MAX_LIGHT_COUNT: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Zeroable, Pod)]
|
||||
#[repr(C)]
|
||||
pub struct Light {
|
||||
pub azimuth: f32,
|
||||
pub altitude: f32,
|
||||
pub color: Color32,
|
||||
pub _pad: u32,
|
||||
}
|
||||
|
||||
impl Default for Light {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
azimuth: PI / 4.,
|
||||
altitude: PI / 4.,
|
||||
color: Color32::WHITE,
|
||||
_pad: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Light {
|
||||
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
||||
let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32));
|
||||
ui.horizontal(|ui| {
|
||||
let del = ui.button("-").clicked();
|
||||
ui.label("color:");
|
||||
ui.color_edit_button_srgba(&mut self.color);
|
||||
ui.label("θ:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.azimuth)
|
||||
.range(0.0..=PI * 2.)
|
||||
.custom_formatter(formater)
|
||||
.speed(0.02),
|
||||
);
|
||||
ui.label("φ:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.altitude)
|
||||
.range(0.0..=PI / 2.)
|
||||
.custom_formatter(formater)
|
||||
.speed(0.02),
|
||||
);
|
||||
|
||||
del
|
||||
})
|
||||
.inner
|
||||
}
|
||||
}
|
||||
+18
@@ -10,8 +10,13 @@
|
||||
|
||||
mod app;
|
||||
mod fractal;
|
||||
mod lights;
|
||||
mod view;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cli;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod headless;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod worker;
|
||||
|
||||
@@ -46,11 +51,24 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
use clap::Parser as _;
|
||||
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.parse_default_env()
|
||||
.init();
|
||||
|
||||
let cli = cli::Cli::parse();
|
||||
if cli.headless {
|
||||
return match headless::run(cli) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
renderer: eframe::Renderer::Wgpu,
|
||||
wgpu_options: wgpu_options(),
|
||||
|
||||
+104
-14
@@ -20,32 +20,43 @@ struct Uniforms {
|
||||
bailout_sq: f32,
|
||||
is_julia: u32,
|
||||
palette_id: u32,
|
||||
shadow_palette_id: u32,
|
||||
aa_level: u32,
|
||||
kind: u32,
|
||||
power: u32,
|
||||
dc_offset: vec2<f32>,
|
||||
phoenix_p: vec2<f32>,
|
||||
lambda_l: vec2<f32>,
|
||||
de_coloring: u32,
|
||||
shadow: u32,
|
||||
};
|
||||
|
||||
struct Light {
|
||||
azimuth: f32,
|
||||
altitude: f32,
|
||||
color: u32,
|
||||
_pad: u32
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var data_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
|
||||
|
||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette
|
||||
// in mandelbrot.wgsl.
|
||||
fn palette(id: u32, t: f32) -> vec3<f32> {
|
||||
if (id == 4u) {
|
||||
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.10, 0.20); // 0: amber / blue
|
||||
if (id == 1u) {
|
||||
if id == 1u {
|
||||
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
|
||||
} else if (id == 2u) {
|
||||
} else if id == 2u {
|
||||
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
|
||||
} else if (id == 3u) {
|
||||
} else if id == 3u {
|
||||
c = vec3<f32>(1.0, 1.0, 0.5);
|
||||
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
|
||||
}
|
||||
@@ -62,17 +73,96 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(verts[idx], 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn load(x: i32, y: i32) -> vec3<f32> {
|
||||
let dist = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
|
||||
return vec3<f32>(f32(x), f32(y), dist);
|
||||
}
|
||||
|
||||
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(max(0., dot(normal, normalize(light))));
|
||||
}
|
||||
|
||||
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
let A = 0.15; // Shoulder strength
|
||||
let B = 0.50; // Linear strength
|
||||
let C = 0.10; // Linear angle
|
||||
let D = 0.20; // Toe strength
|
||||
let E = 0.02; // Toe numerator / shoarder angle/etc.
|
||||
let F = 0.30; // Toe denominator
|
||||
|
||||
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
|
||||
}
|
||||
|
||||
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
|
||||
let exposure_bias = 2.0;
|
||||
let curr = uncharted2tonemap(color * exposure_bias);
|
||||
|
||||
// Valeur blanche maximale de référence
|
||||
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
|
||||
return curr * white_scale;
|
||||
}
|
||||
|
||||
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||
return 1. / (1. + exp(-k * (color - c)));
|
||||
}
|
||||
|
||||
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||
let color_c = s(color, k, c);
|
||||
|
||||
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
||||
let ci = d.r;
|
||||
let de = d.g;
|
||||
let interior_frac = d.b;
|
||||
if u.shadow != 0u {
|
||||
if textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0).b != 0. {
|
||||
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
|
||||
} else {
|
||||
let d = array<vec3<f32>, 3>(load(i32(pos.x), i32(pos.y)), load(i32(pos.x + 1), i32(pos.y)), load(i32(pos.x), i32(pos.y + 1)));
|
||||
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
var col = palette(u.palette_id, t) * de;
|
||||
// Anti-alias the set boundary: fade toward black by the fraction of the
|
||||
// pixel's sub-samples that landed in the interior.
|
||||
col = col * (1.0 - interior_frac);
|
||||
return vec4<f32>(col, 1.0);
|
||||
let normal = normalize(cross(d[1] - d[0], d[2] - d[0]));
|
||||
|
||||
var color: vec3<f32>;
|
||||
if u.shadow_palette_id == 0u {
|
||||
color = compute_light(normal,vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
||||
|
||||
color = filmic(color, 2.5);
|
||||
color = contrast(color, 4., 0.67);
|
||||
} else if u.shadow_palette_id == 1u {
|
||||
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
|
||||
|
||||
color = filmic(color, 4.2);
|
||||
} else {
|
||||
color = vec3<f32>(0);
|
||||
var light_count = 0;
|
||||
for (var i = 0u ; i < 16; i++) {
|
||||
let light_color = unpack4x8unorm(lights[i].color);
|
||||
if any(light_color != vec4<f32>(0)) {
|
||||
light_count += 1;
|
||||
}
|
||||
|
||||
color += compute_light(normal, vec3<f32>(
|
||||
cos(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
|
||||
}
|
||||
|
||||
color = filmic(color, 1. + f32(light_count));
|
||||
}
|
||||
|
||||
return vec4<f32>(color, 1.0);
|
||||
}
|
||||
} else {
|
||||
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
||||
let ci = d.r;
|
||||
let de = d.g;
|
||||
let interior_frac = d.b;
|
||||
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
var col = palette(u.palette_id, t) * de;
|
||||
// Anti-alias the set boundary: fade toward black by the fraction of the
|
||||
// pixel's sub-samples that landed in the interior.
|
||||
col = col * (1.0 - interior_frac);
|
||||
return vec4<f32>(col, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct Uniforms {
|
||||
bailout_sq: f32,
|
||||
is_julia: u32,
|
||||
palette_id: u32,
|
||||
shadow_palette_id: u32,
|
||||
aa_level: u32,
|
||||
// Iteration formula (see the KIND_* constants below).
|
||||
kind: u32,
|
||||
@@ -35,6 +36,8 @@ struct Uniforms {
|
||||
lambda_l: vec2<f32>,
|
||||
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
de_coloring: u32,
|
||||
// 0 = classic colors, 1 = shadows
|
||||
shadow: u32,
|
||||
};
|
||||
|
||||
const KIND_MANDELBROT: u32 = 0u;
|
||||
@@ -158,7 +161,7 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(sq.x, -2.0 * (z.x * da + e.x * abs_yf));
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
// Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e).
|
||||
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0*z.x - e.x, -2.0*z.y - e.y);
|
||||
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y);
|
||||
return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e));
|
||||
}
|
||||
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part)
|
||||
@@ -179,7 +182,7 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
|
||||
return f32(p) * zk;
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
// Lambda: f'(z) = λ·(1-2z).
|
||||
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0*z.x, -2.0*z.y));
|
||||
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y));
|
||||
}
|
||||
return 2.0 * z;
|
||||
}
|
||||
@@ -326,7 +329,11 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
let zmag = sqrt(max(z2, 1.0));
|
||||
let dzmag = sqrt(max(dot(dz, dz), 1e-20));
|
||||
let d = zmag * log(zmag) / dzmag;
|
||||
de = clamp(d / max(px, 1e-30), 0.0, 1.0);
|
||||
var max_de = 1.;
|
||||
if u.shadow != 0u {
|
||||
max_de = 1000.;
|
||||
}
|
||||
de = clamp(d / max(px, 1e-30), 0.0, max_de);
|
||||
}
|
||||
return Sample(ci, de, true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user