Compare commits

..
10 Commits
Author SHA1 Message Date
surv 0ae461aef2 refactor: cleanup code 2026-09-20 13:20:50 +02:00
surv 6b66d205ec fixup: headless 2026-09-20 13:20:29 +02:00
surv 067c588704 refactor: add common shaders helpers + fix shadow export 2026-09-20 13:13:42 +02:00
surv a527a9f812 feat: improve colors when using distance estimate 2026-09-20 13:13:42 +02:00
surv 2110d038a1 feat: improve UI/UX 2026-09-20 13:13:42 +02:00
surv 95b85fdfae feat: Add complex multibrot fractal 2026-09-20 13:13:42 +02:00
surv e954524e99 feat: minor visual fix for PNG export 2026-09-20 13:13:42 +02:00
survandClaude Sonnet 5 b078a3f97a feat: add keyboard shortcuts for pan/zoom/iterations/AA
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:13:42 +02:00
survandClaude Sonnet 5 f34e398223 feat: add fractal info and help overlays
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:51:19 +02:00
survandClaude Sonnet 5 6caa23accd feat: add a headless mode, driven by clap CLI args
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:50:34 +02:00
39 changed files with 1067 additions and 7465 deletions
-3
View File
@@ -1,6 +1,3 @@
/target
Cargo.lock
dist
frames*
out.mp4
__pycache__
+24 -199
View File
@@ -6,15 +6,9 @@ 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 `bignum::Big`:
`rug` natively, `malachite-float` on the web),
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
rebasing to avoid glitches. Plain `f32` deltas run out of exponent range
once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel
(`view::DEEP_PIXEL_SIZE`) a `DEEP` shader variant starts each pixel with
rescaled deltas (f32 mantissa × 2^i32). There's no practical depth limit:
`half_height` is a `view::Scale` (f64 mantissa × 2^i32), floored only at
`Scale::MIN` = 2^-(2^20) to keep shader exponent sums in i32. Runs
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
storage buffers, which the fragment shader needs for the reference orbit).
@@ -23,12 +17,9 @@ 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):
@@ -36,60 +27,19 @@ 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 (builds with --features wasm)
./build-web.sh # -> ./dist
python3 -m http.server -d dist 8080
```
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
`--power`, `--julia re,im`, `--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`, `--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`,
default 1920×1080, `--export-path out.png`) without needing a GPU-backed
window/event loop. Not yet supported with `--buddhabrot`. Run
`mandelbrot --help` for the full list.
`--headless` also has an animation mode, for feeding into `ffmpeg`: give any
end-state flag alongside the start flags (`--view`/`--share`/`--kind`/
`--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags:
`--to-view re,im,half_height[,iterations]` or `--to-share <fragment>` (only
position/zoom/iterations are pulled out of the link), `--to-iterations`,
`--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power`
(or `--to-complex-power-re`/`--to-complex-power-im` to move one component),
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. `--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. `--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
`--to-yaw 720` is two turns) orbit the 3D camera with `--rendering-kind 3d`.
Frames are pipelined across every core (`run_animation`): each frame's
state is a pure function of `t` (`apply_frame`), so all frames'
`FractalApp::reference_job`s are snapshotted up front and `RefJob::compute`d
by a worker pool. The main thread renders them on the GPU as they arrive
(out of order), and another pool PNG-encodes and writes them
(`encode_png`, `Compression::Fast`). Channels are bounded. Once orbits and
encoding are off the main thread, the GPU is usually the bottleneck.
`--power`, `--julia re,im`, `--share <fragment>`,
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
`--buddha-palette`, `--export` (+ `--export-path out.png`). `--headless`
(`src/headless.rs`) skips the window entirely: it builds the same view from
the other flags, creates its own offscreen wgpu device, and renders straight
to a PNG (`--width`/`--height`, default 1920×1080) — implies `--export`'s
save behavior without needing a GPU-backed window/event loop. Not yet
supported with `--buddhabrot`. Run `mandelbrot --help` for the full list.
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
are the fast, headless way to validate a change. `cargo test` also runs but
@@ -108,32 +58,9 @@ 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/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
`-scale_exp` before `to_f64()`, as `dc_offset`/`drift_from` do.
`Display`/`FromStr` use scientific notation of any exponent (share links,
`--view`, the zoom field). Precision (bits) scales with zoom depth
(`precision_for`). `needs_deep` switches rendering to the deep pipeline
once a pixel of the full-resolution render is below `DEEP_PIXEL_SIZE`
(2^-122; the f32 path is exact down to 2^-124 with AA's quarter-pixel
offsets, measured, and the deep path is ~40% slower, so the switch is as
late as that allows). `deep_scale_exp` gives the scale exponent.
`make_uniforms(aspect, height_px)` takes that full-resolution height, the
same during the interaction-downscaled pass so the pipeline doesn't flip.
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
(bits) scales with zoom depth (`precision_for`).
- `src/fractal/kind.rs` — the `FractalKind` enum (Mandelbrot, Burning Ship,
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
Complex Multibrot) plus everything that only needs to switch on it:
@@ -142,24 +69,7 @@ pixel is a handful of `f32` complex multiplies.
`ALL` array used to enumerate every kind.
- `src/fractal/reference.rs` — `compute_reference`/`compute_set_reference`:
iterate the chosen formula at high precision on the CPU, emitting `Z_n` as
`f32` pairs — that's the reference orbit the GPU perturbs from. 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 + `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
`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×
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
creeping up during a zoom doesn't recompute the orbit every frame. The
interactive reference buffers start at 2^17 points and grow on demand
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
128 MiB WebGPU default binding size). That is also the hard iteration
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
reference as escaped. The UI slider only goes to 100k when dragged; typed
values can go higher.
`f32` pairs — that's the reference orbit the GPU perturbs from.
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
no `#include`, so each is compiled by concatenating plain-text fragments
with `concat!`/`include_str!` at the `create_shader_module` call site (see
@@ -172,25 +82,7 @@ pixel is a handful of `f32` complex multiplies.
Because there's no namespacing, a definition must live in exactly one file
among those concatenated together for a given shader — don't redefine a
`common.wgsl`/`iterate_uniforms.wgsl` symbol locally.
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader. It is
**specialized per pipeline** through WGSL `override` constants (`KIND`,
`IS_JULIA`, `DE`), so the per-iteration kind/Julia/DE branches fold away at
pipeline creation. Read those constants in the shader, never `u.kind` /
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE × morph ×
deep variant to SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
constant. `buddhabrot.wgsl` does the same with its own `override KIND`.
Interior pixels exit early through **periodicity detection**. It uses
Brent-style checkpoints plus two guards: the cycle's multiplier must be
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
needed: without them, exterior pixels at cusps and minibrot edges turned
black. Retune them only against f64 ground truth on such views. Each
window saves its iterate closest to the critical point, not the one at the
checkpoint. At an arbitrary phase the relative tolerance is far coarser
than a deep minibrot's scale, and a black disk surrounded the minibrot
(seen at ~1e-13 zoom). Phoenix is excluded (two-term map).
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
relies on `c` being additive in every current kind's formula (a kind where
@@ -200,51 +92,10 @@ pixel is a handful of `f32` complex multiplies.
exact delta). `fprime(z)` is the derivative used for distance-estimation
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
abs-based ones. A `KIND_*` constant (from `common.wgsl`) must match the
matching `FractalKind` variant's discriminant exactly. The per-kind bodies
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 `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
`app.rs`; the uniforms use the morph the *current reference* was built with
(`ref_morph`), not the live one, so orbit and delta formula never disagree.
**Deep views** (`DEEP` override, `u.scale_exp != 0`) handle zooms where
f32 deltas underflow. `make_uniforms` sets `scale_exp = E` (≈ log2 of the
half-height) and uploads `span`/`dc_offset` × 2^-E. The per-pixel `offset`
and `px` are therefore in units of 2^E. `iterate_sample` first runs a
**deep prologue**:
- The delta is carried as `w·2^sx` and the DE derivative as `v·2^sv`
(separate exponents, since they drift apart near the critical point).
- Each step goes through `advance_delta_scaled` →
`deep_step_kind` → `advance_delta_scaled_kind`. These return the step at
its own output scale `t`. Next to the critical point (X tiny or 0), the
linear term vanishes and the step's value is ~e^p, far below 2^sx.
`deep_step_kind` measures X and e in a common unit (the kinds are
p-homogeneous) and the loop moves `sx` there. Assuming the e² terms merely
flush when negligible was wrong exactly there: pixels near deep minibrots
lost their delta and followed the reference forever.
- Rebasing uses X at full range (`ref_fe`).
- Once `|e| > 2^DEEP_EXIT_LOG2` (and dzs is normal), the state converts to
f32 and the ordinary loop continues from the same `n`/`m`.
- Periodicity detection restarts after the prologue with a sentinel save,
because saving the hand-off `z` (an arbitrary phase) made exterior pixels
shadowing a periodic nucleus reference read as interior.
The deep path is exact at any depth: forcing it everywhere (raise
`DEEP_PIXEL_SIZE`, raise `DEEP_EXIT_LOG2` to about -8) must reproduce the
plain f32 renders on non-chaotic views. That's the check to rerun after
changing it. Known gaps: Lambda's critical point is 1/2, so its step keeps
the input scale. Lambda set mode's reference sits at the origin, so it
never reaches deep zooms anyway.
matching `FractalKind` variant's discriminant exactly.
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
the WGSL `Uniforms` struct field-for-field, including padding; it includes
CPU-precomputed data: `cm_coef`, the Complex Multibrot binomial
coefficients from `app.rs::complex_binomials`, `light_count` for the
packed `GpuLight` buffer from `lights.rs::gpu_lights`, and `scale_exp`,
the deep view scale), `ref_exp_buffer` (binding 3, `RefOrbit::exps`), and
the WGSL `Uniforms` struct field-for-field, including padding), and
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
changed buffers and decides whether to re-run the iterate pass, the cheap
colourise pass, or just blit the cached texture). Also `ExportRender`, a
@@ -258,10 +109,7 @@ pixel is a handful of `f32` complex multiplies.
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
`should_request`/`ensure_reference` (decide when the reference is stale and
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
`tick_animations` (drives the interactive animations: colour cycle,
auto-zoom, c/p/λ circle drift via `ConstOrbit`, per-component complex-power
oscillation via `AxisOsc`, 3D camera orbit, kind cycling through
`switch_kind`),
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
`default_view_for` (wraps `FractalKind::default_set_view`, adding the
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
@@ -277,9 +125,7 @@ Touches, in order: `kind.rs` (enum variant + `ALL` slot + `label`/
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms, plus the deep
path's `advance_delta_scaled_kind` arm, its degree in `deep_step_kind` and,
if not z²-like, a `deep_fprime` arm), `buddhabrot.wgsl`
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms), `buddhabrot.wgsl`
(matching arm in `advance()`, if the kind makes sense as a Buddhabrot),
`renderer.rs` `Uniforms` (only if the kind needs a new per-kind constant,
e.g. Phoenix's `phoenix_p`), `app.rs` (`JULIA_PRESETS`/`SET_PRESETS` slot,
@@ -306,28 +152,7 @@ histogram buffer, tone-mapped by a fragment pass every frame. Its own
The interactive path splits iteration (expensive, perturbation) from
colourising (cheap, palette remap) into separate offscreen textures, so
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
vs `color_differs` in `renderer.rs` decide which pass reruns). A frame where
neither differs uploads and renders nothing and only blits. So any new
uniform field must go into one of those two functions (or the lights
comparison), or changing it won't redraw.
AA is **adaptive** on the interactive path. `fs_data` always iterates 1
sample per pixel. When AA is on, `fs_refine` reads that texture and runs the
2×2 grid only on pixels whose 4-neighbours differ (interior/exterior edge, or
`ci`/DE beyond `AA_CI_EPS`/`AA_DE_EPS`), copying the rest. Colourise then
reads the refined texture. PNG export (`fs_color`) still supersamples every
pixel, except in 3D: the raymarcher needs the whole height field, so a 3D
`ExportRender` (`RaymarchExport`) runs the interactive chain instead, with
its tiles iterating `fs_data` into its own data texture and the last tile adding
refine + colourise into the target.
The 3D view (`colorize.wgsl::ray_marching`) sphere-traces the DE height
field straight from the data texture. It's cheap: rays start on the z = 0
plane, and most hit within a few steps (about 4 on average). A min-height
mip pyramid (quadtree height-field tracing) was tried and measured about 3×
slower, because it needs about 12 costlier steps per ray. Don't reintroduce it.
Orbiting the camera only re-runs the colourise pass, never iteration.
While the user is actively panning/zooming, the app renders downscaled with
AA off (`INTERACT_DOWNSCALE`) and snaps back to full resolution once input
settles (`INTERACT_SETTLE`).
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
is actively panning/zooming, the app renders downscaled with AA off
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
(`INTERACT_SETTLE`).
+5 -18
View File
@@ -3,31 +3,18 @@ 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"] }
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"
dashu-float = "0.6.0"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
egui = "0.36.2"
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"] }
@@ -44,7 +31,7 @@ opt-level = 3
# codegen-units = 1
debug = true
# Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
# egui) so the explorer is actually interactive during development.
[profile.dev]
opt-level = 1
@@ -53,4 +40,4 @@ opt-level = 1
opt-level = 3
[dev-dependencies]
naga = { version = "30", features = ["wgsl-in", "spv-out"] }
naga = { version = "30", features = ["wgsl-in"] }
-35
View File
@@ -1,35 +0,0 @@
.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)
+2 -9
View File
@@ -3,8 +3,7 @@
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 `rug` natively, `malachite-float`
on the web), and every pixel is
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid
glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
@@ -28,9 +27,6 @@ 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
@@ -41,7 +37,6 @@ 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
```
@@ -65,8 +60,7 @@ qualifies. Deploy by serving the `dist/` directory as static files.
## How it works
- `src/view.rs` — view state. Center is arbitrary precision (`Big`, from
`src/bignum/`); the pixel
- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range).
- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`.
- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
@@ -90,7 +84,6 @@ 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 --features wasm
cargo build --release --target wasm32-unknown-unknown
echo "==> wasm-bindgen -> $OUT"
mkdir -p "$OUT"
+326 -1167
View File
File diff suppressed because it is too large Load Diff
-225
View File
@@ -1,225 +0,0 @@
//! 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
@@ -1,128 +0,0 @@
//! [`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
@@ -1,173 +0,0 @@
//! [`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)
}
}
-91
View File
@@ -1,91 +0,0 @@
use std::f32::consts::{PI, TAU};
use glam::Vec3;
/// Pitch is kept just short of straight up/down so the view never flips past
/// the pole (and the raymarcher's rays always have `z > 0`).
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
#[derive(Default, Clone)]
pub struct Camera {
pub position: glam::Vec3,
pub yaw: f32,
pub pitch: f32,
pub aspect_ratio: f32,
z_near: f32,
z_far: f32,
}
impl Camera {
pub fn new() -> Self {
Self {
position: Vec3::new(0., 0., -1.),
yaw: 0. * PI / 180.,
pitch: -30. * PI / 180.,
aspect_ratio: 1.,
z_near: 0.1,
z_far: 100.,
}
}
pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
}
/// Adjust yaw/pitch by the given deltas (radians). Pitch is clamped just
/// short of straight up/down to avoid the view flipping past the pole.
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
/// yaw by `t`) always unwinds the short way instead of every past turn.
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Set yaw/pitch (radians) outright. Pitch is clamped as in `rotate`,
/// but yaw is left unwrapped: only a camera that stays in 3D (headless
/// animation, which interpolates yaw across several turns) should use
/// this; `rotate(0., 0.)` afterwards wraps it for the 2D <-> 3D transition.
#[cfg(not(target_arch = "wasm32"))]
pub fn set_angles(&mut self, yaw: f32, pitch: f32) {
self.yaw = yaw;
self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// `render_scale` is the 3D texture's per-axis scale (see
/// `FractalApp::render_scale_3d`): the full 3D camera (`t = 1`) zooms in
/// by the same factor, so one texel still covers one screen pixel and the
/// extra texels become terrain beyond the screen edges.
pub fn orthographic(&self, t: f32, render_scale: f32) -> glam::Mat4 {
let yaw = self.yaw * t;
let pitch = self.pitch * t;
let zoom = 0.5 * (1. + t * (render_scale - 1.));
// Orbit pivot: the center of the fractal texture, which the raymarcher's
// `sdf` lays out over world x ∈ [0, aspect], y ∈ [0, 1] on the z = 0 plane.
let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.))
* glam::Mat4::from_rotation_z(-yaw)
* glam::Mat4::from_rotation_x(-pitch)
* glam::Mat4::from_translation(self.position);
glam::camera::lh::proj::directx::orthographic(
-self.aspect_ratio / 4. / zoom,
self.aspect_ratio / 4. / zoom,
-0.25 / zoom,
0.25 / zoom,
self.z_near,
self.z_far,
) * view.inverse()
}
pub fn direction(&self, t: f32) -> glam::Vec3 {
let yaw = self.yaw * t;
let pitch = self.pitch * t;
let forward =
glam::Mat3::from_rotation_z(-yaw) * glam::Mat3::from_rotation_x(-pitch) * glam::Vec3::Z;
forward.normalize()
}
}
+13 -151
View File
@@ -13,19 +13,7 @@ pub struct Cli {
#[arg(long, value_enum)]
pub kind: Option<KindArg>,
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub julia: Option<String>,
/// Switch to the Buddhabrot renderer.
#[arg(long)]
pub buddhabrot: bool,
/// Rendering mode to use.
#[arg(long)]
pub rendering_kind: Option<RenderingKindArg>,
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
#[arg(long)]
pub power: Option<u32>,
@@ -33,13 +21,9 @@ pub struct Cli {
#[arg(long, value_name = "RE,IM")]
pub complex_power: Option<String>,
/// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev).
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub phoenix_p: Option<String>,
/// Lambda constant λ for the Lambda kind (z -> λ*z*(1 - z)).
#[arg(long, value_name = "RE,IM")]
pub lambda_l: Option<String>,
pub julia: Option<String>,
/// Restore a view from a share-link fragment (the part after '#').
#[arg(long, value_name = "FRAGMENT")]
@@ -49,143 +33,29 @@ pub struct Cli {
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
pub view: Option<String>,
/// Jump to a specific position on startup.
#[arg(long, short('p'), value_name = "RE,IM")]
pub position: Option<String>,
/// Set a maximum iterations count on startup.
#[arg(long, short('i'))]
pub iterations: Option<u32>,
/// Set the zoom level on startup.
#[arg(long("zoom"), short('z'))]
pub half_height: Option<String>,
/// 3D camera yaw in degrees (with --rendering-kind 3d).
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub yaw: Option<f32>,
/// 3D camera pitch in degrees (with --rendering-kind 3d); negative tilts
/// the view down toward the fractal. Clamped short of ±90.
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub pitch: Option<f32>,
/// Enable distance-estimation shading.
#[arg(long)]
pub de: bool,
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
/// Switch to the Buddhabrot renderer.
#[arg(long)]
pub antialias: bool,
pub buddhabrot: bool,
/// Coloring palette index.
/// Buddhabrot tonemap palette index.
#[arg(long, value_name = "INDEX")]
pub palette: Option<u32>,
pub buddha_palette: Option<u32>,
/// 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>/). "-" 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 - ...`.
/// Render a PNG export on startup.
#[arg(long)]
pub export: bool,
/// Output path for --export/--headless (default: fractal-<timestamp>.png).
#[arg(long, value_name = "PATH")]
pub export_path: Option<String>,
/// End view for an animation: "re,im,half_height[,iterations]", the same
/// syntax as --view. Combine with --view (or --share, --kind, --julia...)
/// for the start view; headless then renders a sequence of frames
/// interpolating the camera from start to end instead of a single PNG.
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
pub to_view: Option<String>,
/// End view for an animation, as a share-link fragment (only the
/// position/zoom/iterations are used; alternative to --to-view for
/// pasting a location copied from the app's "Copy share link").
#[arg(long, value_name = "FRAGMENT")]
pub to_share: Option<String>,
/// Set a maximum iterations count at animation end.
#[arg(long)]
pub to_iterations: Option<u32>,
/// End Julia constant for an animation: c is interpolated from --julia
/// to this over the frames.
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
pub to_julia: Option<String>,
/// End Phoenix constant p for an animation (from --phoenix-p).
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
pub to_phoenix_p: Option<String>,
/// End Lambda constant λ for an animation (from --lambda-l).
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
pub to_lambda_l: Option<String>,
/// End Complex Multibrot exponent for an animation (from
/// --complex-power). Shorthand for --to-complex-power-re +
/// --to-complex-power-im.
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
pub to_complex_power: Option<String>,
/// End real part of the Complex Multibrot exponent for an animation;
/// the imaginary part stays put unless --to-complex-power-im is given.
#[arg(long, value_name = "RE", allow_hyphen_values = true)]
pub to_complex_power_re: Option<f64>,
/// End imaginary part of the Complex Multibrot exponent for an animation;
/// the real part stays put unless --to-complex-power-re is given.
#[arg(long, value_name = "IM", allow_hyphen_values = true)]
pub to_complex_power_im: Option<f64>,
/// End 3D camera yaw for an animation, in degrees (from --yaw). Not
/// wrapped: --yaw 0 --to-yaw 720 orbits twice.
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub to_yaw: Option<f32>,
/// End 3D camera pitch for an animation, in degrees (from --pitch).
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
pub to_pitch: Option<f32>,
/// Morph the iteration formula from the start kind (--kind) to this one
/// over the animation. The camera is unaffected (use --to-view for that).
#[arg(long, value_enum)]
pub to_kind: Option<KindArg>,
/// Number of frames to render for an animation. Alternative to --fps +
/// --duration.
#[arg(long, value_name = "N")]
pub frames: Option<u32>,
/// Frames per second, used with --duration to compute the frame count
/// (ignored if --frames is given). Also used in the ffmpeg command
/// hint printed after rendering.
#[arg(long, value_name = "N", default_value_t = 30.0)]
pub fps: f64,
/// Animation duration in seconds, used with --fps to compute the frame
/// count (ignored if --frames is given).
#[arg(long, value_name = "SECONDS")]
pub duration: Option<f64>,
/// Pace animation frames linearly instead of easing in/out (smoothstep).
#[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
/// render an animation instead of a single frame. Not yet supported with --buddhabrot.
/// render. Not yet supported with --buddhabrot.
#[arg(long)]
pub headless: bool,
@@ -217,14 +87,6 @@ pub enum KindArg {
ComplexMultibrot,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum RenderingKindArg {
Classic,
Shadow,
#[value(alias = "3d")]
Dimension3,
}
impl From<KindArg> for FractalKind {
fn from(k: KindArg) -> Self {
match k {
+22 -50
View File
@@ -3,10 +3,7 @@
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
//! this is a separate pipeline from the escape-time perturbation renderer.
use std::collections::HashMap;
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
use eframe::egui_wgpu::{self, 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`
@@ -104,12 +101,7 @@ struct Histogram {
}
pub struct BuddhabrotRenderer {
shader: wgpu::ShaderModule,
compute_pipeline_layout: wgpu::PipelineLayout,
/// Accumulation pipelines, specialized per fractal kind (the shader's
/// `override KIND`, so `advance()` has no per-step kind branches) and
/// built lazily on first use.
compute_pipelines: HashMap<u32, wgpu::ComputePipeline>,
compute_pipeline: wgpu::ComputePipeline,
compute_bind_group_layout: wgpu::BindGroupLayout,
tonemap_pipeline: wgpu::RenderPipeline,
tonemap_bind_group_layout: wgpu::BindGroupLayout,
@@ -126,21 +118,16 @@ pub struct BuddhabrotRenderer {
impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
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 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 uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot uniforms"),
@@ -181,6 +168,14 @@ impl BuddhabrotRenderer {
bind_group_layouts: &[Some(&compute_bind_group_layout)],
immediate_size: 0,
});
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("buddhabrot compute pipeline"),
layout: Some(&compute_pipeline_layout),
module: &shader,
entry_point: Some("cs_main"),
compilation_options: Default::default(),
cache: None,
});
let tonemap_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -241,9 +236,7 @@ impl BuddhabrotRenderer {
});
Self {
shader,
compute_pipeline_layout,
compute_pipelines: HashMap::new(),
compute_pipeline,
compute_bind_group_layout,
tonemap_pipeline,
tonemap_bind_group_layout,
@@ -255,23 +248,6 @@ impl BuddhabrotRenderer {
}
}
/// The accumulation pipeline for `kind`, built on first use.
fn compute_pipeline(&mut self, device: &wgpu::Device, kind: u32) -> &wgpu::ComputePipeline {
self.compute_pipelines.entry(kind).or_insert_with(|| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("buddhabrot compute pipeline"),
layout: Some(&self.compute_pipeline_layout),
module: &self.shader,
entry_point: Some("cs_main"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("KIND", kind as f64)],
..Default::default()
},
cache: None,
})
})
}
/// Ensure the histogram buffer exists at `width`×`height`, recreating (and
/// resetting accumulation) on a size change.
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
@@ -342,7 +318,6 @@ pub struct BuddhabrotCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare(
&self,
@@ -359,9 +334,6 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
let width = self.size_px[0].max(1);
let height = self.size_px[1].max(1);
renderer.ensure_histogram(device, width, height);
let pipeline = renderer
.compute_pipeline(device, self.uniforms.kind)
.clone();
let content = ContentKey::from(&self.uniforms);
let content_changed = renderer.last_content != Some(content);
@@ -393,7 +365,7 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
label: Some("buddhabrot accumulate pass"),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline);
pass.set_pipeline(&renderer.compute_pipeline);
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
pass.dispatch_workgroups(workgroups, 1, 1);
+7 -7
View File
@@ -16,7 +16,7 @@ pub enum FractalKind {
BurningShip = 1,
/// `z -> conj(z)^2 + c` (the Mandelbar).
Tricorn = 2,
/// `z -> z^power + c` (integer power in [2, 20]).
/// `z -> z^power + c` (power >= 2).
Multibrot = 3,
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
Celtic = 4,
@@ -26,7 +26,7 @@ pub enum FractalKind {
Buffalo = 6,
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
Phoenix = 7,
/// `z -> lambda·z(1 - z) + c` (logistic map).
/// `z -> lambda·z(1 - z)` (logistic map).
Lambda = 8,
/// `z -> z^power + c`, where `power` is a complex constant (the
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
@@ -104,7 +104,7 @@ impl FractalKind {
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + c".to_string(),
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
FractalKind::Lambda => "z = λ·z(1 − z)".to_string(),
FractalKind::ComplexMultibrot => {
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
}
@@ -152,13 +152,13 @@ impl FractalKind {
match self {
FractalKind::Mandelbrot => (-0.5, 0.0, 1.25),
FractalKind::BurningShip => (-0.5, -0.5, 1.3),
FractalKind::Tricorn => (-0.25, 0.0, 1.7),
FractalKind::Tricorn => (-0.25, 0.0, 1.6),
FractalKind::Multibrot => (0.0, 0.0, 1.5),
FractalKind::Celtic => (-0.5, 0.0, 1.6),
FractalKind::Perpendicular => (-0.5, 0.0, 1.5),
FractalKind::Buffalo => (-0.5, 0.5, 1.5),
FractalKind::Phoenix => (-0.5, 0.0, 1.5),
FractalKind::Lambda => (-0.5, 0.0, 2.4),
FractalKind::Buffalo => (-0.5, -0.5, 1.5),
FractalKind::Phoenix => (0.0, 0.0, 1.6),
FractalKind::Lambda => (0.0, 0.0, 1.6),
FractalKind::ComplexMultibrot => (0.0, 0.0, 1.5),
}
}
+3 -5
View File
@@ -9,12 +9,10 @@ pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use kind::FractalKind;
pub use reference::{RefOrbit, compute_reference, compute_set_reference};
#[cfg(not(target_arch = "wasm32"))]
pub use renderer::PipelineKey;
pub use reference::{compute_reference, compute_set_reference};
#[cfg(target_arch = "wasm32")]
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, unpad_rgba};
pub use renderer::export_to_png_blocking;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
pub use share::ShareState;
+131 -595
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
//! ([`Big`]: `rug` natively, pure Rust on the web), storing each `Z_n` as an `f32` pair. Every pixel is then
//! (`dashu-float`), storing each `Z_n` as an `f32` pair. Every pixel is then
//! rendered on the GPU as a small `f32` delta from this orbit — that is what
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
//! delta formula there must match the orbit formula here.
@@ -9,11 +9,6 @@
//! The `(z0, c)` form serves both set types:
//! * Mandelbrot-set: `z0 = 0`, `c = view center` (the c-plane point per pixel).
//! * Julia-set: `z0 = view center`, `c = fractal constant` (fixed per view).
//!
//! While switching fractal kinds, the formula is morphed *per iteration*:
//! `Z_{n+1} = (1 - w)·f_kind(Z_n) + w·f_from(Z_n)` (see `morph` below). The
//! map is linear in the two outputs, so the GPU delta is the same blend of
//! the two kinds' deltas and perturbation/rebasing keep working unchanged.
use super::kind::FractalKind;
use crate::view::{Big, big_from_f64};
@@ -23,103 +18,9 @@ use crate::view::{Big, big_from_f64};
/// bailout before the stored orbit runs out.
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// Up to this working precision (bits) the orbit is iterated in plain `f64`
/// 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
/// consumes the orbit as f32 deltas, so two things actually matter:
/// * Each f64 step's rounding (~1e-16 relative) acts like a tiny local error
/// in the pixel orbits too (perturbation reproduces whatever orbit it's
/// given), far below the f32 delta noise — the orbit only has to be a
/// consistent orbit, not the exact one.
/// * The reference center gets rounded to f64 (<= ~2.2e-16 absolute for
/// |c| <= 2), which shifts the image. At 80 bits (zoom_bits <= 32, i.e.
/// half-height >= ~2.3e-10) a pixel is >= ~5e-13 wide, so that shift stays
/// below 0.1% of a pixel.
const F64_MAX_PRECISION: usize = 80;
/// Orbit points with a magnitude below `2^TINY_LOG2` are stored normalized
/// (mantissa + exponent, see [`RefOrbit::exps`]): f32's smallest normal is
/// ~2^-126, and the GPU's deep (rescaled) phase needs these points' exact
/// value to decide rebasing. The margin keeps a few mantissa bits clear of
/// the subnormal range for the smaller component.
const TINY_LOG2: i32 = -100;
/// A reference orbit as uploaded to the GPU.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RefOrbit {
/// `Z_n` as f32 pairs. For points with a non-zero `exps[n]`, a mantissa
/// instead: the true value is `points[n] * 2^exps[n]`.
pub points: Vec<[f32; 2]>,
/// Per-point binary exponent (same length as `points`). Non-zero only for
/// points too small for f32's exponent range (see [`TINY_LOG2`]); only
/// the deep shader pipeline reads it, so [`Self::has_scaled`] forces it.
pub exps: Vec<i32>,
}
impl RefOrbit {
fn with_capacity(n: usize) -> Self {
// Most orbits escape long before `max_iter`; don't reserve hundreds of
// MB up front for a multi-million iteration request.
let n = n.min(1 << 17);
Self {
points: Vec::with_capacity(n),
exps: Vec::with_capacity(n),
}
}
fn push(&mut self, point: [f32; 2]) {
self.points.push(point);
self.exps.push(0);
}
/// Whether any point is stored as mantissa + exponent, i.e. the orbit
/// can only be read by the deep pipeline.
pub fn has_scaled(&self) -> bool {
self.exps.iter().any(|&e| e != 0)
}
}
impl core::ops::Deref for RefOrbit {
type Target = [[f32; 2]];
fn deref(&self) -> &Self::Target {
&self.points
}
}
impl<'a> IntoIterator for &'a RefOrbit {
type Item = &'a [f32; 2];
type IntoIter = core::slice::Iter<'a, [f32; 2]>;
fn into_iter(self) -> Self::IntoIter {
self.points.iter()
}
}
/// 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) = (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() 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() as f32, zi.to_f64() as f32]),
}
}
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
/// `Z_{n+1} = f(Z_n, c)` for the given `kind` (and `power`, for Multibrot), up
/// to `max_iter` steps at `precision` bits. Each entry is `[re, im]` in f32.
///
/// `morph = Some((from, w))` blends in a second kind's formula at every step:
/// `(1 - w)·f_kind + w·f_from` (used by the kind-switch animation).
#[allow(clippy::too_many_arguments)]
pub fn compute_reference(
z0_re: &Big,
@@ -133,294 +34,140 @@ pub fn compute_reference(
phoenix_p: (f64, f64),
lambda_l: (f64, f64),
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
) -> RefOrbit {
// A zero-weight morph is just the plain kind; skip the second formula.
let morph = morph.filter(|&(_, w)| w != 0.0);
if precision <= F64_MAX_PRECISION {
let k = StepConstsF64 {
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(), z0_im.to_f64()), max_iter, kind, &k, morph);
}
let k = StepConsts {
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),
li: big_from_f64(lambda_l.1, precision),
cpow_re: big_from_f64(complex_power.0, precision),
cpow_im: big_from_f64(complex_power.1, precision),
power,
precision,
};
compute_reference_big(z0_re, z0_im, max_iter, kind, &k, morph)
}
) -> Vec<[f32; 2]> {
let cr = c_re.clone().with_precision(precision).value();
let ci = c_im.clone().with_precision(precision).value();
/// `f64` twin of [`StepConsts`].
struct StepConstsF64 {
c: (f64, f64),
p: (f64, f64),
l: (f64, f64),
cpow: (f64, f64),
power: u32,
}
/// [`compute_reference`]'s fast path for shallow views (see
/// [`F64_MAX_PRECISION`]): the same per-kind formulas in plain `f64`.
fn compute_reference_f64(
z0: (f64, f64),
max_iter: u32,
kind: FractalKind,
k: &StepConstsF64,
morph: Option<(FractalKind, f64)>,
) -> RefOrbit {
let (mut zr, mut zi) = z0;
let mut zr = z0_re.clone().with_precision(precision).value();
let mut zi = z0_im.clone().with_precision(precision).value();
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
let mut prev = (0.0f64, 0.0f64);
let mut zr_prev = big_zero(precision);
let mut zi_prev = big_zero(precision);
// Phoenix distortion constant `p` (a small fixed complex number).
let pr = big_from_f64(phoenix_p.0, precision);
let pi = big_from_f64(phoenix_p.1, precision);
// Lambda distortion constant `l` (a small fixed complex number).
let lr = big_from_f64(lambda_l.0, precision);
let li = big_from_f64(lambda_l.1, precision);
// Complex Multibrot exponent (a fixed complex number).
let cpow_re = big_from_f64(complex_power.0, precision);
let cpow_im = big_from_f64(complex_power.1, precision);
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
for _ in 0..=max_iter {
points.push([zr as f32, zi as f32]);
if zr * zr + zi * zi > REFERENCE_ESCAPE_SQ {
break;
}
let (mut new_zr, mut new_zi) = step_f64(kind, k, zr, zi, prev);
if let Some((from, w)) = morph {
let (br, bi) = step_f64(from, k, zr, zi, prev);
new_zr += w * (br - new_zr);
new_zi += w * (bi - new_zi);
}
prev = (zr, zi);
(zr, zi) = (new_zr, new_zi);
}
points
}
/// `f64` twin of [`step`]: one step `f(Z_n)` of `kind`'s formula.
fn step_f64(
kind: FractalKind,
k: &StepConstsF64,
zr: f64,
zi: f64,
prev: (f64, f64),
) -> (f64, f64) {
let (cr, ci) = k.c;
match kind {
FractalKind::Mandelbrot => ((zr + zi) * (zr - zi) + cr, 2.0 * zr * zi + ci),
FractalKind::BurningShip => (zr * zr - zi * zi + cr, (2.0 * zr * zi).abs() + ci),
FractalKind::Tricorn => (zr * zr - zi * zi + cr, ci - 2.0 * zr * zi),
FractalKind::Multibrot => {
let (mut rr, mut ri) = (1.0f64, 0.0f64);
for _ in 0..k.power.max(2) {
(rr, ri) = (rr * zr - ri * zi, rr * zi + ri * zr);
}
(rr + cr, ri + ci)
}
FractalKind::Celtic => ((zr * zr - zi * zi).abs() + cr, 2.0 * zr * zi + ci),
FractalKind::Perpendicular => (zr * zr - zi * zi + cr, ci - 2.0 * zr * zi.abs()),
FractalKind::Buffalo => ((zr * zr - zi * zi).abs() + cr, ci - (2.0 * zr * zi).abs()),
FractalKind::Phoenix => {
let (pr, pi) = k.p;
let (zr_prev, zi_prev) = prev;
(
zr * zr - zi * zi + cr + (pr * zr_prev - pi * zi_prev),
2.0 * zr * zi + ci + (pr * zi_prev + pi * zr_prev),
)
}
FractalKind::Lambda => {
// λ·z(1 - z) + c.
let (lr, li) = k.l;
let (re2, im2) = (1.0 - zr, -zi);
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
(lzr * re2 - lzi * im2 + cr, re2 * lzi + lzr * im2 + ci)
}
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex_f64(zr, zi, k.cpow.0, k.cpow.1);
(pr + cr, pi + ci)
}
}
}
/// `f64` twin of [`complex_pow_complex`] (principal branch, `0^p = 0`).
fn complex_pow_complex_f64(zr: f64, zi: f64, pr: f64, pi: f64) -> (f64, f64) {
if zr == 0.0 && zi == 0.0 {
return (0.0, 0.0);
}
let ln_r = 0.5 * (zr * zr + zi * zi).ln();
let theta = zi.atan2(zr);
let mag = (pr * ln_r - pi * theta).exp();
let (sin_a, cos_a) = (pr * theta + pi * ln_r).sin_cos();
(mag * cos_a, mag * sin_a)
}
/// Everything a single formula step needs besides the orbit state, converted
/// to `Big` once up front.
struct StepConsts {
cr: Big,
ci: Big,
/// Phoenix distortion constant `p`.
pr: Big,
pi: Big,
/// Lambda distortion constant `l`.
lr: Big,
li: Big,
/// Complex Multibrot exponent.
cpow_re: Big,
cpow_im: Big,
power: u32,
precision: usize,
}
/// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
fn compute_reference_big(
z0_re: &Big,
z0_im: &Big,
max_iter: u32,
kind: FractalKind,
k: &StepConsts,
morph: Option<(FractalKind, f64)>,
) -> RefOrbit {
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);
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 points = RefOrbit::with_capacity(max_iter as usize + 1);
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
for _ in 0..=max_iter {
push_big_point(&mut points, &zr, &zi);
let fr = zr.to_f64().value() as f32;
let fi = zi.to_f64().value() as f32;
points.push([fr, fi]);
let [fr, fi] = *points.points.last().unwrap();
let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64);
if mag > REFERENCE_ESCAPE_SQ {
break;
}
let (mut new_zr, mut new_zi) = step(kind, k, &zr, &zi, &zr_prev, &zi_prev);
if let Some((from, w)) = &morph {
// (1 - w)·a + w·b = a + w·(b - a).
let (br, bi) = step(*from, k, &zr, &zi, &zr_prev, &zi_prev);
new_zr = &new_zr + &(w * &(br - &new_zr));
new_zi = &new_zi + &(w * &(bi - &new_zi));
}
let (new_zr, new_zi) = match kind {
FractalKind::Mandelbrot => {
// Z^2 = (zr^2 - zi^2) + (2 zr zi) i.
let re = &zr.sqr() - &zi.sqr() + &cr;
let im = ((&zr * &zi) << 1) + &ci; // << 1 is exact ×2 in base 2
(re, im)
}
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;
(re, im)
}
FractalKind::Tricorn => {
// conj(z)^2 = (zr^2 - zi^2) - 2 zr zi i.
let re = &zr.sqr() - &zi.sqr() + &cr;
let im = &ci - ((&zr * &zi) << 1);
(re, im)
}
FractalKind::Multibrot => {
let (pr, pi) = complex_pow(&zr, &zi, power.max(2), precision);
(pr + &cr, pi + &ci)
}
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 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 = &ci - ((&zr * &big_abs(zi.clone())) << 1);
(re, im)
}
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);
(re, im)
}
FractalKind::Phoenix => {
// z^2 + c + p·z_{n-1}.
let re2 = &zr.sqr() - &zi.sqr();
let im2 = (&zr * &zi) << 1;
let pzr = &pr * &zr_prev - &pi * &zi_prev;
let pzi = &pr * &zi_prev + &pi * &zr_prev;
(re2 + &cr + pzr, im2 + &ci + pzi)
}
FractalKind::Lambda => {
// λ·z(1 - z): logistic map.
let re2 = 1 - &zr;
let im2 = -&zi;
let lzr = &lr * &zr - &li * &zi;
let lzi = &lr * &zi + &li * &zr;
(&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2)
}
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex(&zr, &zi, &cpow_re, &cpow_im, precision);
(pr + &cr, pi + &ci)
}
};
// Shift the previous iterate (only the Phoenix arm reads it).
zr_prev = zr;
zi_prev = zi;
zr = new_zr.with_precision(precision);
zi = new_zi.with_precision(precision);
zr = new_zr.with_precision(precision).value();
zi = new_zi.with_precision(precision).value();
}
points
}
/// One step `f(Z_n)` of `kind`'s formula (including its `+ c`), given the
/// current and previous iterate.
fn step(
kind: FractalKind,
k: &StepConsts,
zr: &Big,
zi: &Big,
zr_prev: &Big,
zi_prev: &Big,
) -> (Big, Big) {
let (cr, ci) = (&k.cr, &k.ci);
match kind {
FractalKind::Mandelbrot => {
// Z^2 = (zr^2 - zi^2) + (2 zr zi) i, with zr^2 - zi^2 as
// (zr + zi)(zr - zi): one multiply instead of two squares.
let re = (zr + zi) * (zr - zi) + cr;
let im = ((zr * zi) << 1) + ci; // << 1 is exact ×2 in base 2
(re, im)
}
FractalKind::BurningShip => {
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = ((zr * zi) << 1).abs() + ci;
(re, im)
}
FractalKind::Tricorn => {
// conj(z)^2 = (zr^2 - zi^2) - 2 zr zi i.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = ci - ((zr * zi) << 1);
(re, im)
}
FractalKind::Multibrot => {
let (pr, pi) = complex_pow(zr, zi, k.power.max(2), k.precision);
(pr + cr, pi + ci)
}
FractalKind::Celtic => {
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
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.is_negative() {
ci + ((zr * zi) << 1)
} else {
ci - ((zr * zi) << 1)
};
(re, im)
}
FractalKind::Buffalo => {
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ci - ((zr * zi) << 1).abs();
(re, im)
}
FractalKind::Phoenix => {
// z^2 + c + p·z_{n-1}.
let re2 = &zr.sqr() - &zi.sqr();
let im2 = (zr * zi) << 1;
let pzr = &k.pr * zr_prev - &k.pi * zi_prev;
let pzi = &k.pr * zi_prev + &k.pi * zr_prev;
(re2 + cr + pzr, im2 + ci + pzi)
}
FractalKind::Lambda => {
// λ·z(1 - z) + c: logistic map plus the usual additive `c`.
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;
let re = &lzr * &re2 - &lzi * &im2;
let im = re2 * lzi + lzr * im2;
(re + cr, im + ci)
}
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex(zr, zi, &k.cpow_re, &k.cpow_im, k.precision);
(pr + cr, pi + ci)
}
}
fn big_zero(precision: usize) -> Big {
Big::from(0i32).with_precision(precision).value()
}
/// Absolute value of a `Big`. The sign check via f64 is exact except for values
/// so tiny that |x| ≈ x either way — negligible against the f32 orbit storage.
fn big_abs(x: Big) -> Big {
if x.to_f64().value() < 0.0 { -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_f64(1.0, precision);
let mut ri = Big::zero(precision);
let mut rr = Big::from(1i32).with_precision(precision).value();
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);
let ni = (&rr * zi + &ri * zr).with_precision(precision);
let nr = (&rr * zr - &ri * zi).with_precision(precision).value();
let ni = (&rr * zi + &ri * zr).with_precision(precision).value();
rr = nr;
ri = ni;
}
(rr, ri)
}
/// `true` if `x` is (numerically) zero. The f64 check is exact for a true
/// zero; only matters here to special-case `ln(0)`.
fn is_big_zero(x: &Big) -> bool {
x.to_f64().value() == 0.0
}
/// `(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`.
@@ -428,14 +175,14 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
/// 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 zr.is_zero() && zi.is_zero() {
return (Big::zero(precision), Big::zero(precision));
if is_big_zero(zr) && is_big_zero(zi) {
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);
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision);
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 mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a)
@@ -454,9 +201,8 @@ pub fn compute_set_reference(
phoenix_p: (f64, f64),
lambda_l: (f64, f64),
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
) -> RefOrbit {
let zero = Big::zero(precision);
) -> Vec<[f32; 2]> {
let zero = big_zero(precision);
compute_reference(
&zero,
&zero,
@@ -469,7 +215,6 @@ pub fn compute_set_reference(
phoenix_p,
lambda_l,
complex_power,
morph,
)
}
@@ -477,23 +222,12 @@ pub fn compute_set_reference(
mod tests {
use super::*;
/// Sign and zero tests must hold far below f64's range, where
/// `to_f64` reads as ±0.
#[test]
fn sign_and_zero_below_f64_range() {
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::from_f64(-0.75, 53);
let ci = Big::from_f64(0.1, 53);
let cr = Big::try_from(-0.75_f64).unwrap();
let ci = Big::try_from(0.1_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -504,7 +238,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
// Independent naive f64 orbit.
@@ -530,104 +263,11 @@ mod tests {
}
}
/// The f64 fast path (shallow views) must produce the same orbit as the
/// arbitrary-precision path, for every kind, in both planes, with and
/// without a kind-switch morph.
#[test]
fn f64_fast_path_matches_big() {
let bits_fast = F64_MAX_PRECISION;
let bits_big = F64_MAX_PRECISION + 64;
let cases = FractalKind::ALL
.into_iter()
.map(|kind| (kind, 3))
.chain([(FractalKind::Multibrot, 20)]); // highest supported power
for (kind, power) in cases {
for julia in [false, true] {
for morph in [None, Some((FractalKind::Phoenix, 0.3))] {
let run = |bits: usize| {
let (a, b) = (big_from_f64(-0.3, bits), big_from_f64(0.2, bits));
let (jr, ji) = (big_from_f64(-0.4, bits), big_from_f64(0.55, bits));
let args = (60, bits, kind, power, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
if julia {
compute_reference(
&a, &b, &jr, &ji, args.0, args.1, args.2, args.3, args.4, args.5,
args.6, morph,
)
} else {
compute_set_reference(
&a, &b, args.0, args.1, args.2, args.3, args.4, args.5, args.6,
morph,
)
}
};
let ctx = format!("{kind:?} power={power} julia={julia} morph={morph:?}");
let (fast, big) = (run(bits_fast), run(bits_big));
assert_eq!(fast.len(), big.len(), "{ctx}: length");
for (i, (f, b)) in fast.iter().zip(&big).enumerate() {
for k in 0..2 {
let tol = 1e-5 * (1.0 + b[k].abs());
assert!(
(f[k] - b[k]).abs() <= tol,
"{ctx}: point {i} {f:?} vs {b:?}"
);
}
}
}
}
}
}
/// Orbit points below f32's range are stored as a normalized mantissa
/// plus exponent (for the deep GPU phase); every other point stays a
/// plain f32 with exponent 0.
#[test]
fn tiny_points_are_stored_normalized() {
let bits = 400;
// c = -1 + δ: X_2 = c(c + 1) = -δ + δ², far below f32's range.
let delta = 1e-45_f64;
let cr = big_from_f64(-1.0, bits) + big_from_f64(delta, bits);
let ci = big_from_f64(0.0, bits);
let orbit = compute_set_reference(
&cr,
&ci,
3,
bits,
FractalKind::Mandelbrot,
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
assert_eq!(orbit.exps.len(), orbit.points.len());
assert!(orbit.has_scaled());
assert_eq!(&orbit.exps[..2], &[0, 0], "X_0 = 0 and X_1 = c are plain");
let [mr, mi] = orbit.points[2];
let k = orbit.exps[2];
assert!(k < TINY_LOG2, "exponent {k}");
assert!(
(0.5..1.0).contains(&mr.abs()),
"mantissa {mr} not normalized"
);
assert_eq!(mi, 0.0);
let x2 = mr as f64 * 2f64.powi(k);
assert!(
(x2 + delta).abs() < 1e-6 * delta,
"X_2 = {x2}, expected {}",
-delta
);
// A shallow orbit stays entirely plain.
let plain = set_ref(-0.75, 0.1, FractalKind::Mandelbrot, None);
assert!(!plain.has_scaled());
assert!(plain.exps.iter().all(|&e| e == 0));
}
/// A point inside the main cardioid never escapes: full-length orbit.
#[test]
fn interior_orbit_runs_full_length() {
let cr = Big::from_f64(-0.2, 53);
let ci = Big::from_f64(0.0, 53);
let cr = Big::try_from(-0.2_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -638,7 +278,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
assert_eq!(points.len(), 501, "interior orbit should not escape");
}
@@ -646,8 +285,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::from_f64(-1.75, 53);
let ci = Big::from_f64(-0.03, 53);
let cr = Big::try_from(-1.75_f64).unwrap();
let ci = Big::try_from(-0.03_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -658,7 +297,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (-1.75_f64, -0.03_f64);
@@ -677,8 +315,8 @@ mod tests {
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
#[test]
fn multibrot3_reference_matches_naive_f64() {
let cr = Big::from_f64(0.3, 53);
let ci = Big::from_f64(0.2, 53);
let cr = Big::try_from(0.3_f64).unwrap();
let ci = Big::try_from(0.2_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -689,7 +327,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (0.3_f64, 0.2_f64);
@@ -710,10 +347,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::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 z0_re = Big::try_from(0.15_f64).unwrap();
let z0_im = Big::try_from(-0.1_f64).unwrap();
let c_re = Big::try_from(-0.8_f64).unwrap();
let c_im = Big::try_from(0.156_f64).unwrap();
let points = compute_reference(
&z0_re,
&z0_im,
@@ -726,7 +363,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
@@ -742,43 +378,11 @@ mod tests {
}
}
/// Lambda Julia orbit adds the Julia `c`: `z -> λ·z(1 - z) + c`.
#[test]
fn lambda_julia_reference_matches_naive_f64() {
let (lr, li) = (-0.5_f64, 0.2_f64);
let (cr, ci) = (0.1_f64, -0.3_f64);
let points = compute_reference(
&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,
2,
(0.0, 0.0),
(lr, li),
(0.0, 0.0),
None,
);
let (mut zr, mut zi) = (0.2_f64, 0.1_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "{point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "{point:?} vs {zi}");
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
let (ar, ai) = (1.0 - zr, -zi);
zr = lzr * ar - lzi * ai + cr;
zi = lzr * ai + lzi * ar + ci;
}
}
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
#[test]
fn celtic_reference_matches_naive_f64() {
let cr = Big::from_f64(-0.6, 53);
let ci = Big::from_f64(0.4, 53);
let cr = Big::try_from(-0.6_f64).unwrap();
let ci = Big::try_from(0.4_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -789,7 +393,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (-0.6_f64, 0.4_f64);
@@ -809,8 +412,8 @@ mod tests {
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
#[test]
fn perpendicular_reference_matches_naive_f64() {
let cr = Big::from_f64(-0.7, 53);
let ci = Big::from_f64(-0.2, 53);
let cr = Big::try_from(-0.7_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -821,7 +424,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (-0.7_f64, -0.2_f64);
@@ -841,8 +443,8 @@ mod tests {
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
#[test]
fn buffalo_reference_matches_naive_f64() {
let cr = Big::from_f64(-1.2, 53);
let ci = Big::from_f64(-0.35, 53);
let cr = Big::try_from(-1.2_f64).unwrap();
let ci = Big::try_from(-0.35_f64).unwrap();
let points = compute_set_reference(
&cr,
&ci,
@@ -853,7 +455,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (-1.2_f64, -0.35_f64);
@@ -873,8 +474,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::from_f64(0.5667, 53);
let ci = Big::from_f64(0.0, 53);
let cr = Big::try_from(0.5667_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let p = (-0.5_f64, 0.0_f64);
let points = compute_set_reference(
&cr,
@@ -886,7 +487,6 @@ mod tests {
p,
(0.0, 0.0),
(0.0, 0.0),
None,
);
let (c_re, c_im) = (0.5667_f64, 0.0_f64);
@@ -912,8 +512,8 @@ mod tests {
/// iteration of `z^p = exp(p·ln z)`.
#[test]
fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::from_f64(0.1, 53);
let ci = Big::from_f64(-0.2, 53);
let cr = Big::try_from(0.1_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference(
&cr,
@@ -925,7 +525,6 @@ mod tests {
(0.0, 0.0),
(0.0, 0.0),
power,
None,
);
// Naive f64 complex power via z^p = exp(p * ln z), ln z = ln|z| + i*arg(z).
@@ -954,67 +553,4 @@ mod tests {
zi = nzi;
}
}
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
compute_set_reference(
&Big::from_f64(cr, 53),
&Big::from_f64(ci, 53),
60,
200,
kind,
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
morph,
)
}
/// Morph weight 0 is the plain kind; weight 1 is entirely the from-kind.
#[test]
fn morph_endpoints_match_plain_kinds() {
let (cr, ci) = (-1.75, -0.03);
let ship = set_ref(cr, ci, FractalKind::BurningShip, None);
let mandel = set_ref(cr, ci, FractalKind::Mandelbrot, None);
let w0 = set_ref(
cr,
ci,
FractalKind::BurningShip,
Some((FractalKind::Mandelbrot, 0.0)),
);
let w1 = set_ref(
cr,
ci,
FractalKind::BurningShip,
Some((FractalKind::Mandelbrot, 1.0)),
);
assert_eq!(w0, ship);
assert_eq!(w1, mandel);
}
/// A half-way Mandelbrot / Burning Ship morph matches a naive f64
/// iteration of the per-step blend.
#[test]
fn morph_blend_matches_naive_f64() {
let (c_re, c_im) = (-0.6_f64, 0.3_f64);
let w = 0.5_f64;
let points = set_ref(
c_re,
c_im,
FractalKind::Mandelbrot,
Some((FractalKind::BurningShip, w)),
);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
let re = zr * zr - zi * zi + c_re; // identical for both kinds
let im_m = 2.0 * zr * zi + c_im;
let im_b = 2.0 * (zr * zi).abs() + c_im;
zr = re;
zi = (1.0 - w) * im_m + w * im_b;
}
}
}
+241 -1295
View File
File diff suppressed because it is too large Load Diff
+4 -15
View File
@@ -2,14 +2,13 @@
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
//! locations can be shared or bookmarked.
//!
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
//! strings.
use std::collections::HashMap;
use crate::fractal::FractalKind;
use crate::view::Scale;
#[derive(Clone, Debug)]
pub struct ShareState {
@@ -19,7 +18,7 @@ pub struct ShareState {
pub power: u32,
pub center_re: String,
pub center_im: String,
pub half_height: Scale,
pub half_height: f64,
pub iterations: u32,
pub julia_c: (f64, f64),
/// Distortion constant for the Phoenix kind (ignored by others).
@@ -116,7 +115,7 @@ mod tests {
power: 5,
center_re: "-0.743643887037158704752191506114774".into(),
center_im: "0.131825904205311970493132056385139".into(),
half_height: Scale::from_f64(1.5e-20),
half_height: 1.5e-20,
iterations: 4000,
julia_c: (-0.123, 0.745),
phoenix_p: (-0.5, 0.1),
@@ -147,15 +146,5 @@ mod tests {
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
assert!(!d.julia);
assert_eq!(d.iterations, 256);
assert_eq!(d.half_height, Scale::from_f64(1.25));
}
/// Zooms past f64's range survive a round trip.
#[test]
fn round_trip_past_f64_range() {
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.5e-1234&it=256").unwrap();
assert_eq!(d.half_height, "1.5e-1234".parse().unwrap());
let d2 = ShareState::decode(&d.encode()).unwrap();
assert_eq!(d2.half_height, d.half_height);
}
}
+15 -519
View File
@@ -3,44 +3,17 @@
// 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. `--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).
// button uses.
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::app::{FractalApp, unix_timestamp};
use crate::cli::Cli;
use crate::fractal::{
ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png,
export_to_png_blocking, render_readback_blocking, unpad_rgba,
};
use crate::view::{
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
precision_for,
};
use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking};
/// Cap on the output image dimension (px), to stay within GPU texture limits.
const MAX_DIM: u32 = 8192 * 16;
/// `--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());
@@ -48,28 +21,13 @@ pub fn run(cli: Cli) -> Result<(), String> {
let width = cli.width.clamp(16, MAX_DIM);
let height = cli.height.clamp(16, MAX_DIM);
// These drive the animation path below; grab them before `apply_cli`
// 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 export_path = cli
.export_path
.clone()
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
let mut app = FractalApp::default_state();
app.apply_cli(cli);
app.set_output_size(width, height);
if targets.any() {
return run_animation(app, targets, width, height, export_path);
}
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
eprintln!("computing reference orbit…");
app.compute_reference_blocking();
@@ -77,13 +35,15 @@ pub fn run(cli: Cli) -> Result<(), String> {
let (device, queue) = pollster::block_on(request_device())?;
let format = wgpu::TextureFormat::Bgra8Unorm;
let renderer = FractalRenderer::new(&device, format);
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
let handles = renderer.export_handles(&device, &uniforms);
let (pipeline, bind_group_layout, format) = renderer.export_handles();
let uniforms = app.make_uniforms(width as f64 / height as f64);
let er = ExportRender::new(
&device,
&queue,
&handles,
pipeline,
&bind_group_layout,
format,
width,
height,
uniforms,
@@ -97,448 +57,11 @@ pub fn run(cli: Cli) -> Result<(), String> {
});
eprintln!();
if export_path == STDOUT_PATH {
let mut out = std::io::stdout().lock();
out.write_all(&png)
.and_then(|()| out.flush())
.map_err(|e| format!("writing to stdout failed: {e}"))?;
eprintln!("wrote PNG to stdout ({width}×{height})");
} else {
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
println!("saved {export_path} ({width}×{height})");
}
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
println!("saved {export_path} ({width}×{height})");
Ok(())
}
/// The `--to-*` end state of a headless animation, plus its pacing. Each
/// target is optional; anything left unset stays at its start value.
struct AnimTargets {
to_view: Option<String>,
to_share: Option<String>,
to_iterations: Option<u32>,
to_julia: Option<(f64, f64)>,
to_phoenix_p: Option<(f64, f64)>,
to_lambda_l: Option<(f64, f64)>,
/// Complex Multibrot exponent, per component (either may move alone).
to_cpow_re: Option<f64>,
to_cpow_im: Option<f64>,
to_kind: Option<FractalKind>,
/// 3D camera, degrees.
to_yaw: Option<f32>,
to_pitch: Option<f32>,
frames: Option<u32>,
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 {
fn from_cli(cli: &Cli) -> Result<Self, String> {
let pair = |flag: &str, v: &Option<String>| -> Result<Option<(f64, f64)>, String> {
v.as_deref()
.map(|s| parse_complex_pair(s).ok_or_else(|| format!("invalid --{flag}: {s}")))
.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(),
to_iterations: cli.to_iterations,
to_julia: pair("to-julia", &cli.to_julia)?,
to_phoenix_p: pair("to-phoenix-p", &cli.to_phoenix_p)?,
to_lambda_l: pair("to-lambda-l", &cli.to_lambda_l)?,
to_cpow_re: cli.to_complex_power_re.or(to_cpow.map(|p| p.0)),
to_cpow_im: cli.to_complex_power_im.or(to_cpow.map(|p| p.1)),
to_kind: cli.to_kind.map(Into::into),
to_yaw: cli.to_yaw,
to_pitch: cli.to_pitch,
frames: cli.frames,
fps: cli.fps,
duration: cli.duration,
linear: cli.linear,
shard,
})
}
/// Whether any end state was given, i.e. this is an animation.
fn any(&self) -> bool {
self.to_view.is_some()
|| self.to_share.is_some()
|| self.to_iterations.is_some()
|| self.to_julia.is_some()
|| self.to_phoenix_p.is_some()
|| self.to_lambda_l.is_some()
|| self.to_cpow_re.is_some()
|| self.to_cpow_im.is_some()
|| self.to_kind.is_some()
|| self.to_yaw.is_some()
|| self.to_pitch.is_some()
}
}
/// Render a sequence of frames interpolating from the app's current (start)
/// 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. 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,
width: u32,
height: u32,
export_path: Option<String>,
) -> Result<(), String> {
let fps = targets.fps;
let frames = match targets.frames {
Some(n) => n,
None => {
let dur = targets
.duration
.ok_or("animation needs --frames, or --duration (with --fps)")?;
((fps * dur).round() as u32).max(2)
}
};
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) =
parse_animation_target(targets.to_view.as_deref(), targets.to_share.as_deref())?
.unwrap_or_else(|| (from.clone(), None));
let to_iterations = targets.to_iterations.or(to_iterations_share);
let from_iterations = app.max_iterations();
if to_iterations.is_none() {
// Iteration count auto-scales with zoom depth per frame, the same way it
// does while zooming interactively — no need to interpolate it by hand.
app.set_auto_iterations(true);
}
let from_consts = app.constants();
let [c0, p0, l0, cp0] = from_consts;
let to_consts = [
targets.to_julia.unwrap_or(c0),
targets.to_phoenix_p.unwrap_or(p0),
targets.to_lambda_l.unwrap_or(l0),
(
targets.to_cpow_re.unwrap_or(cp0.0),
targets.to_cpow_im.unwrap_or(cp0.1),
),
];
let from_kind = app.kind();
let to_kind = targets.to_kind.unwrap_or(from_kind);
let (yaw0, pitch0) = app.camera_angles();
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()));
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.
let apply_frame = |app: &mut FractalApp, i: u32| {
let raw_t = i as f64 / (frames - 1) as f64;
let t = if targets.linear {
raw_t
} else {
smoothstep(raw_t)
};
if let Some(to) = to_iterations {
app.set_max_iterations(
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
);
}
app.set_view(interpolate_view(&from, &to, t));
app.set_constants(std::array::from_fn(|k| {
(
interpolate_f64(from_consts[k].0, to_consts[k].0, t),
interpolate_f64(from_consts[k].1, to_consts[k].1, t),
)
}));
app.set_kind_morph(from_kind, to_kind, t);
app.set_camera_angles(
interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32,
interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32,
);
};
// Snapshot every frame's reference-orbit job up front (cheap: just the
// parameters), so the orbits themselves can be computed in parallel.
// `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()
})
.collect();
let (device, queue) = pollster::block_on(request_device())?;
let format = wgpu::TextureFormat::Bgra8Unorm;
let renderer = FractalRenderer::new(&device, format);
let aspect = width as f64 / height as f64;
// Three-stage pipeline, connected by bounded channels (which also cap
// memory): `threads` workers compute reference orbits (CPU, the expensive
// 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);
let error: Mutex<Option<String>> = Mutex::new(None);
let fail = |e: String| {
failed.store(true, Ordering::Relaxed);
error.lock().unwrap().get_or_insert(e);
};
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, 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;
}
}
});
}
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 || {
loop {
// Hold the lock only for the receive, not the encode.
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) {
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", first as usize + i + 1);
if let Err(e) = std::fs::write(&path, &png) {
fail(format!("save failed: {e}"));
continue;
}
let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
eprint!("\r[{done:>4}/{count}] saved");
}
});
}
// GPU stage, on this thread (it owns the app and the device). The
// shader specialization (kind, Julia, DE, morph) can change between
// frames during a kind morph; build each pipeline once.
let mut pipelines = HashMap::new();
for (i, points) in ref_rx.iter() {
if failed.load(Ordering::Relaxed) {
break;
}
apply_frame(&mut app, first + i as u32);
app.finish_reference(jobs[i].clone(), points);
let uniforms = app.make_uniforms(aspect, height as f64);
let handles = pipelines
.entry(PipelineKey::from_uniforms(&uniforms))
.or_insert_with(|| renderer.export_handles(&device, &uniforms));
let er = ExportRender::new(
&device,
&queue,
handles,
width,
height,
uniforms,
app.reference_points(),
app.lights(),
);
let padded = render_readback_blocking(&device, &queue, &er);
if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() {
break;
}
}
// Dropping the channel ends lets the workers drain and exit.
drop(raw_tx);
drop(png_tx);
drop(ref_rx);
});
eprintln!();
if let Some(e) = error.into_inner().unwrap() {
return Err(e);
}
let saved = saved.into_inner();
if saved != count {
return Err(format!("only {saved} of {count} frames were rendered"));
}
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(())
}
/// Parse `--to-view`/`--to-share` (at most one is used) into the end view
/// of an animation, or `None` if neither is set (the camera stays put). Only position/zoom/iterations are pulled from a
/// share fragment — the rest of its state (kind, colors, ...) is ignored, so
/// pasting a link from the app doesn't unexpectedly change the fractal kind
/// mid-animation.
fn parse_animation_target(
to_view: Option<&str>,
to_share: Option<&str>,
) -> Result<Option<(ViewState, Option<u32>)>, String> {
if let Some(spec) = to_view {
return parse_view_spec(spec)
.map(Some)
.ok_or_else(|| format!("invalid --to-view spec: {spec}"));
}
let Some(frag) = to_share else {
return Ok(None);
};
let state =
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
let bits = precision_for(state.half_height);
let re =
big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
let im =
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?;
Ok(Some((
ViewState::with_center(re, im, state.half_height),
Some(state.iterations),
)))
}
/// 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)
}
/// Set up a wgpu device with no surface/window attached, matching the limits
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
@@ -558,30 +81,3 @@ 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);
}
}
}
}
+2 -44
View File
@@ -1,9 +1,7 @@
use std::f32::consts::PI;
use bytemuck::{Pod, Zeroable};
use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::Ui;
use egui::{Color32, Ui};
/// Maximum number of simultaneous lights.
pub const MAX_LIGHT_COUNT: usize = 16;
@@ -29,14 +27,8 @@ 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| {
s.parse::<u32>()
.ok()
.map(|x| x as f64 * std::f64::consts::PI / 180.)
};
let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32));
ui.horizontal(|ui| {
let del = ui.button("-").clicked();
ui.label("color:");
@@ -46,7 +38,6 @@ impl Light {
egui::DragValue::new(&mut self.azimuth)
.range(0.0..=PI * 2.)
.custom_formatter(formater)
.custom_parser(parser)
.speed(0.02),
);
ui.label("φ:");
@@ -54,7 +45,6 @@ impl Light {
egui::DragValue::new(&mut self.altitude)
.range(0.0..=PI / 2.)
.custom_formatter(formater)
.custom_parser(parser)
.speed(0.02),
);
@@ -63,35 +53,3 @@ impl Light {
.inner
}
}
/// GPU-side light, matching WGSL `Light` in `iterate_uniforms.wgsl`: the unit
/// direction toward the light (precomputed from azimuth/altitude so the
/// shader does no per-pixel trig) plus the packed RGBA colour, whose alpha is
/// the intensity. 16 bytes, so `array<Light, 16>` has a uniform-legal stride.
#[derive(Clone, Copy, PartialEq, Zeroable, Pod, Default)]
#[repr(C)]
pub struct GpuLight {
pub dir: [f32; 3],
pub color: Color32,
}
/// The light buffer's contents: the UI lights with a non-zero colour (the
/// only ones that contribute, and the ones the filmic white point counts),
/// packed to the front, plus how many there are (`Uniforms::light_count`).
pub fn gpu_lights(lights: &[Light]) -> ([GpuLight; MAX_LIGHT_COUNT], u32) {
let mut out = [GpuLight::default(); MAX_LIGHT_COUNT];
let mut n = 0;
for l in lights.iter().filter(|l| l.color != Color32::TRANSPARENT) {
if n == MAX_LIGHT_COUNT {
break;
}
let (sa, ca) = l.altitude.sin_cos();
let (sz, cz) = l.azimuth.sin_cos();
out[n] = GpuLight {
dir: [cz * ca, sz * ca, sa],
color: l.color,
};
n += 1;
}
(out, n as u32)
}
+4 -24
View File
@@ -1,8 +1,6 @@
// 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.
//
@@ -11,8 +9,6 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app;
mod bignum;
mod camera;
mod fractal;
mod lights;
mod view;
@@ -24,17 +20,8 @@ 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
@@ -42,7 +29,6 @@ type MainResult = Result<(), String>;
/// * 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};
@@ -64,7 +50,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> MainResult {
fn main() -> eframe::Result {
use clap::Parser as _;
env_logger::builder()
@@ -83,13 +69,6 @@ fn main() -> MainResult {
};
}
#[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(),
@@ -100,7 +79,6 @@ fn main() -> MainResult {
..Default::default()
};
#[cfg(feature = "gui")]
eframe::run_native(
"Fractal Explorer",
native_options,
@@ -144,7 +122,9 @@ fn main() {
match result {
Ok(_) => loading.remove(),
Err(e) => {
loading.set_inner_html(&format!("<p>The app has crashed.</br>{e:?}</p>"));
loading.set_inner_html(
"<p>The app has crashed. See the developer console for details.</p>",
);
log::error!("failed to start eframe: {e:?}");
}
}
+11 -17
View File
@@ -58,12 +58,6 @@ struct Uniforms {
complex_power: vec2<f32>,
};
// Fractal kind, as a pipeline-overridable constant (set per compute pipeline
// from `u.kind`, see `BuddhabrotRenderer::compute_pipeline`): every kind
// branch in the iteration loop folds away at pipeline creation. Read this,
// never `u.kind`.
override KIND: u32 = 0u;
const PALETTE_NEBULA: u32 = 0u;
const PALETTE_YELLOW: u32 = 1u;
const PALETTE_GRAYSCALE: u32 = 2u;
@@ -101,25 +95,25 @@ fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
// of the same formulas).
fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
if KIND == KIND_BURNING_SHIP {
if u.kind == KIND_BURNING_SHIP {
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
} else if KIND == KIND_TRICORN {
} else if u.kind == KIND_TRICORN {
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
} else if KIND == KIND_MULTIBROT {
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
} else if KIND == KIND_CELTIC {
} else if u.kind == KIND_MULTIBROT {
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
} else if u.kind == KIND_CELTIC {
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
} else if KIND == KIND_PERPENDICULAR {
} else if u.kind == KIND_PERPENDICULAR {
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
} else if KIND == KIND_BUFFALO {
} else if u.kind == KIND_BUFFALO {
return vec2<f32>(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c;
} else if KIND == KIND_PHOENIX {
} else if u.kind == KIND_PHOENIX {
let sq = vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
return sq + c + cmul(u.phoenix_p, zp);
} else if KIND == KIND_LAMBDA {
} else if u.kind == KIND_LAMBDA {
// l * z * (1 - z); c is unused (see file doc comment above).
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
} else if KIND == KIND_COMPLEX_MULTIBROT {
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return cpow(z, u.complex_power) + c;
}
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
@@ -182,7 +176,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
var c = sample;
var z0 = vec2<f32>(0.0, 0.0);
if KIND == KIND_LAMBDA {
if u.kind == KIND_LAMBDA {
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
z0 = sample;
}
+12 -161
View File
@@ -19,41 +19,20 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
}
fn shadow_fragment(pos: vec2<f32>) -> vec4<f32> {
let x = i32(pos.x);
let y = i32(pos.y);
let size = textureDimensions(data_tex);
let here = textureLoad(data_tex, vec2<i32>(x, y), 0);
if here.b != 0. {
return vec4<f32>(shadow_interior_color(), 1.0);
}
// Forward differences, except on the last column/row where x+1 / y+1
// is off the texture: fall back to a backward difference, mirrored
// (h0 + (h0 - h[-1])) so the slope keeps the sign normal_from_heights
// expects — plugging h[-1] in directly would flip the normal there.
let h0 = here.g;
var h1: f32;
if x + 1 < i32(size.x) {
h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
} else {
h1 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x - 1, y), 0).g;
}
var h2: f32;
if y + 1 < i32(size.y) {
h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
} else {
h2 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x, y - 1), 0).g;
}
let normal = normal_from_heights(h0, h1, h2);
return vec4<f32>(shadow_color(normal, here.r), 1.0);
}
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if u.shadow == 2u {
return ray_marching(pos);
} else if u.shadow == 1u {
return shadow_fragment(pos.xy);
if u.shadow != 0u {
let x = i32(pos.x);
let y = i32(pos.y);
if textureLoad(data_tex, vec2<i32>(x, y), 0).b != 0. {
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
} else {
let h0 = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
let h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
let h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
let normal = normal_from_heights(h0, h1, h2);
return vec4<f32>(shadow_color(normal), 1.0);
}
} else {
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
let ci = d.r;
@@ -67,131 +46,3 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(col, 1.0);
}
}
// Colour of rays that miss the fractal's footprint.
const RAY_MISS: vec4<f32> = vec4<f32>(1.0, 0.0, 0.0, 1.0);
// Per-frame constants of the raymarch, computed once per pixel in
// `ray_marching` rather than on each of the up-to-100 `sdf` steps.
struct MarchConsts {
size: vec2<f32>,
// (size.x / aspect_ratio, size.y): world xy -> texel scale.
to_texel: vec2<f32>,
size_i: vec2<i32>,
inv_size_y: f32,
};
fn sdf(pos: vec3<f32>, k: MarchConsts) -> f32 {
let texture_pos_f32 = pos.xy * k.to_texel;
let texture_pos = clamp(vec2<i32>(texture_pos_f32), vec2<i32>(0, 0), k.size_i - vec2<i32>(1, 1));
let to_texture = max(-min(texture_pos_f32, vec2(0.)), max(texture_pos_f32 - k.size, vec2(0.)));
let dist_to_texture = length(to_texture) * k.inv_size_y;
let px = textureLoad(data_tex, texture_pos, 0);
let de = (px.g * k.inv_size_y) * 0.5;
// Height is measured toward -z, the side the camera sits on (it looks
// along +z), so the terrain is solid on +z: interior plateau at z = 0,
// exterior sloping away from the camera as `de` grows.
let signed_z = -pos.z;
let z = max(signed_z, 0.);
var d: f32;
if px.b != 0. {
d = z;
} else {
d = min(sqrt(z * z + de * de), signed_z + 1. - exp(-de * 5.));
}
// Outside the texture footprint, `d` is the distance from the clamped
// point q on the footprint's edge. The terrain lies over the (convex)
// footprint, so |p - x|² ≥ |q - x|² + |p - q|² for every terrain point x:
// combine in quadrature (not by adding, which overshoots). p can't be in
// the solid out here, so a negative `d` counts as 0.
if dist_to_texture > 0. {
let d_pos = max(d, 0.);
return sqrt(d_pos * d_pos + dist_to_texture * dist_to_texture);
}
return d;
}
fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
let size_i = vec2<i32>(textureDimensions(data_tex));
let size = vec2<f32>(size_i);
let aspect_ratio = u.screen_dim.x / u.screen_dim.y;
let k = MarchConsts(size, vec2<f32>(size.x / aspect_ratio, size.y), size_i, 1.0 / size.y);
let in_texture = vec2<f32>(
(pos.x / size.x) * 2. - 1.,
(pos.y / size.y) * 2. - 1.,
);
var world_pos = u.camera_inv_proj * vec4<f32>(in_texture, 0., 1.0);
let ray_origin = world_pos.xyz;
let ray_dir = u.camera_direction;
// Start where the ray crosses z = 0, the topmost possible surface (the
// camera pitch is clamped short of ±90°, so ray_dir.z > 0).
let start = ray_origin - ray_dir * (ray_origin.z / ray_dir.z);
// The terrain only exists over the footprint x in [0, aspect],
// y in [0, 1]: clip the ray's xy to it up front, so rays that miss it cost
// nothing and the rest start marching at its edge. A huge finite 1/d on
// an axis the ray doesn't move along (top-down, during the 2D <-> 3D
// transition) keeps the slab maths finite.
let inv = select(1.0 / ray_dir.xy, vec2<f32>(1e30), abs(ray_dir.xy) < vec2<f32>(1e-20));
let ta = -start.xy * inv;
let tb = (vec2<f32>(aspect_ratio, 1.0) - start.xy) * inv;
let t_leave = min(max(ta.x, tb.x), max(ta.y, tb.y));
var t = max(max(min(ta.x, tb.x), min(ta.y, tb.y)), 0.0);
if t >= t_leave {
return RAY_MISS;
}
// About 1/50 of a texel at typical sizes: tighter only adds steps
// without visibly moving the hit.
let dist_threshold = 0.00001;
// Rays grazing the exponential slope see a tiny `dist` for many steps in
// a row and would crawl along it until the step budget runs out. Force a
// step of at least half a texel (the height field is nearest-sampled, so
// nothing finer exists), and bisect back if that lands inside the solid.
let min_step = 0.5 * k.inv_size_y;
var hit = false;
var t_prev = t;
for (var i = 0u; i < 100u; i++) {
let dist = sdf(start + t * ray_dir, k);
if dist < dist_threshold {
hit = true;
if dist < 0. {
// Overshot: t_prev is outside, t inside. Refine the crossing.
var lo = t_prev;
var hi = t;
for (var j = 0u; j < 8u; j++) {
let mid = 0.5 * (lo + hi);
if sdf(start + mid * ray_dir, k) < dist_threshold {
hi = mid;
} else {
lo = mid;
}
}
t = hi;
}
break;
}
t_prev = t;
t += max(dist, min_step);
// Past the footprint's far edge: nothing left to hit.
if t >= t_leave {
break;
}
}
// Out of steps while still over the footprint: the ray is skimming the
// surface, so shade where it got to rather than reporting a miss.
if !hit && t < t_leave {
hit = true;
}
// Shade outside the loop, so its registers don't weigh on the march.
if !hit {
return RAY_MISS;
}
return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel);
}
-4
View File
@@ -43,10 +43,6 @@ const KIND_MANDELBROT: u32 = 0u;
const KIND_BURNING_SHIP: u32 = 1u;
const KIND_TRICORN: u32 = 2u;
const KIND_MULTIBROT: u32 = 3u;
// Highest Multibrot power (the UI/CLI/share-link clamp in app.rs matches).
// `bailout_sq` in app.rs shrinks the bailout with the power so z^p stays a
// finite f32.
const MULTIBROT_MAX_POWER: u32 = 20u;
const KIND_CELTIC: u32 = 4u;
const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
+24 -60
View File
@@ -19,8 +19,6 @@ struct Uniforms {
kind: u32,
// Exponent for the Multibrot kind.
power: u32,
// Kind-switch morph: the kind blended *from* (see morph_w).
morph_from: u32,
dc_offset: vec2<f32>,
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
@@ -33,29 +31,8 @@ struct Uniforms {
complex_power: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32,
// 0 = classic colors, 1 = shadows, 2 = 3D raymarching rendering
// 0 = classic colors, 1 = shadows
shadow: u32,
// camera direction vector
camera_direction: vec3<f32>,
// Number of live entries at the start of `lights` (fills the vec3's tail
// padding slot).
light_count: u32,
// inverse of the camera's view-projection matrix, for reconstructing a
// world-space ray origin per pixel in the raymarcher
camera_inv_proj: mat4x4<f32>,
// Screen dimensions
screen_dim: vec2<f32>,
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
morph_w: f32,
// Deep views: binary exponent E of the view scale. `span` and `dc_offset`
// are uploaded multiplied by 2^-E so they stay in f32's range; 0 = not
// deep (plain f32 values). Only read by DEEP pipelines (mandelbrot.wgsl).
scale_exp: i32,
// Complex binomial coefficients C(complex_power, k) for k = 1..16, two per
// vec4 (k odd in .xy, k even in .zw), for the Complex Multibrot delta
// series. Precomputed on the CPU since they only depend on the power.
cm_coef: array<vec4<f32>, 8>,
};
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
@@ -90,21 +67,20 @@ fn classic_color(ci: f32, de: f32) -> vec3<f32> {
return palette(u.palette_id, t) * sqrt(de);
}
// A single directional light, built on the CPU from the UI's light list
// (`GpuLight` in lights.rs): `dir` is the unit direction toward the light
// (precomputed from azimuth/altitude so the shader does no trig), `color` a
// packed RGBA8 whose alpha doubles as intensity. Only the first
// `u.light_count` entries are live, all with a non-zero colour. Each shader
// that binds a `lights: array<Light, 16>` uniform (colorize.wgsl,
// mandelbrot.wgsl's export shadow path) uses this same layout.
// A single directional/point light, set by the UI's light list. `color`'s
// alpha channel doubles as intensity (see `shadow_color`'s use of
// `light_color.a`). Each shader that binds a `lights: array<Light, 16>`
// uniform (colorize.wgsl, mandelbrot.wgsl's export shadow path) uses this
// same layout.
struct Light {
dir: vec3<f32>,
azimuth: f32,
altitude: f32,
color: u32,
_pad: u32,
};
// Lambertian term for a unit `light` direction.
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
return vec3<f32>(max(0., dot(normal, light)));
return vec3<f32>(max(0., dot(normal, normalize(light))));
}
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
@@ -150,48 +126,36 @@ fn normal_from_heights(h0: f32, h1: f32, h2: f32) -> vec3<f32> {
}
// Shade a DE-derived surface normal per `u.shadow_palette_id`: 0 = grayscale
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list,
// 3 = the classic escape-time palette at `ci` (the smoothed iteration count),
// lit by the grayscale key light.
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list.
// Shared by the interactive shadow pass (colorize.wgsl) and the PNG-export
// shadow path (mandelbrot.wgsl's `fs_color`), which must render identically.
fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
fn shadow_color(normal: vec3<f32>) -> vec3<f32> {
var color: vec3<f32>;
if u.shadow_palette_id == 0u {
color = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
color = compute_light(normal, vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
color = filmic(color, 2.5);
color = contrast(color, 4., 0.67);
} else if u.shadow_palette_id == 1u {
color = compute_light(normal, vec3<f32>(0., 0.70710678, 0.70710678)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.70710678, 0., 0.70710678)) * vec3<f32>(0.5, 1., 1.);
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
color = filmic(color, 4.2);
} else if u.shadow_palette_id == 3u {
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
// is a height (unclamped, not capped at 1), and the lighting already
// shows the relief.
let t = fract(ci * u.color_scale + u.color_offset);
let ambient = 0.25;
let light = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027));
color = palette(u.palette_id, t) * (ambient + (1.0 - ambient) * light);
} else {
color = vec3<f32>(0);
let light_count = min(u.light_count, 16u);
for (var i = 0u; i < light_count; i++) {
var light_count = 0;
for (var i = 0u; i < 16; i++) {
let light_color = unpack4x8unorm(lights[i].color);
color += compute_light(normal, lights[i].dir) * light_color.xyz * light_color.a;
if any(light_color != vec4<f32>(0)) {
light_count += 1;
}
color += compute_light(normal, vec3<f32>(
cos(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
}
color = filmic(color, 1. + f32(light_count));
}
return color;
}
// Colour of an interior (non-escaped) pixel in shadow/3D modes: black under
// the classic palette, like classic 2D mode, otherwise a dark gray plateau.
fn shadow_interior_color() -> vec3<f32> {
if u.shadow_palette_id == 3u {
return vec3<f32>(0.0);
}
return vec3<f32>(0.1);
}
-121
View File
@@ -1,121 +0,0 @@
// Distance field for the shadow and 3D views of kinds whose map is
// discontinuous (Complex Multibrot's principal-branch z^p). The per-pixel
// DE, |z|·ln|z|/|dz| = G/|G'|, is only a distance when the potential G is
// continuous. Across a branch-cut preimage G jumps (|z| jumps by
// e^(2π·Im p)), each side's DE describes the set as continued on its own
// branch: seams in shadow mode, walls in the raymarched terrain. A true
// distance to the set is continuous, so this rebuilds one from the pixels that are unambiguously
// next to the set ("seeds": interior texels, and exterior texels whose DE is
// below SEED_DE, i.e. within about a pixel of it):
// h(x) = min over seeds y of DE(y) + DIST_SLOPE·|x - y|
// computed by jump flooding: `fs_seed` marks seeds, `fs_jump` runs with
// `step` halving from about half the texture size down to 1 (plus one more
// step-1 pass), each texel keeping the best seed among itself and its 8
// neighbours at ±step, and `fs_compose` writes the result, eased by
// `ease_distance`, as the data texture's G channel. Seeds are carried as coordinates, so the distance is
// Euclidean rather than an 8-direction chamfer approximation.
//
// The set only counts where it's in view: next to the image edge, set just
// outside it is missed and the height there reads a bit high.
//
// Texel layout of the seed textures: (seed x, seed y, seed DE, 1), or all 0
// for "no seed yet". R (palette parameter) and B (interior fraction) of the
// data texture pass through untouched.
struct LipschitzStep {
step: i32,
_pad0: i32,
_pad1: i32,
_pad2: i32,
};
@group(0) @binding(0) var data_tex: texture_2d<f32>;
@group(0) @binding(1) var seed_tex: texture_2d<f32>;
@group(0) @binding(2) var<uniform> ls: LipschitzStep;
// DE units per texel of a true distance: DE is in units of `pixel_size`,
// the texel's (|dx| + |dy|) footprint, i.e. sqrt(2) texels.
const DIST_SLOPE: f32 = 0.70710678;
// Exterior texels with a DE below this (in DE units) are seeds.
const SEED_DE: f32 = 1.0;
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
}
@fragment
fn fs_seed(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let d = textureLoad(data_tex, p, 0);
if d.b != 0.0 {
return vec4<f32>(vec2<f32>(p), 0.0, 1.0);
}
if d.g < SEED_DE {
return vec4<f32>(vec2<f32>(p), d.g, 1.0);
}
return vec4<f32>(0.0);
}
// Height at `p` through seed `s` (a seed texel), or +inf for "no seed".
fn through(p: vec2<i32>, s: vec4<f32>) -> f32 {
if s.a == 0.0 {
return 3.0e38;
}
return s.z + DIST_SLOPE * distance(vec2<f32>(p), s.xy);
}
@fragment
fn fs_jump(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let hi = vec2<i32>(textureDimensions(seed_tex)) - vec2<i32>(1, 1);
let st = ls.step;
var best = textureLoad(seed_tex, p, 0);
var best_h = through(p, best);
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
if dx == 0 && dy == 0 {
continue;
}
let q = p + st * vec2<i32>(dx, dy);
if any(q < vec2<i32>(0, 0)) || any(q > hi) {
continue;
}
let s = textureLoad(seed_tex, q, 0);
let h = through(p, s);
if h < best_h {
best = s;
best_h = h;
}
}
}
return best;
}
// Scale of `ease_distance`, in screen heights.
const EASE_K: f32 = 0.02;
// Concave easing of the distance field `h` (DE units, `size_y` texels
// tall): K·ln(1 + x/K) in screen heights, the unit `sdf` in colorize.wgsl
// reads. Slope 1 at the set, flattening far from it, so the terrain rises
// steeply out of the set and then levels off. The slope never exceeds 1,
// so the field stays a valid (conservative) distance for the sphere tracer.
fn ease_distance(h: f32, size_y: f32) -> f32 {
return EASE_K * log(1.0 + h / (size_y * EASE_K)) * size_y;
}
@fragment
fn fs_compose(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let d = textureLoad(data_tex, p, 0);
if d.b != 0.0 {
return d;
}
// Not min(h, DE): that keeps the too-low side of every jump, so the
// walls come back as shards.
let h = through(p, textureLoad(seed_tex, p, 0));
if h >= 3.0e38 {
return d; // no set anywhere in view: keep the DE
}
return vec4<f32>(d.r, ease_distance(h, f32(textureDimensions(data_tex).y)), d.b, d.a);
}
+144 -1041
View File
File diff suppressed because it is too large Load Diff
+51 -511
View File
@@ -1,304 +1,61 @@
//! Camera / view state over the complex plane.
//!
//! 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
//! i32 exponent arithmetic far from overflow). Once a pixel is smaller than
//! `DEEP_PIXEL_SIZE` the GPU switches to rescaled deltas (see `needs_deep`),
//! since f32 alone bottoms out near 1e-38.
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the
//! center needs the extra digits.
use core::str::FromStr;
pub use crate::bignum::Big;
use dashu_float::round::mode::HalfAway;
use dashu_float::{DBig, FBig};
/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate.
pub type Big = FBig<HalfAway, 2>;
/// Half-height (complex units) of the default view; also the zoom-1 reference.
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
/// Below this pixel size (complex units per pixel) the GPU renders with the
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
/// `2^scale_exp`. Plain f32 stays exact as long as the smallest per-pixel
/// offsets (a quarter pixel, for the AA grid) are normal floats (>= 2^-126),
/// i.e. down to 2^-124 per pixel. Measured: pixel-identical to the deep path
/// down to 2^-124 (no AA), first errors at 2^-126, all black by 2^-136. The
/// deep path is slower, so the switch is as late as that allows, with two
/// binades of margin.
pub const DEEP_PIXEL_SIZE: f64 = 1.0 / (1u128 << 122) as f64; // 2^-122
/// Whether a view rendered `height_px` pixels tall needs the deep pipeline
/// (see `DEEP_PIXEL_SIZE`).
pub fn needs_deep(half_height: Scale, height_px: f64) -> bool {
half_height.mul_f64(2.0 / height_px.max(1.0)) < Scale::from_f64(DEEP_PIXEL_SIZE)
}
/// Binary exponent `E` of the deep view scale: `floor(log2(half_height))`,
/// so the rescaled span is in `[2, 4)`. Never 0, which means "not deep"
/// (see `Uniforms::scale_exp`).
pub fn deep_scale_exp(half_height: Scale) -> i32 {
let e = half_height.exponent();
if e == 0 { -1 } else { e }
}
/// `x * 2^k` for any `k`, saturating to 0 / infinity like the true value
/// would (`powi` alone overflows at 2^±1024 even when the product fits).
fn ldexp(mut x: f64, mut k: i32) -> f64 {
while k > 1000 {
x *= 2f64.powi(1000);
k -= 1000;
if x.is_infinite() || x == 0.0 {
return x;
}
}
while k < -1000 {
x *= 2f64.powi(-1000);
k += 1000;
if x == 0.0 || x.is_infinite() {
return x;
}
}
x * 2f64.powi(k)
}
/// A positive real with f64 precision and an `i32` binary exponent:
/// `m · 2^e`, `m` in `[1, 2)`. The view's half-height (and the pixel size
/// derived from it) is one of these, so zoom isn't bound by f64's range.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Scale {
m: f64,
e: i32,
}
impl Scale {
/// Deepest scale the view can reach: 2^-(2^20) (about 1e-315653). Far
/// past anything a reference orbit can practically be computed for; it
/// only keeps the shader's i32 exponent sums (scale × degree) from
/// overflowing.
pub const MIN: Scale = Scale {
m: 1.0,
e: -(1 << 20),
};
/// `m · 2^e`, normalized. Non-positive or NaN input gives `MIN`.
pub fn from_parts(m: f64, e: i32) -> Self {
if m.is_nan() || m <= 0.0 {
return Self::MIN;
}
if m.is_infinite() {
return Scale {
m: 1.0,
e: i32::MAX / 2,
};
}
// Bring m into [1, 2) through its own binary exponent (exact).
let k = m.log2().floor() as i32;
let mut m = ldexp(m, -k);
let mut e = e.saturating_add(k);
// log2 can round across a power of two.
if m >= 2.0 {
m /= 2.0;
e = e.saturating_add(1);
} else if m < 1.0 {
m *= 2.0;
e = e.saturating_sub(1);
}
Scale { m, e }.max(Self::MIN)
}
pub fn from_f64(x: f64) -> Self {
Self::from_parts(x, 0)
}
/// `2^l`.
pub fn from_log2(l: f64) -> Self {
let e = l.floor();
Self::from_parts((l - e).exp2(), e as i32)
}
/// The value as an f64 (0 or infinity outside its range).
pub fn to_f64(self) -> f64 {
ldexp(self.m, self.e)
}
/// `self · 2^k` as an f64: the value in units of `2^-k`.
pub fn scaled_f64(self, k: i32) -> f64 {
ldexp(self.m, self.e.saturating_add(k))
}
/// `floor(log2(self))`.
pub fn exponent(self) -> i32 {
self.e
}
pub fn log2(self) -> f64 {
self.m.log2() + self.e as f64
}
pub fn log10(self) -> f64 {
self.log2() * core::f64::consts::LOG10_2
}
/// `self · f` (`f > 0`).
pub fn mul_f64(self, f: f64) -> Self {
Self::from_parts(self.m * f, self.e)
}
/// `self / other`, as an f64.
pub fn ratio(self, other: Scale) -> f64 {
ldexp(self.m / other.m, self.e.saturating_sub(other.e))
}
pub fn max(self, other: Scale) -> Self {
if other > self { other } else { self }
}
pub fn min(self, other: Scale) -> Self {
if other < self { other } else { self }
}
pub fn clamp(self, lo: Scale, hi: Scale) -> Self {
self.max(lo).min(hi)
}
/// `f · self` as an exact `Big` at `bits` of precision (`f` any f64).
pub fn big_times(self, f: f64, bits: usize) -> Big {
big_from_f64(f * self.m, bits) << self.e as isize
}
/// Exact binary value as a `Big`.
fn to_big(self) -> Big {
big_from_f64(self.m, 53) << self.e as isize
}
}
impl PartialOrd for Scale {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
// Normalized and positive: the exponent decides, then the mantissa.
Some(self.e.cmp(&other.e).then(self.m.partial_cmp(&other.m)?))
}
}
impl core::fmt::Display for Scale {
/// Scientific notation, `1.5e-20` / `3.7e-4000`. The precision flag
/// (`{:.4}`) sets mantissa digits after the point; without it, enough
/// digits to parse back to the same value.
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let x = self.to_f64();
if x.is_normal() {
return match f.precision() {
Some(p) => write!(f, "{x:.p$e}"),
None => write!(f, "{x:e}"),
};
}
// Out of f64's range: round the exact decimal expansion instead.
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}")
}
};
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)
}
}
}
}
impl FromStr for Scale {
type Err = ();
/// Parses any positive decimal (`1.25`, `1.5e-20`, `3.7e-4000`), rounding
/// to the nearest f64 mantissa. Values past `MIN` clamp to it.
fn from_str(s: &str) -> Result<Self, ()> {
let s = s.trim();
if let Ok(x) = s.parse::<f64>()
&& x.is_normal()
{
return if x > 0.0 {
Ok(Self::from_f64(x))
} else {
Err(())
};
}
// Too small (or large) for f64: go through an exact decimal.
let bin = Big::from_decimal_str(s, 64).ok_or(())?;
if bin.is_negative() {
return Err(());
}
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))
}
}
/// Guard bits added on top of the zoom-dictated precision.
const GUARD_BITS: usize = 48;
/// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
/// against pathological input; the reference orbit is impractically slow
/// long before this.
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
/// Upper bound on center precision (f32 GPU perturbation degrades long before
/// this; the cap just prevents pathological allocation).
const MAX_PRECISION_BITS: usize = 2048;
#[derive(Clone, Debug)]
pub struct ViewState {
pub center_re: Big,
pub center_im: Big,
/// Half the view height in complex-plane units. Zooming in shrinks this.
pub half_height: Scale,
pub half_height: f64,
}
impl Default for ViewState {
fn default() -> Self {
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
let bits = precision_for(DEFAULT_HALF_HEIGHT);
Self {
center_re: big_from_f64(-0.5, bits),
center_im: big_from_f64(0.0, bits),
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
half_height: DEFAULT_HALF_HEIGHT,
}
}
}
impl ViewState {
/// Complex-plane span (width, height) for the given pixel aspect ratio.
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
let h = self.half_height.mul_f64(2.0);
(h.mul_f64(aspect), h)
pub fn span(&self, aspect: f64) -> (f64, f64) {
let h = self.half_height * 2.0;
(h * aspect, h)
}
/// Complex-plane units per pixel, given the viewport height in pixels.
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
self.half_height.mul_f64(2.0 / height_px)
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
(self.half_height * 2.0) / height_px
}
/// log10 of the current magnification relative to the default view.
pub fn magnification_log10(&self) -> f64 {
DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
}
/// Current zoom level.
pub fn zoom(&self) -> Scale {
self.half_height
/// Current magnification relative to the default view.
pub fn magnification(&self) -> f64 {
DEFAULT_HALF_HEIGHT / self.half_height
}
/// Bits of precision the center currently needs for this zoom level.
@@ -311,10 +68,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);
self.center_re = self.center_re.clone().with_precision(bits).value();
}
if self.center_im.precision() < bits {
self.center_im = self.center_im.clone().with_precision(bits);
self.center_im = self.center_im.clone().with_precision(bits).value();
}
}
@@ -324,8 +81,8 @@ impl ViewState {
let cpp = self.complex_per_pixel(height_px);
let bits = self.precision_bits();
// Grab-and-drag: moving the mouse right shows content to the left.
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
}
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
@@ -338,14 +95,14 @@ impl ViewState {
// The cursor's complex offset from the center is (off * cpp). Keeping it
// fixed while scaling the view by `factor` moves the center by
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
let k = 1.0 - factor;
self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
self.half_height = self.half_height.mul_f64(factor);
let k = cpp * (1.0 - factor);
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
self.half_height *= factor;
}
/// Build a view from full-precision center coordinates and a half-height.
pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self {
let mut v = Self {
center_re,
center_im,
@@ -359,253 +116,36 @@ 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> {
Big::from_decimal_str(s, bits)
}
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
/// full precision) into a view and an optional iteration count. Shared by
/// `FractalApp::apply_view_spec` (the `--view` CLI flag) and headless
/// animation's `--to-view`.
pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
let parts: Vec<&str> = spec.split(',').collect();
if parts.len() < 3 {
return None;
}
let half_height = parse_half_height_spec(parts[2])?;
let bits = precision_for(half_height);
let re = big_from_decimal_str(parts[0], bits)?;
let im = big_from_decimal_str(parts[1], bits)?;
let iterations = parts.get(3).and_then(|s| s.trim().parse::<u32>().ok());
Some((ViewState::with_center(re, im, half_height), iterations))
}
/// Parse a half_height spec. Shared by
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
/// animation's `--to-zoom`.
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
spec.parse::<Scale>().ok()
}
/// Parse a "re,im" spec (re/im decimal, parsed at
/// full precision) into a view. Shared by
/// `FractalApp::apply_re_im_spec` (the `--position` CLI flag) and headless
/// animation's `--to-position`.
pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
let parts: Vec<&str> = spec.split(',').collect();
if parts.len() != 2 {
return None;
}
let re = big_from_decimal_str(parts[0], bits)?;
let im = big_from_decimal_str(parts[1], bits)?;
Some((re, im))
}
/// Interpolate between two views for an animation frame, `t` in `[0, 1]`.
/// The half-height interpolates geometrically (log-linear), since zoom depth
/// spans many decades and a linear sweep would crawl at the start and blow
/// past the target at the end. The center has to shrink its offset from the
/// target at that *same* geometric rate: blending it linearly in `t` instead
/// barely moves it while the view is still huge (early frames), so the
/// target stays effectively off-screen — offset/half_height ratio blows up —
/// for nearly the whole animation, and only lands on `to`'s center in the
/// literal last frame where `t == 1` forces an exact match. `g(t)` below
/// tracks the same `q^t` decay used for `half_height` (keeping the
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
/// exactly 0 at `t = 1`.
///
/// With `d = log2(q)`, `g = q^t · (1 - q^(1-t)) / (1 - q)`, all in `Scale`
/// / `expm1` form: zooming in by more than f64's range, `q` (and `q^t`)
/// underflow, yet `g · (from - to)` must keep tracking the half-height.
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
let d = l1 - l0;
let half_height = if t <= 0.0 {
from.half_height
} else if t >= 1.0 {
to.half_height
} else {
Scale::from_log2(l0 + t * d)
};
let bits = precision_for(half_height);
let ln2 = core::f64::consts::LN_2;
let g_big = if d.abs() < 1e-12 {
big_from_f64(1.0 - t, bits)
} else if d < 0.0 {
// Zooming in: q^t may be far below f64's range, keep it as a Scale.
let f = ((1.0 - t) * d * ln2).exp_m1() / (d * ln2).exp_m1();
Scale::from_log2(t * d).big_times(f, bits)
} else {
// 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);
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)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * t
let dec = DBig::from_str(s.trim()).ok()?;
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
}
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
x.to_decimal_string(sig_digits)
let dec = x
.to_decimal()
.value()
.with_precision(sig_digits.max(1))
.value();
format!("{dec}")
}
/// Precision (bits) needed to resolve the center at a given half-height.
pub fn precision_for(half_height: Scale) -> usize {
pub fn precision_for(half_height: f64) -> usize {
// We need enough bits to distinguish points a pixel apart, i.e. roughly
// log2(1 / half_height) significant bits, plus a guard margin.
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
(-half_height.log2()).ceil().max(0.0) as usize
} else {
0
};
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
}
/// Build a `Big` from an f64 with an explicit precision.
/// Build an `FBig` from an f64 with an explicit precision context.
pub fn big_from_f64(x: f64, bits: usize) -> Big {
Big::from_f64(x, bits)
}
#[cfg(test)]
mod tests {
use super::*;
fn sc(x: f64) -> Scale {
Scale::from_f64(x)
}
fn re_im_f64(v: &ViewState) -> (f64, f64) {
let re: f64 = v.center_re.to_f64();
let im: f64 = v.center_im.to_f64();
(re, im)
}
#[test]
fn interpolate_view_hits_exact_endpoints() {
let bits = precision_for(sc(1.0));
let from =
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let to = ViewState::with_center(
big_from_f64(-0.7515, precision_for(sc(1e-20))),
big_from_f64(0.1013, precision_for(sc(1e-20))),
sc(1e-20),
);
let start = interpolate_view(&from, &to, 0.0);
assert_eq!(re_im_f64(&start), re_im_f64(&from));
assert_eq!(start.half_height, from.half_height);
let end = interpolate_view(&from, &to, 1.0);
assert_eq!(re_im_f64(&end), re_im_f64(&to));
assert_eq!(end.half_height, to.half_height);
}
/// Regression test: a deep zoom's center used to be blended linearly in
/// `t` while `half_height` shrank geometrically, so partway through the
/// animation the offset from the target would already be far larger than
/// the (tiny, geometrically-shrunk) view — the target only snapped into
/// frame on the very last frame. The offset/half_height ratio should
/// instead stay roughly bounded throughout.
#[test]
fn interpolate_view_keeps_target_offset_bounded() {
let bits = precision_for(sc(1.0));
let from =
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
let to = ViewState::with_center(
big_from_f64(-0.7515, precision_for(sc(1e-20))),
big_from_f64(0.1013, precision_for(sc(1e-20))),
sc(1e-20),
);
let (to_re, to_im) = re_im_f64(&to);
for i in 1..10 {
let t = i as f64 / 10.0;
let mid = interpolate_view(&from, &to, t);
let (re, im) = re_im_f64(&mid);
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
let ratio = offset / mid.half_height.to_f64();
assert!(
ratio < 10.0,
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
mid.half_height
);
}
}
#[test]
fn scale_parse_display_round_trip() {
for s in [
"1.25",
"1e-20",
"1.5e-20",
"3.7e-4000",
"1e-400",
"9.99999e-310",
] {
let a: Scale = s.parse().unwrap();
let b: Scale = a.to_string().parse().unwrap();
assert_eq!(a, b, "{s} -> {a}");
}
assert_eq!("1.25".parse::<Scale>().unwrap().to_f64(), 1.25);
assert_eq!(sc(1.5e-20).to_string(), "1.5e-20");
let deep: Scale = "3.7e-4000".parse().unwrap();
assert_eq!(deep.to_string(), "3.7e-4000");
assert_eq!(format!("{deep:.2}"), "3.70e-4000");
assert!((deep.log10() - (3.7f64.log10() - 4000.0)).abs() < 1e-9);
assert!("0".parse::<Scale>().is_err());
assert!("-1e-500".parse::<Scale>().is_err());
assert!("abc".parse::<Scale>().is_err());
}
#[test]
fn scale_arithmetic() {
let a: Scale = "1e-1000".parse().unwrap();
let b = a.mul_f64(0.25);
assert!((b.ratio(a) - 0.25).abs() < 1e-15);
assert!(b < a && a > b);
assert_eq!(a.mul_f64(3.0).mul_f64(1.0 / 3.0).exponent(), a.exponent());
assert_eq!(sc(1.0).exponent(), 0);
assert_eq!(sc(0.75).exponent(), -1);
assert_eq!(sc(4.0).scaled_f64(-2), 1.0);
assert_eq!(
a.scaled_f64(-a.exponent()),
a.mul_f64(1.0).scaled_f64(-a.exponent())
);
assert!((1.0..2.0).contains(&a.scaled_f64(-a.exponent())));
assert_eq!(a.to_f64(), 0.0);
assert_eq!(Scale::MIN.mul_f64(0.5), Scale::MIN);
let p = precision_for(a);
assert!((3322 + 48..=3323 + 48).contains(&p), "{p}");
}
/// Past f64's range, the center must still land on the target at the
/// same geometric pace as the half-height.
#[test]
fn interpolate_view_past_f64_range() {
let from = ViewState::with_center(big_from_f64(-0.5, 64), big_from_f64(0.0, 64), sc(1.5));
let hh: Scale = "1e-1000".parse().unwrap();
let bits = precision_for(hh);
let to =
ViewState::with_center(big_from_f64(-0.7515, bits), big_from_f64(0.1013, bits), hh);
let end = interpolate_view(&from, &to, 1.0);
assert_eq!(end.half_height, hh);
assert_eq!(re_im_f64(&end), re_im_f64(&to));
let mut prev = from.half_height;
for i in 1..20 {
let t = i as f64 / 20.0;
let mid = interpolate_view(&from, &to, t);
assert!(mid.half_height < prev);
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();
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}");
}
}
Big::try_from(x)
.unwrap_or_default()
.with_precision(bits)
.value()
}
+7 -17
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 `Big` iterations), which would stutter the UI if done inline.
//! thousands of `FBig` iterations), which would stutter the UI if done inline.
//! This runs it on a thread and coalesces bursts of requests (e.g. during a
//! drag) down to the most recent one. On the web we compute inline instead
//! (browsers need a Web Worker for threads); see `app.rs`.
@@ -9,13 +9,13 @@
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread;
use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
use crate::view::{Big, Scale, big_from_f64};
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
use crate::view::{Big, big_from_f64};
pub struct RefRequest {
pub center_re: Big,
pub center_im: Big,
pub half_height: Scale,
pub half_height: f64,
pub julia: bool,
pub julia_c: (f64, f64),
pub max_iter: u32,
@@ -28,19 +28,13 @@ pub struct RefRequest {
pub lambda_l: (f64, f64),
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
pub complex_power: (f64, f64),
/// Kind-switch morph: `(from_kind, weight)` blended into every step.
pub morph: Option<(FractalKind, f32)>,
}
pub struct RefResult {
pub center_re: Big,
pub center_im: Big,
pub half_height: Scale,
pub points: RefOrbit,
/// The kind and morph `points` was computed with (echoed from the
/// request).
pub kind: FractalKind,
pub morph: Option<(FractalKind, f32)>,
pub half_height: f64,
pub points: Vec<[f32; 2]>,
}
pub struct RefWorker {
@@ -74,8 +68,6 @@ impl RefWorker {
center_im: req.center_im,
half_height: req.half_height,
points,
kind: req.kind,
morph: req.morph,
})
.is_err()
{
@@ -102,7 +94,7 @@ impl RefWorker {
}
}
fn compute(req: &RefRequest) -> RefOrbit {
fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
if req.julia {
let jr = big_from_f64(req.julia_c.0, req.precision);
let ji = big_from_f64(req.julia_c.1, req.precision);
@@ -118,7 +110,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
req.phoenix_p,
req.lambda_l,
req.complex_power,
req.morph.map(|(k, w)| (k, w as f64)),
)
} else {
compute_set_reference(
@@ -131,7 +122,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
req.phoenix_p,
req.lambda_l,
req.complex_power,
req.morph.map(|(k, w)| (k, w as f64)),
)
}
}
+18 -125
View File
@@ -3,7 +3,7 @@
//! shader with the same `naga` version wgpu uses — catching shader errors
//! without needing a GPU or a display.
fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
fn validate(name: &str, src: &str) {
let module = match naga::front::wgsl::parse_str(src) {
Ok(m) => m,
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
@@ -12,102 +12,21 @@ fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
match validator.validate(&module) {
Ok(info) => (module, info),
Err(e) => panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)),
if let Err(e) = validator.validate(&module) {
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src));
}
}
/// Number of fractal kinds, i.e. the `const KIND_*` declarations in
/// common.wgsl (one per `FractalKind` variant, values 0..N).
fn kind_count() -> u32 {
let n = include_str!("../src/shaders/common.wgsl")
.lines()
.filter(|l| l.starts_with("const KIND_"))
.count() as u32;
assert!(n >= 10, "found only {n} KIND_* constants in common.wgsl");
n
}
/// Specialize `module`'s `override`s with `constants` for `entry_point` (as
/// wgpu does at pipeline creation) and compile the result to SPIR-V, so a
/// shader that only breaks once a particular override value folds a branch
/// in or out is still caught.
fn specialize(
name: &str,
module: &naga::Module,
info: &naga::valid::ModuleInfo,
stage: naga::ShaderStage,
entry_point: &str,
constants: &[(&str, f64)],
) {
let mut pc = naga::back::PipelineConstants::default();
for (k, v) in constants {
pc.insert((*k).to_string(), *v);
}
let (module, info) = naga::back::pipeline_constants::process_overrides(
module,
info,
Some((stage, entry_point)),
&pc,
)
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: override error: {e:?}"));
let pipeline = naga::back::spv::PipelineOptions {
shader_stage: stage,
entry_point: entry_point.to_string(),
};
naga::back::spv::write_vec(
&module,
&info,
&naga::back::spv::Options::default(),
Some(&pipeline),
)
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: SPIR-V error: {e:?}"));
}
const MANDELBROT_SRC: &str = concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/iterate_uniforms.wgsl"),
include_str!("../src/shaders/mandelbrot.wgsl"),
);
#[test]
fn mandelbrot_shader_is_valid() {
validate("mandelbrot.wgsl", MANDELBROT_SRC);
}
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
/// DE × morph × deep), for every fragment entry point.
#[test]
fn mandelbrot_shader_specializations_compile() {
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
for kind in 0..kind_count() {
for julia in [0.0, 1.0] {
for de in [0.0, 1.0] {
for morph in [0.0, 1.0] {
for deep in [0.0, 1.0] {
let constants = [
("KIND", kind as f64),
("IS_JULIA", julia),
("DE", de),
("MORPH", morph),
("DEEP", deep),
];
for entry in ["fs_data", "fs_refine", "fs_color"] {
specialize(
"mandelbrot.wgsl",
&module,
&info,
naga::ShaderStage::Fragment,
entry,
&constants,
);
}
}
}
}
}
}
validate(
"mandelbrot.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/iterate_uniforms.wgsl"),
include_str!("../src/shaders/mandelbrot.wgsl"),
),
);
}
#[test]
@@ -122,17 +41,6 @@ fn colorize_shader_is_valid() {
);
}
#[test]
fn lipschitz_shader_is_valid() {
validate(
"lipschitz.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/lipschitz.wgsl"),
),
);
}
#[test]
fn blit_shader_is_valid() {
validate(
@@ -144,28 +52,13 @@ fn blit_shader_is_valid() {
);
}
const BUDDHABROT_SRC: &str = concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/buddhabrot.wgsl"),
);
#[test]
fn buddhabrot_shader_is_valid() {
validate("buddhabrot.wgsl", BUDDHABROT_SRC);
}
/// Every per-kind accumulation pipeline buddhabrot.rs can build.
#[test]
fn buddhabrot_shader_specializations_compile() {
let (module, info) = validate("buddhabrot.wgsl", BUDDHABROT_SRC);
for kind in 0..kind_count() {
specialize(
"buddhabrot.wgsl",
&module,
&info,
naga::ShaderStage::Compute,
"cs_main",
&[("KIND", kind as f64)],
);
}
validate(
"buddhabrot.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/buddhabrot.wgsl"),
),
);
}
-2
View File
@@ -1,2 +0,0 @@
results/
.worktrees/
-48
View File
@@ -1,48 +0,0 @@
# 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
@@ -1,128 +0,0 @@
#!/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
@@ -1,167 +0,0 @@
# 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
@@ -1,145 +0,0 @@
#!/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
@@ -1,3 +0,0 @@
clips/
work/
results/
-8
View File
@@ -1,8 +0,0 @@
# 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
@@ -1,66 +0,0 @@
#!/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
@@ -1,87 +0,0 @@
#!/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"