Compare commits

..
10 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
29 changed files with 1770 additions and 280 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ Cargo.lock
dist
frames*
out.mp4
__pycache__
+35 -10
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
~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
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
@@ -22,9 +23,12 @@ storage buffers, which the fragment shader needs for the reference orbit).
```sh
cargo run --release # native, run (release matters: fractal math is hot)
cargo test # reference-orbit math, share-link round-trip, WGSL validation
cargo test --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 clippy
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):
@@ -32,7 +36,7 @@ Web build (WebGPU):
```sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
./build-web.sh # -> ./dist
./build-web.sh # -> ./dist (builds with --features wasm)
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`,
`--palette`, `--share <fragment>`,
`--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
entirely: it builds the same view from the other flags, creates its own
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).
Anything without a target stays at its start value; colors stay fixed.
`--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
`PipelineKey` changes between frames (kind morph). `view::interpolate_view`
does the camera: half-height geometrically (log-linear, since zoom spans many
decades), center linearly through the complex plane at full `Big` precision;
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
`auto_iteration_count` the interactive app uses while zooming).
`--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
pixel is a handful of `f32` complex multiplies.
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
type alias). The pixel scale (`half_height`) is a `Scale`, an f64
- `src/bignum/` — `Big`, the arbitrary-precision binary float, with one
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
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
@@ -120,10 +145,10 @@ pixel is a handful of `f32` complex multiplies.
`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
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`:
`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
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×
@@ -179,7 +204,7 @@ pixel is a handful of `f32` complex multiplies.
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
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
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
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
detection and the cardioid bypass. App side: `KindMorph` in
+16 -4
View File
@@ -3,19 +3,31 @@ name = "mandelbrot"
version = "0.1.0"
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]
bytemuck = { version = "1.25.2", features = ["derive"] }
dashu-float = "0.6.0"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
egui = "0.36.2"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true }
egui = { version = "0.36.2", optional = true }
ecolor = { version = "0.36.2", features = ["bytemuck"] }
wgpu = "30.0.1"
glam = "0.33.8"
log = "0.4.34"
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]
env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1"
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
@@ -32,7 +44,7 @@ opt-level = 3
# codegen-units = 1
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.
[profile.dev]
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 —
built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of
plain `f64` using **perturbation theory**: one high-precision reference orbit is
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is
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
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
```
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
toolchain and `m4` (on Windows, MSYS2).
## Build & run — web (WebGPU)
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
./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
```
@@ -60,7 +65,8 @@ qualifies. Deploy by serving the `dist/` directory as static files.
## 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).
- `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`
@@ -84,6 +90,7 @@ reference computation to a Web Worker.
```sh
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)
+1 -1
View File
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
OUT="${1:-dist}"
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"
mkdir -p "$OUT"
+33 -20
View File
@@ -1,9 +1,9 @@
use std::sync::{Arc, Mutex};
#[cfg(feature = "gui")]
use eframe::CreationContext;
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
#[cfg(target_arch = "wasm32")]
use eframe::egui_wgpu::wgpu;
use glam::Vec4;
use glam::Vec4Swizzles;
@@ -316,6 +316,7 @@ impl ConstOrbit {
/// Checkbox + speed/radius sliders; (re)centers the orbit on `current`
/// when switched on.
#[cfg(feature = "gui")]
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 {
self.enable(current);
@@ -371,6 +372,7 @@ impl AxisOsc {
self.base + self.amplitude * self.phase.sin()
}
#[cfg(feature = "gui")]
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) {
if ui
.checkbox(&mut self.on, format!("Animate {name}"))
@@ -594,6 +596,7 @@ pub struct FractalApp {
/// PNG export resolution multiplier over the on-screen size.
export_scale: f32,
/// Last on-screen fractal size in physical pixels (for export sizing).
#[cfg(feature = "gui")]
last_size_px: egui::Vec2,
/// egui time (seconds) of the most recent pan/zoom. While recent (within
/// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction.
@@ -646,6 +649,7 @@ fn parse_bits_for(s: &str, min_bits: usize) -> usize {
}
impl FractalApp {
#[cfg(feature = "gui")]
pub fn new(cc: &CreationContext<'_>) -> Self {
let render_state = cc
.wgpu_render_state
@@ -740,6 +744,7 @@ impl FractalApp {
pending: false,
export_scale: 2.0,
render_scale_3d: 2.0,
#[cfg(feature = "gui")]
last_size_px: egui::vec2(1280.0, 720.0),
last_interact_time: -1.0e9,
export_requested: false,
@@ -842,6 +847,9 @@ impl FractalApp {
if cli.de {
self.de_coloring = true;
}
if cli.antialias {
self.antialias = true;
}
if cli.buddhabrot {
self.mode = FractalMode::Buddhabrot;
}
@@ -1136,12 +1144,8 @@ impl FractalApp {
fn drift_from(&self, key: &RequestKey) -> f64 {
let hh = self.view.half_height;
let k = -hh.exponent() as isize;
let dre = ((&self.view.center_re - &key.center_re) << k)
.to_f64()
.value();
let dim = ((&self.view.center_im - &key.center_im) << k)
.to_f64()
.value();
let dre = ((&self.view.center_re - &key.center_re) << k).to_f64();
let dim = ((&self.view.center_im - &key.center_im) << k).to_f64();
(dre * dre + dim * dim).sqrt() / hh.scaled_f64(-hh.exponent())
}
@@ -1190,12 +1194,8 @@ impl FractalApp {
/// underflow f64 at deep zooms).
fn dc_offset(&self, scale_exp: i32) -> (f64, f64) {
let k = -scale_exp as isize;
let dre = ((&self.view.center_re - &self.ref_center_re) << k)
.to_f64()
.value();
let dim = ((&self.view.center_im - &self.ref_center_im) << k)
.to_f64()
.value();
let dre = ((&self.view.center_re - &self.ref_center_re) << k).to_f64();
let dim = ((&self.view.center_im - &self.ref_center_im) << k).to_f64();
(dre, dim)
}
@@ -1414,6 +1414,7 @@ impl FractalApp {
/// 3D-mode zoom toward the screen point `off` (points from the widget
/// center): unproject it through the camera onto the z = 0 fractal plane,
/// 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) {
let ndc = (off / rect.size()) * 2.;
let camera_ndc_pos = self
@@ -1485,8 +1486,8 @@ impl FractalApp {
/// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`).
fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms {
let center = [
self.view.center_re.to_f64().value() as f32,
self.view.center_im.to_f64().value() as f32,
self.view.center_re.to_f64() as f32,
self.view.center_im.to_f64() as f32,
];
BuddhabrotUniforms {
center,
@@ -1515,6 +1516,7 @@ impl FractalApp {
/// 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
/// 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) {
if self.export.is_some() {
return; // one export at a time
@@ -1641,6 +1643,7 @@ impl FractalApp {
/// Pick up a finished export (setting the status line) and keep repainting
/// while one is in flight so its progress bar animates.
#[cfg(feature = "gui")]
fn poll_export(&mut self, ctx: &egui::Context) {
if let Some(shared) = &self.export {
let done = shared.lock().unwrap().result.take();
@@ -1661,6 +1664,7 @@ impl FractalApp {
/// Floating top-left overlay with the panel toggle and fullscreen toggle.
/// Always on top of the fractal, so both stay reachable when the controls
/// panel is collapsed (the common case on a phone).
#[cfg(feature = "gui")]
fn overlay_buttons(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("overlay_buttons"))
.anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0))
@@ -1719,6 +1723,7 @@ impl FractalApp {
/// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left)
/// so it stays out of the way of the panel toggle / fullscreen controls,
/// but is still reachable even when the controls panel is collapsed.
#[cfg(feature = "gui")]
fn info_button(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("info_button"))
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0))
@@ -1740,6 +1745,7 @@ impl FractalApp {
/// Window with details about what's currently on screen: formula, active
/// per-kind constants, zoom depth, iteration count. Reads live state, so
/// it stays correct as the user pans/zooms/switches kinds.
#[cfg(feature = "gui")]
fn info_window(&mut self, ctx: &egui::Context) {
let mut open = self.info_open;
egui::Window::new("Fractal Info")
@@ -1796,6 +1802,7 @@ impl FractalApp {
/// Help window: what the app does, plus a reference for mouse/touch and
/// keyboard controls.
#[cfg(feature = "gui")]
fn help_window(&mut self, ctx: &egui::Context) {
let mut open = self.help_open;
egui::Window::new("Help")
@@ -1886,7 +1893,7 @@ impl FractalApp {
}
/// 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) {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
}
@@ -1894,7 +1901,7 @@ impl FractalApp {
/// Push the desired fullscreen state to the browser. `request_fullscreen`
/// must run inside a user gesture; the button click provides the transient
/// 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) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
@@ -1910,14 +1917,14 @@ impl FractalApp {
/// 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.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
fn sync_fullscreen(&mut self, ctx: &egui::Context) {
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
self.fullscreen = fs;
}
}
#[cfg(target_arch = "wasm32")]
#[cfg(all(feature = "gui", target_arch = "wasm32"))]
fn sync_fullscreen(&mut self, _ctx: &egui::Context) {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
self.fullscreen = doc.fullscreen_element().is_some();
@@ -1929,6 +1936,7 @@ impl FractalApp {
/// divides once the window closes, so the readout is steady rather than
/// 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.
#[cfg(feature = "gui")]
fn update_fps(&mut self, ui: &egui::Ui) {
let now = ui.input(|i| i.time);
// Reset the window if time went backwards or hasn't started yet.
@@ -1948,6 +1956,7 @@ impl FractalApp {
/// 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
/// trigger the interaction low-res pass).
#[cfg(feature = "gui")]
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.
let julia_on = self.anim.julia.on && self.mode == FractalMode::Julia;
@@ -2092,6 +2101,7 @@ impl FractalApp {
}
}
#[cfg(feature = "gui")]
fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer");
ui.separator();
@@ -2574,6 +2584,7 @@ impl FractalApp {
/// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B
/// coloring), exposure, and the progressive-accumulation toggle.
#[cfg(feature = "gui")]
fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) {
ui.separator();
ui.add(
@@ -2614,6 +2625,7 @@ impl FractalApp {
ui.small("PNG export isn't available in Buddhabrot mode yet.");
}
#[cfg(feature = "gui")]
fn fractal_ui(&mut self, ui: &mut egui::Ui) {
let size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
@@ -2939,6 +2951,7 @@ impl FractalApp {
}
}
#[cfg(feature = "gui")]
impl eframe::App for FractalApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
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)]
pub de: bool,
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
#[arg(long)]
pub antialias: bool,
/// Coloring palette index.
#[arg(long, value_name = "INDEX")]
pub palette: Option<u32>,
@@ -81,7 +85,9 @@ pub struct Cli {
/// Output path for --headless (default: fractal-<timestamp>.png). When
/// animating (--to-view/--to-share), this is a directory of
/// 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")]
pub export_path: Option<String>,
@@ -165,6 +171,17 @@ pub struct Cli {
#[arg(long)]
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
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
+18 -11
View File
@@ -5,7 +5,8 @@
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
/// interactive on a modest GPU even when most samples run the full `b_cap`
@@ -125,16 +126,21 @@ pub struct BuddhabrotRenderer {
impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/buddhabrot.wgsl"),
)
.into(),
),
});
let shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/buddhabrot.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot uniforms"),
@@ -336,6 +342,7 @@ pub struct BuddhabrotCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare(
&self,
+1 -1
View File
@@ -16,5 +16,5 @@ pub use renderer::PipelineKey;
pub use renderer::encode_png_with_progress;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
#[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;
+65 -95
View File
@@ -1,7 +1,7 @@
//! High-precision reference-orbit computation for perturbation rendering.
//!
//! 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
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
//! 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;
/// 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).
///
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
@@ -96,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
/// `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)`.
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);
match top {
Some(top) if top < TINY_LOG2 as isize => {
let k = top + 1;
let mr = (zr.clone() << -k).to_f64().value() as f32;
let mi = (zi.clone() << -k).to_f64().value() as f32;
let mr = (zr.clone() << -k).to_f64() as f32;
let mi = (zi.clone() << -k).to_f64() as f32;
orbit.points.push([mr, mi]);
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]),
}
}
@@ -147,23 +139,17 @@ pub fn compute_reference(
let morph = morph.filter(|&(_, w)| w != 0.0);
if precision <= F64_MAX_PRECISION {
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,
l: lambda_l,
cpow: complex_power,
power,
};
return compute_reference_f64(
(z0_re.to_f64().value(), z0_im.to_f64().value()),
max_iter,
kind,
&k,
morph,
);
return compute_reference_f64((z0_re.to_f64(), z0_im.to_f64()), max_iter, kind, &k, morph);
}
let k = StepConsts {
cr: c_re.clone().with_precision(precision).value(),
ci: c_im.clone().with_precision(precision).value(),
cr: c_re.clone().with_precision(precision),
ci: c_im.clone().with_precision(precision),
pr: big_from_f64(phoenix_p.0, precision),
pi: big_from_f64(phoenix_p.1, precision),
lr: big_from_f64(lambda_l.0, precision),
@@ -292,7 +278,7 @@ struct StepConsts {
precision: usize,
}
/// [`compute_reference`] at arbitrary precision (`FBig`), for deep views.
/// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
fn compute_reference_big(
z0_re: &Big,
z0_im: &Big,
@@ -304,11 +290,11 @@ fn compute_reference_big(
let precision = k.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 zi = z0_im.clone().with_precision(precision).value();
let mut zr = z0_re.clone().with_precision(precision);
let mut zi = z0_im.clone().with_precision(precision);
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
let mut zr_prev = big_zero(precision);
let mut zi_prev = big_zero(precision);
let mut zr_prev = Big::zero(precision);
let mut zi_prev = Big::zero(precision);
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
@@ -332,8 +318,8 @@ fn compute_reference_big(
// Shift the previous iterate (only the Phoenix arm reads it).
zr_prev = zr;
zi_prev = zi;
zr = new_zr.with_precision(precision).value();
zi = new_zi.with_precision(precision).value();
zr = new_zr.with_precision(precision);
zi = new_zi.with_precision(precision);
}
points
@@ -361,7 +347,7 @@ fn step(
FractalKind::BurningShip => {
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = big_abs((zr * zi) << 1) + ci;
let im = ((zr * zi) << 1).abs() + ci;
(re, im)
}
FractalKind::Tricorn => {
@@ -376,14 +362,14 @@ fn step(
}
FractalKind::Celtic => {
// |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;
(re, im)
}
FractalKind::Perpendicular => {
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = if *zi < Big::ZERO {
let im = if zi.is_negative() {
ci + ((zr * zi) << 1)
} else {
ci - ((zr * zi) << 1)
@@ -392,8 +378,8 @@ fn step(
}
FractalKind::Buffalo => {
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr;
let im = ci - big_abs((zr * zi) << 1);
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ci - ((zr * zi) << 1).abs();
(re, im)
}
FractalKind::Phoenix => {
@@ -406,7 +392,7 @@ fn step(
}
FractalKind::Lambda => {
// λ·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 lzr = &k.lr * zr - &k.li * zi;
let lzi = &k.lr * zi + &k.li * zr;
@@ -421,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.
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 ri = big_zero(precision);
let mut rr = Big::from_f64(1.0, precision);
let mut ri = Big::zero(precision);
for _ in 0..power {
// (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 ni = (&rr * zi + &ri * zr).with_precision(precision).value();
let nr = (&rr * zr - &ri * zi).with_precision(precision);
let ni = (&rr * zi + &ri * zr).with_precision(precision);
rr = nr;
ri = ni;
}
(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
/// `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`.
@@ -458,14 +428,14 @@ fn is_big_zero(x: &Big) -> bool {
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
/// exposes).
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) {
return (big_zero(precision), big_zero(precision));
if zr.is_zero() && zi.is_zero() {
return (Big::zero(precision), Big::zero(precision));
}
let r2 = &zr.sqr() + &zi.sqr();
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
let theta = zi.atan2(zr);
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
let exp_im = (pr * &theta + pi * &ln_r).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);
let mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a)
@@ -486,7 +456,7 @@ pub fn compute_set_reference(
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
) -> RefOrbit {
let zero = big_zero(precision);
let zero = Big::zero(precision);
compute_reference(
&zero,
&zero,
@@ -511,19 +481,19 @@ mod tests {
/// `to_f64` reads as ±0.
#[test]
fn sign_and_zero_below_f64_range() {
let tiny = Big::try_from(1.0_f64).unwrap().with_precision(64).value() >> 5000;
assert!(!is_big_zero(&tiny));
assert!(is_big_zero(&big_zero(64)));
assert_eq!(big_abs(-tiny.clone()), tiny);
assert_eq!(big_abs(tiny.clone()), tiny);
let tiny = Big::from_f64(1.0, 64) >> 5000;
assert!(!tiny.is_zero());
assert!(Big::zero(64).is_zero());
assert_eq!((-tiny.clone()).abs(), tiny);
assert_eq!(tiny.clone().abs(), tiny);
}
/// The high-precision reference must agree with a plain f64 iteration for a
/// shallow point (where f64 is accurate).
#[test]
fn reference_matches_naive_f64() {
let cr = Big::try_from(-0.75_f64).unwrap();
let ci = Big::try_from(0.1_f64).unwrap();
let cr = Big::from_f64(-0.75, 53);
let ci = Big::from_f64(0.1, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -656,8 +626,8 @@ mod tests {
/// A point inside the main cardioid never escapes: full-length orbit.
#[test]
fn interior_orbit_runs_full_length() {
let cr = Big::try_from(-0.2_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let cr = Big::from_f64(-0.2, 53);
let ci = Big::from_f64(0.0, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -676,8 +646,8 @@ mod tests {
/// Burning Ship reference matches a naive f64 iteration of the same formula.
#[test]
fn burning_ship_reference_matches_naive_f64() {
let cr = Big::try_from(-1.75_f64).unwrap();
let ci = Big::try_from(-0.03_f64).unwrap();
let cr = Big::from_f64(-1.75, 53);
let ci = Big::from_f64(-0.03, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -707,8 +677,8 @@ mod tests {
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
#[test]
fn multibrot3_reference_matches_naive_f64() {
let cr = Big::try_from(0.3_f64).unwrap();
let ci = Big::try_from(0.2_f64).unwrap();
let cr = Big::from_f64(0.3, 53);
let ci = Big::from_f64(0.2, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -740,10 +710,10 @@ mod tests {
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
#[test]
fn julia_reference_matches_naive_f64() {
let z0_re = Big::try_from(0.15_f64).unwrap();
let z0_im = Big::try_from(-0.1_f64).unwrap();
let c_re = Big::try_from(-0.8_f64).unwrap();
let c_im = Big::try_from(0.156_f64).unwrap();
let z0_re = Big::from_f64(0.15, 53);
let z0_im = Big::from_f64(-0.1, 53);
let c_re = Big::from_f64(-0.8, 53);
let c_im = Big::from_f64(0.156, 53);
let points = compute_reference(
&z0_re,
&z0_im,
@@ -778,10 +748,10 @@ mod tests {
let (lr, li) = (-0.5_f64, 0.2_f64);
let (cr, ci) = (0.1_f64, -0.3_f64);
let points = compute_reference(
&Big::try_from(0.2_f64).unwrap(),
&Big::try_from(0.1_f64).unwrap(),
&Big::try_from(cr).unwrap(),
&Big::try_from(ci).unwrap(),
&Big::from_f64(0.2, 53),
&Big::from_f64(0.1, 53),
&Big::from_f64(cr, 53),
&Big::from_f64(ci, 53),
60,
200,
FractalKind::Lambda,
@@ -807,8 +777,8 @@ mod tests {
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
#[test]
fn celtic_reference_matches_naive_f64() {
let cr = Big::try_from(-0.6_f64).unwrap();
let ci = Big::try_from(0.4_f64).unwrap();
let cr = Big::from_f64(-0.6, 53);
let ci = Big::from_f64(0.4, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -839,8 +809,8 @@ mod tests {
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
#[test]
fn perpendicular_reference_matches_naive_f64() {
let cr = Big::try_from(-0.7_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let cr = Big::from_f64(-0.7, 53);
let ci = Big::from_f64(-0.2, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -871,8 +841,8 @@ mod tests {
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
#[test]
fn buffalo_reference_matches_naive_f64() {
let cr = Big::try_from(-1.2_f64).unwrap();
let ci = Big::try_from(-0.35_f64).unwrap();
let cr = Big::from_f64(-1.2, 53);
let ci = Big::from_f64(-0.35, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -903,8 +873,8 @@ mod tests {
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
#[test]
fn phoenix_reference_matches_naive_f64() {
let cr = Big::try_from(0.5667_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let cr = Big::from_f64(0.5667, 53);
let ci = Big::from_f64(0.0, 53);
let p = (-0.5_f64, 0.0_f64);
let points = compute_set_reference(
&cr,
@@ -942,8 +912,8 @@ mod tests {
/// iteration of `z^p = exp(p·ln z)`.
#[test]
fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::try_from(0.1_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let cr = Big::from_f64(0.1, 53);
let ci = Big::from_f64(-0.2, 53);
let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference(
&cr,
@@ -987,8 +957,8 @@ mod tests {
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
compute_set_reference(
&Big::try_from(cr).unwrap(),
&Big::try_from(ci).unwrap(),
&Big::from_f64(cr, 53),
&Big::from_f64(ci, 53),
60,
200,
kind,
+84 -48
View File
@@ -12,7 +12,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use eframe::egui_wgpu::{self, wgpu};
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
use wgpu::util::DeviceExt as _;
use super::kind::FractalKind;
@@ -196,16 +197,21 @@ struct Lipschitz {
impl Lipschitz {
fn new(device: &wgpu::Device) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("lipschitz"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/lipschitz.wgsl"),
)
.into(),
),
});
let module = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("lipschitz"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/lipschitz.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let texture_entry = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -616,17 +622,22 @@ impl FractalRenderer {
}
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mandelbrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/mandelbrot.wgsl"),
)
.into(),
),
});
let shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("mandelbrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/mandelbrot.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("fractal uniforms"),
@@ -747,17 +758,22 @@ impl FractalRenderer {
});
// Colourise pass: data texture + colour uniforms → colour texture.
let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("colorize"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/colorize.wgsl"),
)
.into(),
),
});
let colorize_shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("colorize"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/colorize.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let colorize_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("colorize bind group layout"),
@@ -828,16 +844,21 @@ impl FractalRenderer {
});
// Blit pipeline: samples the cache texture onto egui's surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/blit.wgsl"),
)
.into(),
),
});
let blit_shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/blit.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let blit_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1607,17 +1628,15 @@ pub fn render_readback_blocking(
bytes
}
/// 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.
/// Strip a readback's row padding and convert it to tightly-packed RGBA8
/// (`width * height * 4` bytes, rows top to bottom).
#[cfg(not(target_arch = "wasm32"))]
pub fn encode_png(
pub fn unpad_rgba(
padded: &[u8],
width: u32,
height: u32,
padded_bpr: u32,
swap_rb: bool,
compression: png::Compression,
) -> Vec<u8> {
let row = (width * 4) as usize;
let mut pixels = Vec::with_capacity(row * height as usize);
@@ -1635,6 +1654,22 @@ pub fn encode_png(
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();
{
@@ -1884,6 +1919,7 @@ pub struct FractalCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for FractalCallback {
fn prepare(
&self,
+197 -23
View File
@@ -3,19 +3,20 @@
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
// render); it just creates its own wgpu device, computes the reference orbit
// once, and renders through the same `ExportRender` path the "Export PNG"
// button uses.
// 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::{Mutex, mpsc};
use eframe::egui_wgpu::wgpu;
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
use crate::cli::Cli;
use crate::fractal::{
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::{
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.
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> {
if cli.buddhabrot {
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.
let targets = AnimTargets::from_cli(&cli)?;
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();
app.apply_cli(cli);
@@ -74,8 +97,16 @@ pub fn run(cli: Cli) -> Result<(), String> {
});
eprintln!();
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
println!("saved {export_path} ({width}×{height})");
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}"))?;
println!("saved {export_path} ({width}×{height})");
}
Ok(())
}
@@ -99,6 +130,8 @@ struct AnimTargets {
fps: f64,
duration: Option<f64>,
linear: bool,
/// `--shard K --shards N`: render only the K-th (1-based) of N parts.
shard: Option<(u32, u32)>,
}
impl AnimTargets {
@@ -109,6 +142,16 @@ impl AnimTargets {
.transpose()
};
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 {
to_view: cli.to_view.clone(),
to_share: cli.to_share.clone(),
@@ -125,6 +168,7 @@ impl AnimTargets {
fps: cli.fps,
duration: cli.duration,
linear: cli.linear,
shard,
})
}
@@ -148,7 +192,9 @@ impl AnimTargets {
/// state to `targets`, for feeding into ffmpeg: the camera, iteration count,
/// 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
/// 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(
mut app: FractalApp,
targets: AnimTargets,
@@ -169,6 +215,16 @@ fn run_animation(
if frames < 2 {
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 (to, to_iterations_share) =
@@ -199,8 +255,17 @@ fn run_animation(
let yaw1 = targets.to_yaw.map_or(yaw0, 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()));
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
// 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
// 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| {
apply_frame(&mut app, i);
app.reference_job()
@@ -249,7 +317,15 @@ fn run_animation(
// 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
// (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 window = threads * 4;
let next_job = AtomicUsize::new(0);
let saved = AtomicUsize::new(0);
let failed = AtomicBool::new(false);
@@ -259,20 +335,34 @@ fn run_animation(
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_rx = Mutex::new(png_rx);
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
std::thread::scope(|scope| {
let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2);
for _ in 0..threads {
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 || {
loop {
let i = next_job.fetch_add(1, Ordering::Relaxed);
if i >= jobs.len() || failed.load(Ordering::Relaxed) {
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() {
break;
}
@@ -281,7 +371,34 @@ fn run_animation(
}
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 {
let raw_tx = raw_tx.clone();
let (png_rx, out_dir, saved, failed, fail) =
(&png_rx, &out_dir, &saved, &failed, &fail);
scope.spawn(move || {
@@ -290,18 +407,26 @@ fn run_animation(
let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else {
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) {
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 =
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) {
fail(format!("save failed: {e}"));
break;
continue;
}
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) {
break;
}
apply_frame(&mut app, i as u32);
apply_frame(&mut app, first + i as u32);
app.finish_reference(jobs[i].clone(), points);
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.
drop(raw_tx);
drop(png_tx);
drop(ref_rx);
});
@@ -346,14 +472,27 @@ fn run_animation(
return Err(e);
}
let saved = saved.into_inner();
if saved != frames as usize {
return Err(format!("only {saved} of {frames} frames were rendered"));
if saved != count {
return Err(format!("only {saved} of {count} frames were rendered"));
}
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
println!(
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
);
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!(
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
);
}
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.
fn smoothstep(t: f64) -> f64 {
t * t * (3.0 - 2.0 * t)
@@ -411,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
.await
.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 bytemuck::{Pod, Zeroable};
use egui::{Color32, Ui};
use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::Ui;
/// Maximum number of simultaneous lights.
pub const MAX_LIGHT_COUNT: usize = 16;
@@ -27,6 +29,7 @@ impl Default for Light {
}
impl Light {
#[cfg(feature = "gui")]
pub fn widget(&mut self, ui: &mut Ui) -> bool {
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
let parser = |s: &str| {
+22 -1
View File
@@ -1,6 +1,8 @@
// Without these, rust fails to infer Send/Sync trait impls
// Probably caused by the new trait solver
#![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.
//
@@ -9,6 +11,7 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app;
mod bignum;
mod camera;
mod fractal;
mod lights;
@@ -21,8 +24,17 @@ mod headless;
#[cfg(not(target_arch = "wasm32"))]
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;
#[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
/// reference orbit from a **storage buffer**, so the device must allow storage
/// 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
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
/// all) — failing cleanly on browsers without WebGPU, per the design.
#[cfg(feature = "gui")]
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
use eframe::egui_wgpu::{WgpuSetup, wgpu};
@@ -51,7 +64,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
fn main() -> MainResult {
use clap::Parser as _;
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 {
renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(),
@@ -80,6 +100,7 @@ fn main() -> eframe::Result {
..Default::default()
};
#[cfg(feature = "gui")]
eframe::run_native(
"Fractal Explorer",
native_options,
+50 -60
View File
@@ -1,6 +1,6 @@
//! 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
//! 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
@@ -10,11 +10,7 @@
use core::str::FromStr;
use dashu_float::round::mode::HalfAway;
use dashu_float::{DBig, FBig};
/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate.
pub type Big = FBig<HalfAway, 2>;
pub use crate::bignum::Big;
/// Half-height (complex units) of the default view; also the zoom-1 reference.
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.
let sig = f.precision().map_or(17, |p| p + 1);
let dec = self
.to_big()
.to_decimal()
.value()
.with_precision(sig)
.value();
let repr = dec.repr();
let digits = repr.significand().to_string();
let digits = digits.trim_end_matches('0');
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 tail = match f.precision() {
Some(p) => format!("{tail:0<p$}"),
None => tail.to_string(),
let big = self.to_big();
let sci = |sig: usize, pad: Option<usize>| {
let parts = big.to_decimal_parts(sig);
let digits = if parts.digits.is_empty() {
"0"
} else {
&parts.digits
};
// value = 0.digits · 10^exp10; move the point after the first digit.
let exp10 = parts.exp10 - 1;
let (head, tail) = digits.split_at(1);
let tail = match pad {
Some(p) => format!("{tail:0<p$}"),
None => tail.to_string(),
};
if tail.is_empty() {
format!("{head}e{exp10}")
} else {
format!("{head}.{tail}e{exp10}")
}
};
if tail.is_empty() {
write!(f, "{head}e{exp10}")
} else {
write!(f, "{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.
let dec = DBig::from_str(s).map_err(|_| ())?;
let bin: Big = dec.with_base_and_precision::<2>(64).value();
if bin < Big::ZERO {
let bin = Big::from_decimal_str(s, 64).ok_or(())?;
if bin.is_negative() {
return Err(());
}
let repr = bin.repr();
let digits = repr.digits();
if digits == 0 {
return Err(());
}
let top = repr.exponent() + digits as isize - 1;
let m = (bin.clone() >> top).to_f64().value();
let top = bin.log2_floor().ok_or(())?;
let m = (bin >> top).to_f64();
let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32;
Ok(Self::from_parts(m, e))
}
@@ -312,10 +311,10 @@ impl ViewState {
pub fn sync_precision(&mut self) {
let bits = self.precision_bits();
if self.center_re.precision() < bits {
self.center_re = self.center_re.clone().with_precision(bits).value();
self.center_re = self.center_re.clone().with_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
/// least `bits` of precision. Used for share links and debug view specs.
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
let dec = DBig::from_str(s.trim()).ok()?;
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
Big::from_decimal_str(s, bits)
}
/// 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.
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 im0 = from.center_im.clone().with_precision(bits).value();
let re1 = to.center_re.clone().with_precision(bits).value();
let im1 = to.center_im.clone().with_precision(bits).value();
let re0 = from.center_re.clone().with_precision(bits);
let im0 = from.center_im.clone().with_precision(bits);
let re1 = to.center_re.clone().with_precision(bits);
let im1 = to.center_im.clone().with_precision(bits);
let center_re = &re1 + &(&(&re0 - &re1) * &g_big);
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
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.
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
let dec = x
.to_decimal()
.value()
.with_precision(sig_digits.max(1))
.value();
format!("{dec}")
x.to_decimal_string(sig_digits)
}
/// 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)
}
/// 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 {
Big::try_from(x)
.unwrap_or_default()
.with_precision(bits)
.value()
Big::from_f64(x, bits)
}
#[cfg(test)]
@@ -489,8 +479,8 @@ mod tests {
}
fn re_im_f64(v: &ViewState) -> (f64, f64) {
let re: f64 = v.center_re.to_decimal().value().to_f64().value();
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
let re: f64 = v.center_re.to_f64();
let im: f64 = v.center_im.to_f64();
(re, im)
}
@@ -612,8 +602,8 @@ mod tests {
prev = mid.half_height;
// Offset from the target, in units of the view's half-height.
let k = -mid.half_height.exponent() as isize;
let dre = ((&mid.center_re - &to.center_re) << k).to_f64().value();
let dim = ((&mid.center_im - &to.center_im) << 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();
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}");
}
+1 -1
View File
@@ -1,7 +1,7 @@
//! Native background worker for reference-orbit computation.
//!
//! At deep zoom the high-precision reference can take many milliseconds (tens of
//! thousands of `FBig` iterations), which would stutter the UI if done inline.
//! 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
//! drag) down to the most recent one. On the web we compute inline instead
//! (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"