Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:59:14 +02:00
surv 49130f5ecf perf: remove shader checks 2026-09-28 17:22:08 +02:00
surv 493a743a16 feat: Add benchmark tool 2026-09-28 17:17:38 +02:00
surv 0d7aadb991 perf: switch from dashu to rug & malachite 2026-09-28 11:54:22 +02:00
surv ae7ca97c41 feat: add tools script to find interesting places 2026-09-28 10:52:37 +02:00
surv 3f5853a23c feat: add Makefile to run ffmpeg 2026-09-28 10:44:36 +02:00
surv 2e116ba47b feat: add AA in headless exports 2026-09-28 10:44:24 +02:00
surv a15e951f84 feat: add --shards CLI option to split an animation into multiple run 2026-09-28 09:03:20 +02:00
surv 27c5a1a603 feat: allow emmiting frames on stdout to pipe them in ffmpeg 2026-09-28 08:40:00 +02:00
surv d78594af7e feat: bind GUI real time rendering behind a feature 2026-09-27 14:43:23 +02:00
surv 4212839f0d fix: avoid hitting GPU watchdog 2026-09-27 14:05:09 +02:00
surv 48560de315 fix: Raise max iterations limit 2026-09-27 13:23:53 +02:00
surv 8750bb1590 fix: Smooth 2d shadow view of complex multibrot 2026-09-27 13:20:48 +02:00
surv f30921f7a5 fix: Smooth 3d view of complex multibrot 2026-09-27 13:17:35 +02:00
surv b6467fc18c fix: DE of multibrot fractal 2026-09-27 12:31:30 +02:00
surv 28ff2471b3 feat: multibrot exponent up to 20 2026-09-27 12:23:11 +02:00
surv 1bb3113581 fix: remove zoom limits 2026-09-27 12:17:08 +02:00
surv bfd3d18f3d feat: Add arbitrary precision numbers at deep zooms 2026-09-25 15:30:05 +02:00
surv fbf0ef64af fix: add c parameter again for lambda fractal in julia mode 2026-09-25 10:06:01 +02:00
surv 0d86e7b0e0 fix: periodicity checking stop flagging false positives 2026-09-25 10:02:14 +02:00
surv 583a535806 feat: allow bigger render distance in 3d view 2026-09-25 09:46:36 +02:00
surv ba385be18d fix: remove red band above small slope in 3d 2026-09-25 09:32:11 +02:00
surv 120e73fb10 fix: small zoom in 3d mode no longer create visual artefacts 2026-09-25 09:20:55 +02:00
surv 8889a09bf8 fix: remove weird gray circle around fractals 2026-09-25 09:13:59 +02:00
surv 94f9c488bd fix: complex multibrot rendering 2026-09-25 08:43:59 +02:00
surv a092be84b0 chore: remove warnings on web version 2026-09-25 08:12:02 +02:00
surv 73570fdab8 perf: Use multithreading for headless animation export 2026-09-25 08:09:34 +02:00
surv 9b0dec25e2 perf: reduce memory usage\nAllocate textures only when needed 2026-09-24 22:18:44 +02:00
surv 38cbb1f132 feat: add more animations and 3d export 2026-09-24 22:12:56 +02:00
39 changed files with 5101 additions and 846 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ Cargo.lock
dist
frames*
out.mp4
__pycache__
+142 -34
View File
@@ -6,9 +6,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
reference orbit is computed on the CPU (arbitrary precision via `bignum::Big`:
`rug` natively, `malachite-float` on the web),
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
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
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).
@@ -17,9 +23,12 @@ storage buffers, which the fragment shader needs for the reference orbit).
```sh
cargo run --release # native, run (release matters: fractal math is hot)
cargo test # reference-orbit math, share-link round-trip, WGSL validation
cargo test --features wasm # same, on the web build's malachite big-float backend
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
cargo clippy
cargo fmt # rustfmt.toml just pins edition = "2024"
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
tools/bench/bench.sh --rev HEAD # perf of uncommitted edits vs HEAD (hyperfine; see tools/bench/README.md)
```
Web build (WebGPU):
@@ -27,14 +36,16 @@ Web build (WebGPU):
```sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
./build-web.sh # -> ./dist
./build-web.sh # -> ./dist (builds with --features wasm)
python3 -m http.server -d dist 8080
```
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]`, `--de`, `--buddhabrot`,
`--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`,
@@ -42,20 +53,43 @@ 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`: add
`--to-view re,im,half_height[,iterations]` (or `--to-share <fragment>`, which
only pulls position/zoom/iterations out of the link) alongside a start view
(`--view`/`--share`/`--kind`/`--julia`), plus `--frames N` or
`--fps`/`--duration`. `--export-path` then names an output *directory* of
`frame-00001.png`, `frame-00002.png`, ... instead of a single file. Only the
camera (center + half-height) is animated — kind, colors, and per-kind
constants stay fixed at whatever the start flags set. `view::interpolate_view`
does the interpolation: half-height geometrically (log-linear, since zoom
spans many decades), center linearly through the complex plane at full
`Big` precision; `--linear` swaps the default smoothstep easing for constant
pacing. Iteration count auto-scales with zoom depth per frame (same
`auto_iteration_count` the interactive app uses while zooming), overriding
any iteration count from `--view`/`--share`/`--to-view`/`--to-share`.
`--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.
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
@@ -74,9 +108,32 @@ runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This i
what makes deep zoom cheap — one expensive high-precision orbit, then every
pixel is a handful of `f32` complex multiplies.
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
(bits) scales with zoom depth (`precision_for`).
- `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/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:
@@ -88,10 +145,21 @@ pixel is a handful of `f32` complex multiplies.
`f32` pairs — that's the reference orbit the GPU perturbs from. At
precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
exists twice in this file (f64 + `FBig`) and both must stay in sync;
`f64_fast_path_matches_big` checks they agree. Requests are made with 1.5×
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.
creeping up during a zoom doesn't recompute the orbit every frame. The
interactive reference buffers start at 2^17 points and grow on demand
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
128 MiB WebGPU default binding size). That is also the hard iteration
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
reference as escaped. The UI slider only goes to 100k when dragged; typed
values can go higher.
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
no `#include`, so each is compiled by concatenating plain-text fragments
with `concat!`/`include_str!` at the `create_shader_module` call site (see
@@ -110,16 +178,19 @@ pixel is a handful of `f32` complex multiplies.
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 variant to
SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
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. Phoenix is
excluded (two-term map).
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).
`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
@@ -133,18 +204,47 @@ pixel is a handful of `f32` complex multiplies.
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
to blend two kinds during the kind-switch morph (`u.morph_from`,
`u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by
the `morph` argument of `compute_reference`, in both its f64 and `FBig`
the `morph` argument of `compute_reference`, in both its f64 and `Big`
paths). The blend only exists in pipelines built with the `MORPH` override
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
detection and the cardioid bypass. App side: `KindMorph` in
`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.
- `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`, and `light_count` for the
packed `GpuLight` buffer from `lights.rs::gpu_lights`), and
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
`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
@@ -158,7 +258,10 @@ 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 "morph c/p/λ" and auto-zoom animations),
`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`),
`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`
@@ -174,7 +277,9 @@ 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), `buddhabrot.wgsl`
`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`
(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,
@@ -211,7 +316,10 @@ 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.
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
+16 -4
View File
@@ -3,19 +3,31 @@ name = "mandelbrot"
version = "0.1.0"
edition = "2024"
[features]
default = ["gui"]
# Windowed egui app. Without it only `--headless` rendering is built.
gui = ["dep:eframe", "dep:egui"]
# Pure-Rust big floats for the web build (`rug` needs GMP/MPFR, which can't
# target wasm32). Required for wasm32, optional natively (to test that backend).
wasm = ["dep:malachite-float", "dep:malachite-base"]
[dependencies]
bytemuck = { version = "1.25.2", features = ["derive"] }
dashu-float = "0.6.0"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
egui = "0.36.2"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true }
egui = { version = "0.36.2", optional = true }
ecolor = { version = "0.36.2", features = ["bytemuck"] }
wgpu = "30.0.1"
glam = "0.33.8"
log = "0.4.34"
png = "0.18.1"
malachite-float = { version = "0.12", optional = true }
malachite-base = { version = "0.12", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1"
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
@@ -32,7 +44,7 @@ opt-level = 3
# codegen-units = 1
debug = true
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
# Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
# egui) so the explorer is actually interactive during development.
[profile.dev]
opt-level = 1
+35
View File
@@ -0,0 +1,35 @@
.PHONY: hevc-nvenc x265 av1
FFMPEG=ffmpeg
IN_FMT=rgba
RESOLUTION=1920x1080
FPS=60
OUT=out.mp4
FFMPEG_CMD=$(FFMPEG) -f rawvideo -pix_fmt $(IN_FMT) -s $(RESOLUTION) -framerate $(FPS) -i - -vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=p010le"
hevc-nvenc:
$(FFMPEG_CMD) \
-c:v hevc_nvenc -preset p7 -tune hq -rc vbr -cq 14 -b:v 0 -maxrate 200M -bufsize 400M \
-profile:v main10 -pix_fmt p010le \
-spatial-aq 1 -aq-strength 6 -temporal-aq 1 \
-rc-lookahead 32 -bf 4 -b_ref_mode middle -multipass fullres \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
x265:
$(FFMPEG_CMD) \
-c:v libx265 -crf 18 -preset medium -pix_fmt yuv420p10le \
-x265-params "aq-mode=3:no-sao=1" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
av1:
$(FFMPEG_CMD) \
-c:v libsvtav1 -crf 24 -preset 6 -pix_fmt yuv420p10le \
-svtav1-params "film-grain=0:enable-overlays=0" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
$(OUT)
+9 -2
View File
@@ -3,7 +3,8 @@
A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets —
built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of
plain `f64` using **perturbation theory**: one high-precision reference orbit is
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is
computed on the CPU (arbitrary precision via `rug` natively, `malachite-float`
on the web), and every pixel is
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid
glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
@@ -27,6 +28,9 @@ The `f32` GPU tier reaches roughly **10³⁰× magnification** with sharp detail
cargo run --release
```
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
toolchain and `m4` (on Windows, MSYS2).
## Build & run — web (WebGPU)
Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the
@@ -37,6 +41,7 @@ rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # once
./build-web.sh # outputs ./dist (index.html, .js, .wasm)
# (builds with --features wasm: pure-Rust big floats)
python3 -m http.server -d dist 8080 # serve over http
```
@@ -60,7 +65,8 @@ qualifies. Deploy by serving the `dist/` directory as static files.
## How it works
- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel
- `src/view.rs` — view state. Center is arbitrary precision (`Big`, from
`src/bignum/`); the pixel
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range).
- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`.
- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
@@ -84,6 +90,7 @@ reference computation to a Web Worker.
```sh
cargo test
cargo test --features wasm # same suite on the web build's big-float backend
```
Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia)
+1 -1
View File
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
OUT="${1:-dist}"
echo "==> cargo build (wasm32, release)"
cargo build --release --target wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown --features wasm
echo "==> wasm-bindgen -> $OUT"
mkdir -p "$OUT"
+647 -259
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
//! Arbitrary-precision binary float [`Big`] (one coordinate of a deep-zoom
//! center, or of a reference orbit point), over one of two backends:
//!
//! - native (default): `rug` (GMP/MPFR), the fastest, but C code that can't
//! target `wasm32-unknown-unknown`;
//! - `wasm` feature: a pure-Rust library, required for the web build.
//!
//! Both expose the same inherent API plus the operators below, and follow
//! the same precision rule: an operation's result has the larger of its
//! operands' precisions (in bits), rounded to nearest. Shifts (`<<`/`>>` by
//! an `isize`) are exact multiplications by powers of two.
#[cfg(not(feature = "wasm"))]
mod rug_backend;
#[cfg(not(feature = "wasm"))]
pub use rug_backend::Big;
#[cfg(feature = "wasm")]
mod web_backend;
#[cfg(feature = "wasm")]
pub use web_backend::Big;
// Native `--features wasm` builds (to test the web backend) still link rug.
#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))]
use rug as _;
#[cfg(all(target_arch = "wasm32", not(feature = "wasm")))]
compile_error!("the web build needs `--features wasm` (rug can't target wasm32)");
use core::ops::{Add, Mul, Neg, Shl, Shr, Sub};
/// Implements `$Trait` for every owned/borrowed combination of `Big`
/// operands, through the backend's by-reference `$imp`.
macro_rules! forward_binop {
($Trait:ident, $method:ident, $imp:ident) => {
impl $Trait<&Big> for &Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
self.$imp(rhs)
}
}
impl $Trait<Big> for &Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
self.$imp(&rhs)
}
}
impl $Trait<&Big> for Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
(&self).$imp(rhs)
}
}
impl $Trait<Big> for Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
(&self).$imp(&rhs)
}
}
};
}
forward_binop!(Add, add, add_ref);
forward_binop!(Sub, sub, sub_ref);
forward_binop!(Mul, mul, mul_ref);
impl Neg for Big {
type Output = Big;
fn neg(self) -> Big {
self.negated()
}
}
impl Neg for &Big {
type Output = Big;
fn neg(self) -> Big {
self.clone().negated()
}
}
impl Shl<isize> for Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.mul_pow2(k)
}
}
impl Shl<isize> for &Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.clone().mul_pow2(k)
}
}
impl Shr<isize> for Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.mul_pow2(-k)
}
}
impl Shr<isize> for &Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.clone().mul_pow2(-k)
}
}
/// Significant decimal digits of a value, as a backend's
/// `to_decimal_parts` returns them: the value is `±0.digits × 10^exp10`,
/// `digits` has no trailing zeros, and it is empty for zero.
pub struct DecimalParts {
pub negative: bool,
pub digits: String,
pub exp10: isize,
}
impl DecimalParts {
/// Build from a significand digit string (maybe with trailing zeros)
/// whose value is `0.digits × 10^exp10`.
fn new(negative: bool, digits: &str, exp10: isize) -> Self {
let digits = digits.trim_end_matches('0').to_string();
Self {
negative: negative && !digits.is_empty(),
digits,
exp10,
}
}
}
impl Big {
/// Decimal string with `sig_digits` significant digits, in plain
/// positional notation (`-0.000123`, `4500`), trailing zeros trimmed.
pub fn to_decimal_string(&self, sig_digits: usize) -> String {
let DecimalParts {
negative,
digits,
exp10,
} = self.to_decimal_parts(sig_digits.max(1));
if digits.is_empty() {
return "0".to_string();
}
let sign = if negative { "-" } else { "" };
let len = digits.len() as isize;
if exp10 <= 0 {
let zeros = "0".repeat((-exp10) as usize);
format!("{sign}0.{zeros}{digits}")
} else if exp10 >= len {
let zeros = "0".repeat((exp10 - len) as usize);
format!("{sign}{digits}{zeros}")
} else {
let (int, frac) = digits.split_at(exp10 as usize);
format!("{sign}{int}.{frac}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn big(x: f64) -> Big {
Big::from_f64(x, 200)
}
#[test]
fn decimal_string_is_positional() {
assert_eq!(
big(-0.7515).to_decimal_string(20),
"-0.75149999999999994582"
);
assert_eq!(big(-0.7515).to_decimal_string(5), "-0.7515");
assert_eq!(big(0.0).to_decimal_string(5), "0");
assert_eq!(big(1.0).to_decimal_string(5), "1");
assert_eq!(big(123.456).to_decimal_string(5), "123.46");
assert_eq!(big(1e20).to_decimal_string(5), "100000000000000000000");
assert_eq!(big(0.1).to_decimal_string(5), "0.1");
let tiny = big(1.0) >> 200;
let s = tiny.to_decimal_string(5);
assert_eq!(s, format!("0.{}6223", "0".repeat(60)));
}
#[test]
fn decimal_round_trip() {
let s = "-0.743643887037158704752191506114774";
let x = Big::from_decimal_str(s, 200).unwrap();
assert_eq!(x.to_decimal_string(33), s);
assert_eq!(
Big::from_decimal_str("1.5e-20", 64).unwrap().to_f64(),
1.5e-20
);
assert!(Big::from_decimal_str("abc", 64).is_none());
}
#[test]
fn precision_is_max_of_operands() {
let a = Big::from_f64(1.0, 100);
let b = Big::from_f64(3.0, 200);
assert_eq!((&a + &b).precision(), 200);
assert_eq!((&a * &b).precision(), 200);
assert_eq!((&b - &a).precision(), 200);
assert_eq!(a.clone().with_precision(300).precision(), 300);
}
#[test]
fn exact_shifts_and_log2() {
let x = Big::from_f64(0.75, 64) >> 5000;
assert_eq!(x.log2_floor(), Some(-5001));
assert_eq!((x << 5000).to_f64(), 0.75);
assert_eq!(Big::zero(64).log2_floor(), None);
assert!(Big::from_f64(-1.0, 64).is_negative());
assert!(!Big::zero(64).is_negative());
}
#[test]
fn transcendentals() {
let x = Big::from_f64(0.5, 128);
assert!((x.ln().to_f64() - 0.5f64.ln()).abs() < 1e-15);
assert!((x.exp().to_f64() - 0.5f64.exp()).abs() < 1e-15);
let (s, c) = x.sin_cos();
assert!((s.to_f64() - 0.5f64.sin()).abs() < 1e-15);
assert!((c.to_f64() - 0.5f64.cos()).abs() < 1e-15);
let y = Big::from_f64(-0.3, 128);
assert!((y.atan2(&x).to_f64() - (-0.3f64).atan2(0.5)).abs() < 1e-15);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! [`Big`] over `rug::Float` (MPFR), for native builds.
use core::cmp::Ordering;
use rug::{Assign, Float};
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
#[derive(Clone, Debug, PartialEq)]
pub struct Big(Float);
/// MPFR precision for `bits`, clamped to what it accepts.
fn prec(bits: usize) -> u32 {
(bits as u32).clamp(rug::float::prec_min(), rug::float::prec_max())
}
impl Big {
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let x = if x.is_finite() { x } else { 0.0 };
Self(Float::with_val(prec(bits), x))
}
pub fn zero(bits: usize) -> Self {
Self(Float::new(prec(bits)))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let parsed = Float::parse(s.trim()).ok()?;
let x = Float::with_val(prec(bits.max(53)), parsed);
x.is_finite().then_some(Self(x))
}
pub fn precision(&self) -> usize {
self.0.prec() as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
self.0.set_prec(prec(bits));
self
}
pub fn to_f64(&self) -> f64 {
self.0.to_f64()
}
pub fn is_zero(&self) -> bool {
self.0.is_zero()
}
pub fn is_negative(&self) -> bool {
self.0.cmp0() == Some(Ordering::Less)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
pub fn sqr(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.square_ref()))
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// MPFR normalizes the significand to [0.5, 1).
self.0.get_exp().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.ln_ref()))
}
pub fn exp(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.exp_ref()))
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.0.prec().max(x.0.prec());
Self(Float::with_val(p, self.0.atan2_ref(&x.0)))
}
pub fn sin_cos(&self) -> (Self, Self) {
let p = self.0.prec();
let (mut s, mut c) = (Float::new(p), Float::new(p));
(&mut s, &mut c).assign(self.0.sin_cos_ref());
(Self(s), Self(c))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.0.is_zero() {
return DecimalParts::new(false, "", 0);
}
let (negative, digits, exp) = self.0.to_sign_string_exp(10, Some(sig));
DecimalParts::new(negative, &digits, exp.unwrap_or(0) as isize)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 + &rhs.0))
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 - &rhs.0))
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 * &rhs.0))
}
pub(super) fn negated(self) -> Big {
Big(-self.0)
}
/// `self · 2^k`, exact. `|k|` stays far below `i32::MAX` (precision and
/// zoom depth are capped around 2^20 bits).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big(self.0 << k as i32)
}
}
+173
View File
@@ -0,0 +1,173 @@
//! [`Big`] over `malachite-float`, pure Rust, for the web build.
use malachite_base::num::basic::traits::Zero;
use malachite_base::num::conversion::string::options::ToSciOptions;
use malachite_base::num::conversion::traits::{RoundingFrom, ToSci};
use malachite_base::rounding_modes::RoundingMode::Nearest;
use malachite_float::Float;
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
///
/// The precision is stored next to the value: malachite's zero has none,
/// but "zero at `p` bits" must still lift a sum with it to `p` bits.
#[derive(Clone, Debug)]
pub struct Big {
f: Float,
prec: u64,
}
impl PartialEq for Big {
fn eq(&self, other: &Self) -> bool {
self.f == other.f
}
}
fn prec(bits: usize) -> u64 {
(bits as u64).max(1)
}
impl Big {
fn new(f: Float, prec: u64) -> Self {
// Only finite values reach here; anything else (overflow) is a bug.
debug_assert!(f.is_finite(), "non-finite Big: {f}");
Self { f, prec }
}
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let p = prec(bits);
if !x.is_finite() {
return Self::zero(bits);
}
Self::new(Float::from_primitive_float_prec(x, p).0, p)
}
pub fn zero(bits: usize) -> Self {
Self::new(Float::ZERO, prec(bits))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let p = prec(bits.max(53));
let (f, _) = Float::from_sci_string_prec(s.trim(), p)?;
f.is_finite().then(|| Self::new(f, p))
}
pub fn precision(&self) -> usize {
self.prec as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
let p = prec(bits);
if self.f.get_prec().is_some() {
self.f.set_prec(p);
}
self.prec = p;
self
}
pub fn to_f64(&self) -> f64 {
f64::rounding_from(&self.f, Nearest).0
}
pub fn is_zero(&self) -> bool {
self.f.is_zero()
}
pub fn is_negative(&self) -> bool {
self.f.is_sign_negative() && !self.f.is_zero()
}
pub fn abs(self) -> Self {
if self.f.is_sign_negative() {
self.negated()
} else {
self
}
}
pub fn sqr(&self) -> Self {
Self::new(self.f.square_prec_ref(self.prec).0, self.prec)
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// The significand is normalized to [0.5, 1).
self.f.get_exponent().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self::new(self.f.ln_prec_ref(self.prec).0, self.prec)
}
pub fn exp(&self) -> Self {
Self::new(self.f.exp_prec_ref(self.prec).0, self.prec)
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.prec.max(x.prec);
Self::new(self.f.atan2_prec_ref_ref(&x.f, p).0, p)
}
pub fn sin_cos(&self) -> (Self, Self) {
let (s, c, _, _) = self.f.sin_cos_prec_ref(self.prec);
(Self::new(s, self.prec), Self::new(c, self.prec))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.f.is_zero() {
return DecimalParts::new(false, "", 0);
}
let mut options = ToSciOptions::default();
options.set_precision(sig as u64);
let s = self.f.to_sci_with_options(options).to_string();
// `[-]int[.frac][e±N]`, positional or scientific depending on size.
let (negative, s) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.as_str()),
};
let (mantissa, exp) = match s.split_once(['e', 'E']) {
Some((m, e)) => (m, e.trim_start_matches('+').parse::<isize>().unwrap_or(0)),
None => (s, 0),
};
let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let all = format!("{int}{frac}");
let digits = all.trim_start_matches('0');
let leading_zeros = (all.len() - digits.len()) as isize;
// 0.digits · 10^exp10 = int.frac · 10^exp.
let exp10 = int.len() as isize - leading_zeros + exp;
DecimalParts::new(negative, digits, exp10)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.add_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.sub_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.mul_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn negated(self) -> Big {
Big::new(-self.f, self.prec)
}
/// `self · 2^k`, exact (malachite's exponent range is ±2^30, far past
/// what zoom depth needs).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big::new(self.f << k as i64, self.prec)
}
}
+20 -3
View File
@@ -2,6 +2,10 @@ 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,
@@ -35,16 +39,29 @@ impl Camera {
/// 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) {
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
pub fn orthographic(&self, t: f32) -> glam::Mat4 {
/// 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);
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.))
+73 -4
View File
@@ -25,7 +25,7 @@ pub struct Cli {
#[arg(long)]
pub rendering_kind: Option<RenderingKindArg>,
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
#[arg(long)]
pub power: Option<u32>,
@@ -61,10 +61,23 @@ pub struct Cli {
#[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).
#[arg(long)]
pub antialias: bool,
/// Coloring palette index.
#[arg(long, value_name = "INDEX")]
pub palette: Option<u32>,
@@ -72,7 +85,9 @@ pub struct Cli {
/// Output path for --headless (default: fractal-<timestamp>.png). When
/// animating (--to-view/--to-share), this is a directory of
/// frame-00001.png, frame-00002.png, ... instead (default:
/// frames-<timestamp>/).
/// frames-<timestamp>/). "-" writes to stdout: the PNG for a single
/// image, or raw RGBA8 frames in order for an animation, to pipe into
/// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`.
#[arg(long, value_name = "PATH")]
pub export_path: Option<String>,
@@ -93,6 +108,49 @@ pub struct Cli {
#[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")]
@@ -113,10 +171,21 @@ pub struct Cli {
#[arg(long)]
pub linear: bool,
/// Split the animation into N equal parts (use with --shard) to render
/// it across several runs. Each part keeps the whole animation's timing
/// and easing, so the clips join seamlessly.
#[arg(long, value_name = "N")]
pub shards: Option<u32>,
/// Which part of --shards to render, 1-based. PNG frames keep their
/// global numbering, so all shards can share one --export-path directory.
#[arg(long, value_name = "K")]
pub shard: Option<u32>,
/// Run without opening a window: render the current view to a PNG and
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
/// render, or --to-view/--to-share to render an animation instead of a
/// single frame. Not yet supported with --buddhabrot.
/// 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.
#[arg(long)]
pub headless: bool,
+10 -3
View File
@@ -5,7 +5,8 @@
use std::collections::HashMap;
use eframe::egui_wgpu::{self, wgpu};
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
/// interactive on a modest GPU even when most samples run the full `b_cap`
@@ -125,7 +126,9 @@ pub struct BuddhabrotRenderer {
impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
let shader = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
@@ -134,7 +137,10 @@ impl BuddhabrotRenderer {
)
.into(),
),
});
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("buddhabrot uniforms"),
@@ -336,6 +342,7 @@ pub struct BuddhabrotCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare(
&self,
+3 -3
View File
@@ -16,7 +16,7 @@ pub enum FractalKind {
BurningShip = 1,
/// `z -> conj(z)^2 + c` (the Mandelbar).
Tricorn = 2,
/// `z -> z^power + c` (power >= 2).
/// `z -> z^power + c` (integer power in [2, 20]).
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)` (logistic map).
/// `z -> lambda·z(1 - z) + c` (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)".to_string(),
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
FractalKind::ComplexMultibrot => {
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
}
+5 -3
View File
@@ -9,10 +9,12 @@ pub mod share;
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use kind::FractalKind;
pub use reference::{compute_reference, compute_set_reference};
pub use reference::{RefOrbit, compute_reference, compute_set_reference};
#[cfg(not(target_arch = "wasm32"))]
pub use renderer::PipelineKey;
#[cfg(target_arch = "wasm32")]
pub use renderer::encode_png_with_progress;
#[cfg(not(target_arch = "wasm32"))]
pub use renderer::export_to_png_blocking;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
#[cfg(not(target_arch = "wasm32"))]
pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking, unpad_rgba};
pub use share::ShareState;
+236 -147
View File
@@ -1,7 +1,7 @@
//! High-precision reference-orbit computation for perturbation rendering.
//!
//! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision
//! (`dashu-float`), storing each `Z_n` as an `f32` pair. Every pixel is then
//! ([`Big`]: `rug` natively, pure Rust on the web), storing each `Z_n` as an `f32` pair. Every pixel is then
//! rendered on the GPU as a small `f32` delta from this orbit — that is what
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
//! delta formula there must match the orbit formula here.
@@ -24,7 +24,7 @@ use crate::view::{Big, big_from_f64};
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// Up to this working precision (bits) the orbit is iterated in plain `f64`
/// instead of `FBig` — orders of magnitude faster, which matters most on the
/// instead of `Big` — orders of magnitude faster, which matters most on the
/// web (where the reference is computed inline on the UI thread).
///
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
@@ -39,6 +39,81 @@ const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// 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.
@@ -59,64 +134,22 @@ pub fn compute_reference(
lambda_l: (f64, f64),
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
) -> Vec<[f32; 2]> {
compute_reference_inner(
z0_re,
z0_im,
c_re,
c_im,
max_iter,
precision,
kind,
power,
phoenix_p,
lambda_l,
complex_power,
morph,
false,
)
}
/// [`compute_reference`] plus `set_plane` (see [`StepConsts::set_plane`]):
/// picks the `f64` fast path or the `FBig` path by precision.
#[allow(clippy::too_many_arguments)]
fn compute_reference_inner(
z0_re: &Big,
z0_im: &Big,
c_re: &Big,
c_im: &Big,
max_iter: u32,
precision: usize,
kind: FractalKind,
power: u32,
phoenix_p: (f64, f64),
lambda_l: (f64, f64),
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
set_plane: bool,
) -> Vec<[f32; 2]> {
) -> 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().value(), c_im.to_f64().value()),
c: (c_re.to_f64(), c_im.to_f64()),
p: phoenix_p,
l: lambda_l,
cpow: complex_power,
power,
set_plane,
};
return compute_reference_f64(
(z0_re.to_f64().value(), z0_im.to_f64().value()),
max_iter,
kind,
&k,
morph,
);
return compute_reference_f64((z0_re.to_f64(), z0_im.to_f64()), max_iter, kind, &k, morph);
}
let k = StepConsts {
cr: c_re.clone().with_precision(precision).value(),
ci: c_im.clone().with_precision(precision).value(),
cr: c_re.clone().with_precision(precision),
ci: c_im.clone().with_precision(precision),
pr: big_from_f64(phoenix_p.0, precision),
pi: big_from_f64(phoenix_p.1, precision),
lr: big_from_f64(lambda_l.0, precision),
@@ -125,7 +158,6 @@ fn compute_reference_inner(
cpow_im: big_from_f64(complex_power.1, precision),
power,
precision,
set_plane,
};
compute_reference_big(z0_re, z0_im, max_iter, kind, &k, morph)
}
@@ -137,7 +169,6 @@ struct StepConstsF64 {
l: (f64, f64),
cpow: (f64, f64),
power: u32,
set_plane: bool,
}
/// [`compute_reference`]'s fast path for shallow views (see
@@ -148,12 +179,12 @@ fn compute_reference_f64(
kind: FractalKind,
k: &StepConstsF64,
morph: Option<(FractalKind, f64)>,
) -> Vec<[f32; 2]> {
) -> RefOrbit {
let (mut zr, mut zi) = z0;
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
let mut prev = (0.0f64, 0.0f64);
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
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 {
@@ -204,16 +235,11 @@ fn step_f64(
)
}
FractalKind::Lambda => {
// λ·z(1 - z) (+ c on the parameter plane).
// λ·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);
let (re, im) = (lzr * re2 - lzi * im2, re2 * lzi + lzr * im2);
if k.set_plane {
(re + cr, im + ci)
} else {
(re, im)
}
(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);
@@ -250,12 +276,9 @@ struct StepConsts {
cpow_im: Big,
power: u32,
precision: usize,
/// Parameter plane: the GPU adds `dc` every step for every kind, so the
/// Lambda map (which has no `c` of its own) is `λ·z(1 - z) + c` there.
set_plane: bool,
}
/// [`compute_reference`] at arbitrary precision (`FBig`), for deep views.
/// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
fn compute_reference_big(
z0_re: &Big,
z0_im: &Big,
@@ -263,23 +286,22 @@ fn compute_reference_big(
kind: FractalKind,
k: &StepConsts,
morph: Option<(FractalKind, f64)>,
) -> Vec<[f32; 2]> {
) -> 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).value();
let mut zi = z0_im.clone().with_precision(precision).value();
let mut zr = z0_re.clone().with_precision(precision);
let mut zi = z0_im.clone().with_precision(precision);
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
let mut zr_prev = big_zero(precision);
let mut zi_prev = big_zero(precision);
let mut zr_prev = Big::zero(precision);
let mut zi_prev = Big::zero(precision);
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
for _ in 0..=max_iter {
let fr = zr.to_f64().value() as f32;
let fi = zi.to_f64().value() as f32;
points.push([fr, fi]);
push_big_point(&mut points, &zr, &zi);
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;
@@ -296,8 +318,8 @@ fn compute_reference_big(
// Shift the previous iterate (only the Phoenix arm reads it).
zr_prev = zr;
zi_prev = zi;
zr = new_zr.with_precision(precision).value();
zi = new_zi.with_precision(precision).value();
zr = new_zr.with_precision(precision);
zi = new_zi.with_precision(precision);
}
points
@@ -325,7 +347,7 @@ fn step(
FractalKind::BurningShip => {
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = big_abs((zr * zi) << 1) + ci;
let im = ((zr * zi) << 1).abs() + ci;
(re, im)
}
FractalKind::Tricorn => {
@@ -340,14 +362,14 @@ fn step(
}
FractalKind::Celtic => {
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr;
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ((zr * zi) << 1) + ci;
(re, im)
}
FractalKind::Perpendicular => {
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
let re = &zr.sqr() - &zi.sqr() + cr;
let im = if zi.to_f64().value() < 0.0 {
let im = if zi.is_negative() {
ci + ((zr * zi) << 1)
} else {
ci - ((zr * zi) << 1)
@@ -356,8 +378,8 @@ fn step(
}
FractalKind::Buffalo => {
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr;
let im = ci - big_abs((zr * zi) << 1);
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
let im = ci - ((zr * zi) << 1).abs();
(re, im)
}
FractalKind::Phoenix => {
@@ -369,18 +391,14 @@ fn step(
(re2 + cr + pzr, im2 + ci + pzi)
}
FractalKind::Lambda => {
// λ·z(1 - z): logistic map (+ c on the parameter plane).
let re2 = 1 - zr;
// λ·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;
if k.set_plane {
(re + cr, im + ci)
} else {
(re, im)
}
}
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex(zr, zi, &k.cpow_re, &k.cpow_im, k.precision);
@@ -389,36 +407,20 @@ fn step(
}
}
fn big_zero(precision: usize) -> Big {
Big::from(0i32).with_precision(precision).value()
}
/// Absolute value of a `Big`. The sign 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(1i32).with_precision(precision).value();
let mut ri = big_zero(precision);
let mut rr = Big::from_f64(1.0, precision);
let mut ri = Big::zero(precision);
for _ in 0..power {
// (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i.
let nr = (&rr * zr - &ri * zi).with_precision(precision).value();
let ni = (&rr * zi + &ri * zr).with_precision(precision).value();
let nr = (&rr * zr - &ri * zi).with_precision(precision);
let ni = (&rr * zi + &ri * zr).with_precision(precision);
rr = nr;
ri = ni;
}
(rr, ri)
}
/// `true` if `x` is (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`.
@@ -426,14 +428,14 @@ fn is_big_zero(x: &Big) -> bool {
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
/// exposes).
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
if is_big_zero(zr) && is_big_zero(zi) {
return (big_zero(precision), big_zero(precision));
if zr.is_zero() && zi.is_zero() {
return (Big::zero(precision), Big::zero(precision));
}
let r2 = &zr.sqr() + &zi.sqr();
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
let theta = zi.atan2(zr);
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision).value();
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision);
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision);
let mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a)
@@ -453,9 +455,9 @@ pub fn compute_set_reference(
lambda_l: (f64, f64),
complex_power: (f64, f64),
morph: Option<(FractalKind, f64)>,
) -> Vec<[f32; 2]> {
let zero = big_zero(precision);
compute_reference_inner(
) -> RefOrbit {
let zero = Big::zero(precision);
compute_reference(
&zero,
&zero,
center_re,
@@ -468,7 +470,6 @@ pub fn compute_set_reference(
lambda_l,
complex_power,
morph,
true,
)
}
@@ -476,12 +477,23 @@ 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::try_from(-0.75_f64).unwrap();
let ci = Big::try_from(0.1_f64).unwrap();
let cr = Big::from_f64(-0.75, 53);
let ci = Big::from_f64(0.1, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -525,13 +537,17 @@ mod tests {
fn f64_fast_path_matches_big() {
let bits_fast = F64_MAX_PRECISION;
let bits_big = F64_MAX_PRECISION + 64;
for kind in FractalKind::ALL {
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, 3, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
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,
@@ -544,7 +560,7 @@ mod tests {
)
}
};
let ctx = format!("{kind:?} julia={julia} morph={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() {
@@ -561,11 +577,57 @@ mod tests {
}
}
/// 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::try_from(-0.2_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let cr = Big::from_f64(-0.2, 53);
let ci = Big::from_f64(0.0, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -584,8 +646,8 @@ mod tests {
/// Burning Ship reference matches a naive f64 iteration of the same formula.
#[test]
fn burning_ship_reference_matches_naive_f64() {
let cr = Big::try_from(-1.75_f64).unwrap();
let ci = Big::try_from(-0.03_f64).unwrap();
let cr = Big::from_f64(-1.75, 53);
let ci = Big::from_f64(-0.03, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -615,8 +677,8 @@ mod tests {
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
#[test]
fn multibrot3_reference_matches_naive_f64() {
let cr = Big::try_from(0.3_f64).unwrap();
let ci = Big::try_from(0.2_f64).unwrap();
let cr = Big::from_f64(0.3, 53);
let ci = Big::from_f64(0.2, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -648,10 +710,10 @@ mod tests {
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
#[test]
fn julia_reference_matches_naive_f64() {
let z0_re = Big::try_from(0.15_f64).unwrap();
let z0_im = Big::try_from(-0.1_f64).unwrap();
let c_re = Big::try_from(-0.8_f64).unwrap();
let c_im = Big::try_from(0.156_f64).unwrap();
let z0_re = Big::from_f64(0.15, 53);
let z0_im = Big::from_f64(-0.1, 53);
let c_re = Big::from_f64(-0.8, 53);
let c_im = Big::from_f64(0.156, 53);
let points = compute_reference(
&z0_re,
&z0_im,
@@ -680,11 +742,43 @@ 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::try_from(-0.6_f64).unwrap();
let ci = Big::try_from(0.4_f64).unwrap();
let cr = Big::from_f64(-0.6, 53);
let ci = Big::from_f64(0.4, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -715,8 +809,8 @@ mod tests {
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
#[test]
fn perpendicular_reference_matches_naive_f64() {
let cr = Big::try_from(-0.7_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let cr = Big::from_f64(-0.7, 53);
let ci = Big::from_f64(-0.2, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -747,8 +841,8 @@ mod tests {
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
#[test]
fn buffalo_reference_matches_naive_f64() {
let cr = Big::try_from(-1.2_f64).unwrap();
let ci = Big::try_from(-0.35_f64).unwrap();
let cr = Big::from_f64(-1.2, 53);
let ci = Big::from_f64(-0.35, 53);
let points = compute_set_reference(
&cr,
&ci,
@@ -779,8 +873,8 @@ mod tests {
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
#[test]
fn phoenix_reference_matches_naive_f64() {
let cr = Big::try_from(0.5667_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let cr = Big::from_f64(0.5667, 53);
let ci = Big::from_f64(0.0, 53);
let p = (-0.5_f64, 0.0_f64);
let points = compute_set_reference(
&cr,
@@ -818,8 +912,8 @@ mod tests {
/// iteration of `z^p = exp(p·ln z)`.
#[test]
fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::try_from(0.1_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let cr = Big::from_f64(0.1, 53);
let ci = Big::from_f64(-0.2, 53);
let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference(
&cr,
@@ -861,15 +955,10 @@ mod tests {
}
}
fn set_ref(
cr: f64,
ci: f64,
kind: FractalKind,
morph: Option<(FractalKind, f64)>,
) -> Vec<[f32; 2]> {
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
compute_set_reference(
&Big::try_from(cr).unwrap(),
&Big::try_from(ci).unwrap(),
&Big::from_f64(cr, 53),
&Big::from_f64(ci, 53),
60,
200,
kind,
+924 -101
View File
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -2,13 +2,14 @@
//! 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=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
//! strings.
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
use std::collections::HashMap;
use crate::fractal::FractalKind;
use crate::view::Scale;
#[derive(Clone, Debug)]
pub struct ShareState {
@@ -18,7 +19,7 @@ pub struct ShareState {
pub power: u32,
pub center_re: String,
pub center_im: String,
pub half_height: f64,
pub half_height: Scale,
pub iterations: u32,
pub julia_c: (f64, f64),
/// Distortion constant for the Phoenix kind (ignored by others).
@@ -115,7 +116,7 @@ mod tests {
power: 5,
center_re: "-0.743643887037158704752191506114774".into(),
center_im: "0.131825904205311970493132056385139".into(),
half_height: 1.5e-20,
half_height: Scale::from_f64(1.5e-20),
iterations: 4000,
julia_c: (-0.123, 0.745),
phoenix_p: (-0.5, 0.1),
@@ -146,5 +147,15 @@ 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);
}
}
+424 -82
View File
@@ -3,13 +3,21 @@
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
// render); it just creates its own wgpu device, computes the reference orbit
// once, and renders through the same `ExportRender` path the "Export PNG"
// button uses.
// button uses. `--export-path -` writes to stdout instead: the PNG for a
// single image, or a raw RGBA8 video stream for an animation (for piping
// into ffmpeg).
use eframe::egui_wgpu::wgpu;
use std::collections::{BTreeMap, HashMap};
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc};
use crate::app::{FractalApp, unix_timestamp};
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
use crate::cli::Cli;
use crate::fractal::{ExportRender, FractalRenderer, ShareState, export_to_png_blocking};
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,
@@ -18,6 +26,21 @@ use crate::view::{
/// Cap on the output image dimension (px), to stay within GPU texture limits.
const MAX_DIM: u32 = 8192 * 16;
/// `--export-path` value meaning "write to stdout".
const STDOUT_PATH: &str = "-";
/// Refuse to dump binary image data onto a terminal.
fn check_stdout_piped() -> Result<(), String> {
if std::io::stdout().is_terminal() {
return Err(
"--export-path - writes binary data to stdout; pipe it somewhere \
(e.g. `| ffmpeg ...`)"
.into(),
);
}
Ok(())
}
pub fn run(cli: Cli) -> Result<(), String> {
if cli.buddhabrot {
return Err("headless mode doesn't support --buddhabrot yet".into());
@@ -28,32 +51,22 @@ pub fn run(cli: Cli) -> Result<(), String> {
// These drive the animation path below; grab them before `apply_cli`
// consumes `cli` to build the start state.
let to_view = cli.to_view.clone();
let to_share = cli.to_share.clone();
let to_iterations = cli.to_iterations;
let frames_arg = cli.frames;
let fps = cli.fps;
let duration = cli.duration;
let linear = cli.linear;
let targets = AnimTargets::from_cli(&cli)?;
let export_path = cli.export_path.clone();
if export_path.as_deref() == Some(STDOUT_PATH) {
check_stdout_piped()?;
}
if targets.shard.is_some() && !targets.any() {
return Err("--shard/--shards only apply to animations (give a --to-* target)".into());
}
let mut app = FractalApp::default_state();
app.apply_cli(cli);
app.set_output_size(width, height);
if to_view.is_some() || to_share.is_some() {
return run_animation(
app,
to_view,
to_share,
to_iterations,
frames_arg,
fps,
duration,
linear,
width,
height,
export_path,
);
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()));
@@ -64,15 +77,13 @@ 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);
let (pipeline, bind_group_layout, format) = renderer.export_handles(&device, &uniforms);
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
let handles = renderer.export_handles(&device, &uniforms);
let er = ExportRender::new(
&device,
&queue,
pipeline,
&bind_group_layout,
format,
&handles,
width,
height,
uniforms,
@@ -86,48 +97,140 @@ 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})");
}
Ok(())
}
/// Render a sequence of frames sweeping the camera from the app's current
/// (start) view to an end view, for feeding into ffmpeg. Everything other
/// than the view (kind, colors, iteration cap policy, ...) stays fixed at
/// whatever `apply_cli` set up for the start; only the camera moves.
#[allow(clippy::too_many_arguments)]
fn run_animation(
mut app: FractalApp,
/// 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>,
mut to_iterations: Option<u32>,
frames_arg: Option<u32>,
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 frames = match frames_arg {
let fps = targets.fps;
let frames = match targets.frames {
Some(n) => n,
None => {
let dur = duration.ok_or("animation needs --frames, or --duration (with --fps)")?;
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());
}
let (to, to_iterations_share) =
parse_animation_target(to_view.as_deref(), to_share.as_deref())?;
if to_iterations.is_none()
&& let Some(to_iterations_share) = to_iterations_share
{
to_iterations = Some(to_iterations_share);
// 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
@@ -135,77 +238,281 @@ fn run_animation(
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()));
std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?;
if stream {
eprintln!(
"streaming raw video to stdout; ffmpeg input: \
-f rawvideo -pix_fmt rgba -s {width}x{height} -r {fps} -i -"
);
} else {
std::fs::create_dir_all(&out_dir)
.map_err(|e| format!("failed to create {out_dir}: {e}"))?;
}
let (device, queue) = pollster::block_on(request_device())?;
let format = wgpu::TextureFormat::Bgra8Unorm;
let renderer = FractalRenderer::new(&device, format);
// Only the camera animates, so the shader specialization (kind, Julia,
// DE) is the same for every frame.
let (pipeline, bind_group_layout, format) =
renderer.export_handles(&device, &app.make_uniforms(width as f64 / height as f64));
for i in 0..frames {
// 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 linear { raw_t } else { smoothstep(raw_t) };
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,
);
};
eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1);
app.compute_reference_blocking();
// 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 uniforms = app.make_uniforms(width as f64 / height as f64);
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,
pipeline.clone(),
&bind_group_layout,
format,
handles,
width,
height,
uniforms,
app.reference_points(),
app.lights(),
);
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
eprint!(
"\r[{:>4}/{frames}] {phase} {:>3.0}%",
i + 1,
fraction * 100.0
);
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!();
let path = format!("{out_dir}/frame-{:05}.png", i + 1);
std::fs::write(&path, &png).map_err(|e| format!("save failed: {e}"))?;
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"));
}
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
if stream {
eprintln!("streamed {count} frames ({width}×{height})");
return Ok(());
}
println!("saved {count} frames to {out_dir}/ ({width}×{height})");
if targets.shard.is_some() {
println!(
"tip: once every shard is rendered into {out_dir}/, they form the full sequence; \
this shard alone: ffmpeg -framerate {fps} -start_number {} -i {out_dir}/frame-%05d.png \
-frames:v {count} -c:v libx264 -pix_fmt yuv420p out.mp4",
range.start + 1
);
} else {
println!(
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
);
}
Ok(())
}
/// Parse `--to-view`/`--to-share` (exactly one must be set) into the end
/// view of an animation. Only position/zoom/iterations are pulled from a
/// 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<(ViewState, Option<u32>), String> {
) -> Result<Option<(ViewState, Option<u32>)>, String> {
if let Some(spec) = to_view {
return parse_view_spec(spec).ok_or_else(|| format!("invalid --to-view spec: {spec}"));
return parse_view_spec(spec)
.map(Some)
.ok_or_else(|| format!("invalid --to-view spec: {spec}"));
}
let frag = to_share.expect("run_animation only called with one of to_view/to_share set");
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);
@@ -213,10 +520,18 @@ fn parse_animation_target(
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((
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.
@@ -243,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
.await
.map_err(|e| format!("failed to create device: {e}"))
}
#[cfg(test)]
mod tests {
use super::shard_range;
#[test]
fn shards_tile_all_frames() {
for frames in [2, 3, 10, 97, 1000, u32::MAX] {
for shards in [1, 2, 3, 7, 10] {
if shards > frames {
continue;
}
let mut next = 0;
for k in 1..=shards {
let r = shard_range(frames, k, shards);
assert_eq!(
r.start, next,
"gap/overlap at shard {k}/{shards} of {frames}"
);
assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}");
next = r.end;
}
assert_eq!(next, frames);
}
}
}
}
+4 -1
View File
@@ -1,7 +1,9 @@
use std::f32::consts::PI;
use bytemuck::{Pod, Zeroable};
use egui::{Color32, Ui};
use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::Ui;
/// Maximum number of simultaneous lights.
pub const MAX_LIGHT_COUNT: usize = 16;
@@ -27,6 +29,7 @@ impl Default for Light {
}
impl Light {
#[cfg(feature = "gui")]
pub fn widget(&mut self, ui: &mut Ui) -> bool {
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
let parser = |s: &str| {
+22 -1
View File
@@ -1,6 +1,8 @@
// Without these, rust fails to infer Send/Sync trait impls
// Probably caused by the new trait solver
#![recursion_limit = "256"]
// Without the `gui` feature, UI-only state and helpers go unused.
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
//
@@ -9,6 +11,7 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app;
mod bignum;
mod camera;
mod fractal;
mod lights;
@@ -21,8 +24,17 @@ mod headless;
#[cfg(not(target_arch = "wasm32"))]
mod worker;
#[cfg(all(target_arch = "wasm32", not(feature = "gui")))]
compile_error!("the web build needs the `gui` feature");
#[cfg(feature = "gui")]
use app::FractalApp;
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
type MainResult = eframe::Result;
#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))]
type MainResult = Result<(), String>;
/// wgpu configuration for eframe. The fractal fragment shader reads the
/// reference orbit from a **storage buffer**, so the device must allow storage
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
@@ -30,6 +42,7 @@ use app::FractalApp;
/// * request the adapter's real limits (which include storage buffers), and
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
/// all) — failing cleanly on browsers without WebGPU, per the design.
#[cfg(feature = "gui")]
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
use eframe::egui_wgpu::{WgpuSetup, wgpu};
@@ -51,7 +64,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
fn main() -> MainResult {
use clap::Parser as _;
env_logger::builder()
@@ -70,6 +83,13 @@ fn main() -> eframe::Result {
};
}
#[cfg(not(feature = "gui"))]
{
eprintln!("error: built without the \"gui\" feature; only --headless is supported");
std::process::exit(1);
}
#[cfg(feature = "gui")]
let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(),
@@ -80,6 +100,7 @@ fn main() -> eframe::Result {
..Default::default()
};
#[cfg(feature = "gui")]
eframe::run_native(
"Fractal Explorer",
native_options,
+1 -1
View File
@@ -106,7 +106,7 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
} else if 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, 8u)) + c;
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
} else if 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 {
+27 -1
View File
@@ -150,19 +150,45 @@ fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
// 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 += dist;
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;
+4
View File
@@ -43,6 +43,10 @@ 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;
+5 -1
View File
@@ -48,6 +48,10 @@ struct Uniforms {
// 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.
@@ -164,7 +168,7 @@ fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
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 (clamped to 1000, not 1), and the lighting already
// 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;
+121
View File
@@ -0,0 +1,121 @@
// 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);
}
+681 -23
View File
@@ -11,6 +11,11 @@
// true value |y| drops below the delta |e|, or the reference runs out, we reset
// the reference index to 0 and carry the full value as the new delta (valid
// because X_0 = 0).
//
// Past ~1e30 zoom the deltas themselves leave f32's exponent range (smallest
// normal ~1.2e-38), so `DEEP` pipelines start each pixel in a rescaled form,
// e = w * 2^s with an f32 mantissa `w` and an i32 exponent `s`, and hand over
// to the plain f32 loop once the delta is big enough (see `iterate_sample`).
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
@@ -20,6 +25,11 @@
// Only read by the adaptive-AA refine pass (`fs_refine`): the 1-sample-per-
// pixel data texture written by `fs_data`, which decides where to supersample.
@group(1) @binding(0) var coarse_tex: texture_2d<f32>;
// Per-point binary exponents of the reference orbit (`RefOrbit::exps`): the
// true X[m] is ref_orbit[m] * 2^ref_exp[m]. Non-zero only for points below
// f32's range, which only deep references contain; only `DEEP` pipelines read
// it (see `ref_at`).
@group(0) @binding(3) var<storage, read> ref_exp: array<i32>;
// Pipeline-overridable specialization constants, set per pipeline from the
// uniforms' `kind` / `is_julia` / `de_coloring` (see `PipelineKey` in
@@ -35,6 +45,10 @@ override DE: bool = false;
// second kind, `u.morph_from`. That one is a runtime value (it only lives for
// the length of the animation), so only MORPH pipelines pay for its branches.
override MORPH: bool = false;
// Deep view (`u.scale_exp != 0`): the per-pixel offset `dc`, the pixel size
// and `u.span` / `u.dc_offset` are all in units of 2^scale_exp, and each pixel
// starts in the rescaled deep phase (see `iterate_sample`).
override DEEP: bool = false;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@@ -82,14 +96,18 @@ fn diffabs(c: f32, d: f32) -> f32 {
// tiny, but that only perturbs `s` by a relative f32 epsilon, and the result
// is `e * s`, so the delta keeps full relative precision.
fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
let y = z + e;
return cmul(e, multibrot_sum(z, z + e, p));
}
// The sum in `multibrot_delta`, sum_{k=0}^{p-1} y^k Z^{p-1-k} with y = Z+e.
fn multibrot_sum(z: vec2<f32>, y: vec2<f32>, p: u32) -> vec2<f32> {
var s = vec2<f32>(1.0, 0.0);
var zj = vec2<f32>(1.0, 0.0);
for (var j: u32 = 1u; j < p; j = j + 1u) {
zj = cmul(zj, z); // Z^j
s = cmul(s, y) + zj;
}
return cmul(e, s);
return s;
}
// Maximum number of terms in `complex_multibrot_delta`'s series (matches the
@@ -121,11 +139,21 @@ fn cm_coef(k: u32) -> vec2<f32> {
// dynamics, not a deep-zoom edge case — and the series above would diverge.
// But forming Z+e directly is numerically safe exactly there (e isn't many
// orders of magnitude smaller than Z), so fall back to a plain subtraction.
//
// The series is also wrong when Z -> Z+e crosses the principal branch cut
// (negative real axis) that `cpow` and the CPU reference use. There it
// continues Z's branch, but the true map jumps by a factor e^{2πip}. Which
// pixels crossed then depended on the reference's position, so whole disks
// flipped branch while panning. With |w| < 0.5, arg(1+w) is within ±30°, so an
// Im sign flip with Re(Z) < 0 is exactly a cut crossing. The direct form is
// fine there: the true delta across the cut is large, not tiny.
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let y = z + e;
let crosses_cut = z.x < 0.0 && ((z.y < 0.0) != (y.y < 0.0));
// |w|^2 = |e|^2 / |Z|^2; inf or nan (Z ~ 0, or both ~ 0) correctly fails
// the `< 0.25` test below and falls through to the direct branch.
let w2 = dot(e, e) / dot(z, z);
if w2 < 0.25 {
if w2 < 0.25 && !crosses_cut {
let w = cdiv(e, z);
var wk = w; // w^1
var acc = vec2<f32>(0.0, 0.0);
@@ -138,7 +166,7 @@ fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32
}
return cmul(cpow(z, p), acc);
}
return cpow(z + e, p) - cpow(z, p);
return cpow(y, p) - cpow(z, p);
}
// One perturbation step of `kind`'s delta: e -> f(Z+e) - f(Z), where `z` is
@@ -159,7 +187,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
let ce = conj(e);
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
} else if kind == KIND_MULTIBROT {
return multibrot_delta(z, e, clamp(u.power, 2u, 8u));
return multibrot_delta(z, e, clamp(u.power, 2u, MULTIBROT_MAX_POWER));
} else if kind == KIND_CELTIC {
// z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2).
// Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x).
@@ -194,7 +222,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// enough to de-speckle filaments.
fn fprime_kind(kind: u32, z: vec2<f32>) -> vec2<f32> {
if kind == KIND_MULTIBROT {
let p = clamp(u.power, 2u, 8u);
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
var zk = z; // Z^1
for (var k: u32 = 2u; k < p; k = k + 1u) {
zk = cmul(zk, z); // -> Z^{p-1}
@@ -232,6 +260,324 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
return d;
}
// ---- Deep (rescaled) phase helpers -------------------------------------
//
// A deep value is an f32 mantissa times 2^exponent. All scaling is by exact
// powers of two, so it never rounds.
const LN2: f32 = 0.6931471805599453;
// Where `ldexp_sat` saturates. Only ever compared against small values
// (see `diffabs_scaled`), and far enough from f32's max that doubling it,
// or squaring a value of the size it's compared with, stays finite.
const LDEXP_SAT: f32 = 1.2676506e30; // 2^100
// x * 2^k for any k. WGSL's `ldexp` is only defined for exponents inside
// f32's range, so go through `frexp`: results below the smallest normal
// flush to 0, results above 2^100 saturate to ±2^100.
fn ldexp_sat(x: f32, k: i32) -> f32 {
if x == 0.0 {
return 0.0;
}
let f = frexp(x);
let ex = f.exp + k;
if ex > 100 {
return select(-LDEXP_SAT, LDEXP_SAT, x > 0.0);
}
if ex < -125 {
return 0.0;
}
return ldexp(f.fract, ex);
}
fn ldexp2_sat(v: vec2<f32>, k: i32) -> vec2<f32> {
return vec2<f32>(ldexp_sat(v.x, k), ldexp_sat(v.y, k));
}
// A complex number as mantissa * 2^e, with the mantissa's larger component in
// [0.5, 1) (or exactly 0, with e = 0).
struct Fe {
m: vec2<f32>,
e: i32,
};
fn fe_make(v: vec2<f32>, e: i32) -> Fe {
let a = max(abs(v.x), abs(v.y));
if a == 0.0 {
return Fe(vec2<f32>(0.0, 0.0), 0);
}
let k = frexp(a).exp;
return Fe(ldexp2_sat(v, -k), e + k);
}
// Reference point X[m] as f32 (points stored normalized flush to 0 here;
// only deep references have any, see `ref_exp`).
fn ref_at(m: u32) -> vec2<f32> {
let x = ref_orbit[m];
if DEEP {
let k = ref_exp[m];
if k != 0 {
return ldexp2_sat(x, k);
}
}
return x;
}
// X[m] with its full exponent range (deep phase only).
fn ref_fe(m: u32) -> Fe {
return fe_make(ref_orbit[m], ref_exp[m]);
}
// Complex log of m * 2^e (m != 0).
fn clog_fe(m: vec2<f32>, e: i32) -> vec2<f32> {
return vec2<f32>(0.5 * log(dot(m, m)) + f32(e) * LN2, atan2(m.y, m.x));
}
fn cexp(a: vec2<f32>) -> vec2<f32> {
return exp(a.x) * vec2<f32>(cos(a.y), sin(a.y));
}
// diffabs(c, 2^s * d) / 2^s = diffabs(c / 2^s, d): diffabs is positively
// homogeneous. Saturating c / 2^s is harmless: once it dwarfs |d| the result
// is just ±d.
fn diffabs_scaled(c: f32, d: f32, s: i32) -> f32 {
return diffabs(ldexp_sat(c, -s), d);
}
// Sum of two `Fe`s, at the larger one's exponent.
fn fe_add(a: Fe, b: Fe) -> Fe {
if a.m.x == 0.0 && a.m.y == 0.0 {
return b;
}
if b.m.x == 0.0 && b.m.y == 0.0 {
return a;
}
let e = max(a.e, b.e);
return fe_make(ldexp2_sat(a.m, a.e - e) + ldexp2_sat(b.m, b.e - e), e);
}
// One deep step's delta, (f(X + e) - f(X)) / 2^t: the mantissa `w` and its
// exponent `t`. `t` is the input scale s except next to the critical point,
// where the linear part of the step vanishes and the result is ~e^2, far
// below the input's scale.
struct DeepStep {
w: vec2<f32>,
t: i32,
};
// The per-kind formula of a deep step: (f(X + e) - f(X)) / 2^t for the
// delta e = w * 2^s, given X measured in units of 2^u (`x` = X / 2^u) and
// `sc` = 2^se, se = s - u, the delta's scale in those units. The result's
// scale is t = s + (p-1)·u for a degree-p kind, since every kind here but
// Lambda is p-homogeneous in (X, e) jointly (`diffabs_scaled` rescales the
// fold-point comparisons the same way). Usually u = 0 (x = X, sc = 2^s,
// t = s); see `deep_step_kind` for when it isn't. Lambda always gets u = 0.
// Every kind needs an arm here too.
fn advance_delta_scaled_kind(kind: u32, x: vec2<f32>, w: vec2<f32>, sc: f32, se: i32) -> vec2<f32> {
if kind == KIND_BURNING_SHIP {
let base = 2.0 * cmul(x, w) + sc * cmul(w, w);
let dp = x.x * w.y + x.y * w.x + sc * w.x * w.y;
return vec2<f32>(base.x, 2.0 * diffabs_scaled(x.x * x.y, dp, se));
} else if kind == KIND_TRICORN {
let cx = conj(x);
let cw = conj(w);
return 2.0 * cmul(cx, cw) + sc * cmul(cw, cw);
} else if kind == KIND_MULTIBROT {
return cmul(w, multibrot_sum(x, x + sc * w, clamp(u.power, 2u, MULTIBROT_MAX_POWER)));
} else if kind == KIND_CELTIC {
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
return vec2<f32>(diffabs_scaled(x.x * x.x - x.y * x.y, sq.x, se), sq.y);
} else if kind == KIND_BUFFALO {
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
return vec2<f32>(diffabs_scaled(x.x * x.x - x.y * x.y, sq.x, se),
-diffabs_scaled(2.0 * x.x * x.y, sq.y, se));
} else if kind == KIND_PERPENDICULAR {
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
let da = diffabs_scaled(x.y, w.y, se); // (|Y + ey| - |Y|) / 2^s
let abs_yf = abs(x.y) + sc * da; // |Y + ey| * 2^(s-t)
return vec2<f32>(sq.x, -2.0 * (x.x * da + w.x * abs_yf));
} else if kind == KIND_LAMBDA {
let t = vec2<f32>(1.0 - 2.0 * x.x - sc * w.x, -2.0 * x.y - sc * w.y);
return cmul(u.lambda_l, cmul(w, t));
}
return 2.0 * cmul(x, w) + sc * cmul(w, w); // Mandelbrot (and Phoenix square part)
}
// Below this exponent a reference point counts as next to the critical point
// 0 (see `deep_step_kind`). Above it, the e^2 terms 2^s * w^2 can only flush
// to 0 when they are below 2^-50 of the linear ones.
const DEEP_X_NEAR_LOG2: i32 = -60;
// One deep step of `kind` for the delta e = w * 2^s from X (`x` as f32, `xf`
// at full range). Usually the input scale is kept (t = s). But when X is
// tiny (next to the critical point, e.g. at a minibrot's period), the linear
// term vanishes and the step's value is ~e^p, which would flush to 0 at
// scale s. There every z^p-like kind is p-homogeneous in (X, e) jointly, so
// both are measured in units of 2^u (u = the larger one's exponent) and the
// result lands at t = s + (p-1)·u (`ue` below).
fn deep_step_kind(kind: u32, x: vec2<f32>, xf: Fe, w: vec2<f32>, sc: f32, s: i32) -> DeepStep {
if kind == KIND_COMPLEX_MULTIBROT {
return complex_multibrot_step(xf, w, s);
}
let x_zero = xf.m.x == 0.0 && xf.m.y == 0.0;
// Lambda's critical point is 1/2 and its step has a constant linear
// term (λ·e), so it never needs this.
if kind == KIND_LAMBDA || (!x_zero && xf.e >= DEEP_X_NEAR_LOG2) {
return DeepStep(advance_delta_scaled_kind(kind, x, w, sc, s), s);
}
let kw = deep_log2(w, vec2<f32>(0.0, 0.0));
if kw == DEEP_ZERO {
return DeepStep(w, s); // e = 0: f(X) - f(X)
}
var ue = s + kw;
if !x_zero {
ue = max(ue, xf.e);
}
var deg = 2;
if kind == KIND_MULTIBROT {
deg = i32(clamp(u.power, 2u, MULTIBROT_MAX_POWER));
}
let xk = ldexp2_sat(xf.m, xf.e - ue);
let se = s - ue;
let dw = advance_delta_scaled_kind(kind, xk, w, ldexp_sat(1.0, se), se);
return DeepStep(dw, s + (deg - 1) * ue);
}
// Scaled `complex_multibrot_delta` with X as a full-range `Fe`. Same series
// as the f32 version, rewritten as X^(p-1) * w * sum_k C(p,k) r^(k-1)
// (r = e/X) so nothing is formed at the delta's true scale; X^(p-1)'s own
// exponent goes into the result's `t`. When |e/X| >= 0.5, X is itself tiny
// (|X| <= 2|e|), so both terms of the direct form are taken in log space at
// the larger one's scale. Branch-cut crossings leave the deep phase before
// stepping (`deep_cut_crossing`).
fn complex_multibrot_step(xf: Fe, w: vec2<f32>, s: i32) -> DeepStep {
let p = u.complex_power;
let wf = fe_make(w, s);
if wf.m.x == 0.0 && wf.m.y == 0.0 {
return DeepStep(vec2<f32>(0.0, 0.0), s);
}
if xf.m.x == 0.0 && xf.m.y == 0.0 {
// e^p.
let l = cmul(p, clog_fe(wf.m, wf.e));
let k = i32(floor(l.x / LN2));
return DeepStep(cexp(l - vec2<f32>(f32(k) * LN2, 0.0)), k);
}
let r = ldexp2_sat(cdiv(wf.m, xf.m), wf.e - xf.e);
if dot(r, r) < 0.25 {
var acc = cm_coef(1u);
var rk = r; // r^(k-1)
for (var k: u32 = 2u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) {
acc = acc + cmul(cm_coef(k), rk);
rk = cmul(rk, r);
if dot(rk, rk) < 1e-18 * dot(acc, acc) {
break;
}
}
// X^(p-1) = cexp(l) = cexp(l - k·ln2) * 2^k.
let l = cmul(p - vec2<f32>(1.0, 0.0), clog_fe(xf.m, xf.e));
let k = i32(floor(l.x / LN2));
let x_pm1 = cexp(l - vec2<f32>(f32(k) * LN2, 0.0));
return DeepStep(cmul(cmul(w, x_pm1), acc), s + k);
}
// (X + e)^p - X^p, both in log space (X + e may be exactly 0).
let xs = ldexp2_sat(xf.m, xf.e - s);
let yf = fe_make(xs + w, s);
let lb = cmul(p, clog_fe(xf.m, xf.e));
var k = i32(floor(lb.x / LN2));
var la = vec2<f32>(0.0, 0.0);
let y_zero = yf.m.x == 0.0 && yf.m.y == 0.0;
if !y_zero {
la = cmul(p, clog_fe(yf.m, yf.e));
k = max(k, i32(floor(la.x / LN2)));
}
let kl = vec2<f32>(f32(k) * LN2, 0.0);
var ya = vec2<f32>(0.0, 0.0);
if !y_zero {
ya = cexp(la - kl);
}
return DeepStep(ya - cexp(lb - kl), k);
}
// Deep-phase twin of `advance_delta` (same morph blend, at the larger of the
// two kinds' output scales).
fn advance_delta_scaled(x: vec2<f32>, xf: Fe, w: vec2<f32>, sc: f32, s: i32) -> DeepStep {
let a = deep_step_kind(KIND, x, xf, w, sc, s);
if MORPH {
let b = deep_step_kind(u.morph_from, x, xf, w, sc, s);
let t = max(a.t, b.t);
return DeepStep(mix(ldexp2_sat(a.w, a.t - t), ldexp2_sat(b.w, b.t - t), u.morph_w), t);
}
return a;
}
// Whether this step would take Complex Multibrot's X + e across the branch
// cut (see `complex_multibrot_delta`). The delta then jumps to the size of X,
// so the deep phase ends and the f32 loop takes the step.
fn deep_cut_crossing(xf: Fe, w: vec2<f32>, s: i32) -> bool {
let cm = KIND == KIND_COMPLEX_MULTIBROT || (MORPH && u.morph_from == KIND_COMPLEX_MULTIBROT);
if !cm {
return false;
}
let yn = xf.m + ldexp2_sat(w, s - xf.e); // (X + e) / 2^xe
return xf.m.x < 0.0 && ((xf.m.y < 0.0) != (yn.y < 0.0));
}
// Binary exponent of the largest component of a pair of complex mantissas
// (`frexp` convention: |x| < 2^k), or DEEP_ZERO when both are exactly 0.
const DEEP_ZERO: i32 = -100000;
fn deep_log2(a: vec2<f32>, b: vec2<f32>) -> i32 {
let m = max(max(abs(a.x), abs(a.y)), max(abs(b.x), abs(b.y)));
if m == 0.0 {
return DEEP_ZERO;
}
return frexp(m).exp;
}
// f'(y) at the full value y = X + w * 2^s, as an `Fe`. Plain `fprime` in f32
// unless y is below f32's comfortable range, which only happens next to the
// critical point 0 (after a rebase), where X is itself tiny: then y is
// formed in the 2^s-scaled domain and f' ~ p·y^(p-1) keeps its exponent.
fn deep_fprime(xf: Fe, w: vec2<f32>, s: i32, yt: vec2<f32>) -> Fe {
if MORPH || max(abs(yt.x), abs(yt.y)) >= DEEP_TINY {
return Fe(fprime(yt), 0);
}
let yf = fe_make(ldexp2_sat(xf.m, xf.e - s) + w, s);
if yf.m.x == 0.0 && yf.m.y == 0.0 {
return Fe(fprime(vec2<f32>(0.0, 0.0)), 0);
}
if KIND == KIND_MULTIBROT {
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
var ym = yf.m; // m^(p-1)
for (var k: u32 = 2u; k < p; k = k + 1u) {
ym = cmul(ym, yf.m);
}
return fe_make(f32(p) * ym, yf.e * i32(p - 1u));
} else if KIND == KIND_LAMBDA {
return Fe(fprime(vec2<f32>(0.0, 0.0)), 0); // λ(1 - 2y) ~ λ
} else if KIND == KIND_COMPLEX_MULTIBROT {
// p·y^(p-1) = p·exp(l), l = (p-1)·ln y; keep exp(Re l)'s exponent.
let p = u.complex_power;
let l = cmul(p - vec2<f32>(1.0, 0.0), clog_fe(yf.m, yf.e));
let k = i32(floor(l.x / LN2));
return fe_make(cmul(p, cexp(vec2<f32>(l.x - f32(k) * LN2, l.y))), k);
}
// z^2-like kinds: 2y (|f'| = |2y| for the abs variants too, see fprime).
return Fe(2.0 * yf.m, yf.e);
}
// The deep phase hands over to the f32 loop once the delta's magnitude
// reaches 2^DEEP_EXIT_LOG2: by then |e|^2 is still a normal f32 and the
// pixel offset dc (< 2^-99 on deep views) is below f32 rounding of e. The DE
// derivative only has to be a comfortably normal f32 (DEEP_EXIT_DZ_LOG2).
const DEEP_EXIT_LOG2: i32 = -48;
const DEEP_EXIT_DZ_LOG2: i32 = -100;
// Below this, a full value y goes through `deep_fprime`'s extended path.
const DEEP_TINY: f32 = 7.888609e-31; // 2^-100
// The mantissas are renormalized once their exponent drifts past ±this.
const DEEP_RENORM_LOG2: i32 = 16;
// A reference point can only matter for rebasing when it's within this many
// binades above the delta's scale (|w| < 2^DEEP_RENORM_LOG2).
const DEEP_NEAR_LOG2: i32 = 24;
// Weight of the Phoenix kind's p*z_{n-1} term in the current map: 1 for plain
// Phoenix, its morph share while switching to/from Phoenix, else 0.
fn phoenix_weight() -> f32 {
@@ -245,10 +591,19 @@ fn phoenix_weight() -> f32 {
return w;
}
// Periodicity (interior) detection, Brent-style: the full orbit value is
// saved at iterations PERIOD_FIRST_CHECK, 2x that, 4x ..., and every later
// iterate is compared against the last saved one. Returning within
// PERIOD_EPS2 (relative, squared) means the orbit has closed a cycle.
// Periodicity (interior) detection, Brent-style: windows end at iterations
// PERIOD_FIRST_CHECK, 2x that, 4x ..., and at each window end the window's
// iterate closest to the critical point is saved. Every later iterate is
// compared against the last saved one. Returning within PERIOD_EPS2
// (relative, squared) means the orbit has closed a cycle.
//
// Why the closest-to-critical iterate rather than the one at the window end:
// near a deep minibrot, an orbit follows the minibrot's cycle with a
// deviation at the minibrot's own scale. At an arbitrary phase |z| ~ 1, so
// that deviation is far below the relative tolerance and *any* nearby
// exterior orbit "returned" (a large black disk around a minibrot at
// ~1e-13 zoom). At the phase nearest the critical point, z itself is at the
// minibrot's scale, so the relative tolerance measures the actual return.
//
// A close return alone isn't trusted. A pixel just *outside* the set (at a
// minibrot's edge, or a cusp) can shadow a cycle for thousands of iterations
@@ -290,6 +645,15 @@ fn periodic_enabled() -> bool {
return true;
}
// Critical point of the current map (where f' = 0), the periodicity save
// point's reference: 0 for every z^p-like kind, 1/2 for Lambda's λz(1-z).
fn critical_point() -> vec2<f32> {
if KIND == KIND_LAMBDA {
return vec2<f32>(0.5, 0.0);
}
return vec2<f32>(0.0, 0.0);
}
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
// interior of the set. Splitting iteration from coloring lets a colour change be
@@ -303,13 +667,14 @@ struct Sample {
// Perturbation iterate a single sample. `offset` is the per-pixel offset in
// complex units. For Mandelbrot it is the c-plane offset added every step (delta
// starts at 0); for Julia it is the z-plane offset that seeds the initial delta
// (c is fixed, so nothing is added per step).
// (c is fixed, so nothing is added per step). In `DEEP` pipelines both
// `offset` and `px` are in units of 2^u.scale_exp.
fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// Loop invariants, read once instead of on every iteration.
let max_iter = u.max_iter;
let bailout_sq = u.bailout_sq;
let ref_len = u.ref_len;
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
let z0 = ref_at(0u); // reference start (0 for Mandelbrot, center for Julia)
// Main cardioid / period-2 bulb bypass: those points never escape, so skip
// iterating them (they'd otherwise all burn the full max_iter). `offset` is
@@ -317,7 +682,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// orbit itself, since X_1 = X_0^2 + C_ref = C_ref. That's only f32-accurate,
// so skip the test once a pixel is smaller than that error (deep zoom),
// where it could misclassify pixels right at the boundary.
if KIND == KIND_MANDELBROT && !MORPH && !IS_JULIA && ref_len > 1u && px > 1e-6 {
if KIND == KIND_MANDELBROT && !MORPH && !IS_JULIA && !DEEP && ref_len > 1u && px > 1e-6 {
let c = ref_orbit[1] + offset;
let xq = c.x - 0.25;
let q = xq * xq + c.y * c.y;
@@ -333,6 +698,8 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// seeds the delta and nothing is added per step.
var step_add = offset;
var e = vec2<f32>(0.0, 0.0);
// Pixel size in complex units (`px` is pre-scaled in DEEP pipelines).
var px_t = px;
// Orbit derivative for distance estimation, pre-multiplied by the pixel
// size `px`. For the set plane it is px·d/dc (starts at 0, gains +px each
// step); for Julia it is px·d/dz0 (starts at px). The raw derivative grows
@@ -343,7 +710,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
if IS_JULIA {
step_add = vec2<f32>(0.0, 0.0);
e = offset;
dzs = vec2<f32>(px, 0.0);
dzs = vec2<f32>(px_t, 0.0);
}
// Previous-iterate state for the Phoenix two-term recurrence (delta of
// y_{n-1}, and its scaled derivative for DE). Both start at 0 (y_{-1} = 0).
@@ -368,9 +735,254 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
var check_at = PERIOD_FIRST_CHECK;
var period_hit = false;
var period_streak = 0u;
// This window's save candidate: its iterate closest to the critical
// point, that distance squared, and the |f'|^2 product since it.
let crit = critical_point();
var z_cand = z;
var cand_d2 = 3.0e38;
var mult2_cand = 1.0;
// Deep phase (DEEP pipelines only): iterate the delta as e = w * 2^sx,
// with `w` an f32 mantissa kept near 1 by renormalizing and `sx` an i32
// exponent, while |e| is too small for f32 (it starts at the pixel offset,
// ~2^scale_exp). The DE derivative is linear in the same way and carried
// as dzs = v * 2^sv, with its own exponent: near the critical point (after
// a rebase) f' is tiny, so dzs and e can drift far apart. Once both are
// big enough (or a rebase makes the delta large), the state is converted
// to plain f32 and the loop below carries on from the same n / m.
if DEEP {
let scale_e = u.scale_exp;
var sx = scale_e;
var sv = scale_e;
var sc = ldexp_sat(1.0, sx);
var w = vec2<f32>(0.0, 0.0);
var v = vec2<f32>(0.0, 0.0);
var w_prev = vec2<f32>(0.0, 0.0);
var v_prev = vec2<f32>(0.0, 0.0);
// Per-step additions (dc and px for the set plane), in units of 2^sx
// and 2^sv.
var d = offset;
var pd = px;
if IS_JULIA {
w = offset;
v = vec2<f32>(px, 0.0);
d = vec2<f32>(0.0, 0.0);
pd = 0.0;
}
let z0f = ref_fe(0u);
var xf = z0f;
var xt = z0;
var xf_old = xf;
var xt_old = xt;
var w_old = w;
var rebase_exit = false;
var cut_exit = false;
loop {
// Full value y = X + e as f32 (e flushes to 0 when negligible).
let yt = xt + ldexp2_sat(w, sx);
if dot(yt, yt) > bailout_sq {
escaped = true;
break;
}
if n >= max_iter {
break;
}
if deep_cut_crossing(xf, w, sx) {
cut_exit = true;
break;
}
if DE {
let fp = deep_fprime(xf, w, sx, yt);
if fp.e == 0 {
var v_new = cmul(fp.m, v);
if !IS_JULIA {
v_new.x = v_new.x + pd;
}
if phoenix_w > 0.0 {
v_new = v_new + phoenix_w * cmul(u.phoenix_p, v_prev);
v_prev = v;
}
v = v_new;
} else {
// f' is below f32's range (next to the critical point):
// sum the terms at the largest one's scale and move
// there, like the delta below.
var acc = fe_make(cmul(fp.m, v), fp.e + sv);
if !IS_JULIA {
acc = fe_add(acc, fe_make(vec2<f32>(px, 0.0), scale_e));
}
if phoenix_w > 0.0 {
acc = fe_add(acc, fe_make(phoenix_w * cmul(u.phoenix_p, v_prev), sv));
}
if acc.m.x == 0.0 && acc.m.y == 0.0 {
if phoenix_w > 0.0 {
v_prev = v;
}
v = acc.m;
} else {
if phoenix_w > 0.0 {
v_prev = ldexp2_sat(v, sv - acc.e);
}
v = acc.m;
sv = acc.e;
if !IS_JULIA {
pd = ldexp_sat(px, scale_e - sv);
}
}
}
}
w_old = w;
let st = advance_delta_scaled(xt, xf, w, sc, sx);
if st.t == sx {
w = st.w + d;
if phoenix_w > 0.0 {
w = w + phoenix_w * cmul(u.phoenix_p, w_prev);
w_prev = w_old;
}
} else {
// The step's value is at another scale (next to the
// critical point it is ~e^2, far below 2^sx): add dc and the
// Phoenix term at the largest addend's scale and move there.
var acc = fe_make(st.w, st.t);
if !IS_JULIA {
acc = fe_add(acc, fe_make(offset, scale_e));
}
if phoenix_w > 0.0 {
acc = fe_add(acc, fe_make(phoenix_w * cmul(u.phoenix_p, w_prev), sx));
}
if acc.m.x == 0.0 && acc.m.y == 0.0 {
w = acc.m;
if phoenix_w > 0.0 {
w_prev = w_old;
}
} else {
// w_old (the pre-step delta) is still needed at the new
// scale: it's the next previous delta, and a rebase reads it.
w_old = ldexp2_sat(w_old, sx - acc.e);
if phoenix_w > 0.0 {
w_prev = w_old;
}
w = acc.m;
sx = acc.e;
sc = ldexp_sat(1.0, sx);
if !IS_JULIA {
d = ldexp2_sat(offset, scale_e - sx);
}
}
}
m = m + 1u;
n = n + 1u;
if m >= ref_len {
escaped = true; // see the f32 loop's reference-exhausted case
break;
}
xf_old = xf;
xt_old = xt;
xf = ref_fe(m);
xt = ldexp2_sat(xf.m, xf.e);
// Rebase test |X + e| < |e|, in units of 2^sx. Only possible when
// |X| is within a few binades of |e| (|w| < 2^DEEP_RENORM_LOG2).
if xf.e - sx < DEEP_NEAR_LOG2 {
let q = ldexp2_sat(xf.m, xf.e - sx) + w;
if dot(q, q) < dot(w, w) {
// The new delta y - X[0] stays tiny only if X[0] is
// (always, for the set plane), and for Phoenix only if the
// previous full value y_{n-1} (its new previous delta) is.
let z0_small = (z0f.m.x == 0.0 && z0f.m.y == 0.0)
|| z0f.e - sx < DEEP_NEAR_LOG2;
let prev_small = phoenix_w == 0.0 || xf_old.e - sx < DEEP_NEAR_LOG2;
if !(z0_small && prev_small) {
rebase_exit = true;
break;
}
if phoenix_w > 0.0 {
w_prev = ldexp2_sat(xf_old.m, xf_old.e - sx) + w_old;
}
w = q - ldexp2_sat(z0f.m, z0f.e - sx);
xf = z0f;
xt = z0;
m = 0u;
}
}
// Leave once both the delta and the derivative fit in f32 (an
// exactly-zero one always does); otherwise renormalize the
// mantissas when they drift (exact: powers of two only).
let kw = deep_log2(w, w_prev);
let kv = deep_log2(v, v_prev);
let w_ok = kw == DEEP_ZERO || sx + kw > DEEP_EXIT_LOG2;
let v_ok = !DE || kv == DEEP_ZERO || sv + kv > DEEP_EXIT_DZ_LOG2;
if w_ok && v_ok {
break;
}
if kw != DEEP_ZERO && abs(kw) > DEEP_RENORM_LOG2 {
w = ldexp2_sat(w, -kw);
w_prev = ldexp2_sat(w_prev, -kw);
sx = sx + kw;
sc = ldexp_sat(1.0, sx);
if !IS_JULIA {
d = ldexp2_sat(offset, scale_e - sx);
}
}
if DE && kv != DEEP_ZERO && abs(kv) > DEEP_RENORM_LOG2 {
v = ldexp2_sat(v, -kv);
v_prev = ldexp2_sat(v_prev, -kv);
sv = sv + kv;
if !IS_JULIA {
pd = ldexp_sat(px, scale_e - sv);
}
}
}
// Hand over to the f32 loop. dc and px may flush to 0 here: they
// are below f32 rounding of the (now large enough) delta and
// derivative.
px_t = ldexp_sat(px, scale_e);
if !IS_JULIA {
step_add = ldexp2_sat(offset, scale_e);
}
e = ldexp2_sat(w, sx);
e_prev = ldexp2_sat(w_prev, sx);
dzs = ldexp2_sat(v, sv);
dzs_prev = ldexp2_sat(v_prev, sv);
xm = xt;
if rebase_exit {
// Rebase in plain f32: the new delta (y - X[0], and for Phoenix
// the previous full value) is no longer tiny.
if phoenix_w > 0.0 {
e_prev = xt_old + ldexp2_sat(w_old, sx);
}
e = (xt + e) - z0;
xm = z0;
m = 0u;
}
if cut_exit && e.y == 0.0 && w.y != 0.0 {
// Keep the side of the cut the pixel is on even if e.y flushed
// to 0: that decides the branch in the next (f32) step.
e.y = select(-1.17549435e-38, 1.17549435e-38, w.y > 0.0);
}
z = xm + e;
z2 = dot(z, z);
// Restart periodicity detection from here (check_at must stay ahead
// of n, or no window would ever close). Nothing is saved until the
// first window closes: `z` here is at an arbitrary phase, and the
// pixel still shadows the reference (exactly periodic when it's a
// minibrot nucleus), so comparing against it would flag exterior
// pixels as interior (see PERIOD_FIRST_CHECK). The sentinel is far
// outside the bailout radius, so no return can match it.
z_saved = vec2<f32>(1e18, 1e18);
z_cand = z;
while check_at <= n {
check_at = check_at * 2u;
}
}
loop {
if z2 > bailout_sq {
// (`escaped` may already be set by the deep phase.)
if escaped || z2 > bailout_sq {
escaped = true;
break;
}
@@ -387,12 +999,14 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
fp = fprime(z);
}
if periodic {
mult2 = mult2 * dot(fp, fp);
let fp2 = dot(fp, fp);
mult2 = mult2 * fp2;
mult2_cand = mult2_cand * fp2;
}
if DE {
var dzs_new = cmul(fp, dzs);
if !IS_JULIA {
dzs_new.x = dzs_new.x + px;
dzs_new.x = dzs_new.x + px_t;
}
if phoenix_w > 0.0 {
dzs_new = dzs_new + phoenix_w * cmul(u.phoenix_p, dzs_prev);
@@ -421,7 +1035,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
escaped = true;
break;
}
xm = ref_orbit[m];
xm = ref_at(m);
z = xm + e;
z2 = dot(z, z);
if z2 < dot(e, e) {
@@ -449,13 +1063,20 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
break;
}
}
let dc2 = dot(z - crit, z - crit);
if dc2 < cand_d2 {
cand_d2 = dc2;
z_cand = z;
mult2_cand = 1.0;
}
if n == check_at {
if !period_hit {
period_streak = 0u;
}
period_hit = false;
z_saved = z;
mult2 = 1.0;
z_saved = z_cand;
mult2 = mult2_cand;
cand_d2 = 3.0e38;
check_at = check_at * 2u;
}
}
@@ -492,14 +1113,51 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
// filaments stay crisp instead of aliasing into speckle. If |dzs|
// overflowed (far sub-pixel from the set), de -> 0 and the boundary
// simply reads as dark, which is the correct limit.
// Shadow/3D use DE as a height field, so it must stay unclamped: any
// cap flattens everything farther than that from the set into a
// uniform plateau (a visible circle around the set when zoomed out).
// 3D saturates heights smoothly itself (`sdf` in colorize.wgsl).
let zmag = sqrt(max(z2, 1.0));
let dzmag = sqrt(max(dot(dzs_esc, dzs_esc), 1e-30));
let max_de = select(1.0, 1000.0, u.shadow != 0u);
de = clamp(zmag * log(zmag) / dzmag, 0.0, max_de);
// |z|·ln|z|/|dz| is G/|G'| (G the potential, ln|z|/2^n). Far from the
// set G ~ ln r, so it grows like r·ln r rather than r: zoomed far out,
// the 3D cone got steeper with every zoom step, down to a texel-wide
// needle cut off above the plateau. Replacing G by 2(1 - e^(-G/2))
// leaves it unchanged near the set (G -> 0, and ~0.8x at the edge of
// the default view) but caps it at 2, so far away DE grows like 2r
// and the cone stops narrowing. G underflows to 0 past ~150
// iterations, where the factor is 1 anyway. G divides by the map's
// degree d per step, not 2: with 2^-n, pixels either side of a band
// boundary got G off by d/2, a DE seam on every band for d != 2.
let g = log(zmag) * pow(escape_degree(), -f32(n));
let far = select(1.0, 2.0 * (1.0 - exp(-g / 2.0)) / g, g > 1e-4);
let max_de = select(1.0, 1e30, u.shadow != 0u);
de = clamp(zmag * log(zmag) / dzmag * far, 0.0, max_de);
}
return Sample(ci, de, true);
}
// Degree d of the current map at infinity (|f(z)| ~ |z|^d), which sets how
// fast the potential G = ln|z_n| / d^n shrinks per step. Complex Multibrot's
// |z^p| = |z|^Re(p)·e^(-Im(p)·arg z) grows like |z|^Re(p) (arg is bounded).
// A morph blend is dominated by the higher degree.
fn kind_degree(kind: u32) -> f32 {
if kind == KIND_MULTIBROT {
return f32(clamp(u.power, 2u, MULTIBROT_MAX_POWER));
} else if kind == KIND_COMPLEX_MULTIBROT {
return max(u.complex_power.x, 1.0);
}
return 2.0;
}
fn escape_degree() -> f32 {
let d = kind_degree(KIND);
if MORPH {
return max(d, kind_degree(u.morph_from));
}
return d;
}
// 1 / ln(2), for the smooth iteration count's log2(ln|z| / ln 2).
const INV_LN2: f32 = 1.4426950408889634;
@@ -576,7 +1234,7 @@ fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
// 4-neighbour's 1-spp sample differs from its own by more than this. `ci`
// steps are palette-phase steps of `ci * color_scale` (color_scale <= 1 in the
// UI), so 0.02 keeps anything visibly banded; DE is compared relative to its
// own magnitude (it's in pixels, up to 1000 for shadow/3D height fields).
// own magnitude (it's in pixels, unbounded for shadow/3D height fields).
const AA_CI_EPS: f32 = 0.02;
const AA_DE_EPS: f32 = 0.1;
+400 -86
View File
@@ -1,65 +1,303 @@
//! Camera / view state over the complex plane.
//!
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the
//! center needs the extra digits.
//! 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.
use core::str::FromStr;
use dashu_float::round::mode::HalfAway;
use dashu_float::{DBig, FBig};
/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate.
pub type Big = FBig<HalfAway, 2>;
pub use crate::bignum::Big;
/// Half-height (complex units) of the default view; also the zoom-1 reference.
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
/// 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 (f32 GPU perturbation degrades long before
/// this; the cap just prevents pathological allocation).
const MAX_PRECISION_BITS: usize = 2048;
/// 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;
#[derive(Clone, Debug)]
pub struct ViewState {
pub center_re: Big,
pub center_im: Big,
/// Half the view height in complex-plane units. Zooming in shrinks this.
pub half_height: f64,
pub half_height: Scale,
}
impl Default for ViewState {
fn default() -> Self {
let bits = precision_for(DEFAULT_HALF_HEIGHT);
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
Self {
center_re: big_from_f64(-0.5, bits),
center_im: big_from_f64(0.0, bits),
half_height: DEFAULT_HALF_HEIGHT,
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
}
}
}
impl ViewState {
/// Complex-plane span (width, height) for the given pixel aspect ratio.
pub fn span(&self, aspect: f64) -> (f64, f64) {
let h = self.half_height * 2.0;
(h * aspect, h)
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
let h = self.half_height.mul_f64(2.0);
(h.mul_f64(aspect), h)
}
/// Complex-plane units per pixel, given the viewport height in pixels.
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
(self.half_height * 2.0) / height_px
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
self.half_height.mul_f64(2.0 / height_px)
}
/// Current magnification relative to the default view.
pub fn magnification(&self) -> f64 {
DEFAULT_HALF_HEIGHT / self.half_height
/// 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) -> f64 {
pub fn zoom(&self) -> Scale {
self.half_height
}
@@ -73,10 +311,10 @@ impl ViewState {
pub fn sync_precision(&mut self) {
let bits = self.precision_bits();
if self.center_re.precision() < bits {
self.center_re = self.center_re.clone().with_precision(bits).value();
self.center_re = self.center_re.clone().with_precision(bits);
}
if self.center_im.precision() < bits {
self.center_im = self.center_im.clone().with_precision(bits).value();
self.center_im = self.center_im.clone().with_precision(bits);
}
}
@@ -86,8 +324,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 - &big_from_f64(dx * cpp, bits);
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
}
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
@@ -100,14 +338,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 = 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;
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);
}
/// Build a view from full-precision center coordinates and a half-height.
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self {
pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
let mut v = Self {
center_re,
center_im,
@@ -121,8 +359,7 @@ impl ViewState {
/// Parse a decimal string (any number of digits) losslessly into a `Big` with at
/// least `bits` of precision. Used for share links and debug view specs.
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
let dec = DBig::from_str(s.trim()).ok()?;
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
Big::from_decimal_str(s, bits)
}
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
@@ -134,10 +371,7 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
if parts.len() < 3 {
return None;
}
let half_height = parts[2].trim().parse::<f64>().ok()?;
if !(half_height > 0.0 && half_height.is_finite()) {
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)?;
@@ -148,12 +382,8 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
/// 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<f64> {
let half_height = spec.trim().parse::<f64>().ok()?;
if !(half_height > 0.0 && half_height.is_finite()) {
return None;
}
Some(half_height)
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
@@ -182,77 +412,87 @@ pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
/// 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 q = to.half_height / from.half_height;
let half_height = from.half_height * q.powf(t);
let bits = precision_for(half_height);
let g = if (q - 1.0).abs() < 1e-12 {
1.0 - t
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 {
(q.powf(t) - q) / (1.0 - q)
Scale::from_log2(l0 + t * d)
};
let g_big = big_from_f64(g, bits);
let re0 = from.center_re.clone().with_precision(bits).value();
let im0 = from.center_im.clone().with_precision(bits).value();
let re1 = to.center_re.clone().with_precision(bits).value();
let im1 = to.center_im.clone().with_precision(bits).value();
let 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
}
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
let dec = x
.to_decimal()
.value()
.with_precision(sig_digits.max(1))
.value();
format!("{dec}")
x.to_decimal_string(sig_digits)
}
/// Precision (bits) needed to resolve the center at a given half-height.
pub fn precision_for(half_height: f64) -> usize {
pub fn precision_for(half_height: Scale) -> usize {
// We need enough bits to distinguish points a pixel apart, i.e. roughly
// log2(1 / half_height) significant bits, plus a guard margin.
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
(-half_height.log2()).ceil().max(0.0) as usize
} else {
0
};
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
}
/// Build an `FBig` from an f64 with an explicit precision context.
/// Build a `Big` from an f64 with an explicit precision.
pub fn big_from_f64(x: f64, bits: usize) -> Big {
Big::try_from(x)
.unwrap_or_default()
.with_precision(bits)
.value()
Big::from_f64(x, bits)
}
#[cfg(test)]
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_decimal().value().to_f64().value();
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
let re: f64 = v.center_re.to_f64();
let im: f64 = v.center_im.to_f64();
(re, im)
}
#[test]
fn interpolate_view_hits_exact_endpoints() {
let bits = precision_for(1.0);
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
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(1e-20)),
big_from_f64(0.1013, precision_for(1e-20)),
1e-20,
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);
@@ -272,12 +512,13 @@ mod tests {
/// instead stay roughly bounded throughout.
#[test]
fn interpolate_view_keeps_target_offset_bounded() {
let bits = precision_for(1.0);
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
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(1e-20)),
big_from_f64(0.1013, precision_for(1e-20)),
1e-20,
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);
@@ -286,7 +527,7 @@ mod tests {
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;
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={})",
@@ -294,4 +535,77 @@ mod tests {
);
}
}
#[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}");
}
}
}
+7 -7
View File
@@ -1,7 +1,7 @@
//! Native background worker for reference-orbit computation.
//!
//! At deep zoom the high-precision reference can take many milliseconds (tens of
//! thousands of `FBig` iterations), which would stutter the UI if done inline.
//! thousands of `Big` iterations), which would stutter the UI if done inline.
//! This runs it on a thread and coalesces bursts of requests (e.g. during a
//! drag) down to the most recent one. On the web we compute inline instead
//! (browsers need a Web Worker for threads); see `app.rs`.
@@ -9,13 +9,13 @@
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread;
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
use crate::view::{Big, big_from_f64};
use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
use crate::view::{Big, Scale, big_from_f64};
pub struct RefRequest {
pub center_re: Big,
pub center_im: Big,
pub half_height: f64,
pub half_height: Scale,
pub julia: bool,
pub julia_c: (f64, f64),
pub max_iter: u32,
@@ -35,8 +35,8 @@ pub struct RefRequest {
pub struct RefResult {
pub center_re: Big,
pub center_im: Big,
pub half_height: f64,
pub points: Vec<[f32; 2]>,
pub half_height: Scale,
pub points: RefOrbit,
/// The kind and morph `points` was computed with (echoed from the
/// request).
pub kind: FractalKind,
@@ -102,7 +102,7 @@ impl RefWorker {
}
}
fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
fn compute(req: &RefRequest) -> RefOrbit {
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);
+15 -1
View File
@@ -77,7 +77,7 @@ fn mandelbrot_shader_is_valid() {
}
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
/// DE × morph), for every fragment entry point.
/// DE × morph × deep), for every fragment entry point.
#[test]
fn mandelbrot_shader_specializations_compile() {
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
@@ -85,11 +85,13 @@ fn mandelbrot_shader_specializations_compile() {
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(
@@ -105,6 +107,7 @@ fn mandelbrot_shader_specializations_compile() {
}
}
}
}
}
#[test]
@@ -119,6 +122,17 @@ 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(
+2
View File
@@ -0,0 +1,2 @@
results/
.worktrees/
+48
View File
@@ -0,0 +1,48 @@
# Benchmarks
`bench.sh` times `mandelbrot --headless` on a fixed set of views with
[hyperfine](https://github.com/sharkdp/hyperfine). That's the whole real
pipeline: the reference orbit on the CPU, then the GPU render, readback and PNG
encode. It builds the headless-only binary (`--no-default-features`) itself.
```sh
tools/bench/bench.sh --rev HEAD # did my uncommitted edits help? (vs last commit)
tools/bench/bench.sh --rev main seahorse-1e300 # one scenario vs another branch
tools/bench/bench.sh --quick # all scenarios, 960×540, 3 runs
tools/bench/bench.sh # all scenarios, 1920×1080, 5 runs
tools/bench/bench.sh --list
```
`--rev REF` checks REF out into `.worktrees/<sha>` and builds it there, with its
own target dir, so your `target/` stays warm. Each scenario then runs both
binaries back to back in one hyperfine call. The line to read is
`working tree ran 1.23 ± 0.04 times faster than main (…)`. A ratio within about
2σ of 1.00 is noise.
Results (hyperfine JSON and markdown per scenario) go to
`results/<sha>[-dirty]/`, or `results/<rev>-vs-<sha>/` with `--rev`.
Both `results/` and `.worktrees/` are git-ignored. To clean up:
`rm -rf tools/bench/.worktrees && git worktree prune`.
## Scenarios
| name | mostly measures |
|---|---|
| `shallow` | GPU, plain f32 path, f64 orbit fast path |
| `seahorse-1e30` | GPU f32 perturbation from a `Big` orbit |
| `seahorse-1e100` | the `DEEP` shader pipeline |
| `elephant-1e100-aa-de` | 2×2 antialiasing + DE shading |
| `burning-ship` | a non-holomorphic kind |
| `3d` | the raymarched 3D export path |
| `anim-zoom` | 24-frame zoom (⅓ size): the parallel orbit, GPU and PNG pipeline |
| `seahorse-1e300` *(slow)* | ~270k iterations per pixel on the deep path, ~40 s per run even with `--quick`. It's GPU-bound: the orbit itself is under a second. Only runs when named or with `--all` |
Views come from `tools/deep-zoom/LOCATIONS.md`. Each wall time includes process
start, device creation and shader compilation, so a small change to a GPU-bound
scenario shows up diluted.
## Tips
- Keep the machine idle and on AC power. Other GPU apps (browsers, video) add a lot of noise.
- Use `--quick` while you iterate and a full run to confirm.
- Timings are only comparable on the same machine and GPU.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Benchmark `mandelbrot --headless` on a fixed set of scenarios with hyperfine.
#
# Usage:
# bench.sh [--quick] [--runs N] [--rev REF] [--all] [--list] [scenario...]
#
# (default) time the working tree; results go to results/<sha>[-dirty]/
# --rev REF also build REF (in a git worktree) and time both binaries side
# by side; hyperfine prints "X ± σ times faster" per scenario
# --quick 960×540 and 3 runs instead of 1920×1080 and 5
# --runs N timed runs per command (after 1 warmup run)
# --all include the slow scenarios
# --list print the scenario names and exit
#
# With no scenario names, all but the slow ones run. See README.md.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
root=$(git -C "$here" rev-parse --show-toplevel)
# Views from tools/deep-zoom/LOCATIONS.md.
SEAHORSE_40='-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594'
SEAHORSE_101='-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495'
SEAHORSE_300='-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930'
ELEPHANT_100='0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747'
# name -> headless args (no size or --export-path; those are added per run).
# Order matters for display, hence the separate list. SLOW ones (tens of
# seconds per run) only run when named or with --all.
SCENARIOS=(shallow seahorse-1e30 seahorse-1e100 elephant-1e100-aa-de burning-ship 3d anim-zoom)
SLOW=(seahorse-1e300)
declare -A ARGS=(
[shallow]="--view=-0.75,0,1.5"
[seahorse-1e30]="--view=$SEAHORSE_40,1e-30"
[seahorse-1e100]="--view=$SEAHORSE_101,3.4e-101"
[seahorse-1e300]="--view=$SEAHORSE_300,5.5e-300"
[elephant-1e100-aa-de]="--view=$ELEPHANT_100,1e-75 --antialias --de"
[burning-ship]="--kind burning-ship"
[3d]="--rendering-kind 3d --de --pitch -40 --view=-0.75,0,1.5"
[anim-zoom]="--view=-0.75,0,1.5 --to-view=$SEAHORSE_40,1e-30 --frames 24"
)
quick=0
runs=
rev=
picked=()
while (($#)); do
case $1 in
--quick) quick=1 ;;
--runs) runs=$2; shift ;;
--rev) rev=$2; shift ;;
--all) picked+=("${SCENARIOS[@]}" "${SLOW[@]}") ;;
--list) printf '%s\n' "${SCENARIOS[@]}"; printf '%s (slow)\n' "${SLOW[@]}"; exit 0 ;;
-h|--help) sed -n '2,16s/^# \{0,1\}//p' "$0"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*)
[[ -v ARGS[$1] ]] || { echo "unknown scenario: $1 (try --list)" >&2; exit 2; }
picked+=("$1") ;;
esac
shift
done
((${#picked[@]})) || picked=("${SCENARIOS[@]}")
if ((quick)); then
width=960 height=540 runs=${runs:-3}
else
width=1920 height=1080 runs=${runs:-5}
fi
command -v hyperfine >/dev/null || { echo "hyperfine not found (https://github.com/sharkdp/hyperfine)" >&2; exit 1; }
# Headless-only binary: no eframe/egui, faster to build, same render path.
build() { cargo build --release --no-default-features --quiet --manifest-path "$1/Cargo.toml"; }
echo "building working tree…" >&2
build "$root"
new_bin="$root/target/release/mandelbrot"
if [[ -n $rev ]]; then
rev_sha=$(git -C "$root" rev-parse --short "$rev^{commit}")
wt="$here/.worktrees/$rev_sha"
[[ -d $wt ]] || git -C "$root" worktree add --detach --quiet "$wt" "$rev_sha"
echo "building $rev ($rev_sha)…" >&2
# Separate target dir so switching revisions doesn't invalidate target/.
CARGO_TARGET_DIR="$here/.worktrees/target" build "$wt"
old_bin="$here/.worktrees/target/release/mandelbrot"
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
sha=$(git -C "$root" rev-parse --short HEAD)
git -C "$root" diff --quiet HEAD -- . ':!tools/bench' || sha+=-dirty
out="$here/results/${rev:+$rev_sha-vs-}$sha"
mkdir -p "$out"
# Full headless command line for scenario $2 with binary $1.
command_for() {
local bin=$1 name=$2 w=$width h=$height dest=$tmp/$2.png
if [[ $name == anim-* ]]; then
# Animation: a directory of frames, at a smaller size.
w=$((width / 3)) h=$((height / 3)) dest=$tmp/$name
fi
echo "$bin --headless --width $w --height $h --export-path $dest ${ARGS[$name]}"
}
for name in "${picked[@]}"; do
echo >&2
echo "=== $name ===" >&2
if [[ -n $rev ]]; then
cmds=(-n "$rev ($rev_sha)" "$(command_for "$old_bin" "$name")"
-n "working tree" "$(command_for "$new_bin" "$name")")
else
cmds=(-n "$name" "$(command_for "$new_bin" "$name")")
fi
hyperfine --warmup 1 --runs "$runs" --style basic \
--export-json "$out/$name.json" --export-markdown "$out/$name.md" \
"${cmds[@]}"
done
echo
echo "## Summary (${width}×${height}, $runs runs; results in ${out#"$root"/}/)"
for name in "${picked[@]}"; do
echo
echo "### $name"
cat "$out/$name.md"
done
+167
View File
@@ -0,0 +1,167 @@
# Deep-zoom locations
Beautiful deep Mandelbrot locations, all computed with `find_deep.py` and checked with a
`--headless` render. Every one is an exact minibrot nucleus (Newton's method at
`2·depth + 60` digits), not a coordinate copied from somewhere, so the digits are right all
the way down.
## How to use them
Each location has two views on the same centre:
- **Minibrot:** half-height ≈ 3× the minibrot's size. A tiny copy of the whole set sits in
concentric colour bands, and there are more bands the deeper it is.
- **Embedded Julia set:** half-height ≈ size^0.75, about ¾ of the way down in log-zoom.
This is where each location looks unique. The minibrots all look alike.
```sh
cargo run --release -- --view=RE,IM,HALF_HEIGHT # interactive
cargo run --release -- --headless --view=RE,IM,HALF_HEIGHT --export-path shot.png
# zoom video from the full set down to a minibrot, piped to ffmpeg (see Makefile):
cargo run --release -- --headless --view=-0.75,0,1.5 --to-view=RE,IM,HALF_HEIGHT \
--fps 60 --duration 60 --export-path - | make hevc-nvenc
```
Pass negative coordinates as `--view=-0.77...` (with the `=`): clap would read `--view -0.77...` as a flag.
Iterations scale with depth automatically (about 90k at 10⁻¹⁰⁰ and 270k at 10⁻³⁰⁰), which is
enough for these periods.
## Infinite spirals (Misiurewicz points)
These are the anchor points. Zoom straight into one and the same spiral repeats forever,
at any depth. Every minibrot below sits just beside one of them.
| Region | Point | Preperiod, period | Looks like |
|---|---|---|---|
| Seahorse valley | `-0.7756837680090537974694835039347410457282465046158` `0.13646736829469012473327440961784876519777211159247` | 24, 2 | Two-armed spirals of seahorses, repeating at every depth. |
| Elephant valley | `0.29207386191445391799971751769220573408040189143315` `0.015770917211390118082469028230338337682600488632339` | 32, 3 | Three-armed spirals of elephant trunks. |
| Top antenna | `-0.10110375797210418675706890936004723524958119917843` `0.95629400795235409290719434118824484558046591980423` | 38, 4 | Four-armed branch points on the upper antenna, with thin dendrites instead of spirals. |
## Seahorse valley
### Seahorse valley, minibrot at 3.7e-41 (period 1034)
The embedded Julia set is a pale double spiral: two S-shaped arms of tiny seahorses wind into a bright centre, with a third lace band sweeping around them. The best one for a short video, and it renders in seconds.
```sh
# embedded Julia set
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1e-30
# minibrot
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1.1e-40
```
### Seahorse valley, minibrot at 1.14e-101 (period 2868)
At the Julia depth there's a dark blue field with a glowing core and lace threads crossing it.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,1e-76
# minibrot
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,3.4e-101
```
### Seahorse valley, minibrot at 1.37e-200 (period 5895)
Two filaments cross in an X over a warm brown background, with a small dark spiral where they meet.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,1e-150
# minibrot
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,4.1e-200
```
### Seahorse valley, minibrot at 1.83e-300 (period 8922)
Faint cream threads, and a spiral wound so tight it reads as a point. Period 8922, so the minibrot needs a long render.
```sh
# embedded Julia set
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,1e-225
# minibrot
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,5.5e-300
```
## Elephant valley
### Elephant valley, minibrot at 1.29e-100 (period 782)
A cream-coloured double S-spiral, like the seahorse one but with fatter, beaded arms.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,1e-75
# minibrot
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,3.9e-100
```
### Elephant valley, minibrot at 1.43e-199 (period 1585)
A single huge spiral arm made of blue beads curling across the whole frame.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,1e-149
# minibrot
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,4.3e-199
```
### Elephant valley, minibrot at 3.82e-300 (period 2395)
A spiral galaxy: several beaded arms around a pinwheel centre. One of the prettiest frames here.
```sh
# embedded Julia set
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1e-225
# minibrot
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1.1e-299
```
## Top antenna
### Top antenna, minibrot at 6.11e-101 (period 245)
A dark branching dendrite like a frost cross, with four-way junctions.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1e-75
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1.8e-100
```
### Top antenna, minibrot at 5.28e-200 (period 473)
A chain of six-armed snowflakes linked by thin filaments.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1e-149
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1.6e-199
```
### Top antenna, minibrot at 8.34e-301 (period 705)
A single twelve-armed star, very symmetric. The antenna minibrots have short periods (245-705), so these are the fastest deep renders.
```sh
# embedded Julia set
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,1e-225
# minibrot
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,2.5e-300
```
## Finding more
```sh
./find_deep.py --list # known regions
./find_deep.py seahorse 250 # minibrot of size ~1e-500 near the seahorse point
./find_deep.py seahorse 100 0.2,0.7 # a different minibrot at the same depth
./find_deep.py -0.1011,0.9563,38,4 80 # any Misiurewicz point, given as re,im,k,p
```
A disk of radius 10⁻ᴰ around a Misiurewicz point holds a minibrot of size about 10⁻²ᴰ.
Newton sometimes fails to converge (elephant at depth 100 with the default offset did). The
script detects this from the size and asks for a different offset. Needs `mpmath`.
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Find deep Mandelbrot minibrots near a Misiurewicz point.
Misiurewicz points (preperiodic c, f^(k+p)(0) = f^k(0)) have self-similar
spirals at every depth. A disk of radius r = 10^-D around one contains a
minibrot of size ~r^2, found by:
1. the ball-period method: the first n where the disk's image covers 0
gives the period p of a nucleus inside it;
2. Newton's method on f^p(0) = 0 at high precision for the nucleus;
3. the standard size estimate (Heiland-Allen) for its scale.
Zooming at the nucleus shows spirals, then an embedded Julia set (around
size^0.75), then the minibrot (half-height ~3x size).
Usage:
find_deep.py REGION DEPTH [OFFSET_RE,OFFSET_IM]
find_deep.py --list
REGION is a name from REGIONS or "re,im,k,p" for any Misiurewicz point.
OFFSET (default 0.6,0.3, in units of r) picks a different minibrot at the
same depth. If the printed size is far below 10^-(2*DEPTH), Newton didn't
converge: try another offset.
Needs mpmath (pip install mpmath).
"""
import sys
from mpmath import log10, mp, mpc, mpf
# name: (approximate seed, preperiod k, period p). The exact point is
# refined by Newton at the requested precision.
REGIONS = {
"seahorse": (("-0.77568377", "0.13646737"), 24, 2),
"elephant": (("0.2925", "0.0149"), 32, 3),
"antenna": (("-0.1011", "0.9563"), 38, 4),
}
def converged(d):
return abs(d) < mpf(10) ** (-mp.dps + 20)
def misiurewicz(c, k, p, steps=200):
"""Newton on f^(k+p)(0) - f^k(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
zk = dzk = None
for i in range(1, k + p + 1):
dz = 2 * z * dz + 1
z = z * z + c
if i == k:
zk, dzk = z, dz
d = (z - zk) / (dz - dzk)
c -= d
if converged(d):
break
return c
def period_in_ball(c, r, maxit=1_000_000):
"""First n where the disk of radius r around c maps onto a disk containing 0."""
z = dz = mpc(0)
for n in range(1, maxit):
dz = 2 * z * dz + 1
z = z * z + c
if abs(z) < abs(dz) * r:
return n
if abs(z) > 4:
return None
return None
def nucleus(c, p, steps=200):
"""Newton on f^p(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
for _ in range(p):
dz = 2 * z * dz + 1
z = z * z + c
d = z / dz
c -= d
if converged(d):
break
return c
def size(c, p):
"""Approximate size of the minibrot with nucleus c and period p."""
z = mpc(0)
l = b = mpc(1)
for _ in range(1, p):
z = z * z + c
l = 2 * z * l
b = b + 1 / l
return abs(1 / (b * l * l))
def main(argv):
if len(argv) >= 1 and argv[0] == "--list":
for name, ((re, im), k, p) in REGIONS.items():
print(f"{name:10} ~{re}{'+' if not im.startswith('-') else ''}{im}i M({k},{p})")
return 0
if len(argv) not in (2, 3):
print(__doc__, file=sys.stderr)
return 2
region, depth = argv[0], int(argv[1])
if region in REGIONS:
(re, im), k, p = REGIONS[region]
else:
re, im, k, p = region.split(",")
k, p = int(k), int(p)
off_re, off_im = argv[2].split(",") if len(argv) == 3 else ("0.6", "0.3")
mp.dps = 2 * depth + 60
m = misiurewicz(mpc(re, im), k, p)
r = mpf(10) ** (-depth)
c0 = m + r * mpc(off_re, off_im)
period = period_in_ball(c0, r)
if period is None:
print("no period found (the disk escapes)", file=sys.stderr)
return 1
n = nucleus(c0, period)
s = size(n, period)
if s < mpf(10) ** (-2 * depth - 10):
print(f"size {mp.nstr(s, 3)} is implausibly small: Newton didn't converge, "
"try another offset", file=sys.stderr)
return 1
digits = int(-log10(s)) + 10
log_s = float(log10(s))
re_s = mp.nstr(n.real, digits, strip_zeros=False)
im_s = mp.nstr(n.imag, digits, strip_zeros=False)
print(f"# {region} depth={depth} period={period} size={mp.nstr(s, 3)}")
print("# minibrot:")
print(f"--view={re_s},{im_s},{mp.nstr(3 * s, 2)}")
print(f"# embedded Julia set on the way down:")
print(f"--view={re_s},{im_s},1e{round(0.75 * log_s)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3
View File
@@ -0,0 +1,3 @@
clips/
work/
results/
+8
View File
@@ -0,0 +1,8 @@
# Pass 1: which presets fit the 3-5 fps budget at 4K on this machine.
x265-fast | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset fast -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-medium | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset medium -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-slow | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset slow -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
av1-p5 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 5 -crf 26 -svtav1-params film-grain=0
av1-p6 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 6 -crf 26 -svtav1-params film-grain=0
av1-p7 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 7 -crf 26 -svtav1-params film-grain=0
av1-p8 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 8 -crf 26 -svtav1-params film-grain=0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Render the lossless 4K60 test clips that sweep.sh encodes.
#
# Each clip goes through the same RGBA -> bt709 tv-range 10-bit conversion as
# the Makefile, then into lossless FFV1. The clips are therefore exactly what the
# encoders see, so VMAF against them measures the encoder alone.
#
# tools/encode/make_clips.sh [--size WxH] [--seconds S] [--force] [clip...]
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
out="$here/clips"
size=3840x2160
seconds=4
fps=60
force=0
only=()
while [[ $# -gt 0 ]]; do
case $1 in
--size) size=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--force) force=1; shift ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) only+=("$1"); shift ;;
esac
done
# Seahorse valley, minibrot at 3.7e-41 (tools/deep-zoom/LOCATIONS.md).
SH_RE=-0.77568376800905379745347652613832924487504096622022
SH_IM=0.13646736829469012473375311880735014411233827361594
# name | mandelbrot flags. Zoom speeds bracket a typical 60 s dive (~0.7 decade/s).
clips=(
# Worst case: dense filaments, 1.5 decades/s of scaling motion.
"filaments|--view=$SH_RE,$SH_IM,1e-8 --to-view=$SH_RE,$SH_IM,3e-15 --linear"
# Smooth concentric bands closing in on the minibrot: banding/blocking risk.
"bands|--view=$SH_RE,$SH_IM,1e-38 --to-view=$SH_RE,$SH_IM,1.1e-40 --linear"
# Embedded Julia set, slow drift: fine static texture, psy/detail retention.
"julia|--view=$SH_RE,$SH_IM,2e-30 --to-view=$SH_RE,$SH_IM,1e-30 --linear"
)
[[ -x $root/target/release/mandelbrot ]] || cargo build --release --manifest-path "$root/Cargo.toml"
mkdir -p "$out"
w=${size%x*}
h=${size#*x}
for entry in "${clips[@]}"; do
name=${entry%%|*}
flags=${entry#*|}
if [[ ${#only[@]} -gt 0 && ! " ${only[*]} " =~ " $name " ]]; then continue; fi
dst="$out/$name.mkv"
if [[ -f $dst && $force = 0 ]]; then echo "skip $name (exists, --force to redo)"; continue; fi
echo "render $name ($size, ${seconds}s @ ${fps}fps)"
# shellcheck disable=SC2086
"$root/target/release/mandelbrot" --headless --antialias $flags \
--width "$w" --height "$h" --fps "$fps" --duration "$seconds" --export-path - |
ffmpeg -hide_banner -loglevel error -y \
-f rawvideo -pix_fmt rgba -s "$size" -framerate "$fps" -i - \
-vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=yuv420p10le" \
-c:v ffv1 -level 3 -slices 16 -g 1 \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
"$dst.tmp.mkv"
mv "$dst.tmp.mkv" "$dst"
done
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Encode every test clip with every config, then score each encode.
#
# tools/encode/sweep.sh [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE...
#
# A config line is `name | ffmpeg output args` (blank lines and # comments are
# skipped). The args go between `-i clip.mkv` and the output file, e.g.
# x265-crf18 | -c:v libx265 -preset medium -crf 18 -x265-params aq-mode=3
# Each row of results/<tag>.tsv has encode fps, bitrate, VMAF 4K (mean, 1st
# percentile, min), VMAF-NEG mean, CAMBI banding (mean, max) and PSNR-Y.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
clipdir="$here/clips"
model=/usr/share/model
export SVT_LOG=1 # errors only
clips=""
keep=0
tag=$(date +%Y%m%d-%H%M%S)
configs=()
while [[ $# -gt 0 ]]; do
case $1 in
--clips) clips=$2; shift 2 ;;
--keep) keep=1; shift ;;
--tag) tag=$2; shift 2 ;;
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
*) configs+=("$1"); shift ;;
esac
done
[[ ${#configs[@]} -gt 0 ]] || { echo "usage: $0 [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE..." >&2; exit 1; }
if [[ -z $clips ]]; then
clips=$(cd "$clipdir" && ls ./*.mkv | sed 's|^\./||; s|\.mkv$||' | paste -sd,)
fi
[[ -n $clips ]] || { echo "no clips in $clipdir: run make_clips.sh first" >&2; exit 1; }
mkdir -p "$here/results" "$here/work"
tsv="$here/results/$tag.tsv"
[[ -f $tsv ]] || printf 'clip\tconfig\tfps\tmbps\tvmaf\tvmaf_p1\tvmaf_min\tvmaf_neg\tcambi\tcambi_max\tpsnr_y\n' > "$tsv"
IFS=, read -ra clip_list <<< "$clips"
for cfg in "${configs[@]}"; do
while IFS= read -r line || [[ -n $line ]]; do
[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
name=$(sed 's/[[:space:]]*|.*//' <<< "$line")
args=$(sed 's/^[^|]*|[[:space:]]*//' <<< "$line")
for clip in "${clip_list[@]}"; do
ref="$clipdir/$clip.mkv"
enc="$here/work/$clip--$name.mp4"
log="$here/work/$clip--$name.json"
frames=$(ffprobe -v error -count_packets -select_streams v:0 \
-show_entries stream=nb_read_packets -of csv=p=0 "$ref")
printf '%-10s %-40s ' "$clip" "$name"
t0=$(date +%s.%N)
# shellcheck disable=SC2086
ffmpeg -hide_banner -loglevel error -y -i "$ref" $args -an "$enc" < /dev/null
t1=$(date +%s.%N)
# 4K model for both VMAF variants; CAMBI flags banding, which VMAF
# barely sees and which is the main risk on smooth fractal gradients.
ffmpeg -hide_banner -loglevel error -i "$enc" -i "$ref" -lavfi \
"[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]libvmaf=log_fmt=json:log_path=$log:n_threads=$(nproc):n_subsample=2:model='path=$model/vmaf_4k_v0.6.1.json\\:name=vmaf|path=$model/vmaf_4k_v0.6.1neg.json\\:name=neg':feature='name=cambi|name=psnr'" \
-f null - < /dev/null
row=$(python3 - "$log" "$enc" "$frames" "$t0" "$t1" <<'PY'
import json, os, sys
log, enc, frames, t0, t1 = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
fr = json.load(open(log))["frames"]
col = lambda k: sorted(f["metrics"][k] for f in fr)
v = col("vmaf")
p1 = v[max(0, int(0.01 * len(v)) - 1)] if len(v) >= 100 else v[0]
mean = lambda xs: sum(xs) / len(xs)
cambi = col("cambi")
fps = frames / (t1 - t0)
mbps = os.path.getsize(enc) * 8 / (frames / 60) / 1e6
print(f"{fps:.2f}\t{mbps:.1f}\t{mean(v):.2f}\t{p1:.2f}\t{v[0]:.2f}\t{mean(col('neg')):.2f}\t{mean(cambi):.2f}\t{cambi[-1]:.2f}\t{mean(col('psnr_y')):.2f}")
PY
)
printf '%s\t%s\t%s\n' "$clip" "$name" "$row" >> "$tsv"
awk -F'\t' '{printf "%6s fps %7s Mb/s vmaf %s p1 %s min %s neg %s cambi %s/%s psnr %s\n",$1,$2,$3,$4,$5,$6,$7,$8,$9}' <<< "$row"
[[ $keep = 1 ]] || rm -f "$enc"
done
done < "$cfg"
done
echo "results: $tsv"