Compare commits

...
12 Commits
Author SHA1 Message Date
survandClaude Opus 5.5 da992c139a feat: add encoder tuning harness for 4K60 fractal videos
make_clips.sh renders lossless FFV1 4K60 test clips through the Makefile's
colour conversion; sweep.sh encodes them with a list of ffmpeg configs and
scores each with VMAF 4K (mean/p1/min), VMAF-NEG, CAMBI banding and PSNR-Y.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:59:14 +02:00
surv 49130f5ecf perf: remove shader checks 2026-09-28 17:22:08 +02:00
surv 493a743a16 feat: Add benchmark tool 2026-09-28 17:17:38 +02:00
surv 0d7aadb991 perf: switch from dashu to rug & malachite 2026-09-28 11:54:22 +02:00
surv ae7ca97c41 feat: add tools script to find interesting places 2026-09-28 10:52:37 +02:00
surv 3f5853a23c feat: add Makefile to run ffmpeg 2026-09-28 10:44:36 +02:00
surv 2e116ba47b feat: add AA in headless exports 2026-09-28 10:44:24 +02:00
surv a15e951f84 feat: add --shards CLI option to split an animation into multiple run 2026-09-28 09:03:20 +02:00
surv 27c5a1a603 feat: allow emmiting frames on stdout to pipe them in ffmpeg 2026-09-28 08:40:00 +02:00
surv d78594af7e feat: bind GUI real time rendering behind a feature 2026-09-27 14:43:23 +02:00
surv 4212839f0d fix: avoid hitting GPU watchdog 2026-09-27 14:05:09 +02:00
surv 48560de315 fix: Raise max iterations limit 2026-09-27 13:23:53 +02:00
29 changed files with 2006 additions and 328 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ Cargo.lock
dist dist
frames* frames*
out.mp4 out.mp4
__pycache__
+42 -11
View File
@@ -6,7 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision ~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`), reference orbit is computed on the CPU (arbitrary precision via `bignum::Big`:
`rug` natively, `malachite-float` on the web),
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
rebasing to avoid glitches. Plain `f32` deltas run out of exponent range rebasing to avoid glitches. Plain `f32` deltas run out of exponent range
once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel
@@ -22,9 +23,12 @@ storage buffers, which the fragment shader needs for the reference orbit).
```sh ```sh
cargo run --release # native, run (release matters: fractal math is hot) cargo run --release # native, run (release matters: fractal math is hot)
cargo test # reference-orbit math, share-link round-trip, WGSL validation cargo test # reference-orbit math, share-link round-trip, WGSL validation
cargo test --features wasm # same, on the web build's malachite big-float backend
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed) cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
cargo clippy cargo clippy
cargo fmt # rustfmt.toml just pins edition = "2024" cargo fmt # rustfmt.toml just pins edition = "2024"
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
tools/bench/bench.sh --rev HEAD # perf of uncommitted edits vs HEAD (hyperfine; see tools/bench/README.md)
``` ```
Web build (WebGPU): Web build (WebGPU):
@@ -32,7 +36,7 @@ Web build (WebGPU):
```sh ```sh
rustup target add wasm32-unknown-unknown rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
./build-web.sh # -> ./dist ./build-web.sh # -> ./dist (builds with --features wasm)
python3 -m http.server -d dist 8080 python3 -m http.server -d dist 8080
``` ```
@@ -40,7 +44,8 @@ Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`, `--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
`--palette`, `--share <fragment>`, `--palette`, `--share <fragment>`,
`--view re,im,half_height[,iterations]`, `--rendering-kind`, `--view re,im,half_height[,iterations]`, `--rendering-kind`,
`--yaw`/`--pitch` (3D camera, degrees), `--de`, `--buddhabrot`, `--yaw`/`--pitch` (3D camera, degrees), `--de`, `--antialias` (2×2),
`--buddhabrot`,
`--buddha-palette`. `--headless` (`src/headless.rs`) skips the window `--buddha-palette`. `--headless` (`src/headless.rs`) skips the window
entirely: it builds the same view from the other flags, creates its own entirely: it builds the same view from the other flags, creates its own
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`, offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`,
@@ -58,13 +63,22 @@ position/zoom/iterations are pulled out of the link), `--to-iterations`,
and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched). and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
Anything without a target stays at its start value; colors stay fixed. Anything without a target stays at its start value; colors stay fixed.
`--export-path` then names an output *directory* of `frame-00001.png`, `--export-path` then names an output *directory* of `frame-00001.png`,
`frame-00002.png`, ... instead of a single file. `headless.rs::AnimTargets` `frame-00002.png`, ... instead of a single file. `--export-path -` writes
to stdout instead (refused on a terminal): the PNG for a still, or for an
animation raw RGBA8 frames in order (`unpad_rgba`, no PNG encode) for
`ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i -`. A single writer
thread reorders the frames. Its buffer is bounded by the orbit workers not
starting a frame more than `window` past the last one written, not by
blocking the writer, which could deadlock. `headless.rs::AnimTargets`
collects the targets; the export pipeline is rebuilt only when the collects the targets; the export pipeline is rebuilt only when the
`PipelineKey` changes between frames (kind morph). `view::interpolate_view` `PipelineKey` changes between frames (kind morph). `view::interpolate_view`
does the camera: half-height geometrically (log-linear, since zoom spans many does the camera: half-height geometrically (log-linear, since zoom spans many
decades), center linearly through the complex plane at full `Big` precision; decades), center linearly through the complex plane at full `Big` precision;
constants interpolate linearly. `--linear` swaps the default smoothstep constants interpolate linearly. `--linear` swaps the default smoothstep
easing for constant pacing. Without `--to-iterations` (or a share link's), easing for constant pacing. `--shards N --shard K` (1-based) renders only
the K-th of N contiguous parts (`shard_range`), still timed against the whole
animation (global `t`, global `frame-NNNNN.png` numbers; stdout streams just
that part), so separately rendered clips join seamlessly. Without `--to-iterations` (or a share link's),
iteration count auto-scales with zoom depth per frame (same iteration count auto-scales with zoom depth per frame (same
`auto_iteration_count` the interactive app uses while zooming). `auto_iteration_count` the interactive app uses while zooming).
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so `--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
@@ -94,8 +108,19 @@ runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This i
what makes deep zoom cheap — one expensive high-precision orbit, then every what makes deep zoom cheap — one expensive high-precision orbit, then every
pixel is a handful of `f32` complex multiplies. pixel is a handful of `f32` complex multiplies.
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big` - `src/bignum/` — `Big`, the arbitrary-precision binary float, with one
type alias). The pixel scale (`half_height`) is a `Scale`, an f64 backend per library behind the same inherent API + operators: `rug`
(GMP/MPFR, default, fastest, can't target wasm32) and `malachite-float`
(pure Rust, the `wasm` feature, required for the web build; a
`compile_error!` enforces it). Cargo features are additive, so rug is a
non-wasm32 target dependency and the backend is picked by
`cfg(feature = "wasm")`. Both follow the precision rule: a result has the
larger operand precision, rounded to nearest; shifts are exact. Malachite's
zero has no precision, so its wrapper stores `prec` alongside. Any new
`Big` operation must be added to both backends (`cargo test` and
`cargo test --features wasm` run the same tests on each).
- `src/view.rs` — `ViewState`; center is arbitrary-precision `Big`
(re-exported as `view::Big`). The pixel scale (`half_height`) is a `Scale`, an f64
mantissa with its own i32 exponent, so it goes past f64's ~1e-308. Never mantissa with its own i32 exponent, so it goes past f64's ~1e-308. Never
collapse it (or a center difference) to a plain `f64` on a path used at collapse it (or a center difference) to a plain `f64` on a path used at
depth. Rescale first: `Scale::scaled_f64(k)`, or shift the `Big` by depth. Rescale first: `Scale::scaled_f64(k)`, or shift the `Big` by
@@ -120,15 +145,21 @@ pixel is a handful of `f32` complex multiplies.
`f32` pairs — that's the reference orbit the GPU perturbs from. At `f32` pairs — that's the reference orbit the GPU perturbs from. At
precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
exists twice in this file (f64 + `FBig`) and both must stay in sync; exists twice in this file (f64 + `Big`) and both must stay in sync;
`f64_fast_path_matches_big` checks they agree. The result is a `RefOrbit`: `f64_fast_path_matches_big` checks they agree. The result is a `RefOrbit`:
`points` plus a parallel `exps`. A point below 2^-100 (only possible on the `points` plus a parallel `exps`. A point below 2^-100 (only possible on the
`FBig` path) is stored as a normalized mantissa with its exponent in `exps` `Big` path) is stored as a normalized mantissa with its exponent in `exps`
(the true value is `points[n]·2^exps[n]`). That happens when the orbit (the true value is `points[n]·2^exps[n]`). That happens when the orbit
passes near 0 at a deep minibrot. `has_scaled()` then forces the deep passes near 0 at a deep minibrot. `has_scaled()` then forces the deep
pipeline, the only one that reads `exps`. Requests are made with 1.5× pipeline, the only one that reads `exps`. Requests are made with 1.5×
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
creeping up during a zoom doesn't recompute the orbit every frame. creeping up during a zoom doesn't recompute the orbit every frame. The
interactive reference buffers start at 2^17 points and grow on demand
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
128 MiB WebGPU default binding size). That is also the hard iteration
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
reference as escaped. The UI slider only goes to 100k when dragged; typed
values can go higher.
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has - `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
no `#include`, so each is compiled by concatenating plain-text fragments no `#include`, so each is compiled by concatenating plain-text fragments
with `concat!`/`include_str!` at the `create_shader_module` call site (see with `concat!`/`include_str!` at the `create_shader_module` call site (see
@@ -173,7 +204,7 @@ pixel is a handful of `f32` complex multiplies.
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
to blend two kinds during the kind-switch morph (`u.morph_from`, to blend two kinds during the kind-switch morph (`u.morph_from`,
`u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by `u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by
the `morph` argument of `compute_reference`, in both its f64 and `FBig` the `morph` argument of `compute_reference`, in both its f64 and `Big`
paths). The blend only exists in pipelines built with the `MORPH` override paths). The blend only exists in pipelines built with the `MORPH` override
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity (part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
detection and the cardioid bypass. App side: `KindMorph` in detection and the cardioid bypass. App side: `KindMorph` in
+16 -4
View File
@@ -3,19 +3,31 @@ name = "mandelbrot"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
default = ["gui"]
# Windowed egui app. Without it only `--headless` rendering is built.
gui = ["dep:eframe", "dep:egui"]
# Pure-Rust big floats for the web build (`rug` needs GMP/MPFR, which can't
# target wasm32). Required for wasm32, optional natively (to test that backend).
wasm = ["dep:malachite-float", "dep:malachite-base"]
[dependencies] [dependencies]
bytemuck = { version = "1.25.2", features = ["derive"] } 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"], optional = true }
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] } egui = { version = "0.36.2", optional = true }
egui = "0.36.2" ecolor = { version = "0.36.2", features = ["bytemuck"] }
wgpu = "30.0.1"
glam = "0.33.8" glam = "0.33.8"
log = "0.4.34" log = "0.4.34"
png = "0.18.1" png = "0.18.1"
malachite-float = { version = "0.12", optional = true }
malachite-base = { version = "0.12", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.11" env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] } clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1" pollster = "1.0.1"
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] } futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
@@ -32,7 +44,7 @@ opt-level = 3
# codegen-units = 1 # codegen-units = 1
debug = true debug = true
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu, # Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
# egui) so the explorer is actually interactive during development. # egui) so the explorer is actually interactive during development.
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
+35
View File
@@ -0,0 +1,35 @@
.PHONY: hevc-nvenc x265 av1
FFMPEG=ffmpeg
IN_FMT=rgba
RESOLUTION=1920x1080
FPS=60
OUT=out.mp4
FFMPEG_CMD=$(FFMPEG) -f rawvideo -pix_fmt $(IN_FMT) -s $(RESOLUTION) -framerate $(FPS) -i - -vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=p010le"
hevc-nvenc:
$(FFMPEG_CMD) \
-c:v hevc_nvenc -preset p7 -tune hq -rc vbr -cq 14 -b:v 0 -maxrate 200M -bufsize 400M \
-profile:v main10 -pix_fmt p010le \
-spatial-aq 1 -aq-strength 6 -temporal-aq 1 \
-rc-lookahead 32 -bf 4 -b_ref_mode middle -multipass fullres \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
x265:
$(FFMPEG_CMD) \
-c:v libx265 -crf 18 -preset medium -pix_fmt yuv420p10le \
-x265-params "aq-mode=3:no-sao=1" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
av1:
$(FFMPEG_CMD) \
-c:v libsvtav1 -crf 24 -preset 6 -pix_fmt yuv420p10le \
-svtav1-params "film-grain=0:enable-overlays=0" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
$(OUT)
+9 -2
View File
@@ -3,7 +3,8 @@
A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets — 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 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 plain `f64` using **perturbation theory**: one high-precision reference orbit is
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is computed on the CPU (arbitrary precision via `rug` natively, `malachite-float`
on the web), and every pixel is
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid 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). glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
@@ -27,6 +28,9 @@ The `f32` GPU tier reaches roughly **10³⁰× magnification** with sharp detail
cargo run --release cargo run --release
``` ```
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
toolchain and `m4` (on Windows, MSYS2).
## Build & run — web (WebGPU) ## Build & run — web (WebGPU)
Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the
@@ -37,6 +41,7 @@ rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # once cargo install wasm-bindgen-cli --version 0.2.128 # once
./build-web.sh # outputs ./dist (index.html, .js, .wasm) ./build-web.sh # outputs ./dist (index.html, .js, .wasm)
# (builds with --features wasm: pure-Rust big floats)
python3 -m http.server -d dist 8080 # serve over http python3 -m http.server -d dist 8080 # serve over http
``` ```
@@ -60,7 +65,8 @@ qualifies. Deploy by serving the `dist/` directory as static files.
## How it works ## How it works
- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel - `src/view.rs` — view state. Center is arbitrary precision (`Big`, from
`src/bignum/`); the pixel
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range). 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/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` - `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
@@ -84,6 +90,7 @@ reference computation to a Web Worker.
```sh ```sh
cargo test cargo test
cargo test --features wasm # same suite on the web build's big-float backend
``` ```
Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia) Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia)
+1 -1
View File
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
OUT="${1:-dist}" OUT="${1:-dist}"
echo "==> cargo build (wasm32, release)" echo "==> cargo build (wasm32, release)"
cargo build --release --target wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown --features wasm
echo "==> wasm-bindgen -> $OUT" echo "==> wasm-bindgen -> $OUT"
mkdir -p "$OUT" mkdir -p "$OUT"
+54 -33
View File
@@ -1,9 +1,9 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(feature = "gui")]
use eframe::CreationContext; use eframe::CreationContext;
#[cfg(feature = "gui")]
use eframe::egui_wgpu; use eframe::egui_wgpu;
#[cfg(target_arch = "wasm32")]
use eframe::egui_wgpu::wgpu;
use glam::Vec4; use glam::Vec4;
use glam::Vec4Swizzles; use glam::Vec4Swizzles;
@@ -43,6 +43,10 @@ fn bailout_sq(kind: FractalKind, power: u32) -> f32 {
} }
/// Cap on exported image dimension (px), to stay within GPU texture limits. /// Cap on exported image dimension (px), to stay within GPU texture limits.
const MAX_EXPORT_DIM: u32 = 8192 * 16; const MAX_EXPORT_DIM: u32 = 8192 * 16;
/// Hard ceiling on the iteration count: the longest reference orbit the GPU
/// buffer holds (past it, the shader would read pixels as escaped).
const MAX_ITERATIONS: u32 = MAX_REF_POINTS as u32 - 1;
/// While the user is actively panning/zooming, the fractal is rendered into a /// While the user is actively panning/zooming, the fractal is rendered into a
/// cache texture downscaled by this factor per axis (and with AA forced off), so /// cache texture downscaled by this factor per axis (and with AA forced off), so
/// each interacting frame is cheap; the linear blit upsamples it to the widget. /// each interacting frame is cheap; the linear blit upsamples it to the widget.
@@ -312,6 +316,7 @@ impl ConstOrbit {
/// Checkbox + speed/radius sliders; (re)centers the orbit on `current` /// Checkbox + speed/radius sliders; (re)centers the orbit on `current`
/// when switched on. /// when switched on.
#[cfg(feature = "gui")]
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) { fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) {
if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on { if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on {
self.enable(current); self.enable(current);
@@ -367,6 +372,7 @@ impl AxisOsc {
self.base + self.amplitude * self.phase.sin() self.base + self.amplitude * self.phase.sin()
} }
#[cfg(feature = "gui")]
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) { fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) {
if ui if ui
.checkbox(&mut self.on, format!("Animate {name}")) .checkbox(&mut self.on, format!("Animate {name}"))
@@ -590,6 +596,7 @@ pub struct FractalApp {
/// PNG export resolution multiplier over the on-screen size. /// PNG export resolution multiplier over the on-screen size.
export_scale: f32, export_scale: f32,
/// Last on-screen fractal size in physical pixels (for export sizing). /// Last on-screen fractal size in physical pixels (for export sizing).
#[cfg(feature = "gui")]
last_size_px: egui::Vec2, last_size_px: egui::Vec2,
/// egui time (seconds) of the most recent pan/zoom. While recent (within /// egui time (seconds) of the most recent pan/zoom. While recent (within
/// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction. /// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction.
@@ -642,6 +649,7 @@ fn parse_bits_for(s: &str, min_bits: usize) -> usize {
} }
impl FractalApp { impl FractalApp {
#[cfg(feature = "gui")]
pub fn new(cc: &CreationContext<'_>) -> Self { pub fn new(cc: &CreationContext<'_>) -> Self {
let render_state = cc let render_state = cc
.wgpu_render_state .wgpu_render_state
@@ -736,6 +744,7 @@ impl FractalApp {
pending: false, pending: false,
export_scale: 2.0, export_scale: 2.0,
render_scale_3d: 2.0, render_scale_3d: 2.0,
#[cfg(feature = "gui")]
last_size_px: egui::vec2(1280.0, 720.0), last_size_px: egui::vec2(1280.0, 720.0),
last_interact_time: -1.0e9, last_interact_time: -1.0e9,
export_requested: false, export_requested: false,
@@ -827,7 +836,7 @@ impl FractalApp {
} }
if let Some(iterations) = cli.iterations { if let Some(iterations) = cli.iterations {
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = iterations; self.max_iterations = iterations.clamp(32, MAX_ITERATIONS);
} }
if let Some(half_height) = cli.half_height { if let Some(half_height) = cli.half_height {
self.apply_half_height_spec(&half_height); self.apply_half_height_spec(&half_height);
@@ -838,6 +847,9 @@ impl FractalApp {
if cli.de { if cli.de {
self.de_coloring = true; self.de_coloring = true;
} }
if cli.antialias {
self.antialias = true;
}
if cli.buddhabrot { if cli.buddhabrot {
self.mode = FractalMode::Buddhabrot; self.mode = FractalMode::Buddhabrot;
} }
@@ -858,7 +870,7 @@ impl FractalApp {
self.view = view; self.view = view;
if let Some(v) = iterations { if let Some(v) = iterations {
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); self.max_iterations = v.clamp(32, MAX_ITERATIONS);
} }
true true
} }
@@ -912,7 +924,7 @@ impl FractalApp {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) fn set_max_iterations(&mut self, i: u32) { pub(crate) fn set_max_iterations(&mut self, i: u32) {
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = i; self.max_iterations = i.clamp(32, MAX_ITERATIONS);
} }
/// Per-kind constants `(julia_c, phoenix_p, lambda_l, complex_power)`. /// Per-kind constants `(julia_c, phoenix_p, lambda_l, complex_power)`.
@@ -1004,7 +1016,7 @@ impl FractalApp {
self.morph = None; self.morph = None;
// Presets carry a hand-tuned count; don't let the auto-scaler clobber it. // Presets carry a hand-tuned count; don't let the auto-scaler clobber it.
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); self.max_iterations = iterations.clamp(32, MAX_ITERATIONS);
} }
} }
@@ -1014,7 +1026,7 @@ impl FractalApp {
fn auto_iteration_count(&self) -> u32 { fn auto_iteration_count(&self) -> u32 {
let decades = self.view.magnification_log10().max(0.0); let decades = self.view.magnification_log10().max(0.0);
let iters = 400.0 + 900.0 * decades; let iters = 400.0 + 900.0 * decades;
(iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1) (iters.round() as u32).clamp(200, MAX_ITERATIONS)
} }
/// Snapshot the current view as a shareable state. /// Snapshot the current view as a shareable state.
@@ -1061,7 +1073,7 @@ impl FractalApp {
// The link carries an explicit iteration count; honor it rather than // The link carries an explicit iteration count; honor it rather than
// letting the auto-scaler immediately overwrite it. // letting the auto-scaler immediately overwrite it.
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = s.iterations.clamp(32, MAX_REF_POINTS as u32 - 1); self.max_iterations = s.iterations.clamp(32, MAX_ITERATIONS);
let bits = precision_for(s.half_height); let bits = precision_for(s.half_height);
if let (Some(re), Some(im)) = ( if let (Some(re), Some(im)) = (
big_from_decimal_str(&s.center_re, bits), big_from_decimal_str(&s.center_re, bits),
@@ -1132,12 +1144,8 @@ impl FractalApp {
fn drift_from(&self, key: &RequestKey) -> f64 { fn drift_from(&self, key: &RequestKey) -> f64 {
let hh = self.view.half_height; let hh = self.view.half_height;
let k = -hh.exponent() as isize; let k = -hh.exponent() as isize;
let dre = ((&self.view.center_re - &key.center_re) << k) let dre = ((&self.view.center_re - &key.center_re) << k).to_f64();
.to_f64() let dim = ((&self.view.center_im - &key.center_im) << k).to_f64();
.value();
let dim = ((&self.view.center_im - &key.center_im) << k)
.to_f64()
.value();
(dre * dre + dim * dim).sqrt() / hh.scaled_f64(-hh.exponent()) (dre * dre + dim * dim).sqrt() / hh.scaled_f64(-hh.exponent())
} }
@@ -1157,7 +1165,7 @@ impl FractalApp {
// while auto-iterations creep up during a zoom (the shader clamps // while auto-iterations creep up during a zoom (the shader clamps
// to `max_iterations`); only recompute once it's too short, or // to `max_iterations`); only recompute once it's too short, or
// far longer than needed. // far longer than needed.
|| self.max_iterations > key.iter || self.max_iterations.min(MAX_ITERATIONS) > key.iter
|| self.max_iterations.saturating_mul(4) < key.iter || self.max_iterations.saturating_mul(4) < key.iter
|| key.kind != self.kind || key.kind != self.kind
|| key.power != self.power || key.power != self.power
@@ -1186,12 +1194,8 @@ impl FractalApp {
/// underflow f64 at deep zooms). /// underflow f64 at deep zooms).
fn dc_offset(&self, scale_exp: i32) -> (f64, f64) { fn dc_offset(&self, scale_exp: i32) -> (f64, f64) {
let k = -scale_exp as isize; let k = -scale_exp as isize;
let dre = ((&self.view.center_re - &self.ref_center_re) << k) let dre = ((&self.view.center_re - &self.ref_center_re) << k).to_f64();
.to_f64() let dim = ((&self.view.center_im - &self.ref_center_im) << k).to_f64();
.value();
let dim = ((&self.view.center_im - &self.ref_center_im) << k)
.to_f64()
.value();
(dre, dim) (dre, dim)
} }
@@ -1410,6 +1414,7 @@ impl FractalApp {
/// 3D-mode zoom toward the screen point `off` (points from the widget /// 3D-mode zoom toward the screen point `off` (points from the widget
/// center): unproject it through the camera onto the z = 0 fractal plane, /// center): unproject it through the camera onto the z = 0 fractal plane,
/// then zoom the 2D view about the matching fractal-texture pixel. /// then zoom the 2D view about the matching fractal-texture pixel.
#[cfg(feature = "gui")]
fn zoom_3d_at(&mut self, off: egui::Vec2, rect: egui::Rect, height_px: f64, factor: f64) { fn zoom_3d_at(&mut self, off: egui::Vec2, rect: egui::Rect, height_px: f64, factor: f64) {
let ndc = (off / rect.size()) * 2.; let ndc = (off / rect.size()) * 2.;
let camera_ndc_pos = self let camera_ndc_pos = self
@@ -1481,8 +1486,8 @@ impl FractalApp {
/// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`). /// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`).
fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms { fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms {
let center = [ let center = [
self.view.center_re.to_f64().value() as f32, self.view.center_re.to_f64() as f32,
self.view.center_im.to_f64().value() as f32, self.view.center_im.to_f64() as f32,
]; ];
BuddhabrotUniforms { BuddhabrotUniforms {
center, center,
@@ -1511,6 +1516,7 @@ impl FractalApp {
/// Render the current view to a PNG at `export_scale` × the on-screen size, /// Render the current view to a PNG at `export_scale` × the on-screen size,
/// then save it (native: file in cwd; web: browser download). Runs off the /// then save it (native: file in cwd; web: browser download). Runs off the
/// UI thread so a progress bar can animate; progress lands in `self.export`. /// UI thread so a progress bar can animate; progress lands in `self.export`.
#[cfg(feature = "gui")]
fn do_export(&mut self, frame: &mut eframe::Frame) { fn do_export(&mut self, frame: &mut eframe::Frame) {
if self.export.is_some() { if self.export.is_some() {
return; // one export at a time return; // one export at a time
@@ -1637,6 +1643,7 @@ impl FractalApp {
/// Pick up a finished export (setting the status line) and keep repainting /// Pick up a finished export (setting the status line) and keep repainting
/// while one is in flight so its progress bar animates. /// while one is in flight so its progress bar animates.
#[cfg(feature = "gui")]
fn poll_export(&mut self, ctx: &egui::Context) { fn poll_export(&mut self, ctx: &egui::Context) {
if let Some(shared) = &self.export { if let Some(shared) = &self.export {
let done = shared.lock().unwrap().result.take(); let done = shared.lock().unwrap().result.take();
@@ -1657,6 +1664,7 @@ impl FractalApp {
/// Floating top-left overlay with the panel toggle and fullscreen toggle. /// Floating top-left overlay with the panel toggle and fullscreen toggle.
/// Always on top of the fractal, so both stay reachable when the controls /// Always on top of the fractal, so both stay reachable when the controls
/// panel is collapsed (the common case on a phone). /// panel is collapsed (the common case on a phone).
#[cfg(feature = "gui")]
fn overlay_buttons(&mut self, ui: &mut egui::Ui) { fn overlay_buttons(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("overlay_buttons")) egui::Area::new(egui::Id::new("overlay_buttons"))
.anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0)) .anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0))
@@ -1715,6 +1723,7 @@ impl FractalApp {
/// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left) /// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left)
/// so it stays out of the way of the panel toggle / fullscreen controls, /// so it stays out of the way of the panel toggle / fullscreen controls,
/// but is still reachable even when the controls panel is collapsed. /// but is still reachable even when the controls panel is collapsed.
#[cfg(feature = "gui")]
fn info_button(&mut self, ui: &mut egui::Ui) { fn info_button(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("info_button")) egui::Area::new(egui::Id::new("info_button"))
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0)) .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0))
@@ -1736,6 +1745,7 @@ impl FractalApp {
/// Window with details about what's currently on screen: formula, active /// Window with details about what's currently on screen: formula, active
/// per-kind constants, zoom depth, iteration count. Reads live state, so /// per-kind constants, zoom depth, iteration count. Reads live state, so
/// it stays correct as the user pans/zooms/switches kinds. /// it stays correct as the user pans/zooms/switches kinds.
#[cfg(feature = "gui")]
fn info_window(&mut self, ctx: &egui::Context) { fn info_window(&mut self, ctx: &egui::Context) {
let mut open = self.info_open; let mut open = self.info_open;
egui::Window::new("Fractal Info") egui::Window::new("Fractal Info")
@@ -1792,6 +1802,7 @@ impl FractalApp {
/// Help window: what the app does, plus a reference for mouse/touch and /// Help window: what the app does, plus a reference for mouse/touch and
/// keyboard controls. /// keyboard controls.
#[cfg(feature = "gui")]
fn help_window(&mut self, ctx: &egui::Context) { fn help_window(&mut self, ctx: &egui::Context) {
let mut open = self.help_open; let mut open = self.help_open;
egui::Window::new("Help") egui::Window::new("Help")
@@ -1882,7 +1893,7 @@ impl FractalApp {
} }
/// Push the desired fullscreen state to the platform. /// Push the desired fullscreen state to the platform.
#[cfg(not(target_arch = "wasm32"))] #[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
fn apply_fullscreen(&mut self, ctx: &egui::Context) { fn apply_fullscreen(&mut self, ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
} }
@@ -1890,7 +1901,7 @@ impl FractalApp {
/// Push the desired fullscreen state to the browser. `request_fullscreen` /// Push the desired fullscreen state to the browser. `request_fullscreen`
/// must run inside a user gesture; the button click provides the transient /// must run inside a user gesture; the button click provides the transient
/// activation that carries into this frame. /// activation that carries into this frame.
#[cfg(target_arch = "wasm32")] #[cfg(all(feature = "gui", target_arch = "wasm32"))]
fn apply_fullscreen(&mut self, _ctx: &egui::Context) { fn apply_fullscreen(&mut self, _ctx: &egui::Context) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return; return;
@@ -1906,14 +1917,14 @@ impl FractalApp {
/// Refresh `self.fullscreen` from the real platform state, so the label is /// Refresh `self.fullscreen` from the real platform state, so the label is
/// correct even when fullscreen is left by Esc/F11 or the browser UI. /// correct even when fullscreen is left by Esc/F11 or the browser UI.
#[cfg(not(target_arch = "wasm32"))] #[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
fn sync_fullscreen(&mut self, ctx: &egui::Context) { fn sync_fullscreen(&mut self, ctx: &egui::Context) {
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
self.fullscreen = fs; self.fullscreen = fs;
} }
} }
#[cfg(target_arch = "wasm32")] #[cfg(all(feature = "gui", target_arch = "wasm32"))]
fn sync_fullscreen(&mut self, _ctx: &egui::Context) { fn sync_fullscreen(&mut self, _ctx: &egui::Context) {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) { if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
self.fullscreen = doc.fullscreen_element().is_some(); self.fullscreen = doc.fullscreen_element().is_some();
@@ -1925,6 +1936,7 @@ impl FractalApp {
/// divides once the window closes, so the readout is steady rather than /// divides once the window closes, so the readout is steady rather than
/// jittering every frame. Only advances when egui repaints — i.e. while the /// jittering every frame. Only advances when egui repaints — i.e. while the
/// app is doing work — so an idle app shows its last measured rate. /// app is doing work — so an idle app shows its last measured rate.
#[cfg(feature = "gui")]
fn update_fps(&mut self, ui: &egui::Ui) { fn update_fps(&mut self, ui: &egui::Ui) {
let now = ui.input(|i| i.time); let now = ui.input(|i| i.time);
// Reset the window if time went backwards or hasn't started yet. // Reset the window if time went backwards or hasn't started yet.
@@ -1944,6 +1956,7 @@ impl FractalApp {
/// Advance any enabled animations by the frame's elapsed time, and request a /// Advance any enabled animations by the frame's elapsed time, and request a
/// repaint while active. Animations render at full resolution/AA (they do not /// repaint while active. Animations render at full resolution/AA (they do not
/// trigger the interaction low-res pass). /// trigger the interaction low-res pass).
#[cfg(feature = "gui")]
fn tick_animations(&mut self, ui: &egui::Ui) { fn tick_animations(&mut self, ui: &egui::Ui) {
// Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind. // Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind.
let julia_on = self.anim.julia.on && self.mode == FractalMode::Julia; let julia_on = self.anim.julia.on && self.mode == FractalMode::Julia;
@@ -2088,6 +2101,7 @@ impl FractalApp {
} }
} }
#[cfg(feature = "gui")]
fn controls_ui(&mut self, ui: &mut egui::Ui) { fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer"); ui.heading("Fractal Explorer");
ui.separator(); ui.separator();
@@ -2209,7 +2223,7 @@ impl FractalApp {
for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] { for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] {
if ui.small_button(name).clicked() { if ui.small_button(name).clicked() {
self.julia_c = (re, im); self.julia_c = (re, im);
self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); self.max_iterations = iterations.clamp(32, MAX_ITERATIONS);
if let Some(phoenix) = phoenix { if let Some(phoenix) = phoenix {
self.phoenix_p = phoenix; self.phoenix_p = phoenix;
@@ -2266,11 +2280,15 @@ impl FractalApp {
if self.auto_iterations { if self.auto_iterations {
ui.label(format!("iterations: {} (auto)", self.max_iterations)); ui.label(format!("iterations: {} (auto)", self.max_iterations));
} else { } else {
// Dragging stays within the slider's range, but a typed value
// isn't clamped to it: only to what the reference buffer can hold.
ui.add( ui.add(
egui::Slider::new(&mut self.max_iterations, 32..=100_000) egui::Slider::new(&mut self.max_iterations, 32..=100_000)
.text("iterations") .text("iterations")
.logarithmic(true), .logarithmic(true)
.clamping(egui::SliderClamping::Never),
); );
self.max_iterations = self.max_iterations.clamp(32, MAX_ITERATIONS);
} }
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
.on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); .on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
@@ -2566,6 +2584,7 @@ impl FractalApp {
/// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B /// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B
/// coloring), exposure, and the progressive-accumulation toggle. /// coloring), exposure, and the progressive-accumulation toggle.
#[cfg(feature = "gui")]
fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) { fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) {
ui.separator(); ui.separator();
ui.add( ui.add(
@@ -2606,6 +2625,7 @@ impl FractalApp {
ui.small("PNG export isn't available in Buddhabrot mode yet."); ui.small("PNG export isn't available in Buddhabrot mode yet.");
} }
#[cfg(feature = "gui")]
fn fractal_ui(&mut self, ui: &mut egui::Ui) { fn fractal_ui(&mut self, ui: &mut egui::Ui) {
let size = ui.available_size(); let size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag()); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
@@ -2812,13 +2832,13 @@ impl FractalApp {
} }
if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) { if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) {
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = ((self.max_iterations as f64 * 1.25).round() as u32) self.max_iterations =
.clamp(32, MAX_REF_POINTS as u32 - 1); ((self.max_iterations as f64 * 1.25).round() as u32).clamp(32, MAX_ITERATIONS);
} }
if ui.input(|i| i.key_pressed(egui::Key::Minus)) { if ui.input(|i| i.key_pressed(egui::Key::Minus)) {
self.auto_iterations = false; self.auto_iterations = false;
self.max_iterations = ((self.max_iterations as f64 / 1.25).round() as u32) self.max_iterations =
.clamp(32, MAX_REF_POINTS as u32 - 1); ((self.max_iterations as f64 / 1.25).round() as u32).clamp(32, MAX_ITERATIONS);
} }
} }
@@ -2931,6 +2951,7 @@ impl FractalApp {
} }
} }
#[cfg(feature = "gui")]
impl eframe::App for FractalApp { impl eframe::App for FractalApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.poll_export(ui.ctx()); self.poll_export(ui.ctx());
+225
View File
@@ -0,0 +1,225 @@
//! Arbitrary-precision binary float [`Big`] (one coordinate of a deep-zoom
//! center, or of a reference orbit point), over one of two backends:
//!
//! - native (default): `rug` (GMP/MPFR), the fastest, but C code that can't
//! target `wasm32-unknown-unknown`;
//! - `wasm` feature: a pure-Rust library, required for the web build.
//!
//! Both expose the same inherent API plus the operators below, and follow
//! the same precision rule: an operation's result has the larger of its
//! operands' precisions (in bits), rounded to nearest. Shifts (`<<`/`>>` by
//! an `isize`) are exact multiplications by powers of two.
#[cfg(not(feature = "wasm"))]
mod rug_backend;
#[cfg(not(feature = "wasm"))]
pub use rug_backend::Big;
#[cfg(feature = "wasm")]
mod web_backend;
#[cfg(feature = "wasm")]
pub use web_backend::Big;
// Native `--features wasm` builds (to test the web backend) still link rug.
#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))]
use rug as _;
#[cfg(all(target_arch = "wasm32", not(feature = "wasm")))]
compile_error!("the web build needs `--features wasm` (rug can't target wasm32)");
use core::ops::{Add, Mul, Neg, Shl, Shr, Sub};
/// Implements `$Trait` for every owned/borrowed combination of `Big`
/// operands, through the backend's by-reference `$imp`.
macro_rules! forward_binop {
($Trait:ident, $method:ident, $imp:ident) => {
impl $Trait<&Big> for &Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
self.$imp(rhs)
}
}
impl $Trait<Big> for &Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
self.$imp(&rhs)
}
}
impl $Trait<&Big> for Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
(&self).$imp(rhs)
}
}
impl $Trait<Big> for Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
(&self).$imp(&rhs)
}
}
};
}
forward_binop!(Add, add, add_ref);
forward_binop!(Sub, sub, sub_ref);
forward_binop!(Mul, mul, mul_ref);
impl Neg for Big {
type Output = Big;
fn neg(self) -> Big {
self.negated()
}
}
impl Neg for &Big {
type Output = Big;
fn neg(self) -> Big {
self.clone().negated()
}
}
impl Shl<isize> for Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.mul_pow2(k)
}
}
impl Shl<isize> for &Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.clone().mul_pow2(k)
}
}
impl Shr<isize> for Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.mul_pow2(-k)
}
}
impl Shr<isize> for &Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.clone().mul_pow2(-k)
}
}
/// Significant decimal digits of a value, as a backend's
/// `to_decimal_parts` returns them: the value is `±0.digits × 10^exp10`,
/// `digits` has no trailing zeros, and it is empty for zero.
pub struct DecimalParts {
pub negative: bool,
pub digits: String,
pub exp10: isize,
}
impl DecimalParts {
/// Build from a significand digit string (maybe with trailing zeros)
/// whose value is `0.digits × 10^exp10`.
fn new(negative: bool, digits: &str, exp10: isize) -> Self {
let digits = digits.trim_end_matches('0').to_string();
Self {
negative: negative && !digits.is_empty(),
digits,
exp10,
}
}
}
impl Big {
/// Decimal string with `sig_digits` significant digits, in plain
/// positional notation (`-0.000123`, `4500`), trailing zeros trimmed.
pub fn to_decimal_string(&self, sig_digits: usize) -> String {
let DecimalParts {
negative,
digits,
exp10,
} = self.to_decimal_parts(sig_digits.max(1));
if digits.is_empty() {
return "0".to_string();
}
let sign = if negative { "-" } else { "" };
let len = digits.len() as isize;
if exp10 <= 0 {
let zeros = "0".repeat((-exp10) as usize);
format!("{sign}0.{zeros}{digits}")
} else if exp10 >= len {
let zeros = "0".repeat((exp10 - len) as usize);
format!("{sign}{digits}{zeros}")
} else {
let (int, frac) = digits.split_at(exp10 as usize);
format!("{sign}{int}.{frac}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn big(x: f64) -> Big {
Big::from_f64(x, 200)
}
#[test]
fn decimal_string_is_positional() {
assert_eq!(
big(-0.7515).to_decimal_string(20),
"-0.75149999999999994582"
);
assert_eq!(big(-0.7515).to_decimal_string(5), "-0.7515");
assert_eq!(big(0.0).to_decimal_string(5), "0");
assert_eq!(big(1.0).to_decimal_string(5), "1");
assert_eq!(big(123.456).to_decimal_string(5), "123.46");
assert_eq!(big(1e20).to_decimal_string(5), "100000000000000000000");
assert_eq!(big(0.1).to_decimal_string(5), "0.1");
let tiny = big(1.0) >> 200;
let s = tiny.to_decimal_string(5);
assert_eq!(s, format!("0.{}6223", "0".repeat(60)));
}
#[test]
fn decimal_round_trip() {
let s = "-0.743643887037158704752191506114774";
let x = Big::from_decimal_str(s, 200).unwrap();
assert_eq!(x.to_decimal_string(33), s);
assert_eq!(
Big::from_decimal_str("1.5e-20", 64).unwrap().to_f64(),
1.5e-20
);
assert!(Big::from_decimal_str("abc", 64).is_none());
}
#[test]
fn precision_is_max_of_operands() {
let a = Big::from_f64(1.0, 100);
let b = Big::from_f64(3.0, 200);
assert_eq!((&a + &b).precision(), 200);
assert_eq!((&a * &b).precision(), 200);
assert_eq!((&b - &a).precision(), 200);
assert_eq!(a.clone().with_precision(300).precision(), 300);
}
#[test]
fn exact_shifts_and_log2() {
let x = Big::from_f64(0.75, 64) >> 5000;
assert_eq!(x.log2_floor(), Some(-5001));
assert_eq!((x << 5000).to_f64(), 0.75);
assert_eq!(Big::zero(64).log2_floor(), None);
assert!(Big::from_f64(-1.0, 64).is_negative());
assert!(!Big::zero(64).is_negative());
}
#[test]
fn transcendentals() {
let x = Big::from_f64(0.5, 128);
assert!((x.ln().to_f64() - 0.5f64.ln()).abs() < 1e-15);
assert!((x.exp().to_f64() - 0.5f64.exp()).abs() < 1e-15);
let (s, c) = x.sin_cos();
assert!((s.to_f64() - 0.5f64.sin()).abs() < 1e-15);
assert!((c.to_f64() - 0.5f64.cos()).abs() < 1e-15);
let y = Big::from_f64(-0.3, 128);
assert!((y.atan2(&x).to_f64() - (-0.3f64).atan2(0.5)).abs() < 1e-15);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! [`Big`] over `rug::Float` (MPFR), for native builds.
use core::cmp::Ordering;
use rug::{Assign, Float};
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
#[derive(Clone, Debug, PartialEq)]
pub struct Big(Float);
/// MPFR precision for `bits`, clamped to what it accepts.
fn prec(bits: usize) -> u32 {
(bits as u32).clamp(rug::float::prec_min(), rug::float::prec_max())
}
impl Big {
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let x = if x.is_finite() { x } else { 0.0 };
Self(Float::with_val(prec(bits), x))
}
pub fn zero(bits: usize) -> Self {
Self(Float::new(prec(bits)))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let parsed = Float::parse(s.trim()).ok()?;
let x = Float::with_val(prec(bits.max(53)), parsed);
x.is_finite().then_some(Self(x))
}
pub fn precision(&self) -> usize {
self.0.prec() as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
self.0.set_prec(prec(bits));
self
}
pub fn to_f64(&self) -> f64 {
self.0.to_f64()
}
pub fn is_zero(&self) -> bool {
self.0.is_zero()
}
pub fn is_negative(&self) -> bool {
self.0.cmp0() == Some(Ordering::Less)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
pub fn sqr(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.square_ref()))
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// MPFR normalizes the significand to [0.5, 1).
self.0.get_exp().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.ln_ref()))
}
pub fn exp(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.exp_ref()))
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.0.prec().max(x.0.prec());
Self(Float::with_val(p, self.0.atan2_ref(&x.0)))
}
pub fn sin_cos(&self) -> (Self, Self) {
let p = self.0.prec();
let (mut s, mut c) = (Float::new(p), Float::new(p));
(&mut s, &mut c).assign(self.0.sin_cos_ref());
(Self(s), Self(c))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.0.is_zero() {
return DecimalParts::new(false, "", 0);
}
let (negative, digits, exp) = self.0.to_sign_string_exp(10, Some(sig));
DecimalParts::new(negative, &digits, exp.unwrap_or(0) as isize)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 + &rhs.0))
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 - &rhs.0))
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 * &rhs.0))
}
pub(super) fn negated(self) -> Big {
Big(-self.0)
}
/// `self · 2^k`, exact. `|k|` stays far below `i32::MAX` (precision and
/// zoom depth are capped around 2^20 bits).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big(self.0 << k as i32)
}
}
+173
View File
@@ -0,0 +1,173 @@
//! [`Big`] over `malachite-float`, pure Rust, for the web build.
use malachite_base::num::basic::traits::Zero;
use malachite_base::num::conversion::string::options::ToSciOptions;
use malachite_base::num::conversion::traits::{RoundingFrom, ToSci};
use malachite_base::rounding_modes::RoundingMode::Nearest;
use malachite_float::Float;
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
///
/// The precision is stored next to the value: malachite's zero has none,
/// but "zero at `p` bits" must still lift a sum with it to `p` bits.
#[derive(Clone, Debug)]
pub struct Big {
f: Float,
prec: u64,
}
impl PartialEq for Big {
fn eq(&self, other: &Self) -> bool {
self.f == other.f
}
}
fn prec(bits: usize) -> u64 {
(bits as u64).max(1)
}
impl Big {
fn new(f: Float, prec: u64) -> Self {
// Only finite values reach here; anything else (overflow) is a bug.
debug_assert!(f.is_finite(), "non-finite Big: {f}");
Self { f, prec }
}
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let p = prec(bits);
if !x.is_finite() {
return Self::zero(bits);
}
Self::new(Float::from_primitive_float_prec(x, p).0, p)
}
pub fn zero(bits: usize) -> Self {
Self::new(Float::ZERO, prec(bits))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let p = prec(bits.max(53));
let (f, _) = Float::from_sci_string_prec(s.trim(), p)?;
f.is_finite().then(|| Self::new(f, p))
}
pub fn precision(&self) -> usize {
self.prec as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
let p = prec(bits);
if self.f.get_prec().is_some() {
self.f.set_prec(p);
}
self.prec = p;
self
}
pub fn to_f64(&self) -> f64 {
f64::rounding_from(&self.f, Nearest).0
}
pub fn is_zero(&self) -> bool {
self.f.is_zero()
}
pub fn is_negative(&self) -> bool {
self.f.is_sign_negative() && !self.f.is_zero()
}
pub fn abs(self) -> Self {
if self.f.is_sign_negative() {
self.negated()
} else {
self
}
}
pub fn sqr(&self) -> Self {
Self::new(self.f.square_prec_ref(self.prec).0, self.prec)
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// The significand is normalized to [0.5, 1).
self.f.get_exponent().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self::new(self.f.ln_prec_ref(self.prec).0, self.prec)
}
pub fn exp(&self) -> Self {
Self::new(self.f.exp_prec_ref(self.prec).0, self.prec)
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.prec.max(x.prec);
Self::new(self.f.atan2_prec_ref_ref(&x.f, p).0, p)
}
pub fn sin_cos(&self) -> (Self, Self) {
let (s, c, _, _) = self.f.sin_cos_prec_ref(self.prec);
(Self::new(s, self.prec), Self::new(c, self.prec))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.f.is_zero() {
return DecimalParts::new(false, "", 0);
}
let mut options = ToSciOptions::default();
options.set_precision(sig as u64);
let s = self.f.to_sci_with_options(options).to_string();
// `[-]int[.frac][e±N]`, positional or scientific depending on size.
let (negative, s) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.as_str()),
};
let (mantissa, exp) = match s.split_once(['e', 'E']) {
Some((m, e)) => (m, e.trim_start_matches('+').parse::<isize>().unwrap_or(0)),
None => (s, 0),
};
let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let all = format!("{int}{frac}");
let digits = all.trim_start_matches('0');
let leading_zeros = (all.len() - digits.len()) as isize;
// 0.digits · 10^exp10 = int.frac · 10^exp.
let exp10 = int.len() as isize - leading_zeros + exp;
DecimalParts::new(negative, digits, exp10)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.add_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.sub_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.mul_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn negated(self) -> Big {
Big::new(-self.f, self.prec)
}
/// `self · 2^k`, exact (malachite's exponent range is ±2^30, far past
/// what zoom depth needs).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big::new(self.f << k as i64, self.prec)
}
}
+18 -1
View File
@@ -74,6 +74,10 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub de: bool, pub de: bool,
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
#[arg(long)]
pub antialias: bool,
/// Coloring palette index. /// Coloring palette index.
#[arg(long, value_name = "INDEX")] #[arg(long, value_name = "INDEX")]
pub palette: Option<u32>, pub palette: Option<u32>,
@@ -81,7 +85,9 @@ pub struct Cli {
/// Output path for --headless (default: fractal-<timestamp>.png). When /// Output path for --headless (default: fractal-<timestamp>.png). When
/// animating (--to-view/--to-share), this is a directory of /// animating (--to-view/--to-share), this is a directory of
/// frame-00001.png, frame-00002.png, ... instead (default: /// frame-00001.png, frame-00002.png, ... instead (default:
/// frames-<timestamp>/). /// frames-<timestamp>/). "-" writes to stdout: the PNG for a single
/// image, or raw RGBA8 frames in order for an animation, to pipe into
/// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`.
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
pub export_path: Option<String>, pub export_path: Option<String>,
@@ -165,6 +171,17 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub linear: bool, pub linear: bool,
/// Split the animation into N equal parts (use with --shard) to render
/// it across several runs. Each part keeps the whole animation's timing
/// and easing, so the clips join seamlessly.
#[arg(long, value_name = "N")]
pub shards: Option<u32>,
/// Which part of --shards to render, 1-based. PNG frames keep their
/// global numbering, so all shards can share one --export-path directory.
#[arg(long, value_name = "K")]
pub shard: Option<u32>,
/// Run without opening a window: render the current view to a PNG and /// 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 /// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to /// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
+10 -3
View File
@@ -5,7 +5,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use eframe::egui_wgpu::{self, wgpu}; #[cfg(feature = "gui")]
use eframe::egui_wgpu;
/// Random samples dispatched per accumulating frame. Chosen so a frame stays /// Random samples dispatched per accumulating frame. Chosen so a frame stays
/// interactive on a modest GPU even when most samples run the full `b_cap` /// interactive on a modest GPU even when most samples run the full `b_cap`
@@ -125,7 +126,9 @@ pub struct BuddhabrotRenderer {
impl BuddhabrotRenderer { impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self { pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"), label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
concat!( concat!(
@@ -134,7 +137,10 @@ impl BuddhabrotRenderer {
) )
.into(), .into(),
), ),
}); },
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot uniforms"), label: Some("buddhabrot uniforms"),
@@ -336,6 +342,7 @@ pub struct BuddhabrotCallback {
pub size_px: [u32; 2], pub size_px: [u32; 2],
} }
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for BuddhabrotCallback { impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare( fn prepare(
&self, &self,
+1 -1
View File
@@ -16,5 +16,5 @@ pub use renderer::PipelineKey;
pub use renderer::encode_png_with_progress; pub use renderer::encode_png_with_progress;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms}; pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking}; pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking, unpad_rgba};
pub use share::ShareState; pub use share::ShareState;
+68 -95
View File
@@ -1,7 +1,7 @@
//! High-precision reference-orbit computation for perturbation rendering. //! High-precision reference-orbit computation for perturbation rendering.
//! //!
//! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision //! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision
//! (`dashu-float`), storing each `Z_n` as an `f32` pair. Every pixel is then //! ([`Big`]: `rug` natively, pure Rust on the web), 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 //! 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 //! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
//! delta formula there must match the orbit formula here. //! delta formula there must match the orbit formula here.
@@ -24,7 +24,7 @@ use crate::view::{Big, big_from_f64};
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10; const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// Up to this working precision (bits) the orbit is iterated in plain `f64` /// Up to this working precision (bits) the orbit is iterated in plain `f64`
/// instead of `FBig` — orders of magnitude faster, which matters most on the /// instead of `Big` — orders of magnitude faster, which matters most on the
/// web (where the reference is computed inline on the UI thread). /// web (where the reference is computed inline on the UI thread).
/// ///
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only /// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
@@ -60,6 +60,9 @@ pub struct RefOrbit {
impl RefOrbit { impl RefOrbit {
fn with_capacity(n: usize) -> Self { fn with_capacity(n: usize) -> Self {
// Most orbits escape long before `max_iter`; don't reserve hundreds of
// MB up front for a multi-million iteration request.
let n = n.min(1 << 17);
Self { Self {
points: Vec::with_capacity(n), points: Vec::with_capacity(n),
exps: Vec::with_capacity(n), exps: Vec::with_capacity(n),
@@ -93,29 +96,21 @@ impl<'a> IntoIterator for &'a RefOrbit {
} }
} }
/// `floor(log2|x|)`, or `None` for zero. Exact (from the binary
/// representation), and works far below f64's range.
fn big_log2_floor(x: &Big) -> Option<isize> {
let repr = x.repr();
let digits = repr.digits();
(digits > 0).then(|| repr.exponent() + digits as isize - 1)
}
/// Store `(zr, zi)` into `orbit`, as plain f32 unless its magnitude is below /// Store `(zr, zi)` into `orbit`, as plain f32 unless its magnitude is below
/// `2^TINY_LOG2`, in which case both components share an exponent `k` and /// `2^TINY_LOG2`, in which case both components share an exponent `k` and
/// the stored mantissa `Z * 2^-k` has its larger component in `[0.5, 1)`. /// the stored mantissa `Z * 2^-k` has its larger component in `[0.5, 1)`.
fn push_big_point(orbit: &mut RefOrbit, zr: &Big, zi: &Big) { fn push_big_point(orbit: &mut RefOrbit, zr: &Big, zi: &Big) {
let (lr, li) = (big_log2_floor(zr), big_log2_floor(zi)); let (lr, li) = (zr.log2_floor(), zi.log2_floor());
let top = lr.max(li); let top = lr.max(li);
match top { match top {
Some(top) if top < TINY_LOG2 as isize => { Some(top) if top < TINY_LOG2 as isize => {
let k = top + 1; let k = top + 1;
let mr = (zr.clone() << -k).to_f64().value() as f32; let mr = (zr.clone() << -k).to_f64() as f32;
let mi = (zi.clone() << -k).to_f64().value() as f32; let mi = (zi.clone() << -k).to_f64() as f32;
orbit.points.push([mr, mi]); orbit.points.push([mr, mi]);
orbit.exps.push(k as i32); orbit.exps.push(k as i32);
} }
_ => orbit.push([zr.to_f64().value() as f32, zi.to_f64().value() as f32]), _ => orbit.push([zr.to_f64() as f32, zi.to_f64() as f32]),
} }
} }
@@ -144,23 +139,17 @@ pub fn compute_reference(
let morph = morph.filter(|&(_, w)| w != 0.0); let morph = morph.filter(|&(_, w)| w != 0.0);
if precision <= F64_MAX_PRECISION { if precision <= F64_MAX_PRECISION {
let k = StepConstsF64 { let k = StepConstsF64 {
c: (c_re.to_f64().value(), c_im.to_f64().value()), c: (c_re.to_f64(), c_im.to_f64()),
p: phoenix_p, p: phoenix_p,
l: lambda_l, l: lambda_l,
cpow: complex_power, cpow: complex_power,
power, power,
}; };
return compute_reference_f64( return compute_reference_f64((z0_re.to_f64(), z0_im.to_f64()), max_iter, kind, &k, morph);
(z0_re.to_f64().value(), z0_im.to_f64().value()),
max_iter,
kind,
&k,
morph,
);
} }
let k = StepConsts { let k = StepConsts {
cr: c_re.clone().with_precision(precision).value(), cr: c_re.clone().with_precision(precision),
ci: c_im.clone().with_precision(precision).value(), ci: c_im.clone().with_precision(precision),
pr: big_from_f64(phoenix_p.0, precision), pr: big_from_f64(phoenix_p.0, precision),
pi: big_from_f64(phoenix_p.1, precision), pi: big_from_f64(phoenix_p.1, precision),
lr: big_from_f64(lambda_l.0, precision), lr: big_from_f64(lambda_l.0, precision),
@@ -289,7 +278,7 @@ struct StepConsts {
precision: usize, precision: usize,
} }
/// [`compute_reference`] at arbitrary precision (`FBig`), for deep views. /// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
fn compute_reference_big( fn compute_reference_big(
z0_re: &Big, z0_re: &Big,
z0_im: &Big, z0_im: &Big,
@@ -301,11 +290,11 @@ fn compute_reference_big(
let precision = k.precision; let precision = k.precision;
let morph = morph.map(|(from, w)| (from, big_from_f64(w, precision))); let morph = morph.map(|(from, w)| (from, big_from_f64(w, precision)));
let mut zr = z0_re.clone().with_precision(precision).value(); let mut zr = z0_re.clone().with_precision(precision);
let mut zi = z0_im.clone().with_precision(precision).value(); let mut zi = z0_im.clone().with_precision(precision);
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0). // Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
let mut zr_prev = big_zero(precision); let mut zr_prev = Big::zero(precision);
let mut zi_prev = big_zero(precision); let mut zi_prev = Big::zero(precision);
let mut points = RefOrbit::with_capacity(max_iter as usize + 1); let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
@@ -329,8 +318,8 @@ fn compute_reference_big(
// Shift the previous iterate (only the Phoenix arm reads it). // Shift the previous iterate (only the Phoenix arm reads it).
zr_prev = zr; zr_prev = zr;
zi_prev = zi; zi_prev = zi;
zr = new_zr.with_precision(precision).value(); zr = new_zr.with_precision(precision);
zi = new_zi.with_precision(precision).value(); zi = new_zi.with_precision(precision);
} }
points points
@@ -358,7 +347,7 @@ fn step(
FractalKind::BurningShip => { FractalKind::BurningShip => {
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i. // (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
let re = &zr.sqr() - &zi.sqr() + cr; let re = &zr.sqr() - &zi.sqr() + cr;
let im = big_abs((zr * zi) << 1) + ci; let im = ((zr * zi) << 1).abs() + ci;
(re, im) (re, im)
} }
FractalKind::Tricorn => { FractalKind::Tricorn => {
@@ -373,14 +362,14 @@ fn step(
} }
FractalKind::Celtic => { FractalKind::Celtic => {
// |Re(z^2)| + i·Im(z^2): abs the real output of the square. // |Re(z^2)| + i·Im(z^2): abs the real output of the square.
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr; let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ((zr * zi) << 1) + ci; let im = ((zr * zi) << 1) + ci;
(re, im) (re, im)
} }
FractalKind::Perpendicular => { FractalKind::Perpendicular => {
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input. // (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
let re = &zr.sqr() - &zi.sqr() + cr; let re = &zr.sqr() - &zi.sqr() + cr;
let im = if *zi < Big::ZERO { let im = if zi.is_negative() {
ci + ((zr * zi) << 1) ci + ((zr * zi) << 1)
} else { } else {
ci - ((zr * zi) << 1) ci - ((zr * zi) << 1)
@@ -389,8 +378,8 @@ fn step(
} }
FractalKind::Buffalo => { FractalKind::Buffalo => {
// |Re(z^2)| - |Im(z^2)| i: abs both outputs. // |Re(z^2)| - |Im(z^2)| i: abs both outputs.
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr; let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ci - big_abs((zr * zi) << 1); let im = ci - ((zr * zi) << 1).abs();
(re, im) (re, im)
} }
FractalKind::Phoenix => { FractalKind::Phoenix => {
@@ -403,7 +392,7 @@ fn step(
} }
FractalKind::Lambda => { FractalKind::Lambda => {
// λ·z(1 - z) + c: logistic map plus the usual additive `c`. // λ·z(1 - z) + c: logistic map plus the usual additive `c`.
let re2 = 1 - zr; let re2 = Big::from_f64(1.0, k.precision) - zr;
let im2 = -zi; let im2 = -zi;
let lzr = &k.lr * zr - &k.li * zi; let lzr = &k.lr * zr - &k.li * zi;
let lzi = &k.lr * zi + &k.li * zr; let lzi = &k.lr * zi + &k.li * zr;
@@ -418,36 +407,20 @@ fn step(
} }
} }
fn big_zero(precision: usize) -> Big {
Big::from(0i32).with_precision(precision).value()
}
/// Absolute value of a `Big`. The sign comes from the `Big` itself: through
/// f64, anything below ~1e-308 reads as ±0 and would keep its sign.
fn big_abs(x: Big) -> Big {
if x < Big::ZERO { -x } else { x }
}
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits. /// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) { fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
let mut rr = Big::from(1i32).with_precision(precision).value(); let mut rr = Big::from_f64(1.0, precision);
let mut ri = big_zero(precision); let mut ri = Big::zero(precision);
for _ in 0..power { for _ in 0..power {
// (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i. // (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i.
let nr = (&rr * zr - &ri * zi).with_precision(precision).value(); let nr = (&rr * zr - &ri * zi).with_precision(precision);
let ni = (&rr * zi + &ri * zr).with_precision(precision).value(); let ni = (&rr * zi + &ri * zr).with_precision(precision);
rr = nr; rr = nr;
ri = ni; ri = ni;
} }
(rr, ri) (rr, ri)
} }
/// `true` if `x` is exactly zero (special-cases `ln(0)`). Not via f64,
/// which flushes values below ~1e-308 to zero.
fn is_big_zero(x: &Big) -> bool {
x.repr().significand().is_zero()
}
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch /// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
/// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by /// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by
/// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`. /// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`.
@@ -455,14 +428,14 @@ fn is_big_zero(x: &Big) -> bool {
/// panic; this is the correct limit for the `Re(p) > 0` region the UI /// panic; this is the correct limit for the `Re(p) > 0` region the UI
/// exposes). /// exposes).
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) { fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
if is_big_zero(zr) && is_big_zero(zi) { if zr.is_zero() && zi.is_zero() {
return (big_zero(precision), big_zero(precision)); return (Big::zero(precision), Big::zero(precision));
} }
let r2 = &zr.sqr() + &zi.sqr(); let r2 = &zr.sqr() + &zi.sqr();
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving. let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
let theta = zi.atan2(zr); let theta = zi.atan2(zr);
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value(); let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision);
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision).value(); let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision);
let mag = exp_re.exp(); let mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos(); let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a) (&mag * &cos_a, &mag * &sin_a)
@@ -483,7 +456,7 @@ pub fn compute_set_reference(
complex_power: (f64, f64), complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>, morph: Option<(FractalKind, f64)>,
) -> RefOrbit { ) -> RefOrbit {
let zero = big_zero(precision); let zero = Big::zero(precision);
compute_reference( compute_reference(
&zero, &zero,
&zero, &zero,
@@ -508,19 +481,19 @@ mod tests {
/// `to_f64` reads as ±0. /// `to_f64` reads as ±0.
#[test] #[test]
fn sign_and_zero_below_f64_range() { fn sign_and_zero_below_f64_range() {
let tiny = Big::try_from(1.0_f64).unwrap().with_precision(64).value() >> 5000; let tiny = Big::from_f64(1.0, 64) >> 5000;
assert!(!is_big_zero(&tiny)); assert!(!tiny.is_zero());
assert!(is_big_zero(&big_zero(64))); assert!(Big::zero(64).is_zero());
assert_eq!(big_abs(-tiny.clone()), tiny); assert_eq!((-tiny.clone()).abs(), tiny);
assert_eq!(big_abs(tiny.clone()), tiny); assert_eq!(tiny.clone().abs(), tiny);
} }
/// The high-precision reference must agree with a plain f64 iteration for a /// The high-precision reference must agree with a plain f64 iteration for a
/// shallow point (where f64 is accurate). /// shallow point (where f64 is accurate).
#[test] #[test]
fn reference_matches_naive_f64() { fn reference_matches_naive_f64() {
let cr = Big::try_from(-0.75_f64).unwrap(); let cr = Big::from_f64(-0.75, 53);
let ci = Big::try_from(0.1_f64).unwrap(); let ci = Big::from_f64(0.1, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -653,8 +626,8 @@ mod tests {
/// A point inside the main cardioid never escapes: full-length orbit. /// A point inside the main cardioid never escapes: full-length orbit.
#[test] #[test]
fn interior_orbit_runs_full_length() { fn interior_orbit_runs_full_length() {
let cr = Big::try_from(-0.2_f64).unwrap(); let cr = Big::from_f64(-0.2, 53);
let ci = Big::try_from(0.0_f64).unwrap(); let ci = Big::from_f64(0.0, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -673,8 +646,8 @@ mod tests {
/// Burning Ship reference matches a naive f64 iteration of the same formula. /// Burning Ship reference matches a naive f64 iteration of the same formula.
#[test] #[test]
fn burning_ship_reference_matches_naive_f64() { fn burning_ship_reference_matches_naive_f64() {
let cr = Big::try_from(-1.75_f64).unwrap(); let cr = Big::from_f64(-1.75, 53);
let ci = Big::try_from(-0.03_f64).unwrap(); let ci = Big::from_f64(-0.03, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -704,8 +677,8 @@ mod tests {
/// Multibrot (power 3) reference matches a naive f64 cube iteration. /// Multibrot (power 3) reference matches a naive f64 cube iteration.
#[test] #[test]
fn multibrot3_reference_matches_naive_f64() { fn multibrot3_reference_matches_naive_f64() {
let cr = Big::try_from(0.3_f64).unwrap(); let cr = Big::from_f64(0.3, 53);
let ci = Big::try_from(0.2_f64).unwrap(); let ci = Big::from_f64(0.2, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -737,10 +710,10 @@ mod tests {
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration. /// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
#[test] #[test]
fn julia_reference_matches_naive_f64() { fn julia_reference_matches_naive_f64() {
let z0_re = Big::try_from(0.15_f64).unwrap(); let z0_re = Big::from_f64(0.15, 53);
let z0_im = Big::try_from(-0.1_f64).unwrap(); let z0_im = Big::from_f64(-0.1, 53);
let c_re = Big::try_from(-0.8_f64).unwrap(); let c_re = Big::from_f64(-0.8, 53);
let c_im = Big::try_from(0.156_f64).unwrap(); let c_im = Big::from_f64(0.156, 53);
let points = compute_reference( let points = compute_reference(
&z0_re, &z0_re,
&z0_im, &z0_im,
@@ -775,10 +748,10 @@ mod tests {
let (lr, li) = (-0.5_f64, 0.2_f64); let (lr, li) = (-0.5_f64, 0.2_f64);
let (cr, ci) = (0.1_f64, -0.3_f64); let (cr, ci) = (0.1_f64, -0.3_f64);
let points = compute_reference( let points = compute_reference(
&Big::try_from(0.2_f64).unwrap(), &Big::from_f64(0.2, 53),
&Big::try_from(0.1_f64).unwrap(), &Big::from_f64(0.1, 53),
&Big::try_from(cr).unwrap(), &Big::from_f64(cr, 53),
&Big::try_from(ci).unwrap(), &Big::from_f64(ci, 53),
60, 60,
200, 200,
FractalKind::Lambda, FractalKind::Lambda,
@@ -804,8 +777,8 @@ mod tests {
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr. /// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
#[test] #[test]
fn celtic_reference_matches_naive_f64() { fn celtic_reference_matches_naive_f64() {
let cr = Big::try_from(-0.6_f64).unwrap(); let cr = Big::from_f64(-0.6, 53);
let ci = Big::try_from(0.4_f64).unwrap(); let ci = Big::from_f64(0.4, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -836,8 +809,8 @@ mod tests {
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci. /// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
#[test] #[test]
fn perpendicular_reference_matches_naive_f64() { fn perpendicular_reference_matches_naive_f64() {
let cr = Big::try_from(-0.7_f64).unwrap(); let cr = Big::from_f64(-0.7, 53);
let ci = Big::try_from(-0.2_f64).unwrap(); let ci = Big::from_f64(-0.2, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -868,8 +841,8 @@ mod tests {
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci. /// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
#[test] #[test]
fn buffalo_reference_matches_naive_f64() { fn buffalo_reference_matches_naive_f64() {
let cr = Big::try_from(-1.2_f64).unwrap(); let cr = Big::from_f64(-1.2, 53);
let ci = Big::try_from(-0.35_f64).unwrap(); let ci = Big::from_f64(-0.35, 53);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
&ci, &ci,
@@ -900,8 +873,8 @@ mod tests {
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0). /// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
#[test] #[test]
fn phoenix_reference_matches_naive_f64() { fn phoenix_reference_matches_naive_f64() {
let cr = Big::try_from(0.5667_f64).unwrap(); let cr = Big::from_f64(0.5667, 53);
let ci = Big::try_from(0.0_f64).unwrap(); let ci = Big::from_f64(0.0, 53);
let p = (-0.5_f64, 0.0_f64); let p = (-0.5_f64, 0.0_f64);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
@@ -939,8 +912,8 @@ mod tests {
/// iteration of `z^p = exp(p·ln z)`. /// iteration of `z^p = exp(p·ln z)`.
#[test] #[test]
fn complex_multibrot_reference_matches_naive_f64() { fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::try_from(0.1_f64).unwrap(); let cr = Big::from_f64(0.1, 53);
let ci = Big::try_from(-0.2_f64).unwrap(); let ci = Big::from_f64(-0.2, 53);
let power = (2.5_f64, 0.3_f64); let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference( let points = compute_set_reference(
&cr, &cr,
@@ -984,8 +957,8 @@ mod tests {
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit { fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
compute_set_reference( compute_set_reference(
&Big::try_from(cr).unwrap(), &Big::from_f64(cr, 53),
&Big::try_from(ci).unwrap(), &Big::from_f64(ci, 53),
60, 60,
200, 200,
kind, kind,
+255 -48
View File
@@ -12,16 +12,23 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use eframe::egui_wgpu::{self, wgpu}; #[cfg(feature = "gui")]
use eframe::egui_wgpu;
use wgpu::util::DeviceExt as _; use wgpu::util::DeviceExt as _;
use super::kind::FractalKind; use super::kind::FractalKind;
use super::reference::RefOrbit; use super::reference::RefOrbit;
use crate::lights::{GpuLight, Light, MAX_LIGHT_COUNT, gpu_lights}; use crate::lights::{GpuLight, Light, MAX_LIGHT_COUNT, gpu_lights};
/// Maximum reference-orbit length (points) the storage buffer can hold. Also /// Maximum reference-orbit length (points), and so the hard ceiling on the
/// bounds the iteration count. 128k points * 8 bytes = 1 MiB. /// iteration count (the shader treats an exhausted reference as escaped).
pub const MAX_REF_POINTS: usize = 1 << 17; /// 16M points * 8 bytes = 128 MiB, WebGPU's default
/// `max_storage_buffer_binding_size`, so every device can bind it.
pub const MAX_REF_POINTS: usize = 1 << 24;
/// Initial capacity (points) of the interactive reference buffers; they grow
/// (by powers of two, up to `MAX_REF_POINTS`) when a longer orbit arrives.
const INITIAL_REF_POINTS: usize = 1 << 17;
/// Format of the intermediate iteration-data texture holding, per pixel, /// Format of the intermediate iteration-data texture holding, per pixel,
/// `(ci, DE factor, interior fraction)`. 32-bit float keeps the smooth iteration /// `(ci, DE factor, interior fraction)`. 32-bit float keeps the smooth iteration
@@ -190,7 +197,9 @@ struct Lipschitz {
impl Lipschitz { impl Lipschitz {
fn new(device: &wgpu::Device) -> Self { fn new(device: &wgpu::Device) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { let module = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("lipschitz"), label: Some("lipschitz"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
concat!( concat!(
@@ -199,7 +208,10 @@ impl Lipschitz {
) )
.into(), .into(),
), ),
}); },
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let texture_entry = |binding| wgpu::BindGroupLayoutEntry { let texture_entry = |binding| wgpu::BindGroupLayoutEntry {
binding, binding,
visibility: wgpu::ShaderStages::FRAGMENT, visibility: wgpu::ShaderStages::FRAGMENT,
@@ -233,7 +245,15 @@ impl Lipschitz {
immediate_size: 0, immediate_size: 0,
}); });
let pipeline = |label, entry| { let pipeline = |label, entry| {
fullscreen_pipeline(device, label, &module, &pipeline_layout, entry, DATA_FORMAT, &[]) fullscreen_pipeline(
device,
label,
&module,
&pipeline_layout,
entry,
DATA_FORMAT,
&[],
)
}; };
let mut contents = vec![0u8; (LIPSCHITZ_STRIDE * LIPSCHITZ_SLOTS) as usize]; let mut contents = vec![0u8; (LIPSCHITZ_STRIDE * LIPSCHITZ_SLOTS) as usize];
for k in 0..LIPSCHITZ_SLOTS { for k in 0..LIPSCHITZ_SLOTS {
@@ -380,7 +400,13 @@ impl Envelope {
pass(encoder, &self.views[0], &lp.seed, &self.inputs[1], 0); pass(encoder, &self.views[0], &lp.seed, &self.inputs[1], 0);
// Pass i reads seeds i % 2 and writes the other. // Pass i reads seeds i % 2 and writes the other.
for (i, &slot) in self.slots.iter().enumerate() { for (i, &slot) in self.slots.iter().enumerate() {
pass(encoder, &self.views[(i + 1) % 2], &lp.jump, &self.inputs[i % 2], slot); pass(
encoder,
&self.views[(i + 1) % 2],
&lp.jump,
&self.inputs[i % 2],
slot,
);
} }
let last = self.slots.len() % 2; let last = self.slots.len() % 2;
pass(encoder, &self.views[2], &lp.compose, &self.inputs[last], 0); pass(encoder, &self.views[2], &lp.compose, &self.inputs[last], 0);
@@ -531,6 +557,8 @@ pub struct FractalRenderer {
uniform_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer,
ref_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer,
ref_exp_buffer: wgpu::Buffer, ref_exp_buffer: wgpu::Buffer,
/// Points `ref_buffer` / `ref_exp_buffer` can hold (see `ensure_ref_capacity`).
ref_capacity: usize,
lights_buffer: wgpu::Buffer, lights_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup, bind_group: wgpu::BindGroup,
target_format: wgpu::TextureFormat, target_format: wgpu::TextureFormat,
@@ -562,8 +590,41 @@ pub struct FractalRenderer {
} }
impl FractalRenderer { impl FractalRenderer {
/// Grow the reference buffers (and rebuild the bind group pointing at
/// them) so they hold at least `needed` points. The caller re-uploads the
/// orbit right after, so the old contents aren't copied over.
fn ensure_ref_capacity(&mut self, device: &wgpu::Device, needed: usize) {
if needed <= self.ref_capacity {
return;
}
let capacity = needed.next_power_of_two().min(MAX_REF_POINTS);
self.ref_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reference orbit"),
size: (capacity * std::mem::size_of::<[f32; 2]>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reference orbit exponents"),
size: (capacity * std::mem::size_of::<i32>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.bind_group = iterate_bind_group(
device,
&self.bind_group_layout,
&self.uniform_buffer,
&self.ref_buffer,
&self.lights_buffer,
&self.ref_exp_buffer,
);
self.ref_capacity = capacity;
}
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self { pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("mandelbrot"), label: Some("mandelbrot"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
concat!( concat!(
@@ -573,7 +634,10 @@ impl FractalRenderer {
) )
.into(), .into(),
), ),
}); },
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("fractal uniforms"), label: Some("fractal uniforms"),
@@ -584,7 +648,7 @@ impl FractalRenderer {
let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor { let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reference orbit"), label: Some("reference orbit"),
size: (MAX_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64, size: (INITIAL_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
@@ -593,7 +657,7 @@ impl FractalRenderer {
// read by deep pipelines. // read by deep pipelines.
let ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor { let ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reference orbit exponents"), label: Some("reference orbit exponents"),
size: (MAX_REF_POINTS * std::mem::size_of::<i32>()) as u64, size: (INITIAL_REF_POINTS * std::mem::size_of::<i32>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
@@ -654,28 +718,14 @@ impl FractalRenderer {
], ],
}); });
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { let bind_group = iterate_bind_group(
label: Some("fractal bind group"), device,
layout: &bind_group_layout, &bind_group_layout,
entries: &[ &uniform_buffer,
wgpu::BindGroupEntry { &ref_buffer,
binding: 0, &lights_buffer,
resource: uniform_buffer.as_entire_binding(), &ref_exp_buffer,
}, );
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: lights_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: ref_exp_buffer.as_entire_binding(),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("fractal pipeline layout"), label: Some("fractal pipeline layout"),
@@ -708,7 +758,9 @@ impl FractalRenderer {
}); });
// Colourise pass: data texture + colour uniforms → colour texture. // Colourise pass: data texture + colour uniforms → colour texture.
let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let colorize_shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("colorize"), label: Some("colorize"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
concat!( concat!(
@@ -718,7 +770,10 @@ impl FractalRenderer {
) )
.into(), .into(),
), ),
}); },
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let colorize_bind_group_layout = let colorize_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("colorize bind group layout"), label: Some("colorize bind group layout"),
@@ -789,7 +844,9 @@ impl FractalRenderer {
}); });
// Blit pipeline: samples the cache texture onto egui's surface. // Blit pipeline: samples the cache texture onto egui's surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let blit_shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("blit"), label: Some("blit"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
concat!( concat!(
@@ -798,7 +855,10 @@ impl FractalRenderer {
) )
.into(), .into(),
), ),
}); },
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let blit_bind_group_layout = let blit_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -872,6 +932,7 @@ impl FractalRenderer {
uniform_buffer, uniform_buffer,
ref_buffer, ref_buffer,
ref_exp_buffer, ref_exp_buffer,
ref_capacity: INITIAL_REF_POINTS,
lights_buffer, lights_buffer,
bind_group, bind_group,
target_format, target_format,
@@ -1269,8 +1330,16 @@ impl ExportRender {
}); });
// ~128px bands, kept to a sane range so progress is smooth without too // ~128px bands, kept to a sane range so progress is smooth without too
// many submissions. // many submissions; more at high iteration counts so no single
let tiles = (height / 128).clamp(8, 64).min(height.max(1)); // submission runs long enough to trip a GPU reset (see
// `WORK_PER_SUBMIT`). Export supersamples every pixel (×3 in shadow
// mode, which also iterates two neighbours).
let aa = uniforms.aa_level.max(1);
let samples = aa * aa * if uniforms.rendering_mode != 0 { 3 } else { 1 };
let tiles = (height / 128)
.clamp(8, 64)
.max(band_count(width, height, uniforms.max_iter, samples))
.min(height.max(1));
let swap_rb = matches!( let swap_rb = matches!(
target_format, target_format,
@@ -1559,17 +1628,15 @@ pub fn render_readback_blocking(
bytes bytes
} }
/// Like [`encode_png_with_progress`], but encodes the whole image at once /// Strip a readback's row padding and convert it to tightly-packed RGBA8
/// (no progress) at the given compression level. Non-streaming, so the fast /// (`width * height * 4` bytes, rows top to bottom).
/// `fdeflate` levels don't pay the streaming-mode size penalty.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn encode_png( pub fn unpad_rgba(
padded: &[u8], padded: &[u8],
width: u32, width: u32,
height: u32, height: u32,
padded_bpr: u32, padded_bpr: u32,
swap_rb: bool, swap_rb: bool,
compression: png::Compression,
) -> Vec<u8> { ) -> Vec<u8> {
let row = (width * 4) as usize; let row = (width * 4) as usize;
let mut pixels = Vec::with_capacity(row * height as usize); let mut pixels = Vec::with_capacity(row * height as usize);
@@ -1587,6 +1654,22 @@ pub fn encode_png(
pixels.extend_from_slice(src); pixels.extend_from_slice(src);
} }
} }
pixels
}
/// Like [`encode_png_with_progress`], but encodes the whole image at once
/// (no progress) at the given compression level. Non-streaming, so the fast
/// `fdeflate` levels don't pay the streaming-mode size penalty.
#[cfg(not(target_arch = "wasm32"))]
pub fn encode_png(
padded: &[u8],
width: u32,
height: u32,
padded_bpr: u32,
swap_rb: bool,
compression: png::Compression,
) -> Vec<u8> {
let pixels = unpad_rgba(padded, width, height, padded_bpr, swap_rb);
let mut out = Vec::new(); let mut out = Vec::new();
{ {
@@ -1648,6 +1731,40 @@ pub fn encode_png_with_progress(
/// Colourise pass input: the uniforms, the data texture `data` to colour and /// Colourise pass input: the uniforms, the data texture `data` to colour and
/// the lights. /// the lights.
/// Group 0 of the iterate/refine pipelines: uniforms, reference orbit,
/// lights, reference exponents.
fn iterate_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
uniform_buffer: &wgpu::Buffer,
ref_buffer: &wgpu::Buffer,
lights_buffer: &wgpu::Buffer,
ref_exp_buffer: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("fractal bind group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: lights_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: ref_exp_buffer.as_entire_binding(),
},
],
})
}
fn colorize_bind_group( fn colorize_bind_group(
device: &wgpu::Device, device: &wgpu::Device,
layout: &wgpu::BindGroupLayout, layout: &wgpu::BindGroupLayout,
@@ -1684,6 +1801,25 @@ fn data_pass(
pipeline: &wgpu::RenderPipeline, pipeline: &wgpu::RenderPipeline,
bind_groups: &[&wgpu::BindGroup], bind_groups: &[&wgpu::BindGroup],
) { ) {
band_pass(encoder, label, target, pipeline, bind_groups, None, true);
}
/// [`data_pass`] restricted to `rows` (`[y0, y1)` of a `width`-wide target),
/// clearing the whole attachment first only if `clear`.
fn band_pass(
encoder: &mut wgpu::CommandEncoder,
label: &str,
target: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
bind_groups: &[&wgpu::BindGroup],
rows: Option<(u32, u32, u32)>,
clear: bool,
) {
let load = if clear {
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
} else {
wgpu::LoadOp::Load
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label), label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@@ -1691,7 +1827,7 @@ fn data_pass(
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), load,
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -1700,6 +1836,11 @@ fn data_pass(
occlusion_query_set: None, occlusion_query_set: None,
multiview_mask: None, multiview_mask: None,
}); });
if let Some((width, y0, y1)) = rows {
// Full-viewport triangle (so the pixel→plane mapping is unchanged),
// scissored to the band.
pass.set_scissor_rect(0, y0, width, y1 - y0);
}
pass.set_pipeline(pipeline); pass.set_pipeline(pipeline);
for (i, bg) in bind_groups.iter().enumerate() { for (i, bg) in bind_groups.iter().enumerate() {
pass.set_bind_group(i as u32, *bg, &[]); pass.set_bind_group(i as u32, *bg, &[]);
@@ -1707,6 +1848,60 @@ fn data_pass(
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
} }
/// Worst-case pixel·iteration work allowed in one GPU submission. Drivers
/// reset the GPU when a single draw runs too long (i915 on integrated Intel
/// gives up after ~640 ms when it can't preempt, and a fullscreen draw can't
/// be preempted mid-way), which loses the device. At deep zooms the iteration
/// count reaches millions, so iteration passes are split into row bands, each
/// its own submission, so the driver can schedule other work in between.
/// 2^30 is a few tens of ms at worst on an integrated GPU.
const WORK_PER_SUBMIT: f64 = (1u64 << 30) as f64;
/// Number of row bands a `width`×`height` pass of up to `samples` ×
/// `max_iter` iterations per pixel needs to stay within [`WORK_PER_SUBMIT`]
/// each (at most one band per row).
fn band_count(width: u32, height: u32, max_iter: u32, samples: u32) -> u32 {
let work = width as f64 * height as f64 * max_iter.max(1) as f64 * samples.max(1) as f64;
((work / WORK_PER_SUBMIT).ceil() as u32).clamp(1, height.max(1))
}
/// [`data_pass`] split into `bands` row bands. One band is recorded into
/// `encoder` as usual; more are each submitted on their own right away (so
/// they run before `encoder`, which is submitted later and reads the result).
#[allow(clippy::too_many_arguments)]
fn banded_data_pass(
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
label: &str,
target: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
bind_groups: &[&wgpu::BindGroup],
[width, height]: [u32; 2],
bands: u32,
) {
if bands <= 1 {
data_pass(encoder, label, target, pipeline, bind_groups);
return;
}
let band = height.div_ceil(bands);
for y0 in (0..height).step_by(band as usize) {
let y1 = (y0 + band).min(height);
let mut band_encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
band_pass(
&mut band_encoder,
label,
target,
pipeline,
bind_groups,
Some((width, y0, y1)),
y0 == 0,
);
queue.submit([band_encoder.finish()]);
}
}
/// A per-frame paint callback. Carries this frame's uniforms plus a reference to /// 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 /// the current reference orbit (cheap `Arc` clone). The orbit is only re-uploaded
/// when its `generation` changes; the expensive iteration pass re-runs only when /// when its `generation` changes; the expensive iteration pass re-runs only when
@@ -1724,6 +1919,7 @@ pub struct FractalCallback {
pub size_px: [u32; 2], pub size_px: [u32; 2],
} }
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for FractalCallback { impl egui_wgpu::CallbackTrait for FractalCallback {
fn prepare( fn prepare(
&self, &self,
@@ -1779,6 +1975,7 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
&& !self.reference.is_empty() && !self.reference.is_empty()
{ {
let count = self.reference.len().min(MAX_REF_POINTS); let count = self.reference.len().min(MAX_REF_POINTS);
renderer.ensure_ref_capacity(device, count);
queue.write_buffer( queue.write_buffer(
&renderer.ref_buffer, &renderer.ref_buffer,
0, 0,
@@ -1832,22 +2029,32 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
let pipelines = &renderer.pipelines[&PipelineKey::from_uniforms(&self.uniforms)]; let pipelines = &renderer.pipelines[&PipelineKey::from_uniforms(&self.uniforms)];
if let Some(cache) = &renderer.cache { if let Some(cache) = &renderer.cache {
if iter_dirty { if iter_dirty {
let max_iter = self.uniforms.max_iter;
// Iteration pass: 1-spp perturbation iterate → data texture. // Iteration pass: 1-spp perturbation iterate → data texture.
data_pass( banded_data_pass(
device,
queue,
egui_encoder, egui_encoder,
"fractal iterate pass", "fractal iterate pass",
&cache.data_view, &cache.data_view,
&pipelines.iterate, &pipelines.iterate,
&[&renderer.bind_group], &[&renderer.bind_group],
[width, height],
band_count(width, height, max_iter, 1),
); );
if let Some((data_aa_view, _)) = &cache.aa { if let Some((data_aa_view, _)) = &cache.aa {
// Adaptive AA: supersample only the non-smooth pixels. // Adaptive AA: supersample only the non-smooth pixels.
data_pass( let aa = self.uniforms.aa_level;
banded_data_pass(
device,
queue,
egui_encoder, egui_encoder,
"fractal AA refine pass", "fractal AA refine pass",
data_aa_view, data_aa_view,
&pipelines.refine, &pipelines.refine,
&[&renderer.bind_group, &cache.refine_bind_group], &[&renderer.bind_group, &cache.refine_bind_group],
[width, height],
band_count(width, height, max_iter, aa * aa),
); );
} }
} }
+192 -18
View File
@@ -3,19 +3,20 @@
// event loop, no worker-thread debounce (nothing to debounce for a one-shot // 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 // render); it just creates its own wgpu device, computes the reference orbit
// once, and renders through the same `ExportRender` path the "Export PNG" // once, and renders through the same `ExportRender` path the "Export PNG"
// button uses. // button uses. `--export-path -` writes to stdout instead: the PNG for a
// single image, or a raw RGBA8 video stream for an animation (for piping
// into ffmpeg).
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc}; use std::sync::{Mutex, mpsc};
use eframe::egui_wgpu::wgpu;
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp}; use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
use crate::cli::Cli; use crate::cli::Cli;
use crate::fractal::{ use crate::fractal::{
ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png, ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png,
export_to_png_blocking, render_readback_blocking, export_to_png_blocking, render_readback_blocking, unpad_rgba,
}; };
use crate::view::{ use crate::view::{
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
@@ -25,6 +26,21 @@ use crate::view::{
/// Cap on the output image dimension (px), to stay within GPU texture limits. /// Cap on the output image dimension (px), to stay within GPU texture limits.
const MAX_DIM: u32 = 8192 * 16; const MAX_DIM: u32 = 8192 * 16;
/// `--export-path` value meaning "write to stdout".
const STDOUT_PATH: &str = "-";
/// Refuse to dump binary image data onto a terminal.
fn check_stdout_piped() -> Result<(), String> {
if std::io::stdout().is_terminal() {
return Err(
"--export-path - writes binary data to stdout; pipe it somewhere \
(e.g. `| ffmpeg ...`)"
.into(),
);
}
Ok(())
}
pub fn run(cli: Cli) -> Result<(), String> { pub fn run(cli: Cli) -> Result<(), String> {
if cli.buddhabrot { if cli.buddhabrot {
return Err("headless mode doesn't support --buddhabrot yet".into()); return Err("headless mode doesn't support --buddhabrot yet".into());
@@ -37,6 +53,13 @@ pub fn run(cli: Cli) -> Result<(), String> {
// consumes `cli` to build the start state. // consumes `cli` to build the start state.
let targets = AnimTargets::from_cli(&cli)?; let targets = AnimTargets::from_cli(&cli)?;
let export_path = cli.export_path.clone(); let export_path = cli.export_path.clone();
if export_path.as_deref() == Some(STDOUT_PATH) {
check_stdout_piped()?;
}
if targets.shard.is_some() && !targets.any() {
return Err("--shard/--shards only apply to animations (give a --to-* target)".into());
}
let mut app = FractalApp::default_state(); let mut app = FractalApp::default_state();
app.apply_cli(cli); app.apply_cli(cli);
@@ -74,8 +97,16 @@ pub fn run(cli: Cli) -> Result<(), String> {
}); });
eprintln!(); eprintln!();
if export_path == STDOUT_PATH {
let mut out = std::io::stdout().lock();
out.write_all(&png)
.and_then(|()| out.flush())
.map_err(|e| format!("writing to stdout failed: {e}"))?;
eprintln!("wrote PNG to stdout ({width}×{height})");
} else {
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?; std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
println!("saved {export_path} ({width}×{height})"); println!("saved {export_path} ({width}×{height})");
}
Ok(()) Ok(())
} }
@@ -99,6 +130,8 @@ struct AnimTargets {
fps: f64, fps: f64,
duration: Option<f64>, duration: Option<f64>,
linear: bool, linear: bool,
/// `--shard K --shards N`: render only the K-th (1-based) of N parts.
shard: Option<(u32, u32)>,
} }
impl AnimTargets { impl AnimTargets {
@@ -109,6 +142,16 @@ impl AnimTargets {
.transpose() .transpose()
}; };
let to_cpow = pair("to-complex-power", &cli.to_complex_power)?; let to_cpow = pair("to-complex-power", &cli.to_complex_power)?;
let shard = match (cli.shard, cli.shards) {
(None, None) | (None, Some(1)) => None,
(Some(k), Some(n)) if (1..=n).contains(&k) => Some((k, n)),
(Some(k), Some(n)) => {
return Err(format!(
"--shard {k} is out of range 1..={n} (--shards {n})"
));
}
_ => return Err("--shard and --shards must be given together".into()),
};
Ok(Self { Ok(Self {
to_view: cli.to_view.clone(), to_view: cli.to_view.clone(),
to_share: cli.to_share.clone(), to_share: cli.to_share.clone(),
@@ -125,6 +168,7 @@ impl AnimTargets {
fps: cli.fps, fps: cli.fps,
duration: cli.duration, duration: cli.duration,
linear: cli.linear, linear: cli.linear,
shard,
}) })
} }
@@ -148,7 +192,9 @@ impl AnimTargets {
/// state to `targets`, for feeding into ffmpeg: the camera, iteration count, /// state to `targets`, for feeding into ffmpeg: the camera, iteration count,
/// per-kind constants (c, p, λ, complex power) and, through a kind morph, /// per-kind constants (c, p, λ, complex power) and, through a kind morph,
/// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at /// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at
/// whatever `apply_cli` set up for the start. /// whatever `apply_cli` set up for the start. With `--export-path -`, frames
/// are streamed in order to stdout as raw RGBA8 (for ffmpeg's `rawvideo`
/// demuxer) instead of being written as PNGs.
fn run_animation( fn run_animation(
mut app: FractalApp, mut app: FractalApp,
targets: AnimTargets, targets: AnimTargets,
@@ -169,6 +215,16 @@ fn run_animation(
if frames < 2 { if frames < 2 {
return Err("animation needs at least 2 frames".into()); return Err("animation needs at least 2 frames".into());
} }
// Frames are still timed against the whole animation (`apply_frame` takes
// the global index); a shard only picks which of them this run renders.
let range = match targets.shard {
Some((_, n)) if n > frames => {
return Err(format!("--shards {n} is more than the {frames} frames"));
}
Some((k, n)) => shard_range(frames, k, n),
None => 0..frames,
};
let (first, count) = (range.start, range.len());
let from = app.view_state().clone(); let from = app.view_state().clone();
let (to, to_iterations_share) = let (to, to_iterations_share) =
@@ -199,8 +255,17 @@ fn run_animation(
let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians); let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians);
let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians); let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians);
let stream = export_path.as_deref() == Some(STDOUT_PATH);
let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp())); let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp()));
std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?; if stream {
eprintln!(
"streaming raw video to stdout; ffmpeg input: \
-f rawvideo -pix_fmt rgba -s {width}x{height} -r {fps} -i -"
);
} else {
std::fs::create_dir_all(&out_dir)
.map_err(|e| format!("failed to create {out_dir}: {e}"))?;
}
// Everything about frame `i` is a pure function of its `t`, so the app can // Everything about frame `i` is a pure function of its `t`, so the app can
// be put into any frame's state at any time, in any order. // be put into any frame's state at any time, in any order.
@@ -232,7 +297,10 @@ fn run_animation(
// Snapshot every frame's reference-orbit job up front (cheap: just the // Snapshot every frame's reference-orbit job up front (cheap: just the
// parameters), so the orbits themselves can be computed in parallel. // parameters), so the orbits themselves can be computed in parallel.
let jobs: Vec<RefJob> = (0..frames) // `jobs[j]` is global frame `first + j`; the pipeline below works in
// local indices `j`, so the stdout writer's ordering is per shard.
let jobs: Vec<RefJob> = range
.clone()
.map(|i| { .map(|i| {
apply_frame(&mut app, i); apply_frame(&mut app, i);
app.reference_job() app.reference_job()
@@ -249,7 +317,15 @@ fn run_animation(
// part at deep zoom) → this thread renders each frame on the GPU → `threads` // part at deep zoom) → this thread renders each frame on the GPU → `threads`
// workers PNG-encode and write frames. Frames flow through out of order // workers PNG-encode and write frames. Frames flow through out of order
// (at most ~`threads` apart); each is written under its own index. // (at most ~`threads` apart); each is written under its own index.
//
// When streaming, the last stage instead unpads frames to raw RGBA and a
// single writer thread puts them back in order before writing to stdout.
// Its reorder buffer can't apply backpressure (blocking it while waiting
// for frame `k` could stall the pipeline before `k` gets through), so the
// orbit workers bound it instead: they don't start a frame more than
// `window` ahead of the last one written.
let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let window = threads * 4;
let next_job = AtomicUsize::new(0); let next_job = AtomicUsize::new(0);
let saved = AtomicUsize::new(0); let saved = AtomicUsize::new(0);
let failed = AtomicBool::new(false); let failed = AtomicBool::new(false);
@@ -259,20 +335,34 @@ fn run_animation(
error.lock().unwrap().get_or_insert(e); error.lock().unwrap().get_or_insert(e);
}; };
eprintln!("rendering {frames} frames ({width}×{height}) on {threads} threads…"); if let Some((k, n)) = targets.shard {
eprintln!(
"shard {k}/{n}: frames {}–{} of {frames}",
range.start + 1,
range.end
);
}
eprintln!("rendering {count} frames ({width}×{height}) on {threads} threads…");
let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec<u8>, u32, bool)>(threads * 2); let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec<u8>, u32, bool)>(threads * 2);
let png_rx = Mutex::new(png_rx); let png_rx = Mutex::new(png_rx);
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
std::thread::scope(|scope| { std::thread::scope(|scope| {
let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2); let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2);
for _ in 0..threads { for _ in 0..threads {
let ref_tx = ref_tx.clone(); let ref_tx = ref_tx.clone();
let (jobs, next_job, failed) = (&jobs, &next_job, &failed); let (jobs, next_job, saved, failed) = (&jobs, &next_job, &saved, &failed);
scope.spawn(move || { scope.spawn(move || {
loop { loop {
let i = next_job.fetch_add(1, Ordering::Relaxed); let i = next_job.fetch_add(1, Ordering::Relaxed);
if i >= jobs.len() || failed.load(Ordering::Relaxed) { if i >= jobs.len() || failed.load(Ordering::Relaxed) {
break; break;
} }
while stream
&& i >= saved.load(Ordering::Relaxed) + window
&& !failed.load(Ordering::Relaxed)
{
std::thread::sleep(std::time::Duration::from_millis(2));
}
if ref_tx.send((i, jobs[i].compute())).is_err() { if ref_tx.send((i, jobs[i].compute())).is_err() {
break; break;
} }
@@ -281,7 +371,34 @@ fn run_animation(
} }
drop(ref_tx); drop(ref_tx);
if stream {
let (saved, fail) = (&saved, &fail);
scope.spawn(move || {
let mut out = std::io::stdout().lock();
let mut pending = BTreeMap::new();
let mut next = 0;
for (i, raw) in raw_rx.iter() {
pending.insert(i, raw);
while let Some(raw) = pending.remove(&next) {
if let Err(e) = out.write_all(&raw) {
fail(format!("writing to stdout failed: {e}"));
return;
}
next += 1;
saved.store(next, Ordering::Relaxed);
eprint!("\r[{next:>4}/{count}] streamed");
}
}
if let Err(e) = out.flush() {
fail(format!("writing to stdout failed: {e}"));
}
});
} else {
drop(raw_rx);
}
for _ in 0..threads { for _ in 0..threads {
let raw_tx = raw_tx.clone();
let (png_rx, out_dir, saved, failed, fail) = let (png_rx, out_dir, saved, failed, fail) =
(&png_rx, &out_dir, &saved, &failed, &fail); (&png_rx, &out_dir, &saved, &failed, &fail);
scope.spawn(move || { scope.spawn(move || {
@@ -290,18 +407,26 @@ fn run_animation(
let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else { let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else {
break; break;
}; };
// After a failure, keep draining (without work) until the
// GPU stage hangs up, so it can't block on a full channel.
if failed.load(Ordering::Relaxed) { if failed.load(Ordering::Relaxed) {
break; continue;
}
if stream {
let raw = unpad_rgba(&padded, width, height, bpr, swap_rb);
// Only fails once the writer has failed and hung up.
let _ = raw_tx.send((i, raw));
continue;
} }
let png = let png =
encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast); encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast);
let path = format!("{out_dir}/frame-{:05}.png", i + 1); let path = format!("{out_dir}/frame-{:05}.png", first as usize + i + 1);
if let Err(e) = std::fs::write(&path, &png) { if let Err(e) = std::fs::write(&path, &png) {
fail(format!("save failed: {e}")); fail(format!("save failed: {e}"));
break; continue;
} }
let done = saved.fetch_add(1, Ordering::Relaxed) + 1; let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
eprint!("\r[{done:>4}/{frames}] saved"); eprint!("\r[{done:>4}/{count}] saved");
} }
}); });
} }
@@ -314,7 +439,7 @@ fn run_animation(
if failed.load(Ordering::Relaxed) { if failed.load(Ordering::Relaxed) {
break; break;
} }
apply_frame(&mut app, i as u32); apply_frame(&mut app, first + i as u32);
app.finish_reference(jobs[i].clone(), points); app.finish_reference(jobs[i].clone(), points);
let uniforms = app.make_uniforms(aspect, height as f64); let uniforms = app.make_uniforms(aspect, height as f64);
@@ -337,6 +462,7 @@ fn run_animation(
} }
} }
// Dropping the channel ends lets the workers drain and exit. // Dropping the channel ends lets the workers drain and exit.
drop(raw_tx);
drop(png_tx); drop(png_tx);
drop(ref_rx); drop(ref_rx);
}); });
@@ -346,14 +472,27 @@ fn run_animation(
return Err(e); return Err(e);
} }
let saved = saved.into_inner(); let saved = saved.into_inner();
if saved != frames as usize { if saved != count {
return Err(format!("only {saved} of {frames} frames were rendered")); return Err(format!("only {saved} of {count} frames were rendered"));
} }
println!("saved {frames} frames to {out_dir}/ ({width}×{height})"); if stream {
eprintln!("streamed {count} frames ({width}×{height})");
return Ok(());
}
println!("saved {count} frames to {out_dir}/ ({width}×{height})");
if targets.shard.is_some() {
println!(
"tip: once every shard is rendered into {out_dir}/, they form the full sequence; \
this shard alone: ffmpeg -framerate {fps} -start_number {} -i {out_dir}/frame-%05d.png \
-frames:v {count} -c:v libx264 -pix_fmt yuv420p out.mp4",
range.start + 1
);
} else {
println!( println!(
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4" "tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
); );
}
Ok(()) Ok(())
} }
@@ -387,6 +526,14 @@ fn parse_animation_target(
))) )))
} }
/// Global frame indices of shard `shard` (1-based) out of `shards` equal
/// parts of a `frames`-frame animation. Consecutive shards tile `0..frames`
/// with no gap or overlap.
fn shard_range(frames: u32, shard: u32, shards: u32) -> std::ops::Range<u32> {
let bound = |k: u32| (k as u64 * frames as u64 / shards as u64) as u32;
bound(shard - 1)..bound(shard)
}
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle. /// Ease-in/ease-out pacing: slow at both ends, fast through the middle.
fn smoothstep(t: f64) -> f64 { fn smoothstep(t: f64) -> f64 {
t * t * (3.0 - 2.0 * t) t * t * (3.0 - 2.0 * t)
@@ -411,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
.await .await
.map_err(|e| format!("failed to create device: {e}")) .map_err(|e| format!("failed to create device: {e}"))
} }
#[cfg(test)]
mod tests {
use super::shard_range;
#[test]
fn shards_tile_all_frames() {
for frames in [2, 3, 10, 97, 1000, u32::MAX] {
for shards in [1, 2, 3, 7, 10] {
if shards > frames {
continue;
}
let mut next = 0;
for k in 1..=shards {
let r = shard_range(frames, k, shards);
assert_eq!(
r.start, next,
"gap/overlap at shard {k}/{shards} of {frames}"
);
assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}");
next = r.end;
}
assert_eq!(next, frames);
}
}
}
}
+4 -1
View File
@@ -1,7 +1,9 @@
use std::f32::consts::PI; use std::f32::consts::PI;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
use egui::{Color32, Ui}; use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::Ui;
/// Maximum number of simultaneous lights. /// Maximum number of simultaneous lights.
pub const MAX_LIGHT_COUNT: usize = 16; pub const MAX_LIGHT_COUNT: usize = 16;
@@ -27,6 +29,7 @@ impl Default for Light {
} }
impl Light { impl Light {
#[cfg(feature = "gui")]
pub fn widget(&mut self, ui: &mut Ui) -> bool { pub fn widget(&mut self, ui: &mut Ui) -> bool {
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32)); let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
let parser = |s: &str| { let parser = |s: &str| {
+22 -1
View File
@@ -1,6 +1,8 @@
// Without these, rust fails to infer Send/Sync trait impls // Without these, rust fails to infer Send/Sync trait impls
// Probably caused by the new trait solver // Probably caused by the new trait solver
#![recursion_limit = "256"] #![recursion_limit = "256"]
// Without the `gui` feature, UI-only state and helpers go unused.
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot. // Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
// //
@@ -9,6 +11,7 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>. // and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app; mod app;
mod bignum;
mod camera; mod camera;
mod fractal; mod fractal;
mod lights; mod lights;
@@ -21,8 +24,17 @@ mod headless;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod worker; mod worker;
#[cfg(all(target_arch = "wasm32", not(feature = "gui")))]
compile_error!("the web build needs the `gui` feature");
#[cfg(feature = "gui")]
use app::FractalApp; use app::FractalApp;
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
type MainResult = eframe::Result;
#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))]
type MainResult = Result<(), String>;
/// wgpu configuration for eframe. The fractal fragment shader reads the /// wgpu configuration for eframe. The fractal fragment shader reads the
/// reference orbit from a **storage buffer**, so the device must allow storage /// reference orbit from a **storage buffer**, so the device must allow storage
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel /// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
@@ -30,6 +42,7 @@ use app::FractalApp;
/// * request the adapter's real limits (which include storage buffers), and /// * request the adapter's real limits (which include storage buffers), and
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at /// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
/// all) — failing cleanly on browsers without WebGPU, per the design. /// all) — failing cleanly on browsers without WebGPU, per the design.
#[cfg(feature = "gui")]
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
use eframe::egui_wgpu::{WgpuSetup, wgpu}; use eframe::egui_wgpu::{WgpuSetup, wgpu};
@@ -51,7 +64,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result { fn main() -> MainResult {
use clap::Parser as _; use clap::Parser as _;
env_logger::builder() env_logger::builder()
@@ -70,6 +83,13 @@ fn main() -> eframe::Result {
}; };
} }
#[cfg(not(feature = "gui"))]
{
eprintln!("error: built without the \"gui\" feature; only --headless is supported");
std::process::exit(1);
}
#[cfg(feature = "gui")]
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu, renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(), wgpu_options: wgpu_options(),
@@ -80,6 +100,7 @@ fn main() -> eframe::Result {
..Default::default() ..Default::default()
}; };
#[cfg(feature = "gui")]
eframe::run_native( eframe::run_native(
"Fractal Explorer", "Fractal Explorer",
native_options, native_options,
+45 -55
View File
@@ -1,6 +1,6 @@
//! Camera / view state over the complex plane. //! Camera / view state over the complex plane.
//! //!
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us //! The center is stored in arbitrary precision ([`Big`]) — this is what lets us
//! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an //! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
//! f64 mantissa with its own `i32` binary exponent, so it isn't bound by //! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
//! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's //! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's
@@ -10,11 +10,7 @@
use core::str::FromStr; use core::str::FromStr;
use dashu_float::round::mode::HalfAway; pub use crate::bignum::Big;
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. /// Half-height (complex units) of the default view; also the zoom-1 reference.
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25; pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
@@ -194,28 +190,37 @@ impl core::fmt::Display for Scale {
}; };
} }
// Out of f64's range: round the exact decimal expansion instead. // Out of f64's range: round the exact decimal expansion instead.
let sig = f.precision().map_or(17, |p| p + 1); let big = self.to_big();
let dec = self let sci = |sig: usize, pad: Option<usize>| {
.to_big() let parts = big.to_decimal_parts(sig);
.to_decimal() let digits = if parts.digits.is_empty() {
.value() "0"
.with_precision(sig) } else {
.value(); &parts.digits
let repr = dec.repr(); };
let digits = repr.significand().to_string(); // value = 0.digits · 10^exp10; move the point after the first digit.
let digits = digits.trim_end_matches('0'); let exp10 = parts.exp10 - 1;
let digits = if digits.is_empty() { "0" } else { digits };
// value = significand · 10^exponent; move the point after the first digit.
let exp10 = repr.exponent() + repr.significand().to_string().len() as isize - 1;
let (head, tail) = digits.split_at(1); let (head, tail) = digits.split_at(1);
let tail = match f.precision() { let tail = match pad {
Some(p) => format!("{tail:0<p$}"), Some(p) => format!("{tail:0<p$}"),
None => tail.to_string(), None => tail.to_string(),
}; };
if tail.is_empty() { if tail.is_empty() {
write!(f, "{head}e{exp10}") format!("{head}e{exp10}")
} else { } else {
write!(f, "{head}.{tail}e{exp10}") format!("{head}.{tail}e{exp10}")
}
};
match f.precision() {
Some(p) => f.write_str(&sci(p + 1, Some(p))),
None => {
// Like `{:e}` on f64: the shortest string that parses back.
let s = (1..17)
.map(|sig| sci(sig, None))
.find(|s| s.parse::<Scale>() == Ok(*self))
.unwrap_or_else(|| sci(17, None));
f.write_str(&s)
}
} }
} }
} }
@@ -237,18 +242,12 @@ impl FromStr for Scale {
}; };
} }
// Too small (or large) for f64: go through an exact decimal. // Too small (or large) for f64: go through an exact decimal.
let dec = DBig::from_str(s).map_err(|_| ())?; let bin = Big::from_decimal_str(s, 64).ok_or(())?;
let bin: Big = dec.with_base_and_precision::<2>(64).value(); if bin.is_negative() {
if bin < Big::ZERO {
return Err(()); return Err(());
} }
let repr = bin.repr(); let top = bin.log2_floor().ok_or(())?;
let digits = repr.digits(); let m = (bin >> top).to_f64();
if digits == 0 {
return Err(());
}
let top = repr.exponent() + digits as isize - 1;
let m = (bin.clone() >> top).to_f64().value();
let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32; let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32;
Ok(Self::from_parts(m, e)) Ok(Self::from_parts(m, e))
} }
@@ -312,10 +311,10 @@ impl ViewState {
pub fn sync_precision(&mut self) { pub fn sync_precision(&mut self) {
let bits = self.precision_bits(); let bits = self.precision_bits();
if self.center_re.precision() < bits { if self.center_re.precision() < bits {
self.center_re = self.center_re.clone().with_precision(bits).value(); self.center_re = self.center_re.clone().with_precision(bits);
} }
if self.center_im.precision() < bits { if self.center_im.precision() < bits {
self.center_im = self.center_im.clone().with_precision(bits).value(); self.center_im = self.center_im.clone().with_precision(bits);
} }
} }
@@ -360,8 +359,7 @@ impl ViewState {
/// Parse a decimal string (any number of digits) losslessly into a `Big` with at /// 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. /// least `bits` of precision. Used for share links and debug view specs.
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> { pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
let dec = DBig::from_str(s.trim()).ok()?; Big::from_decimal_str(s, bits)
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
} }
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at /// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
@@ -440,10 +438,10 @@ pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
// Zooming out: g = (1 - q^(t-1)) / (1 - q^-1), every term bounded. // Zooming out: g = (1 - q^(t-1)) / (1 - q^-1), every term bounded.
big_from_f64(((t - 1.0) * d * ln2).exp_m1() / (-d * ln2).exp_m1(), bits) big_from_f64(((t - 1.0) * d * ln2).exp_m1() / (-d * ln2).exp_m1(), bits)
}; };
let re0 = from.center_re.clone().with_precision(bits).value(); let re0 = from.center_re.clone().with_precision(bits);
let im0 = from.center_im.clone().with_precision(bits).value(); let im0 = from.center_im.clone().with_precision(bits);
let re1 = to.center_re.clone().with_precision(bits).value(); let re1 = to.center_re.clone().with_precision(bits);
let im1 = to.center_im.clone().with_precision(bits).value(); let im1 = to.center_im.clone().with_precision(bits);
let center_re = &re1 + &(&(&re0 - &re1) * &g_big); let center_re = &re1 + &(&(&re0 - &re1) * &g_big);
let center_im = &im1 + &(&(&im0 - &im1) * &g_big); let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
ViewState::with_center(center_re, center_im, half_height) ViewState::with_center(center_re, center_im, half_height)
@@ -456,12 +454,7 @@ pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
/// Render a `Big` as a decimal string with `sig_digits` significant digits. /// Render a `Big` as a decimal string with `sig_digits` significant digits.
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String { pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
let dec = x x.to_decimal_string(sig_digits)
.to_decimal()
.value()
.with_precision(sig_digits.max(1))
.value();
format!("{dec}")
} }
/// Precision (bits) needed to resolve the center at a given half-height. /// Precision (bits) needed to resolve the center at a given half-height.
@@ -472,12 +465,9 @@ pub fn precision_for(half_height: Scale) -> usize {
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS) (zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
} }
/// Build an `FBig` from an f64 with an explicit precision context. /// Build a `Big` from an f64 with an explicit precision.
pub fn big_from_f64(x: f64, bits: usize) -> Big { pub fn big_from_f64(x: f64, bits: usize) -> Big {
Big::try_from(x) Big::from_f64(x, bits)
.unwrap_or_default()
.with_precision(bits)
.value()
} }
#[cfg(test)] #[cfg(test)]
@@ -489,8 +479,8 @@ mod tests {
} }
fn re_im_f64(v: &ViewState) -> (f64, f64) { fn re_im_f64(v: &ViewState) -> (f64, f64) {
let re: f64 = v.center_re.to_decimal().value().to_f64().value(); let re: f64 = v.center_re.to_f64();
let im: f64 = v.center_im.to_decimal().value().to_f64().value(); let im: f64 = v.center_im.to_f64();
(re, im) (re, im)
} }
@@ -612,8 +602,8 @@ mod tests {
prev = mid.half_height; prev = mid.half_height;
// Offset from the target, in units of the view's half-height. // Offset from the target, in units of the view's half-height.
let k = -mid.half_height.exponent() as isize; let k = -mid.half_height.exponent() as isize;
let dre = ((&mid.center_re - &to.center_re) << k).to_f64().value(); let dre = ((&mid.center_re - &to.center_re) << k).to_f64();
let dim = ((&mid.center_im - &to.center_im) << k).to_f64().value(); let dim = ((&mid.center_im - &to.center_im) << k).to_f64();
let ratio = (dre * dre + dim * dim).sqrt() / mid.half_height.scaled_f64(k as i32); let ratio = (dre * dre + dim * dim).sqrt() / mid.half_height.scaled_f64(k as i32);
assert!(ratio > 0.01 && ratio < 10.0, "t={t}: ratio {ratio}"); assert!(ratio > 0.01 && ratio < 10.0, "t={t}: ratio {ratio}");
} }
+1 -1
View File
@@ -1,7 +1,7 @@
//! Native background worker for reference-orbit computation. //! Native background worker for reference-orbit computation.
//! //!
//! At deep zoom the high-precision reference can take many milliseconds (tens of //! 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. //! thousands of `Big` iterations), which would stutter the UI if done inline.
//! This runs it on a thread and coalesces bursts of requests (e.g. during a //! 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 //! drag) down to the most recent one. On the web we compute inline instead
//! (browsers need a Web Worker for threads); see `app.rs`. //! (browsers need a Web Worker for threads); see `app.rs`.
+2
View File
@@ -0,0 +1,2 @@
results/
.worktrees/
+48
View File
@@ -0,0 +1,48 @@
# Benchmarks
`bench.sh` times `mandelbrot --headless` on a fixed set of views with
[hyperfine](https://github.com/sharkdp/hyperfine). That's the whole real
pipeline: the reference orbit on the CPU, then the GPU render, readback and PNG
encode. It builds the headless-only binary (`--no-default-features`) itself.
```sh
tools/bench/bench.sh --rev HEAD # did my uncommitted edits help? (vs last commit)
tools/bench/bench.sh --rev main seahorse-1e300 # one scenario vs another branch
tools/bench/bench.sh --quick # all scenarios, 960×540, 3 runs
tools/bench/bench.sh # all scenarios, 1920×1080, 5 runs
tools/bench/bench.sh --list
```
`--rev REF` checks REF out into `.worktrees/<sha>` and builds it there, with its
own target dir, so your `target/` stays warm. Each scenario then runs both
binaries back to back in one hyperfine call. The line to read is
`working tree ran 1.23 ± 0.04 times faster than main (…)`. A ratio within about
2σ of 1.00 is noise.
Results (hyperfine JSON and markdown per scenario) go to
`results/<sha>[-dirty]/`, or `results/<rev>-vs-<sha>/` with `--rev`.
Both `results/` and `.worktrees/` are git-ignored. To clean up:
`rm -rf tools/bench/.worktrees && git worktree prune`.
## Scenarios
| name | mostly measures |
|---|---|
| `shallow` | GPU, plain f32 path, f64 orbit fast path |
| `seahorse-1e30` | GPU f32 perturbation from a `Big` orbit |
| `seahorse-1e100` | the `DEEP` shader pipeline |
| `elephant-1e100-aa-de` | 2×2 antialiasing + DE shading |
| `burning-ship` | a non-holomorphic kind |
| `3d` | the raymarched 3D export path |
| `anim-zoom` | 24-frame zoom (⅓ size): the parallel orbit, GPU and PNG pipeline |
| `seahorse-1e300` *(slow)* | ~270k iterations per pixel on the deep path, ~40 s per run even with `--quick`. It's GPU-bound: the orbit itself is under a second. Only runs when named or with `--all` |
Views come from `tools/deep-zoom/LOCATIONS.md`. Each wall time includes process
start, device creation and shader compilation, so a small change to a GPU-bound
scenario shows up diluted.
## Tips
- Keep the machine idle and on AC power. Other GPU apps (browsers, video) add a lot of noise.
- Use `--quick` while you iterate and a full run to confirm.
- Timings are only comparable on the same machine and GPU.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Benchmark `mandelbrot --headless` on a fixed set of scenarios with hyperfine.
#
# Usage:
# bench.sh [--quick] [--runs N] [--rev REF] [--all] [--list] [scenario...]
#
# (default) time the working tree; results go to results/<sha>[-dirty]/
# --rev REF also build REF (in a git worktree) and time both binaries side
# by side; hyperfine prints "X ± σ times faster" per scenario
# --quick 960×540 and 3 runs instead of 1920×1080 and 5
# --runs N timed runs per command (after 1 warmup run)
# --all include the slow scenarios
# --list print the scenario names and exit
#
# With no scenario names, all but the slow ones run. See README.md.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
root=$(git -C "$here" rev-parse --show-toplevel)
# Views from tools/deep-zoom/LOCATIONS.md.
SEAHORSE_40='-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594'
SEAHORSE_101='-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495'
SEAHORSE_300='-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930'
ELEPHANT_100='0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747'
# name -> headless args (no size or --export-path; those are added per run).
# Order matters for display, hence the separate list. SLOW ones (tens of
# seconds per run) only run when named or with --all.
SCENARIOS=(shallow seahorse-1e30 seahorse-1e100 elephant-1e100-aa-de burning-ship 3d anim-zoom)
SLOW=(seahorse-1e300)
declare -A ARGS=(
[shallow]="--view=-0.75,0,1.5"
[seahorse-1e30]="--view=$SEAHORSE_40,1e-30"
[seahorse-1e100]="--view=$SEAHORSE_101,3.4e-101"
[seahorse-1e300]="--view=$SEAHORSE_300,5.5e-300"
[elephant-1e100-aa-de]="--view=$ELEPHANT_100,1e-75 --antialias --de"
[burning-ship]="--kind burning-ship"
[3d]="--rendering-kind 3d --de --pitch -40 --view=-0.75,0,1.5"
[anim-zoom]="--view=-0.75,0,1.5 --to-view=$SEAHORSE_40,1e-30 --frames 24"
)
quick=0
runs=
rev=
picked=()
while (($#)); do
case $1 in
--quick) quick=1 ;;
--runs) runs=$2; shift ;;
--rev) rev=$2; shift ;;
--all) picked+=("${SCENARIOS[@]}" "${SLOW[@]}") ;;
--list) printf '%s\n' "${SCENARIOS[@]}"; printf '%s (slow)\n' "${SLOW[@]}"; exit 0 ;;
-h|--help) sed -n '2,16s/^# \{0,1\}//p' "$0"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*)
[[ -v ARGS[$1] ]] || { echo "unknown scenario: $1 (try --list)" >&2; exit 2; }
picked+=("$1") ;;
esac
shift
done
((${#picked[@]})) || picked=("${SCENARIOS[@]}")
if ((quick)); then
width=960 height=540 runs=${runs:-3}
else
width=1920 height=1080 runs=${runs:-5}
fi
command -v hyperfine >/dev/null || { echo "hyperfine not found (https://github.com/sharkdp/hyperfine)" >&2; exit 1; }
# Headless-only binary: no eframe/egui, faster to build, same render path.
build() { cargo build --release --no-default-features --quiet --manifest-path "$1/Cargo.toml"; }
echo "building working tree…" >&2
build "$root"
new_bin="$root/target/release/mandelbrot"
if [[ -n $rev ]]; then
rev_sha=$(git -C "$root" rev-parse --short "$rev^{commit}")
wt="$here/.worktrees/$rev_sha"
[[ -d $wt ]] || git -C "$root" worktree add --detach --quiet "$wt" "$rev_sha"
echo "building $rev ($rev_sha)…" >&2
# Separate target dir so switching revisions doesn't invalidate target/.
CARGO_TARGET_DIR="$here/.worktrees/target" build "$wt"
old_bin="$here/.worktrees/target/release/mandelbrot"
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
sha=$(git -C "$root" rev-parse --short HEAD)
git -C "$root" diff --quiet HEAD -- . ':!tools/bench' || sha+=-dirty
out="$here/results/${rev:+$rev_sha-vs-}$sha"
mkdir -p "$out"
# Full headless command line for scenario $2 with binary $1.
command_for() {
local bin=$1 name=$2 w=$width h=$height dest=$tmp/$2.png
if [[ $name == anim-* ]]; then
# Animation: a directory of frames, at a smaller size.
w=$((width / 3)) h=$((height / 3)) dest=$tmp/$name
fi
echo "$bin --headless --width $w --height $h --export-path $dest ${ARGS[$name]}"
}
for name in "${picked[@]}"; do
echo >&2
echo "=== $name ===" >&2
if [[ -n $rev ]]; then
cmds=(-n "$rev ($rev_sha)" "$(command_for "$old_bin" "$name")"
-n "working tree" "$(command_for "$new_bin" "$name")")
else
cmds=(-n "$name" "$(command_for "$new_bin" "$name")")
fi
hyperfine --warmup 1 --runs "$runs" --style basic \
--export-json "$out/$name.json" --export-markdown "$out/$name.md" \
"${cmds[@]}"
done
echo
echo "## Summary (${width}×${height}, $runs runs; results in ${out#"$root"/}/)"
for name in "${picked[@]}"; do
echo
echo "### $name"
cat "$out/$name.md"
done
+167
View File
@@ -0,0 +1,167 @@
# Deep-zoom locations
Beautiful deep Mandelbrot locations, all computed with `find_deep.py` and checked with a
`--headless` render. Every one is an exact minibrot nucleus (Newton's method at
`2·depth + 60` digits), not a coordinate copied from somewhere, so the digits are right all
the way down.
## How to use them
Each location has two views on the same centre:
- **Minibrot:** half-height ≈ 3× the minibrot's size. A tiny copy of the whole set sits in
concentric colour bands, and there are more bands the deeper it is.
- **Embedded Julia set:** half-height ≈ size^0.75, about ¾ of the way down in log-zoom.
This is where each location looks unique. The minibrots all look alike.
```sh
cargo run --release -- --view=RE,IM,HALF_HEIGHT # interactive
cargo run --release -- --headless --view=RE,IM,HALF_HEIGHT --export-path shot.png
# zoom video from the full set down to a minibrot, piped to ffmpeg (see Makefile):
cargo run --release -- --headless --view=-0.75,0,1.5 --to-view=RE,IM,HALF_HEIGHT \
--fps 60 --duration 60 --export-path - | make hevc-nvenc
```
Pass negative coordinates as `--view=-0.77...` (with the `=`): clap would read `--view -0.77...` as a flag.
Iterations scale with depth automatically (about 90k at 10⁻¹⁰⁰ and 270k at 10⁻³⁰⁰), which is
enough for these periods.
## Infinite spirals (Misiurewicz points)
These are the anchor points. Zoom straight into one and the same spiral repeats forever,
at any depth. Every minibrot below sits just beside one of them.
| Region | Point | Preperiod, period | Looks like |
|---|---|---|---|
| Seahorse valley | `-0.7756837680090537974694835039347410457282465046158` `0.13646736829469012473327440961784876519777211159247` | 24, 2 | Two-armed spirals of seahorses, repeating at every depth. |
| Elephant valley | `0.29207386191445391799971751769220573408040189143315` `0.015770917211390118082469028230338337682600488632339` | 32, 3 | Three-armed spirals of elephant trunks. |
| Top antenna | `-0.10110375797210418675706890936004723524958119917843` `0.95629400795235409290719434118824484558046591980423` | 38, 4 | Four-armed branch points on the upper antenna, with thin dendrites instead of spirals. |
## Seahorse valley
### Seahorse valley, minibrot at 3.7e-41 (period 1034)
The embedded Julia set is a pale double spiral: two S-shaped arms of tiny seahorses wind into a bright centre, with a third lace band sweeping around them. The best one for a short video, and it renders in seconds.
```sh
# embedded Julia set
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1e-30
# minibrot
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1.1e-40
```
### Seahorse valley, minibrot at 1.14e-101 (period 2868)
At the Julia depth there's a dark blue field with a glowing core and lace threads crossing it.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,1e-76
# minibrot
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,3.4e-101
```
### Seahorse valley, minibrot at 1.37e-200 (period 5895)
Two filaments cross in an X over a warm brown background, with a small dark spiral where they meet.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,1e-150
# minibrot
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,4.1e-200
```
### Seahorse valley, minibrot at 1.83e-300 (period 8922)
Faint cream threads, and a spiral wound so tight it reads as a point. Period 8922, so the minibrot needs a long render.
```sh
# embedded Julia set
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,1e-225
# minibrot
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,5.5e-300
```
## Elephant valley
### Elephant valley, minibrot at 1.29e-100 (period 782)
A cream-coloured double S-spiral, like the seahorse one but with fatter, beaded arms.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,1e-75
# minibrot
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,3.9e-100
```
### Elephant valley, minibrot at 1.43e-199 (period 1585)
A single huge spiral arm made of blue beads curling across the whole frame.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,1e-149
# minibrot
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,4.3e-199
```
### Elephant valley, minibrot at 3.82e-300 (period 2395)
A spiral galaxy: several beaded arms around a pinwheel centre. One of the prettiest frames here.
```sh
# embedded Julia set
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1e-225
# minibrot
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1.1e-299
```
## Top antenna
### Top antenna, minibrot at 6.11e-101 (period 245)
A dark branching dendrite like a frost cross, with four-way junctions.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1e-75
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1.8e-100
```
### Top antenna, minibrot at 5.28e-200 (period 473)
A chain of six-armed snowflakes linked by thin filaments.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1e-149
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1.6e-199
```
### Top antenna, minibrot at 8.34e-301 (period 705)
A single twelve-armed star, very symmetric. The antenna minibrots have short periods (245-705), so these are the fastest deep renders.
```sh
# embedded Julia set
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,1e-225
# minibrot
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,2.5e-300
```
## Finding more
```sh
./find_deep.py --list # known regions
./find_deep.py seahorse 250 # minibrot of size ~1e-500 near the seahorse point
./find_deep.py seahorse 100 0.2,0.7 # a different minibrot at the same depth
./find_deep.py -0.1011,0.9563,38,4 80 # any Misiurewicz point, given as re,im,k,p
```
A disk of radius 10⁻ᴰ around a Misiurewicz point holds a minibrot of size about 10⁻²ᴰ.
Newton sometimes fails to converge (elephant at depth 100 with the default offset did). The
script detects this from the size and asks for a different offset. Needs `mpmath`.
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Find deep Mandelbrot minibrots near a Misiurewicz point.
Misiurewicz points (preperiodic c, f^(k+p)(0) = f^k(0)) have self-similar
spirals at every depth. A disk of radius r = 10^-D around one contains a
minibrot of size ~r^2, found by:
1. the ball-period method: the first n where the disk's image covers 0
gives the period p of a nucleus inside it;
2. Newton's method on f^p(0) = 0 at high precision for the nucleus;
3. the standard size estimate (Heiland-Allen) for its scale.
Zooming at the nucleus shows spirals, then an embedded Julia set (around
size^0.75), then the minibrot (half-height ~3x size).
Usage:
find_deep.py REGION DEPTH [OFFSET_RE,OFFSET_IM]
find_deep.py --list
REGION is a name from REGIONS or "re,im,k,p" for any Misiurewicz point.
OFFSET (default 0.6,0.3, in units of r) picks a different minibrot at the
same depth. If the printed size is far below 10^-(2*DEPTH), Newton didn't
converge: try another offset.
Needs mpmath (pip install mpmath).
"""
import sys
from mpmath import log10, mp, mpc, mpf
# name: (approximate seed, preperiod k, period p). The exact point is
# refined by Newton at the requested precision.
REGIONS = {
"seahorse": (("-0.77568377", "0.13646737"), 24, 2),
"elephant": (("0.2925", "0.0149"), 32, 3),
"antenna": (("-0.1011", "0.9563"), 38, 4),
}
def converged(d):
return abs(d) < mpf(10) ** (-mp.dps + 20)
def misiurewicz(c, k, p, steps=200):
"""Newton on f^(k+p)(0) - f^k(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
zk = dzk = None
for i in range(1, k + p + 1):
dz = 2 * z * dz + 1
z = z * z + c
if i == k:
zk, dzk = z, dz
d = (z - zk) / (dz - dzk)
c -= d
if converged(d):
break
return c
def period_in_ball(c, r, maxit=1_000_000):
"""First n where the disk of radius r around c maps onto a disk containing 0."""
z = dz = mpc(0)
for n in range(1, maxit):
dz = 2 * z * dz + 1
z = z * z + c
if abs(z) < abs(dz) * r:
return n
if abs(z) > 4:
return None
return None
def nucleus(c, p, steps=200):
"""Newton on f^p(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
for _ in range(p):
dz = 2 * z * dz + 1
z = z * z + c
d = z / dz
c -= d
if converged(d):
break
return c
def size(c, p):
"""Approximate size of the minibrot with nucleus c and period p."""
z = mpc(0)
l = b = mpc(1)
for _ in range(1, p):
z = z * z + c
l = 2 * z * l
b = b + 1 / l
return abs(1 / (b * l * l))
def main(argv):
if len(argv) >= 1 and argv[0] == "--list":
for name, ((re, im), k, p) in REGIONS.items():
print(f"{name:10} ~{re}{'+' if not im.startswith('-') else ''}{im}i M({k},{p})")
return 0
if len(argv) not in (2, 3):
print(__doc__, file=sys.stderr)
return 2
region, depth = argv[0], int(argv[1])
if region in REGIONS:
(re, im), k, p = REGIONS[region]
else:
re, im, k, p = region.split(",")
k, p = int(k), int(p)
off_re, off_im = argv[2].split(",") if len(argv) == 3 else ("0.6", "0.3")
mp.dps = 2 * depth + 60
m = misiurewicz(mpc(re, im), k, p)
r = mpf(10) ** (-depth)
c0 = m + r * mpc(off_re, off_im)
period = period_in_ball(c0, r)
if period is None:
print("no period found (the disk escapes)", file=sys.stderr)
return 1
n = nucleus(c0, period)
s = size(n, period)
if s < mpf(10) ** (-2 * depth - 10):
print(f"size {mp.nstr(s, 3)} is implausibly small: Newton didn't converge, "
"try another offset", file=sys.stderr)
return 1
digits = int(-log10(s)) + 10
log_s = float(log10(s))
re_s = mp.nstr(n.real, digits, strip_zeros=False)
im_s = mp.nstr(n.imag, digits, strip_zeros=False)
print(f"# {region} depth={depth} period={period} size={mp.nstr(s, 3)}")
print("# minibrot:")
print(f"--view={re_s},{im_s},{mp.nstr(3 * s, 2)}")
print(f"# embedded Julia set on the way down:")
print(f"--view={re_s},{im_s},1e{round(0.75 * log_s)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3
View File
@@ -0,0 +1,3 @@
clips/
work/
results/
+8
View File
@@ -0,0 +1,8 @@
# Pass 1: which presets fit the 3-5 fps budget at 4K on this machine.
x265-fast | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset fast -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-medium | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset medium -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-slow | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset slow -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
av1-p5 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 5 -crf 26 -svtav1-params film-grain=0
av1-p6 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 6 -crf 26 -svtav1-params film-grain=0
av1-p7 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 7 -crf 26 -svtav1-params film-grain=0
av1-p8 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 8 -crf 26 -svtav1-params film-grain=0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Render the lossless 4K60 test clips that sweep.sh encodes.
#
# Each clip goes through the same RGBA -> bt709 tv-range 10-bit conversion as
# the Makefile, then into lossless FFV1. The clips are therefore exactly what the
# encoders see, so VMAF against them measures the encoder alone.
#
# tools/encode/make_clips.sh [--size WxH] [--seconds S] [--force] [clip...]
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
out="$here/clips"
size=3840x2160
seconds=4
fps=60
force=0
only=()
while [[ $# -gt 0 ]]; do
case $1 in
--size) size=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--force) force=1; shift ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) only+=("$1"); shift ;;
esac
done
# Seahorse valley, minibrot at 3.7e-41 (tools/deep-zoom/LOCATIONS.md).
SH_RE=-0.77568376800905379745347652613832924487504096622022
SH_IM=0.13646736829469012473375311880735014411233827361594
# name | mandelbrot flags. Zoom speeds bracket a typical 60 s dive (~0.7 decade/s).
clips=(
# Worst case: dense filaments, 1.5 decades/s of scaling motion.
"filaments|--view=$SH_RE,$SH_IM,1e-8 --to-view=$SH_RE,$SH_IM,3e-15 --linear"
# Smooth concentric bands closing in on the minibrot: banding/blocking risk.
"bands|--view=$SH_RE,$SH_IM,1e-38 --to-view=$SH_RE,$SH_IM,1.1e-40 --linear"
# Embedded Julia set, slow drift: fine static texture, psy/detail retention.
"julia|--view=$SH_RE,$SH_IM,2e-30 --to-view=$SH_RE,$SH_IM,1e-30 --linear"
)
[[ -x $root/target/release/mandelbrot ]] || cargo build --release --manifest-path "$root/Cargo.toml"
mkdir -p "$out"
w=${size%x*}
h=${size#*x}
for entry in "${clips[@]}"; do
name=${entry%%|*}
flags=${entry#*|}
if [[ ${#only[@]} -gt 0 && ! " ${only[*]} " =~ " $name " ]]; then continue; fi
dst="$out/$name.mkv"
if [[ -f $dst && $force = 0 ]]; then echo "skip $name (exists, --force to redo)"; continue; fi
echo "render $name ($size, ${seconds}s @ ${fps}fps)"
# shellcheck disable=SC2086
"$root/target/release/mandelbrot" --headless --antialias $flags \
--width "$w" --height "$h" --fps "$fps" --duration "$seconds" --export-path - |
ffmpeg -hide_banner -loglevel error -y \
-f rawvideo -pix_fmt rgba -s "$size" -framerate "$fps" -i - \
-vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=yuv420p10le" \
-c:v ffv1 -level 3 -slices 16 -g 1 \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
"$dst.tmp.mkv"
mv "$dst.tmp.mkv" "$dst"
done
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Encode every test clip with every config, then score each encode.
#
# tools/encode/sweep.sh [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE...
#
# A config line is `name | ffmpeg output args` (blank lines and # comments are
# skipped). The args go between `-i clip.mkv` and the output file, e.g.
# x265-crf18 | -c:v libx265 -preset medium -crf 18 -x265-params aq-mode=3
# Each row of results/<tag>.tsv has encode fps, bitrate, VMAF 4K (mean, 1st
# percentile, min), VMAF-NEG mean, CAMBI banding (mean, max) and PSNR-Y.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
clipdir="$here/clips"
model=/usr/share/model
export SVT_LOG=1 # errors only
clips=""
keep=0
tag=$(date +%Y%m%d-%H%M%S)
configs=()
while [[ $# -gt 0 ]]; do
case $1 in
--clips) clips=$2; shift 2 ;;
--keep) keep=1; shift ;;
--tag) tag=$2; shift 2 ;;
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
*) configs+=("$1"); shift ;;
esac
done
[[ ${#configs[@]} -gt 0 ]] || { echo "usage: $0 [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE..." >&2; exit 1; }
if [[ -z $clips ]]; then
clips=$(cd "$clipdir" && ls ./*.mkv | sed 's|^\./||; s|\.mkv$||' | paste -sd,)
fi
[[ -n $clips ]] || { echo "no clips in $clipdir: run make_clips.sh first" >&2; exit 1; }
mkdir -p "$here/results" "$here/work"
tsv="$here/results/$tag.tsv"
[[ -f $tsv ]] || printf 'clip\tconfig\tfps\tmbps\tvmaf\tvmaf_p1\tvmaf_min\tvmaf_neg\tcambi\tcambi_max\tpsnr_y\n' > "$tsv"
IFS=, read -ra clip_list <<< "$clips"
for cfg in "${configs[@]}"; do
while IFS= read -r line || [[ -n $line ]]; do
[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
name=$(sed 's/[[:space:]]*|.*//' <<< "$line")
args=$(sed 's/^[^|]*|[[:space:]]*//' <<< "$line")
for clip in "${clip_list[@]}"; do
ref="$clipdir/$clip.mkv"
enc="$here/work/$clip--$name.mp4"
log="$here/work/$clip--$name.json"
frames=$(ffprobe -v error -count_packets -select_streams v:0 \
-show_entries stream=nb_read_packets -of csv=p=0 "$ref")
printf '%-10s %-40s ' "$clip" "$name"
t0=$(date +%s.%N)
# shellcheck disable=SC2086
ffmpeg -hide_banner -loglevel error -y -i "$ref" $args -an "$enc" < /dev/null
t1=$(date +%s.%N)
# 4K model for both VMAF variants; CAMBI flags banding, which VMAF
# barely sees and which is the main risk on smooth fractal gradients.
ffmpeg -hide_banner -loglevel error -i "$enc" -i "$ref" -lavfi \
"[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]libvmaf=log_fmt=json:log_path=$log:n_threads=$(nproc):n_subsample=2:model='path=$model/vmaf_4k_v0.6.1.json\\:name=vmaf|path=$model/vmaf_4k_v0.6.1neg.json\\:name=neg':feature='name=cambi|name=psnr'" \
-f null - < /dev/null
row=$(python3 - "$log" "$enc" "$frames" "$t0" "$t1" <<'PY'
import json, os, sys
log, enc, frames, t0, t1 = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
fr = json.load(open(log))["frames"]
col = lambda k: sorted(f["metrics"][k] for f in fr)
v = col("vmaf")
p1 = v[max(0, int(0.01 * len(v)) - 1)] if len(v) >= 100 else v[0]
mean = lambda xs: sum(xs) / len(xs)
cambi = col("cambi")
fps = frames / (t1 - t0)
mbps = os.path.getsize(enc) * 8 / (frames / 60) / 1e6
print(f"{fps:.2f}\t{mbps:.1f}\t{mean(v):.2f}\t{p1:.2f}\t{v[0]:.2f}\t{mean(col('neg')):.2f}\t{mean(cambi):.2f}\t{cambi[-1]:.2f}\t{mean(col('psnr_y')):.2f}")
PY
)
printf '%s\t%s\t%s\n' "$clip" "$name" "$row" >> "$tsv"
awk -F'\t' '{printf "%6s fps %7s Mb/s vmaf %s p1 %s min %s neg %s cambi %s/%s psnr %s\n",$1,$2,$3,$4,$5,$6,$7,$8,$9}' <<< "$row"
[[ $keep = 1 ]] || rm -f "$enc"
done
done < "$cfg"
done
echo "results: $tsv"